Deliveries run on a Mongo job queue with leases, backoff and per-host limits

The single serial DeliveryWorker is replaced by Infrastructure/Jobs:
- Job rows are leased with one FindOneAndUpdate (oldest RunAt first, a
  two-minute lease) and a reaper returns expired leases every 30 s;
- enqueueing wakes the workers, which otherwise poll every five seconds;
- delivery runs eight at a time with at most two per host, so a slow or
  dead server holds two slots, not the queue;
- a failure waits n^4 + 15 + jitter seconds (Mastodon's curve) for up to
  16 attempts; a 4xx other than 408/429 is final, a 429 honours
  Retry-After;
- RemoteInstance is a per-host circuit breaker: ten consecutive failures
  quarantine a host for an hour, doubling to a week, and its jobs wait
  without spending attempts;
- a delivery is queued once per activity and inbox (unique DedupeKey), and
  finished jobs expire after seven days (TTL on FinishedAt).

Migration _004 moves pending Delivery rows into jobs and marks them
abandoned, so a rollback to the old worker cannot send them twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:09:35 +02:00
1 parent 934b6fe687
commit d1a91c40c4
15 files changed
+704 -99

No files matched your search

+10
View File
@@ -4,6 +4,7 @@ using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
@@ -48,6 +49,15 @@ namespace PrivaPub.Infrastructure.Data
await Plain<DmGroup>(token, g => g.ParticipantsKey);
await Plain<Delivery>(token, d => d.DeliveredAt, d => d.AbandonedAt, d => d.NextAttemptAt);
await Plain<Job>(token, j => j.Kind, j => j.State, j => j.RunAt);
await Plain<Job>(token, j => j.State, j => j.LeasedUntil);
await Unique<Job>(j => j.DedupeKey, Builders<Job>.Filter.Type(j => j.DedupeKey, BsonType.String), token);
await DB.Default.Index<Job>()
.Key(j => j.FinishedAt, KeyType.Ascending)
.Option(o => o.ExpireAfter = TimeSpan.FromDays(7))
.CreateAsync(token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
}
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
@@ -0,0 +1,43 @@
using MongoDB.Entities;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _004_pending_deliveries_become_jobs : IMigration
{
public async Task UpgradeAsync()
{
var pending = await DB.Default.Find<Delivery>()
.Match(d => !d.DeliveredAt.HasValue && !d.AbandonedAt.HasValue)
.ExecuteAsync();
foreach (var delivery in pending)
{
if (Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox))
{
var activityId = JsonNode.Parse(delivery.Body)?["id"]?.GetValue<string>();
var job = new Job
{
Kind = JobKind.Deliver,
Host = inbox.Host.ToLowerInvariant(),
DedupeKey = activityId == default ? default : $"{activityId}|{delivery.InboxURL}",
Attempts = delivery.Attempts,
RunAt = delivery.NextAttemptAt,
Payload = JsonSerializer.Serialize(new DeliveryPayload(delivery.SignerId, delivery.SignerKind, delivery.InboxURL, delivery.Body))
};
if (!await DB.Default.Find<Job>().Match(j => j.DedupeKey == job.DedupeKey && job.DedupeKey != null).ExecuteAnyAsync())
await DB.Default.SaveAsync(job);
}
await DB.Default.Update<Delivery>().MatchID(delivery.ID)
.Modify(d => d.AbandonedAt, DateTime.UtcNow)
.Modify(d => d.LastError, "moved to the job queue")
.ExecuteAsync();
}
}
}
}