@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -14,13 +14,17 @@ import {
14
14
  deliveryQueue,
15
15
  follows,
16
16
  inbox as inboxTable,
17
+ insertMany,
18
+ runBatch,
19
+ type D1Statement,
17
20
  } from "../../../db/index.ts";
18
21
  import { isLocal, isSafeRemoteUrl } from "../../federation-helpers.ts";
19
- import { isActorBlocked } from "../blocklist.ts";
22
+ import { isActorBlockedStrict } from "../blocklist.ts";
20
23
  import { planEndpointsFromActorCache } from "./planner.ts";
21
24
  import {
22
25
  fetchAndUpsertActorCache,
23
26
  getInstanceFetchSignerByDb,
27
+ isRemoteActorTombstoned,
24
28
  } from "../activitypub-actor-cache.ts";
25
29
  import {
26
30
  DELIVERY_QUEUE_MESSAGE_VERSION,
@@ -38,6 +42,7 @@ import {
38
42
  import {
39
43
  buildDeliverEndpointMessage,
40
44
  buildResolveActorMessage,
45
+ enqueuePendingDeliveryEndpointJobs,
41
46
  MAX_RECONCILE_ATTEMPTS,
42
47
  nowIso,
43
48
  type QueueEnv,
@@ -46,13 +51,23 @@ import {
46
51
  upsertDeliveryJob,
47
52
  } from "./queue.ts";
48
53
  import { logger } from "../logger.ts";
54
+ import {
55
+ claimDeliveryResolutionJob,
56
+ completeDeliveryResolutionJob,
57
+ deferDeliveryResolutionJob,
58
+ enqueueDeliveryResolutionJobs,
59
+ enqueuePendingDeliveryResolutionJobs,
60
+ MAX_RESOLVE_ATTEMPTS,
61
+ retryDeliveryResolutionJob,
62
+ } from "./resolution-outbox.ts";
63
+ import {
64
+ completeDeliveryFanoutJob,
65
+ deliveryFanoutIntentFromMessage,
66
+ getDeliveryFanoutState,
67
+ resetDeliveryFanoutJob,
68
+ } from "./fanout-outbox.ts";
49
69
 
50
70
  const DELIVERY_HTTP_TIMEOUT_MS = 8000;
51
- // Cap on resolve_actor self-requeues (each ~60s apart) before giving up on a
52
- // permanently-unresolvable recipient — otherwise a dead host churns one queue
53
- // message every 60s forever.
54
- const MAX_RESOLVE_ATTEMPTS = 8;
55
-
56
71
  const log = logger.child({ component: "delivery.batching" });
57
72
 
58
73
  async function fetchAndCacheRemoteActor(
@@ -113,7 +128,7 @@ async function sendQueueBatchChunked(
113
128
  */
114
129
  export async function enqueueFollowerEndpointDeliveries(
115
130
  db: Database,
116
- queue: QueueEnv["DELIVERY_QUEUE"],
131
+ queue: QueueEnv["DELIVERY_QUEUE"] | undefined,
117
132
  baseUrl: string,
118
133
  activityId: string,
119
134
  followeeApId: string,
@@ -168,16 +183,41 @@ export async function enqueueFollowerEndpointDeliveries(
168
183
  const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
169
184
  for (const group of planned.groups) {
170
185
  const jobId = await computeDeliveryJobId(activityId, group.endpoint);
171
- await upsertDeliveryJob(db, jobId, activityId, group.endpoint);
172
- deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
186
+ const active = await upsertDeliveryJob(
187
+ db,
188
+ jobId,
189
+ activityId,
190
+ group.endpoint,
191
+ group.recipientActorApIds,
192
+ );
193
+ if (active) {
194
+ deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
195
+ }
173
196
  }
174
197
 
175
- const resolveRequests = planned.unknownRecipients.map((apId) => ({
176
- body: buildResolveActorMessage(activityId, apId),
177
- }));
198
+ // Persist every durable first-hop row before the first Queue RPC. Queue
199
+ // messages are only wakeups; losing or lacking the producer must never
200
+ // erase the delivery authority for an irreversible account teardown.
201
+ await enqueueDeliveryResolutionJobs(
202
+ db,
203
+ undefined,
204
+ planned.unknownRecipients.map((recipientActorApId) => ({
205
+ activityId,
206
+ recipientActorApId,
207
+ })),
208
+ );
178
209
 
179
- await sendQueueBatchChunked(queue, deliverRequests);
180
- await sendQueueBatchChunked(queue, resolveRequests);
210
+ if (queue) {
211
+ await sendQueueBatchChunked(queue, deliverRequests);
212
+ await enqueueDeliveryResolutionJobs(
213
+ db,
214
+ queue,
215
+ planned.unknownRecipients.map((recipientActorApId) => ({
216
+ activityId,
217
+ recipientActorApId,
218
+ })),
219
+ );
220
+ }
181
221
  }
182
222
 
183
223
  processed += page.length;
@@ -199,10 +239,10 @@ export async function enqueueFollowerEndpointDeliveries(
199
239
  * `fanout_followers` consumer would otherwise run after the follower graph is
200
240
  * gone and reach zero remote followers.
201
241
  *
202
- * Best-effort and queue-aware: if the delivery queue bindings are missing it
203
- * is a no-op (mirrors enqueueFanoutToFollowers' silent producer-unavailable
204
- * behavior). Never throws into the caller's teardown transaction the caller
205
- * still wraps it so federation can never block local deletion.
242
+ * Persistence does not depend on Queue bindings: Queue messages are wakeups,
243
+ * while delivery_queue / delivery_resolutions remain the retry authority after
244
+ * the follower graph is erased. The full graph is snapshotted before any
245
+ * producer RPC, so a producer outage cannot leave only the first page durable.
206
246
  */
207
247
  export async function snapshotAndEnqueueFollowerDeliveries(
208
248
  db: Database,
@@ -210,14 +250,20 @@ export async function snapshotAndEnqueueFollowerDeliveries(
210
250
  activityId: string,
211
251
  followeeApId: string,
212
252
  ): Promise<void> {
213
- const queue = (env as Partial<QueueEnv>).DELIVERY_QUEUE;
253
+ const queueEnv = env as Partial<QueueEnv>;
254
+ const queue =
255
+ queueEnv.DELIVERY_QUEUE && queueEnv.DELIVERY_DLQ
256
+ ? queueEnv.DELIVERY_QUEUE
257
+ : undefined;
214
258
  if (!queue) {
215
- log.warn("Delivery queue unavailable; follower snapshot delivery skipped", {
216
- event: "delivery.fanout.snapshot_queue_unavailable",
217
- followee: followeeApId,
218
- activityId,
219
- });
220
- return;
259
+ log.warn(
260
+ "Delivery queue unavailable; follower snapshot persisted without wakeups",
261
+ {
262
+ event: "delivery.fanout.snapshot_queue_unavailable",
263
+ followee: followeeApId,
264
+ activityId,
265
+ },
266
+ );
221
267
  }
222
268
 
223
269
  let cursor: string | null = null;
@@ -225,7 +271,7 @@ export async function snapshotAndEnqueueFollowerDeliveries(
225
271
  do {
226
272
  const page = await enqueueFollowerEndpointDeliveries(
227
273
  db,
228
- queue,
274
+ undefined,
229
275
  env.APP_URL,
230
276
  activityId,
231
277
  followeeApId,
@@ -243,6 +289,25 @@ export async function snapshotAndEnqueueFollowerDeliveries(
243
289
  processed,
244
290
  });
245
291
  }
292
+
293
+ if (queue) {
294
+ // Publish only after every follower page is durable. These bounded sweeps
295
+ // wake the first tranche immediately; request/queue/cron recovery drains
296
+ // any remaining rows from the same outboxes.
297
+ try {
298
+ await enqueuePendingDeliveryEndpointJobs(env, new Date(), {
299
+ includeFreshPending: true,
300
+ });
301
+ await enqueuePendingDeliveryResolutionJobs(env);
302
+ } catch (error) {
303
+ log.error("Follower snapshot persisted but Queue wakeup failed", {
304
+ event: "delivery.fanout.snapshot_wakeup_failed",
305
+ followee: followeeApId,
306
+ activityId,
307
+ error,
308
+ });
309
+ }
310
+ }
246
311
  }
247
312
 
248
313
  export async function processFanoutFollowers(
@@ -251,7 +316,31 @@ export async function processFanoutFollowers(
251
316
  msg: DeliveryFanoutFollowersMessageV1,
252
317
  message: IQueueMessage<DeliveryQueueMessageV1>,
253
318
  ): Promise<void> {
254
- if (!requireQueue(env, "fanout", message)) return;
319
+ const intent = deliveryFanoutIntentFromMessage(msg);
320
+ const outboxState = await getDeliveryFanoutState(db, intent);
321
+ if (outboxState === "terminal") {
322
+ message.ack();
323
+ return;
324
+ }
325
+ const activityExists = await db
326
+ .select({ apId: activities.apId })
327
+ .from(activities)
328
+ .where(eq(activities.apId, msg.activityId))
329
+ .get();
330
+ if (!activityExists) {
331
+ await completeDeliveryFanoutJob(
332
+ db,
333
+ intent,
334
+ "discarded",
335
+ "Activity was deleted before fanout completed",
336
+ );
337
+ message.ack();
338
+ return;
339
+ }
340
+ if (!requireQueue(env, "fanout", message)) {
341
+ await resetDeliveryFanoutJob(db, intent, "Queue bindings unavailable");
342
+ return;
343
+ }
255
344
  const queueEnv = env as QueueEnv;
256
345
 
257
346
  const { processed, capped, nextCursor } =
@@ -284,6 +373,10 @@ export async function processFanoutFollowers(
284
373
  });
285
374
  }
286
375
 
376
+ if (!capped) {
377
+ await completeDeliveryFanoutJob(db, intent);
378
+ }
379
+
287
380
  message.ack();
288
381
  }
289
382
 
@@ -309,24 +402,60 @@ export async function processFanoutCommunity(
309
402
  ): Promise<void> {
310
403
  const baseUrl = env.APP_URL;
311
404
 
312
- if (!requireQueue(env, "fanout_community", message)) return;
313
- const queueEnv = env as QueueEnv;
405
+ const intent = deliveryFanoutIntentFromMessage(msg);
406
+ const outboxState = await getDeliveryFanoutState(db, intent);
407
+ if (outboxState === "terminal") {
408
+ message.ack();
409
+ return;
410
+ }
314
411
 
315
- // Resolve the activity's author so we never echo the post back into the
316
- // author's own inbox.
317
- const activityRow = await db
318
- .select({ actorApId: activities.actorApId })
412
+ const activityExists = await db
413
+ .select({ apId: activities.apId, actorApId: activities.actorApId })
319
414
  .from(activities)
320
415
  .where(eq(activities.apId, msg.activityId))
321
416
  .get();
322
- const authorApId = activityRow?.actorApId ?? null;
417
+ if (!activityExists) {
418
+ await completeDeliveryFanoutJob(
419
+ db,
420
+ intent,
421
+ "discarded",
422
+ "Activity was deleted before fanout completed",
423
+ );
424
+ message.ack();
425
+ return;
426
+ }
323
427
 
324
- // ----- 1. Local members: deliver to their inbox directly. ----------------
325
- let memberCursor: string | null = null;
326
- while (true) {
428
+ if (!requireQueue(env, "fanout_community", message)) {
429
+ await resetDeliveryFanoutJob(db, intent, "Queue bindings unavailable");
430
+ return;
431
+ }
432
+ const queueEnv = env as QueueEnv;
433
+
434
+ const authorApId = activityExists.actorApId;
435
+ const stage = msg.stage ?? "local_members";
436
+ const cursor = msg.cursor ?? null;
437
+ const enqueueContinuation = async (
438
+ nextStage: NonNullable<DeliveryFanoutCommunityMessageV1["stage"]>,
439
+ nextCursor: string | null,
440
+ ) => {
441
+ await sendQueueMessage(env, {
442
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
443
+ type: "fanout_community",
444
+ activityId: msg.activityId,
445
+ communityApId: msg.communityApId,
446
+ ...(msg.announceActivityId
447
+ ? { announceActivityId: msg.announceActivityId }
448
+ : {}),
449
+ stage: nextStage,
450
+ ...(nextCursor ? { cursor: nextCursor } : {}),
451
+ scheduledAt: nowIso(),
452
+ });
453
+ };
454
+
455
+ if (stage === "local_members") {
327
456
  const conditions = [eq(communityMembers.communityApId, msg.communityApId)];
328
- if (memberCursor !== null) {
329
- conditions.push(sql`${communityMembers.actorApId} > ${memberCursor}`);
457
+ if (cursor !== null) {
458
+ conditions.push(sql`${communityMembers.actorApId} > ${cursor}`);
330
459
  }
331
460
  const page = await db
332
461
  .select({ actorApId: communityMembers.actorApId })
@@ -334,138 +463,120 @@ export async function processFanoutCommunity(
334
463
  .where(and(...conditions))
335
464
  .orderBy(communityMembers.actorApId)
336
465
  .limit(FANOUT_FOLLOWER_PAGE_SIZE);
337
-
338
- if (page.length === 0) break;
339
- memberCursor = page[page.length - 1].actorApId;
340
-
341
466
  const localRecipients = page
342
- .map((m) => m.actorApId)
467
+ .map((row) => row.actorApId)
343
468
  .filter((apId) => isLocal(apId, baseUrl) && apId !== authorApId);
344
-
345
469
  if (localRecipients.length > 0) {
346
470
  const now = nowIso();
347
- await db
348
- .insert(inboxTable)
349
- .values(
350
- localRecipients.map((actorApId) => ({
351
- actorApId,
352
- activityApId: msg.activityId,
353
- read: 0,
354
- createdAt: now,
355
- })),
356
- )
357
- .onConflictDoNothing();
358
- }
359
-
360
- if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
361
- }
362
-
363
- // ----- 2. Remote recipients: plan endpoints and deliver. -----------------
364
- // Remote members of the community plus accepted followers of the community
365
- // actor. A community member set is typically modest; followers of the
366
- // community actor capture remote servers that follow the Group to receive
367
- // its activities. Both are deduped before planning.
368
- const remoteRecipients = new Set<string>();
369
-
370
- // Remote community members.
371
- {
372
- let cursor: string | null = null;
373
- let processed = 0;
374
- while (processed < FANOUT_MAX_FOLLOWERS) {
375
- const conditions = [
376
- eq(communityMembers.communityApId, msg.communityApId),
377
- ];
378
- if (cursor !== null) {
379
- conditions.push(sql`${communityMembers.actorApId} > ${cursor}`);
380
- }
381
- const page = await db
382
- .select({ actorApId: communityMembers.actorApId })
383
- .from(communityMembers)
384
- .where(and(...conditions))
385
- .orderBy(communityMembers.actorApId)
386
- .limit(FANOUT_FOLLOWER_PAGE_SIZE);
387
- if (page.length === 0) break;
388
- cursor = page[page.length - 1].actorApId;
389
- for (const m of page) {
390
- if (!isLocal(m.actorApId, baseUrl) && m.actorApId !== authorApId) {
391
- remoteRecipients.add(m.actorApId);
392
- }
471
+ const statements = insertMany(
472
+ db,
473
+ inboxTable,
474
+ localRecipients.map((actorApId) => ({
475
+ actorApId,
476
+ activityApId: msg.activityId,
477
+ read: 0,
478
+ createdAt: now,
479
+ })),
480
+ { conflict: "ignore" },
481
+ );
482
+ if (statements.length > 0) {
483
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
393
484
  }
394
- processed += page.length;
395
- if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
396
485
  }
486
+ if (page.length === FANOUT_FOLLOWER_PAGE_SIZE) {
487
+ await enqueueContinuation(
488
+ "local_members",
489
+ page[page.length - 1].actorApId,
490
+ );
491
+ } else {
492
+ await enqueueContinuation("remote_members", null);
493
+ }
494
+ message.ack();
495
+ return;
397
496
  }
398
497
 
399
- // Accepted followers of the community actor.
400
- {
401
- let cursor: string | null = null;
402
- let processed = 0;
403
- while (processed < FANOUT_MAX_FOLLOWERS) {
404
- const conditions = [
405
- eq(follows.followingApId, msg.communityApId),
406
- eq(follows.status, "accepted"),
407
- ];
408
- if (cursor !== null) {
409
- conditions.push(sql`${follows.followerApId} > ${cursor}`);
410
- }
411
- const page = await db
412
- .select({ followerApId: follows.followerApId })
413
- .from(follows)
414
- .where(and(...conditions))
415
- .orderBy(follows.followerApId)
416
- .limit(FANOUT_FOLLOWER_PAGE_SIZE);
417
- if (page.length === 0) break;
418
- cursor = page[page.length - 1].followerApId;
419
- for (const f of page) {
420
- if (
421
- !isLocal(f.followerApId, baseUrl) &&
422
- f.followerApId !== authorApId
423
- ) {
424
- remoteRecipients.add(f.followerApId);
425
- }
426
- }
427
- processed += page.length;
428
- if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
498
+ let pageActorApIds: string[];
499
+ if (stage === "remote_members") {
500
+ const conditions = [eq(communityMembers.communityApId, msg.communityApId)];
501
+ if (cursor !== null) {
502
+ conditions.push(sql`${communityMembers.actorApId} > ${cursor}`);
429
503
  }
504
+ const page = await db
505
+ .select({ actorApId: communityMembers.actorApId })
506
+ .from(communityMembers)
507
+ .where(and(...conditions))
508
+ .orderBy(communityMembers.actorApId)
509
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
510
+ pageActorApIds = page.map((row) => row.actorApId);
511
+ } else {
512
+ const conditions = [
513
+ eq(follows.followingApId, msg.communityApId),
514
+ eq(follows.status, "accepted"),
515
+ ];
516
+ if (cursor !== null) {
517
+ conditions.push(sql`${follows.followerApId} > ${cursor}`);
518
+ }
519
+ const page = await db
520
+ .select({ actorApId: follows.followerApId })
521
+ .from(follows)
522
+ .where(and(...conditions))
523
+ .orderBy(follows.followerApId)
524
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
525
+ pageActorApIds = page.map((row) => row.actorApId);
430
526
  }
431
527
 
432
- const remoteList = [...remoteRecipients];
433
- if (remoteList.length > 0) {
434
- // Announce-relay: remote followers receive the GROUP's Announce of the post
435
- // (when present) rather than the raw author activity, so it is attributed
436
- // to the community. Local members above kept the raw `activityId`.
528
+ const remoteRecipients = [...new Set(pageActorApIds)].filter(
529
+ (apId) => !isLocal(apId, baseUrl) && apId !== authorApId,
530
+ );
531
+ if (remoteRecipients.length > 0) {
532
+ // Announce-relay: remote recipients receive the Group's Announce (when
533
+ // present); local members above retain the raw author Activity.
437
534
  const remoteActivityId = msg.announceActivityId ?? msg.activityId;
438
- const planned = await planEndpointsFromActorCache(db, remoteList, {
535
+ const planned = await planEndpointsFromActorCache(db, remoteRecipients, {
439
536
  metricTags: {
440
537
  community: msg.communityApId,
441
538
  activity: remoteActivityId,
442
539
  },
443
540
  });
444
-
445
541
  const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
446
542
  for (const group of planned.groups) {
447
543
  const jobId = await computeDeliveryJobId(
448
544
  remoteActivityId,
449
545
  group.endpoint,
450
546
  );
451
- await upsertDeliveryJob(db, jobId, remoteActivityId, group.endpoint);
452
- deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
547
+ const active = await upsertDeliveryJob(
548
+ db,
549
+ jobId,
550
+ remoteActivityId,
551
+ group.endpoint,
552
+ group.recipientActorApIds,
553
+ );
554
+ if (active) {
555
+ deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
556
+ }
453
557
  }
454
-
455
- const resolveRequests = planned.unknownRecipients.map((apId) => ({
456
- body: buildResolveActorMessage(remoteActivityId, apId),
457
- }));
458
-
459
558
  await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, deliverRequests);
460
- await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, resolveRequests);
559
+ await enqueueDeliveryResolutionJobs(
560
+ db,
561
+ queueEnv.DELIVERY_QUEUE,
562
+ planned.unknownRecipients.map((recipientActorApId) => ({
563
+ activityId: remoteActivityId,
564
+ recipientActorApId,
565
+ })),
566
+ );
461
567
  }
462
568
 
463
- // TODO(remote-inbox-optimization): when the community has a large remote
464
- // footprint, prefer delivering once to each remote server's shared inbox via
465
- // the community's own followers collection rather than expanding the full
466
- // member/follower set here. Local delivery and community audience/addressing
467
- // are already correct; this is purely a remote fan-out efficiency follow-up.
569
+ if (pageActorApIds.length === FANOUT_FOLLOWER_PAGE_SIZE) {
570
+ await enqueueContinuation(stage, pageActorApIds[pageActorApIds.length - 1]);
571
+ } else if (stage === "remote_members") {
572
+ await enqueueContinuation("remote_followers", null);
573
+ } else {
574
+ await completeDeliveryFanoutJob(db, intent);
575
+ }
468
576
 
577
+ // TODO(remote-inbox-optimization): prefer one shared-inbox delivery per
578
+ // remote server when a community has a very large remote footprint. The
579
+ // stage/cursor lifecycle above is complete; this is an efficiency follow-up.
469
580
  message.ack();
470
581
  }
471
582
 
@@ -477,11 +588,88 @@ export async function processResolveActor(
477
588
  ): Promise<void> {
478
589
  if (!requireQueue(env, "resolve_actor", message)) return;
479
590
 
591
+ const resolutionClaim = await claimDeliveryResolutionJob(
592
+ db,
593
+ msg.activityId,
594
+ msg.recipientActorApId,
595
+ );
596
+ if (resolutionClaim.state === "terminal") {
597
+ message.ack();
598
+ return;
599
+ }
600
+ if (
601
+ resolutionClaim.state === "deferred" ||
602
+ resolutionClaim.state === "busy"
603
+ ) {
604
+ message.retry({ delaySeconds: resolutionClaim.delaySeconds });
605
+ return;
606
+ }
607
+ const managedClaim =
608
+ resolutionClaim.state === "claimed" ? resolutionClaim : null;
609
+
610
+ // A projection cascade may delete the Activity while an old resolve message
611
+ // remains in flight. Never recreate delivery work for a deleted Activity.
612
+ const activityExists = await db
613
+ .select({ apId: activities.apId })
614
+ .from(activities)
615
+ .where(eq(activities.apId, msg.activityId))
616
+ .get();
617
+ if (!activityExists) {
618
+ if (managedClaim) {
619
+ await completeDeliveryResolutionJob(
620
+ db,
621
+ managedClaim,
622
+ "discarded",
623
+ "activity_not_found",
624
+ );
625
+ }
626
+ message.ack();
627
+ return;
628
+ }
629
+
630
+ // A verified self-Delete is durable recipient authority. The Delete handler
631
+ // normally removes actor-addressed resolution rows atomically, but an
632
+ // already-claimed Queue message may outlive that row. Fence it here before
633
+ // any remote fetch or endpoint materialization; endpoint jobs may aggregate
634
+ // a shared inbox and therefore cannot safely be cancelled per actor later.
635
+ if (await isRemoteActorTombstoned(db, msg.recipientActorApId)) {
636
+ if (managedClaim) {
637
+ await completeDeliveryResolutionJob(
638
+ db,
639
+ managedClaim,
640
+ "discarded",
641
+ "recipient_tombstoned",
642
+ );
643
+ }
644
+ message.ack();
645
+ return;
646
+ }
647
+
480
648
  // Defense-in-depth: the fanout/enqueue side already drops blocked recipients
481
649
  // via planEndpointsFromActorCache, but enforce the operator blocklist at the
482
650
  // resolve seam too so a re-resolved actor (or a domain blocked after enqueue)
483
651
  // never gets a delivery job. ACK silently — same posture as the inbox handler.
484
- if (await isActorBlocked(db, msg.recipientActorApId)) {
652
+ let recipientBlocked: boolean;
653
+ try {
654
+ recipientBlocked = await isActorBlockedStrict(db, msg.recipientActorApId);
655
+ } catch (error) {
656
+ if (managedClaim) {
657
+ const owned = await deferDeliveryResolutionJob(db, managedClaim, error);
658
+ if (owned) message.retry({ delaySeconds: 60 });
659
+ else message.ack();
660
+ return;
661
+ }
662
+ throw error;
663
+ }
664
+ if (recipientBlocked) {
665
+ if (managedClaim) {
666
+ await completeDeliveryResolutionJob(
667
+ db,
668
+ managedClaim,
669
+ "discarded",
670
+ "recipient_blocked",
671
+ );
672
+ }
485
673
  message.ack();
486
674
  return;
487
675
  }
@@ -504,6 +692,37 @@ export async function processResolveActor(
504
692
  try {
505
693
  await fetchAndCacheRemoteActor(db, msg.recipientActorApId);
506
694
  } catch (e) {
695
+ if (managedClaim) {
696
+ const result = await retryDeliveryResolutionJob(db, managedClaim, e);
697
+ const nextAttempt = managedClaim.attempts + 1;
698
+ if (!result.owned) {
699
+ message.ack();
700
+ return;
701
+ }
702
+ if (result.terminal) {
703
+ log.warn("resolve_actor giving up after max attempts", {
704
+ event: "delivery.resolve_actor.exhausted",
705
+ actor: msg.recipientActorApId,
706
+ activityId: msg.activityId,
707
+ attempts: nextAttempt,
708
+ error: e,
709
+ });
710
+ message.ack();
711
+ return;
712
+ }
713
+ log.warn("resolve_actor fetch failed", {
714
+ event: "delivery.resolve_actor.failed",
715
+ actor: msg.recipientActorApId,
716
+ activityId: msg.activityId,
717
+ attempt: nextAttempt,
718
+ error: e,
719
+ });
720
+ // The retry intent is already durable. Ask the current queue message to
721
+ // retry too; if that signal is lost, the outbox sweep republishes it.
722
+ message.retry({ delaySeconds: 60 });
723
+ return;
724
+ }
725
+
507
726
  // Bound the retry: a permanently-unresolvable recipient (dead host,
508
727
  // persistent 5xx, SSRF-blocked) must NOT re-enqueue a fresh resolve_actor
509
728
  // every 60s forever. Give up after MAX_RESOLVE_ATTEMPTS so the activity is
@@ -557,13 +776,38 @@ export async function processResolveActor(
557
776
  actor: msg.recipientActorApId,
558
777
  activityId: msg.activityId,
559
778
  });
779
+ if (managedClaim) {
780
+ await completeDeliveryResolutionJob(
781
+ db,
782
+ managedClaim,
783
+ "discarded",
784
+ "endpoint_unresolved",
785
+ );
786
+ }
560
787
  message.ack();
561
788
  return;
562
789
  }
563
790
 
564
791
  const jobId = await computeDeliveryJobId(msg.activityId, endpoint);
565
- await upsertDeliveryJob(db, jobId, msg.activityId, endpoint);
792
+ const active = await upsertDeliveryJob(db, jobId, msg.activityId, endpoint, [
793
+ msg.recipientActorApId,
794
+ ]);
795
+ if (!active) {
796
+ if (managedClaim) {
797
+ await completeDeliveryResolutionJob(
798
+ db,
799
+ managedClaim,
800
+ "discarded",
801
+ "recipient_tombstoned",
802
+ );
803
+ }
804
+ message.ack();
805
+ return;
806
+ }
566
807
  await sendQueueMessage(env, buildDeliverEndpointMessage(jobId));
808
+ if (managedClaim) {
809
+ await completeDeliveryResolutionJob(db, managedClaim, "resolved");
810
+ }
567
811
  message.ack();
568
812
  }
569
813