Follow requests nobody answers are sent again

A Follow was resent only when the persona followed again. Lemmy 1.0
sends nothing it queued for a server before it started sending there, so
the Accept of a community follow made on first contact was lost for good:
the village's persona stayed "requested" a day while Lemmy listed her as a
follower, and every post the community announced was refused as not
followed. A request still unanswered is now sent again, the same activity,
after 15 minutes, an hour, 6 hours, a day, two and four days
(FollowResender, every 15 minutes); a server that holds the follow answers
the copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 07:33:59 +02:00
1 parent 7e37f56db6
commit 01808fa644
7 files changed
+103 -2

No files matched your search

+3
View File
@@ -224,6 +224,9 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists - **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin. deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days, the
same activity each time: a server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a
server before it started sending there), and one that holds the follow already answers the copy.
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, - **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
@@ -42,6 +42,41 @@ namespace PrivaPub.Tests.Federation
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
// a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a
// follow it took before it started sending to us, and the community's posts were refused as not followed)
[Fact]
public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var quiet = new RemoteActor(_harness.Peer, "quiet");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token);
async Task<int> Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue<string>() == "Follow");
var made = following.CreatedAt;
Assert.Equal(1, await Follows());
// (the store is shared with other tests' requests: what this one got is counted on its own inbox)
async Task<int> After(TimeSpan wait)
{
var before = await Follows();
await _harness.Follows.ResendPending(made + wait, token);
return await Follows() - before;
}
Assert.Equal(0, await After(TimeSpan.FromMinutes(10)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(16)));
Assert.Equal(0, await After(TimeSpan.FromMinutes(30)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(77)));
Assert.Equal(3, await Follows());
Assert.All(await _harness.Outgoing(quiet.Id + "/inbox"), a => Assert.Equal(following.FollowActivityURI, a["id"]!.GetValue<string>()));
// a request older than the count is sent again too; an answered one never
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token);
Assert.Equal(1, await After(TimeSpan.FromDays(1)));
await DB.Default.Update<Following>().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
Assert.Equal(0, await After(TimeSpan.FromDays(30)));
}
// Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and their Undo: the post is // Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and their Undo: the post is
// found the same as by its id // found the same as by its id
[Fact] [Fact]
+2 -1
View File
@@ -23,7 +23,8 @@ namespace PrivaPub.Tests.Support.Host
public const string ClientHeader = "X-Test-Client"; public const string ClientHeader = "X-Test-Client";
static readonly SemaphoreSlim Boot = new(1, 1); static readonly SemaphoreSlim Boot = new(1, 1);
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule) }; static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule),
typeof(PrivaPub.Domain.Social.FollowResender) };
static PrivaPubHost _shared; static PrivaPubHost _shared;
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}"); readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
+27
View File
@@ -0,0 +1,27 @@
namespace PrivaPub.Domain.Social
{
// sends again, now and then, the follow requests still unanswered (FollowService.ResendPending)
public sealed class FollowResender(IServiceScopeFactory scopes, ILogger<FollowResender> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromMinutes(15);
protected override async Task ExecuteAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(Every);
while (await timer.WaitForNextTickAsync(token))
{
try
{
using var scope = scopes.CreateScope();
var sent = await scope.ServiceProvider.GetRequiredService<IFollowService>().ResendPending(DateTime.UtcNow, token);
if (sent > 0)
logger.LogInformation("Sent {Count} unanswered follow requests again", sent);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Unanswered follow requests could not be sent again");
}
}
}
}
}
+32
View File
@@ -29,6 +29,7 @@ namespace PrivaPub.Domain.Social
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token); Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
Task UnfollowAs(LocalActor follower, string target, CancellationToken token); Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token); Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
Task<int> ResendPending(DateTime now, CancellationToken token);
} }
public class FollowService : IFollowService public class FollowService : IFollowService
@@ -276,6 +277,37 @@ namespace PrivaPub.Domain.Social
return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token)); return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token));
} }
// how long after it was made, or last sent again, a request still unanswered is sent again: a server can take a Follow and
// lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started sending there), and one that
// already holds the follow answers the copy again
public static readonly TimeSpan[] ResendAfter =
{
TimeSpan.FromMinutes(15), TimeSpan.FromHours(1), TimeSpan.FromHours(6), TimeSpan.FromDays(1), TimeSpan.FromDays(2), TimeSpan.FromDays(4)
};
// the requests to remote accounts due to be sent again; how many were
public async Task<int> ResendPending(DateTime now, CancellationToken token)
{
var sent = 0;
// (a request older than the count has no Resent yet: "not at least" matches it, "less than" would not)
var pending = await _dbEntities.Followings.Match(f => f.State == FollowState.Requested && !f.TargetIsLocal && !(f.Resent >= ResendAfter.Length))
.ExecuteAsync(token);
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
{
var follower = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
if (follower != default && !string.IsNullOrEmpty(inbox))
{
await _delivery.Enqueue(follower, new[] { inbox }, FollowActivity(follower, following), token,
again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture));
sent++;
}
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Inc(f => f.Resent, 1)).Modify(f => f.ResentAt, now)
.ExecuteAsync(token);
}
return sent;
}
static JsonObject FollowActivity(LocalActor follower, Following following) => new() static JsonObject FollowActivity(LocalActor follower, Following following) => new()
{ {
["@context"] = ActivityPubRenderer.ActivityStreams, ["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -113,7 +113,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobQueue, JobQueue>() .AddSingleton<IJobQueue, JobQueue>()
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>() .AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
.AddSingleton<IJobHandler, DeliveryJobHandler>() .AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>(); .AddHostedService<JobWorker>()
.AddHostedService<Domain.Social.FollowResender>();
} }
public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration) public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration)
{ {
+2
View File
@@ -13,6 +13,8 @@ namespace PrivaPub.Models.Social
public string FollowActivityURI { get; set; } public string FollowActivityURI { get; set; }
public bool ShowReblogs { get; set; } = true; public bool ShowReblogs { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int Resent { get; set; }//how many times a request still unanswered was sent again (FollowService.ResendPending)
public DateTime? ResentAt { get; set; }
} }
public enum FollowState public enum FollowState