@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.
- package/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -9,9 +9,29 @@ import type {
|
|
|
9
9
|
IQueueMessage,
|
|
10
10
|
IQueueProducer,
|
|
11
11
|
} from "../../runtime/queue.ts";
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import {
|
|
13
|
+
and,
|
|
14
|
+
asc,
|
|
15
|
+
eq,
|
|
16
|
+
exists,
|
|
17
|
+
inArray,
|
|
18
|
+
lt,
|
|
19
|
+
lte,
|
|
20
|
+
notInArray,
|
|
21
|
+
notExists,
|
|
22
|
+
or,
|
|
23
|
+
sql,
|
|
24
|
+
} from "drizzle-orm";
|
|
25
|
+
import {
|
|
26
|
+
actorCache,
|
|
27
|
+
affectedRowCount,
|
|
28
|
+
deliveryEndpointRecipients,
|
|
29
|
+
deliveryQueue,
|
|
30
|
+
insertMany,
|
|
31
|
+
runBatch,
|
|
32
|
+
type D1Statement,
|
|
33
|
+
type Database,
|
|
34
|
+
} from "../../../db/index.ts";
|
|
15
35
|
import { isSafeRemoteUrl } from "../../federation-helpers.ts";
|
|
16
36
|
import {
|
|
17
37
|
DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
@@ -24,12 +44,29 @@ import {
|
|
|
24
44
|
import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
|
|
25
45
|
import { emitMetric } from "./metrics.ts";
|
|
26
46
|
import { logger } from "../logger.ts";
|
|
27
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
filterBlockedActorApIdsStrict,
|
|
49
|
+
isActorBlockedStrict,
|
|
50
|
+
} from "../blocklist.ts";
|
|
28
51
|
import {
|
|
29
52
|
enqueuePendingNotificationPushJobs,
|
|
30
53
|
processNotificationPushJob,
|
|
31
54
|
recoverDeadLetteredNotificationPushJob,
|
|
32
55
|
} from "../notification-push.ts";
|
|
56
|
+
import {
|
|
57
|
+
buildResolveActorMessage,
|
|
58
|
+
enqueueDeliveryResolutionJobs,
|
|
59
|
+
enqueuePendingDeliveryResolutionJobs,
|
|
60
|
+
persistDeliveryResolutionJobs,
|
|
61
|
+
} from "./resolution-outbox.ts";
|
|
62
|
+
import {
|
|
63
|
+
deliveryFanoutIntentFromMessage,
|
|
64
|
+
enqueueDeliveryFanoutJob,
|
|
65
|
+
enqueuePendingDeliveryFanoutJobs,
|
|
66
|
+
failDeliveryFanoutJob,
|
|
67
|
+
} from "./fanout-outbox.ts";
|
|
68
|
+
|
|
69
|
+
export { buildResolveActorMessage } from "./resolution-outbox.ts";
|
|
33
70
|
|
|
34
71
|
const log = logger.child({ component: "delivery.queue" });
|
|
35
72
|
|
|
@@ -165,6 +202,24 @@ export async function sendQueueMessage(
|
|
|
165
202
|
);
|
|
166
203
|
}
|
|
167
204
|
|
|
205
|
+
/**
|
|
206
|
+
* DLQ recovery cannot use the ordinary best-effort producer contract: a
|
|
207
|
+
* missing binding must keep the DLQ message retryable rather than look like a
|
|
208
|
+
* successful redrive and get ACKed. Runtime send failures already throw; make
|
|
209
|
+
* binding drift do the same while preserving the shared alert/metric.
|
|
210
|
+
*/
|
|
211
|
+
async function sendQueueMessageRequired(
|
|
212
|
+
env: Env,
|
|
213
|
+
body: DeliveryQueueMessageV1,
|
|
214
|
+
delaySeconds?: number,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
if (!queueAvailable(env)) {
|
|
217
|
+
reportProducerUnavailable("sendQueueMessageRequired");
|
|
218
|
+
throw new Error("DELIVERY_QUEUE and DELIVERY_DLQ bindings are required");
|
|
219
|
+
}
|
|
220
|
+
await sendQueueMessage(env, body, delaySeconds);
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
export async function sendDlqMessage(
|
|
169
224
|
env: Env,
|
|
170
225
|
payload: DeliveryDlqMessageV1,
|
|
@@ -184,6 +239,12 @@ export async function sendDlqMessage(
|
|
|
184
239
|
// reconcile worker (processReconcileJob) share one source of truth.
|
|
185
240
|
export const MAX_RECONCILE_ATTEMPTS = 5;
|
|
186
241
|
|
|
242
|
+
// Cloudflare puts the raw MAIN-queue body onto the DLQ after transport/runtime
|
|
243
|
+
// retries are exhausted. Redrive idempotent delivery work a few times so one
|
|
244
|
+
// infrastructure incident does not become permanent data loss, while keeping
|
|
245
|
+
// a poison message or persistent code failure from cycling MAIN -> DLQ forever.
|
|
246
|
+
export const MAX_AUTO_DLQ_REDRIVES = 3;
|
|
247
|
+
|
|
187
248
|
export function buildDeliverEndpointMessage(
|
|
188
249
|
jobId: string,
|
|
189
250
|
reconcileAttempt = 0,
|
|
@@ -199,21 +260,6 @@ export function buildDeliverEndpointMessage(
|
|
|
199
260
|
};
|
|
200
261
|
}
|
|
201
262
|
|
|
202
|
-
export function buildResolveActorMessage(
|
|
203
|
-
activityId: string,
|
|
204
|
-
recipientActorApId: string,
|
|
205
|
-
attempts = 0,
|
|
206
|
-
): DeliveryQueueMessageV1 {
|
|
207
|
-
return {
|
|
208
|
-
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
209
|
-
type: "resolve_actor",
|
|
210
|
-
activityId,
|
|
211
|
-
recipientActorApId,
|
|
212
|
-
...(attempts > 0 ? { attempts } : {}),
|
|
213
|
-
scheduledAt: nowIso(),
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
263
|
export function buildReconcileJobMessage(
|
|
218
264
|
jobId: string,
|
|
219
265
|
reconcileAttempt: number,
|
|
@@ -236,32 +282,188 @@ export async function upsertDeliveryJob(
|
|
|
236
282
|
jobId: string,
|
|
237
283
|
activityId: string,
|
|
238
284
|
endpoint: string,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
285
|
+
recipientActorApIds: readonly string[],
|
|
286
|
+
): Promise<boolean> {
|
|
287
|
+
const uniqueRecipientActorApIds = [...new Set(recipientActorApIds)];
|
|
288
|
+
if (uniqueRecipientActorApIds.length === 0) return false;
|
|
289
|
+
|
|
290
|
+
const recipientInserts = insertMany(
|
|
291
|
+
db,
|
|
292
|
+
deliveryEndpointRecipients,
|
|
293
|
+
uniqueRecipientActorApIds.map((recipientActorApId) => ({
|
|
294
|
+
deliveryJobId: jobId,
|
|
295
|
+
recipientActorApId,
|
|
296
|
+
})),
|
|
297
|
+
{ conflict: "ignore" },
|
|
298
|
+
);
|
|
299
|
+
const firstRecipientInsert = recipientInserts[0];
|
|
300
|
+
if (!firstRecipientInsert) return false;
|
|
301
|
+
const hasRetainedRecipient = () =>
|
|
302
|
+
exists(
|
|
303
|
+
db
|
|
304
|
+
.select({ deliveryJobId: deliveryEndpointRecipients.deliveryJobId })
|
|
305
|
+
.from(deliveryEndpointRecipients)
|
|
306
|
+
.where(eq(deliveryEndpointRecipients.deliveryJobId, jobId)),
|
|
307
|
+
);
|
|
308
|
+
const now = nowIso();
|
|
309
|
+
|
|
310
|
+
// One deep materialization unit owns endpoint aggregation, recipient
|
|
311
|
+
// attribution, tombstone fencing, and compatibility with legacy jobs. The
|
|
312
|
+
// recipient trigger drops a deleted actor before the final cleanup runs.
|
|
313
|
+
// A newly-created job is marked attributed; a pre-0029 conflict retains its
|
|
314
|
+
// default 0 because its full historical recipient set cannot be recovered.
|
|
315
|
+
await runBatch(db, [
|
|
316
|
+
firstRecipientInsert,
|
|
317
|
+
...recipientInserts.slice(1),
|
|
318
|
+
db
|
|
319
|
+
.insert(deliveryQueue)
|
|
320
|
+
.values({
|
|
321
|
+
id: jobId,
|
|
322
|
+
inboxUrl: endpoint,
|
|
323
|
+
activityApId: activityId,
|
|
324
|
+
attempts: 0,
|
|
325
|
+
nextAttemptAt: now,
|
|
326
|
+
status: "pending",
|
|
327
|
+
recipientAttributionComplete: 1,
|
|
328
|
+
})
|
|
329
|
+
.onConflictDoNothing() as unknown as D1Statement,
|
|
330
|
+
// Guard against overwriting in-flight or completed jobs, and do not update
|
|
331
|
+
// a new all-tombstoned candidate that retained no recipient mapping.
|
|
332
|
+
db
|
|
333
|
+
.update(deliveryQueue)
|
|
334
|
+
.set({ inboxUrl: endpoint, activityApId: activityId })
|
|
335
|
+
.where(
|
|
336
|
+
and(
|
|
337
|
+
eq(deliveryQueue.id, jobId),
|
|
338
|
+
notInArray(deliveryQueue.status, ["processing", "delivered"]),
|
|
339
|
+
hasRetainedRecipient(),
|
|
340
|
+
),
|
|
341
|
+
) as unknown as D1Statement,
|
|
342
|
+
// Only jobs born under the recipient-aware implementation are safe to
|
|
343
|
+
// delete when every supplied recipient was already tombstoned. Legacy jobs
|
|
344
|
+
// remain conservative because they may represent unknown co-recipients.
|
|
345
|
+
db.delete(deliveryQueue).where(
|
|
260
346
|
and(
|
|
261
347
|
eq(deliveryQueue.id, jobId),
|
|
262
|
-
|
|
348
|
+
eq(deliveryQueue.recipientAttributionComplete, 1),
|
|
349
|
+
notExists(
|
|
350
|
+
db
|
|
351
|
+
.select({
|
|
352
|
+
deliveryJobId: deliveryEndpointRecipients.deliveryJobId,
|
|
353
|
+
})
|
|
354
|
+
.from(deliveryEndpointRecipients)
|
|
355
|
+
.where(eq(deliveryEndpointRecipients.deliveryJobId, jobId)),
|
|
356
|
+
),
|
|
263
357
|
),
|
|
358
|
+
) as unknown as D1Statement,
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
return Boolean(
|
|
362
|
+
await db
|
|
363
|
+
.select({ id: deliveryQueue.id })
|
|
364
|
+
.from(deliveryQueue)
|
|
365
|
+
.where(eq(deliveryQueue.id, jobId))
|
|
366
|
+
.get(),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const PENDING_DELIVERY_SCAN_LIMIT = 500;
|
|
371
|
+
const STALE_DELIVERY_PROCESSING_MS = 2 * 60 * 1000;
|
|
372
|
+
const TERMINAL_DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
373
|
+
const TERMINAL_DELIVERY_PURGE_LIMIT = 50;
|
|
374
|
+
const TERMINAL_DELIVERY_STATUSES = [
|
|
375
|
+
"delivered",
|
|
376
|
+
"dead_letter",
|
|
377
|
+
"failed",
|
|
378
|
+
] as const;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Bound terminal endpoint-ledger growth while retaining a 30-day idempotency
|
|
382
|
+
* and diagnostic window. Activity deletion still owns immediate projection
|
|
383
|
+
* cleanup; this path handles Activities that legitimately remain in history.
|
|
384
|
+
*/
|
|
385
|
+
export async function purgeTerminalDeliveryEndpointJobs(
|
|
386
|
+
db: Database,
|
|
387
|
+
nowDate = new Date(),
|
|
388
|
+
): Promise<number> {
|
|
389
|
+
const cutoff = new Date(
|
|
390
|
+
nowDate.getTime() - TERMINAL_DELIVERY_RETENTION_MS,
|
|
391
|
+
).toISOString();
|
|
392
|
+
const rows = await db
|
|
393
|
+
.select({ id: deliveryQueue.id })
|
|
394
|
+
.from(deliveryQueue)
|
|
395
|
+
.where(
|
|
396
|
+
and(
|
|
397
|
+
inArray(deliveryQueue.status, [...TERMINAL_DELIVERY_STATUSES]),
|
|
398
|
+
lte(deliveryQueue.createdAt, cutoff),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
.orderBy(asc(deliveryQueue.createdAt))
|
|
402
|
+
.limit(TERMINAL_DELIVERY_PURGE_LIMIT);
|
|
403
|
+
if (rows.length === 0) return 0;
|
|
404
|
+
const result = await db.delete(deliveryQueue).where(
|
|
405
|
+
and(
|
|
406
|
+
inArray(
|
|
407
|
+
deliveryQueue.id,
|
|
408
|
+
rows.map((row) => row.id),
|
|
409
|
+
),
|
|
410
|
+
inArray(deliveryQueue.status, [...TERMINAL_DELIVERY_STATUSES]),
|
|
411
|
+
lte(deliveryQueue.createdAt, cutoff),
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
return affectedRowCount(result);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Re-publish durable endpoint jobs whose first Queue RPC was lost, whose
|
|
419
|
+
* backoff has elapsed, or whose processing worker disappeared. Queue messages
|
|
420
|
+
* are wakeups; delivery_queue remains the retry authority.
|
|
421
|
+
*/
|
|
422
|
+
export async function enqueuePendingDeliveryEndpointJobs(
|
|
423
|
+
env: Env,
|
|
424
|
+
nowDate = new Date(),
|
|
425
|
+
options: {
|
|
426
|
+
readonly includeDeferred?: boolean;
|
|
427
|
+
readonly includeFreshPending?: boolean;
|
|
428
|
+
} = {},
|
|
429
|
+
): Promise<number> {
|
|
430
|
+
await purgeTerminalDeliveryEndpointJobs(env.DB_INSTANCE, nowDate);
|
|
431
|
+
if (!env.DELIVERY_QUEUE) return 0;
|
|
432
|
+
const now = nowDate.toISOString();
|
|
433
|
+
const staleBefore = new Date(
|
|
434
|
+
nowDate.getTime() - STALE_DELIVERY_PROCESSING_MS,
|
|
435
|
+
).toISOString();
|
|
436
|
+
const rows = await env.DB_INSTANCE.select({ id: deliveryQueue.id })
|
|
437
|
+
.from(deliveryQueue)
|
|
438
|
+
.where(
|
|
439
|
+
or(
|
|
440
|
+
options.includeFreshPending
|
|
441
|
+
? eq(deliveryQueue.status, "pending")
|
|
442
|
+
: and(
|
|
443
|
+
eq(deliveryQueue.status, "pending"),
|
|
444
|
+
lt(deliveryQueue.createdAt, staleBefore),
|
|
445
|
+
),
|
|
446
|
+
options.includeDeferred
|
|
447
|
+
? eq(deliveryQueue.status, "retry_wait")
|
|
448
|
+
: and(
|
|
449
|
+
eq(deliveryQueue.status, "retry_wait"),
|
|
450
|
+
lte(deliveryQueue.nextAttemptAt, now),
|
|
451
|
+
),
|
|
452
|
+
and(
|
|
453
|
+
eq(deliveryQueue.status, "processing"),
|
|
454
|
+
lt(deliveryQueue.processingStartedAt, staleBefore),
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
.limit(PENDING_DELIVERY_SCAN_LIMIT);
|
|
459
|
+
for (let offset = 0; offset < rows.length; offset += 100) {
|
|
460
|
+
await env.DELIVERY_QUEUE.sendBatch(
|
|
461
|
+
rows.slice(offset, offset + 100).map((row) => ({
|
|
462
|
+
body: buildDeliverEndpointMessage(row.id),
|
|
463
|
+
})),
|
|
264
464
|
);
|
|
465
|
+
}
|
|
466
|
+
return rows.length;
|
|
265
467
|
}
|
|
266
468
|
|
|
267
469
|
export async function enqueueResolveForEndpointActors(
|
|
@@ -326,16 +528,35 @@ export async function enqueueResolveForEndpointActors(
|
|
|
326
528
|
// Drop recipients the operator has defederated before re-enqueueing
|
|
327
529
|
// resolve_actor jobs (outbound blocklist enforcement). Batched (2 queries)
|
|
328
530
|
// rather than a serial isActorBlocked per candidate.
|
|
329
|
-
|
|
531
|
+
let blockedSet: Set<string>;
|
|
532
|
+
try {
|
|
533
|
+
blockedSet = await filterBlockedActorApIdsStrict(db, candidates);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
// Preserve the resolution authority before surfacing a transient local
|
|
536
|
+
// moderation outage. The resolver itself repeats the strict check, so
|
|
537
|
+
// recovery cannot deliver to a blocked actor once the DB is readable.
|
|
538
|
+
await persistDeliveryResolutionJobs(
|
|
539
|
+
db,
|
|
540
|
+
candidates.map((recipientActorApId) => ({
|
|
541
|
+
activityId,
|
|
542
|
+
recipientActorApId,
|
|
543
|
+
})),
|
|
544
|
+
);
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
330
547
|
const slice = candidates.filter((apId) => !blockedSet.has(apId));
|
|
331
548
|
|
|
332
549
|
if (slice.length === 0) continue;
|
|
333
550
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
551
|
+
await enqueueDeliveryResolutionJobs(
|
|
552
|
+
db,
|
|
553
|
+
env.DELIVERY_QUEUE,
|
|
554
|
+
slice.map((recipientActorApId) => ({
|
|
555
|
+
activityId,
|
|
556
|
+
recipientActorApId,
|
|
557
|
+
})),
|
|
558
|
+
{ reopenTerminal: true },
|
|
559
|
+
);
|
|
339
560
|
enqueued += slice.length;
|
|
340
561
|
}
|
|
341
562
|
|
|
@@ -368,13 +589,26 @@ export async function enqueueFanoutToFollowers(
|
|
|
368
589
|
activityId: string,
|
|
369
590
|
followeeApId: string,
|
|
370
591
|
): Promise<void> {
|
|
371
|
-
|
|
592
|
+
const body = {
|
|
372
593
|
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
373
|
-
type: "fanout_followers",
|
|
594
|
+
type: "fanout_followers" as const,
|
|
374
595
|
activityId,
|
|
375
596
|
followeeApId,
|
|
376
597
|
scheduledAt: nowIso(),
|
|
377
|
-
}
|
|
598
|
+
};
|
|
599
|
+
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
600
|
+
if (db) {
|
|
601
|
+
const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
|
|
602
|
+
await enqueueDeliveryFanoutJob(
|
|
603
|
+
db,
|
|
604
|
+
queue,
|
|
605
|
+
deliveryFanoutIntentFromMessage(body),
|
|
606
|
+
);
|
|
607
|
+
if (!queue) reportProducerUnavailable("enqueueFanoutToFollowers");
|
|
608
|
+
else producerUnavailableReported = false;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await sendQueueMessage(env, body);
|
|
378
612
|
}
|
|
379
613
|
|
|
380
614
|
export async function enqueueDeliveryToActor(
|
|
@@ -385,16 +619,35 @@ export async function enqueueDeliveryToActor(
|
|
|
385
619
|
// Enforce the operator blocklist on the OUTBOUND single-actor path (DMs,
|
|
386
620
|
// Accept/Follow responses, targeted post/story interactions). A defederated
|
|
387
621
|
// domain/actor must never receive our activities, mirroring the inbound
|
|
388
|
-
// enforcement in the inbox handler.
|
|
389
|
-
//
|
|
622
|
+
// enforcement in the inbox handler. A DB read failure retains the delivery
|
|
623
|
+
// intent and propagates so unavailable authority never becomes a bypass.
|
|
390
624
|
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
391
|
-
if (db
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
625
|
+
if (db) {
|
|
626
|
+
try {
|
|
627
|
+
if (await isActorBlockedStrict(db, recipientActorApId)) {
|
|
628
|
+
log.info("Skipping outbound delivery to blocked actor", {
|
|
629
|
+
event: "delivery.blocklist.actor_skip",
|
|
630
|
+
actor: recipientActorApId,
|
|
631
|
+
activityId,
|
|
632
|
+
});
|
|
633
|
+
emitMetric("delivery.blocklist.actor_skip", 1, {});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
// The Activity may already be committed and some callers deliberately
|
|
638
|
+
// treat queue publication as best-effort. Persist the semantic intent so
|
|
639
|
+
// a later outbox sweep can resume after moderation authority recovers.
|
|
640
|
+
await persistDeliveryResolutionJobs(db, [
|
|
641
|
+
{ activityId, recipientActorApId },
|
|
642
|
+
]);
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
|
|
646
|
+
await enqueueDeliveryResolutionJobs(db, queue, [
|
|
647
|
+
{ activityId, recipientActorApId },
|
|
648
|
+
]);
|
|
649
|
+
if (!queue) reportProducerUnavailable("enqueueDeliveryToActor");
|
|
650
|
+
else producerUnavailableReported = false;
|
|
398
651
|
return;
|
|
399
652
|
}
|
|
400
653
|
|
|
@@ -404,6 +657,75 @@ export async function enqueueDeliveryToActor(
|
|
|
404
657
|
);
|
|
405
658
|
}
|
|
406
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Enqueue several distinct activities to the same actor without one Queue RPC
|
|
662
|
+
* per activity. Cloudflare Queues accepts at most 100 messages per sendBatch;
|
|
663
|
+
* keep the provider-neutral port bounded to that same size. Replaying a batch
|
|
664
|
+
* is safe: endpoint resolution later derives the durable delivery-job id from
|
|
665
|
+
* activity id + endpoint and upserts it idempotently.
|
|
666
|
+
*/
|
|
667
|
+
export async function enqueueDeliveriesToActor(
|
|
668
|
+
env: Env,
|
|
669
|
+
activityIds: readonly string[],
|
|
670
|
+
recipientActorApId: string,
|
|
671
|
+
): Promise<void> {
|
|
672
|
+
if (activityIds.length === 0) return;
|
|
673
|
+
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
674
|
+
const intents = activityIds.map((activityId) => ({
|
|
675
|
+
activityId,
|
|
676
|
+
recipientActorApId,
|
|
677
|
+
}));
|
|
678
|
+
if (db) {
|
|
679
|
+
try {
|
|
680
|
+
if (await isActorBlockedStrict(db, recipientActorApId)) {
|
|
681
|
+
log.info("Skipping outbound deliveries to blocked actor", {
|
|
682
|
+
event: "delivery.blocklist.actor_batch_skip",
|
|
683
|
+
actor: recipientActorApId,
|
|
684
|
+
activityCount: activityIds.length,
|
|
685
|
+
});
|
|
686
|
+
emitMetric(
|
|
687
|
+
"delivery.blocklist.actor_batch_skip",
|
|
688
|
+
activityIds.length,
|
|
689
|
+
{},
|
|
690
|
+
);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
await persistDeliveryResolutionJobs(db, intents);
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (db) await persistDeliveryResolutionJobs(db, intents);
|
|
700
|
+
if (!queueAvailable(env)) {
|
|
701
|
+
reportProducerUnavailable("enqueueDeliveriesToActor");
|
|
702
|
+
// This batched path is used by account Move after its pending edges and
|
|
703
|
+
// outbound activities have already committed. A silent no-op would make
|
|
704
|
+
// those local users follow a destination that never learned about them,
|
|
705
|
+
// with no remaining old edges from which to reconstruct delivery. Throw so
|
|
706
|
+
// the inbound dispatch stays retryable and can resume the durable activity
|
|
707
|
+
// namespace after the operator restores the producer bindings.
|
|
708
|
+
throw new Error(
|
|
709
|
+
"DELIVERY_QUEUE and DELIVERY_DLQ bindings are required for batched delivery",
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
producerUnavailableReported = false;
|
|
713
|
+
|
|
714
|
+
if (db) {
|
|
715
|
+
await enqueueDeliveryResolutionJobs(db, env.DELIVERY_QUEUE, intents);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const SEND_BATCH_SIZE = 100;
|
|
720
|
+
for (let offset = 0; offset < activityIds.length; offset += SEND_BATCH_SIZE) {
|
|
721
|
+
await env.DELIVERY_QUEUE.sendBatch(
|
|
722
|
+
activityIds.slice(offset, offset + SEND_BATCH_SIZE).map((activityId) => ({
|
|
723
|
+
body: buildResolveActorMessage(activityId, recipientActorApId),
|
|
724
|
+
})),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
407
729
|
/**
|
|
408
730
|
* Fan an activity out to a community's audience (members + community
|
|
409
731
|
* followers) instead of the author's personal follower graph. Used for
|
|
@@ -415,14 +737,27 @@ export async function enqueueFanoutToCommunity(
|
|
|
415
737
|
communityApId: string,
|
|
416
738
|
announceActivityId?: string,
|
|
417
739
|
): Promise<void> {
|
|
418
|
-
|
|
740
|
+
const body = {
|
|
419
741
|
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
420
|
-
type: "fanout_community",
|
|
742
|
+
type: "fanout_community" as const,
|
|
421
743
|
activityId,
|
|
422
744
|
communityApId,
|
|
423
745
|
...(announceActivityId ? { announceActivityId } : {}),
|
|
424
746
|
scheduledAt: nowIso(),
|
|
425
|
-
}
|
|
747
|
+
};
|
|
748
|
+
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
749
|
+
if (db) {
|
|
750
|
+
const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
|
|
751
|
+
await enqueueDeliveryFanoutJob(
|
|
752
|
+
db,
|
|
753
|
+
queue,
|
|
754
|
+
deliveryFanoutIntentFromMessage(body),
|
|
755
|
+
);
|
|
756
|
+
if (!queue) reportProducerUnavailable("enqueueFanoutToCommunity");
|
|
757
|
+
else producerUnavailableReported = false;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
await sendQueueMessage(env, body);
|
|
426
761
|
}
|
|
427
762
|
|
|
428
763
|
// ---------------------------------------------------------------------------
|
|
@@ -527,6 +862,16 @@ export async function handleDeliveryQueueBatch(
|
|
|
527
862
|
|
|
528
863
|
// Community fanout can create local inbox rows inside this consumer rather
|
|
529
864
|
// than an HTTP request. Flush the same DB-triggered outbox choke point here.
|
|
865
|
+
try {
|
|
866
|
+
await enqueuePendingDeliveryFanoutJobs(env);
|
|
867
|
+
await enqueuePendingDeliveryEndpointJobs(env);
|
|
868
|
+
await enqueuePendingDeliveryResolutionJobs(env);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
log.error("Failed to enqueue durable federation outbox", {
|
|
871
|
+
event: "delivery.outbox.enqueue_failed",
|
|
872
|
+
error,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
530
875
|
try {
|
|
531
876
|
await enqueuePendingNotificationPushJobs(env);
|
|
532
877
|
} catch (error) {
|
|
@@ -546,7 +891,11 @@ export async function handleDeliveryDlqBatch(
|
|
|
546
891
|
env: Env,
|
|
547
892
|
): Promise<void> {
|
|
548
893
|
for (const message of batch.messages) {
|
|
549
|
-
|
|
894
|
+
// The configured DLQ receives both app-built DeliveryDlqMessageV1 bodies
|
|
895
|
+
// and Cloudflare's raw original MAIN-queue bodies. The batch generic can
|
|
896
|
+
// express only one of those at a time, so narrow the runtime union from
|
|
897
|
+
// unknown through the two wire validators below.
|
|
898
|
+
const body: unknown = message.body;
|
|
550
899
|
if (!isDeliveryDlqMessageV1(body)) {
|
|
551
900
|
// Not an app-built `dlq` message. Cloudflare Queues also delivers here
|
|
552
901
|
// the RAW original body of any MAIN-queue message that exhausted its
|
|
@@ -554,8 +903,22 @@ export async function handleDeliveryDlqBatch(
|
|
|
554
903
|
// "invalid": a lost fanout/resolve drops local notifications or delivery
|
|
555
904
|
// planning, and a lost notification_push strands its durable outbox row.
|
|
556
905
|
if (isDeliveryQueueMessageV1(body)) {
|
|
557
|
-
|
|
558
|
-
|
|
906
|
+
try {
|
|
907
|
+
await handleAutoDeadLetteredMessage(env, body);
|
|
908
|
+
message.ack();
|
|
909
|
+
} catch (error) {
|
|
910
|
+
log.warn("Failed to recover auto-dead-lettered message", {
|
|
911
|
+
event: "delivery.dlq.auto_recovery_failed",
|
|
912
|
+
messageType: body.type,
|
|
913
|
+
error,
|
|
914
|
+
});
|
|
915
|
+
emitMetric("delivery.dlq.auto_recovery_failed", 1, {
|
|
916
|
+
message_type: body.type,
|
|
917
|
+
});
|
|
918
|
+
// Keep the raw DLQ message alive until its durable repair or bounded
|
|
919
|
+
// MAIN-queue redrive succeeds.
|
|
920
|
+
message.retry({ delaySeconds: 60 });
|
|
921
|
+
}
|
|
559
922
|
continue;
|
|
560
923
|
}
|
|
561
924
|
log.warn("Invalid DLQ message format, skipping", {
|
|
@@ -596,7 +959,7 @@ export async function handleDeliveryDlqBatch(
|
|
|
596
959
|
continue;
|
|
597
960
|
}
|
|
598
961
|
try {
|
|
599
|
-
await
|
|
962
|
+
await sendQueueMessageRequired(
|
|
600
963
|
env,
|
|
601
964
|
buildReconcileJobMessage(body.jobId, reconcileAttempt + 1),
|
|
602
965
|
6 * 60 * 60,
|
|
@@ -607,6 +970,14 @@ export async function handleDeliveryDlqBatch(
|
|
|
607
970
|
jobId: body.jobId,
|
|
608
971
|
error: e,
|
|
609
972
|
});
|
|
973
|
+
// The dead-letter row is already the durable evidence that delivery did
|
|
974
|
+
// not complete. ACKing its DLQ message here used to discard the only
|
|
975
|
+
// trigger that can revive it after a transient main-queue outage, leaving
|
|
976
|
+
// the ActivityPub delivery permanently dead_letter. Ask Cloudflare
|
|
977
|
+
// Queues to redeliver this exact DLQ message instead; the successful path
|
|
978
|
+
// below remains the sole ACK point.
|
|
979
|
+
message.retry({ delaySeconds: 60 });
|
|
980
|
+
continue;
|
|
610
981
|
}
|
|
611
982
|
|
|
612
983
|
message.ack();
|
|
@@ -617,7 +988,7 @@ export async function handleDeliveryDlqBatch(
|
|
|
617
988
|
* Recover / account for a MAIN-queue message that Cloudflare auto-dead-lettered
|
|
618
989
|
* (retries exhausted with the raw body). `notification_push` rows are durable,
|
|
619
990
|
* so reset the job to retry through the outbox instead of stranding it;
|
|
620
|
-
*
|
|
991
|
+
* idempotent federation work is sent back to MAIN with a bounded generation.
|
|
621
992
|
*/
|
|
622
993
|
async function handleAutoDeadLetteredMessage(
|
|
623
994
|
env: Env,
|
|
@@ -642,16 +1013,51 @@ async function handleAutoDeadLetteredMessage(
|
|
|
642
1013
|
error,
|
|
643
1014
|
});
|
|
644
1015
|
emitMetric("delivery.dlq.notification_push_recover_failed", 1, {});
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const autoDlqAttempt = body.autoDlqAttempt ?? 0;
|
|
1022
|
+
if (autoDlqAttempt >= MAX_AUTO_DLQ_REDRIVES) {
|
|
1023
|
+
if (body.type === "fanout_followers" || body.type === "fanout_community") {
|
|
1024
|
+
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
1025
|
+
if (db) {
|
|
1026
|
+
await failDeliveryFanoutJob(
|
|
1027
|
+
db,
|
|
1028
|
+
deliveryFanoutIntentFromMessage(body),
|
|
1029
|
+
`Queue delivery exhausted after ${autoDlqAttempt} automatic redrives`,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
645
1032
|
}
|
|
1033
|
+
log.error("Delivery message exhausted automatic DLQ redrives; dropping", {
|
|
1034
|
+
event: "delivery.dlq.auto_redrive_exhausted",
|
|
1035
|
+
messageType: body.type,
|
|
1036
|
+
autoDlqAttempt,
|
|
1037
|
+
});
|
|
1038
|
+
emitMetric("delivery.dlq.auto_redrive_exhausted", 1, {
|
|
1039
|
+
message_type: body.type,
|
|
1040
|
+
});
|
|
646
1041
|
return;
|
|
647
1042
|
}
|
|
648
1043
|
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
1044
|
+
// Fanout pages preserve their cursor/stage, resolve preserves its own fetch
|
|
1045
|
+
// attempt, and deliver/reconcile preserve the durable job generation. Their
|
|
1046
|
+
// handlers are idempotent (stable inbox/job keys plus CAS), so replay the
|
|
1047
|
+
// exact semantic position with only the transport-redrive generation and
|
|
1048
|
+
// schedule timestamp changed.
|
|
1049
|
+
const redriven = {
|
|
1050
|
+
...body,
|
|
1051
|
+
autoDlqAttempt: autoDlqAttempt + 1,
|
|
1052
|
+
scheduledAt: nowIso(),
|
|
1053
|
+
} as DeliveryQueueMessageV1;
|
|
1054
|
+
await sendQueueMessageRequired(env, redriven, 60);
|
|
1055
|
+
log.error("Delivery message auto-dead-lettered; bounded redrive scheduled", {
|
|
1056
|
+
event: "delivery.dlq.auto_redrive_scheduled",
|
|
654
1057
|
messageType: body.type,
|
|
1058
|
+
autoDlqAttempt: autoDlqAttempt + 1,
|
|
1059
|
+
});
|
|
1060
|
+
emitMetric("delivery.dlq.auto_redrive_scheduled", 1, {
|
|
1061
|
+
message_type: body.type,
|
|
655
1062
|
});
|
|
656
|
-
emitMetric("delivery.dlq.auto_dead_lettered", 1, { message_type: body.type });
|
|
657
1063
|
}
|