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
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.
- **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
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,
@@ -42,6 +42,41 @@ namespace PrivaPub.Tests.Federation
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
// found the same as by its id
[Fact]
+2 -1
View File
@@ -23,7 +23,8 @@ namespace PrivaPub.Tests.Support.Host
public const string ClientHeader = "X-Test-Client";
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;
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 UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
Task<int> ResendPending(DateTime now, CancellationToken token);
}
public class FollowService : IFollowService
@@ -276,6 +277,37 @@ namespace PrivaPub.Domain.Social
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()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -113,7 +113,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobQueue, JobQueue>()
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
.AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>();
.AddHostedService<JobWorker>()
.AddHostedService<Domain.Social.FollowResender>();
}
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 bool ShowReblogs { get; set; } = true;
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