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:
1 parent
7e37f56db6
commit
01808fa644
7 files changed
+103
-2
No files matched your search
@@ -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]
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user