@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
|
@@ -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 {
|
|
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(
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
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(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
const
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 (
|
|
329
|
-
conditions.push(sql`${communityMembers.actorApId} > ${
|
|
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((
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
400
|
-
{
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
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,
|
|
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(
|
|
452
|
-
|
|
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
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
|