From 7bcfa4cc76ee43d2258979744d78113efef74e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 6 Jun 2026 22:02:04 +0200 Subject: [PATCH] feat(journal): async federation inbox via Fedify message queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mastodon gives inbox deliveries a 10s read timeout; without a queue, Fedify runs inbox listeners AND outbound deliveries (the Accept we push back after a Follow) inside the inbound request. A slow or unreachable remote then blows the budget, the sender times out and its circuit breaker (Stoplight on Mastodon) cuts us off — observed live during the staging soak. InProcessMessageQueue + manuallyStartQueue: the queue consumer starts explicitly on first federation use (skipped under vitest so tests don't leak timers). In-process queueing is acceptable for the single-process journal: queued work lost on restart is recoverable (remotes retry Follows; activity push delivery is owned by our own pg-boss jobs). Revisit with a Postgres-backed MessageQueue if that changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/app/lib/federation.server.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts index f10cd72..761f93b 100644 --- a/apps/journal/app/lib/federation.server.ts +++ b/apps/journal/app/lib/federation.server.ts @@ -26,7 +26,7 @@ // exactly the "acknowledge and drop" the spec wants; the same applies // to wrapped types we inspect and ignore (e.g. Undo(Like)). import { configure, getConsoleSink } from "@logtape/logtape"; -import { createFederation, type Federation } from "@fedify/fedify"; +import { createFederation, InProcessMessageQueue, type Federation } from "@fedify/fedify"; import { Accept, Follow, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab"; import { eq } from "drizzle-orm"; import { users } from "@trails-cool/db/schema/journal"; @@ -129,10 +129,28 @@ function buildFederation(): Federation { configureFederationLogging(); const federation = createFederation({ kv: new PostgresKvStore(), + // Message queue so inbox listener execution and outbound deliveries + // (e.g. the Accept we push back after a Follow) happen asynchronously + // with Fedify's own retry policy, instead of inside the inbound HTTP + // request — Mastodon gives deliveries a 10s read timeout, and a slow + // remote must never make us blow it. In-process is acceptable for + // the single-process journal: queued work is lost on restart, but + // every message type we enqueue is recoverable (remotes retry + // Follows; our own pg-boss jobs own activity delivery). Revisit with + // a Postgres-backed MessageQueue if that stops being true. + queue: new InProcessMessageQueue(), + // Started explicitly below — keeps vitest runs (which build this + // instance via handleFederationRequest) free of dangling timers. + manuallyStartQueue: true, // Canonical origin so generated IRIs are correct behind the Caddy // proxy (the Node server itself only sees plain HTTP). origin: getOrigin(), }); + if (!process.env.VITEST) { + // Fire-and-forget: runs the queue consumer loop for the process + // lifetime. Dev and prod both reach this on first federation use. + void federation.startQueue(undefined); + } federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {