@rivet-dev/vercel-world 2.3.6

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 (81) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +31 -0
  3. package/dist/actors/coordinator.d.ts +114 -0
  4. package/dist/actors/coordinator.d.ts.map +1 -0
  5. package/dist/actors/coordinator.js +232 -0
  6. package/dist/actors/coordinator.js.map +1 -0
  7. package/dist/actors/db.d.ts +8 -0
  8. package/dist/actors/db.d.ts.map +1 -0
  9. package/dist/actors/db.js +282 -0
  10. package/dist/actors/db.js.map +1 -0
  11. package/dist/actors/dispatcher.d.ts +84 -0
  12. package/dist/actors/dispatcher.d.ts.map +1 -0
  13. package/dist/actors/dispatcher.js +498 -0
  14. package/dist/actors/dispatcher.js.map +1 -0
  15. package/dist/actors/hook-token.d.ts +26 -0
  16. package/dist/actors/hook-token.d.ts.map +1 -0
  17. package/dist/actors/hook-token.js +151 -0
  18. package/dist/actors/hook-token.js.map +1 -0
  19. package/dist/actors/shared.d.ts +366 -0
  20. package/dist/actors/shared.d.ts.map +1 -0
  21. package/dist/actors/shared.js +112 -0
  22. package/dist/actors/shared.js.map +1 -0
  23. package/dist/actors/streams.d.ts +26 -0
  24. package/dist/actors/streams.d.ts.map +1 -0
  25. package/dist/actors/streams.js +127 -0
  26. package/dist/actors/streams.js.map +1 -0
  27. package/dist/actors/workflow-run.d.ts +890 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +863 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2204 -0
  32. package/dist/actors.d.ts.map +1 -0
  33. package/dist/actors.js +16 -0
  34. package/dist/actors.js.map +1 -0
  35. package/dist/codec.d.ts +4 -0
  36. package/dist/codec.d.ts.map +1 -0
  37. package/dist/codec.js +27 -0
  38. package/dist/codec.js.map +1 -0
  39. package/dist/index.d.ts +843 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +553 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/runtime.d.ts +2 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +24 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/package.json +51 -0
  48. package/scripts/conformance/run.ts +278 -0
  49. package/scripts/integration/run.ts +935 -0
  50. package/src/actors/coordinator.ts +360 -0
  51. package/src/actors/db.ts +291 -0
  52. package/src/actors/dispatcher.ts +787 -0
  53. package/src/actors/hook-token.ts +239 -0
  54. package/src/actors/shared.ts +153 -0
  55. package/src/actors/streams.ts +215 -0
  56. package/src/actors/workflow-run.ts +1466 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +768 -0
  60. package/src/runtime.ts +29 -0
  61. package/tests/conformance.test.ts +8 -0
  62. package/tests/helpers/db.ts +62 -0
  63. package/tests/helpers/dispatcher-driver.ts +71 -0
  64. package/tests/helpers/harness.ts +161 -0
  65. package/tests/integration/crash-restart.test.ts +145 -0
  66. package/tests/integration/dispatcher-loop.test.ts +144 -0
  67. package/tests/integration/hook-token.test.ts +160 -0
  68. package/tests/integration/hooks.test.ts +123 -0
  69. package/tests/integration/streams.test.ts +178 -0
  70. package/tests/integration/workflow-events.test.ts +326 -0
  71. package/tests/setup.ts +10 -0
  72. package/tests/unit/codec.test.ts +73 -0
  73. package/tests/unit/coordinator-record.test.ts +177 -0
  74. package/tests/unit/db-migrations.test.ts +65 -0
  75. package/tests/unit/dispatcher-queue.test.ts +274 -0
  76. package/tests/unit/logging.test.ts +49 -0
  77. package/tests/unit/readiness.test.ts +102 -0
  78. package/tests/unit/transaction.test.ts +76 -0
  79. package/tsconfig.build.json +13 -0
  80. package/tsconfig.json +12 -0
  81. package/vitest.config.ts +32 -0
@@ -0,0 +1,787 @@
1
+ import { MessageId, QueuePayloadSchema, parseQueueName } from "@workflow/world";
2
+ import type { Ctx } from "./shared.js";
3
+
4
+ type DispatchRow = {
5
+ message_id: string;
6
+ queue_name: string;
7
+ route: "flow" | "step";
8
+ runtime_url: string;
9
+ body: string;
10
+ headers: Record<string, string>;
11
+ attempt: number;
12
+ };
13
+
14
+ type QueueSnapshotRow = {
15
+ messageId: string;
16
+ queueName: string;
17
+ route: string;
18
+ status: string;
19
+ attempt: number;
20
+ nextAt: number;
21
+ startedAt: number | null;
22
+ finishedAt: number | null;
23
+ httpStatus: number | null;
24
+ error: string | null;
25
+ };
26
+
27
+ export type DispatcherVars = {
28
+ /** Per-actor-instance latch: at most one drain loop runs at a time. */
29
+ draining: boolean;
30
+ /** Monotonic signal used to close the enqueue-vs-idle lost-wakeup window. */
31
+ requestedGeneration: number;
32
+ /** Resolves when newly committed work should wake the active drain loop. */
33
+ drainSignal: DispatchSignal;
34
+ /** Message IDs currently being delivered by this actor process. */
35
+ activeMessageIds: Set<string>;
36
+ /** Earliest currently-armed `wake` schedule (ms epoch), or null if none. */
37
+ wakeArmedAt: number | null;
38
+ /** Serializes mutating actor actions with scheduled wake delivery. */
39
+ serialTail: Promise<void>;
40
+ };
41
+
42
+ type DispatchSignal = {
43
+ promise: Promise<void>;
44
+ resolve: () => void;
45
+ };
46
+
47
+ function createDispatchSignal(): DispatchSignal {
48
+ let resolve!: () => void;
49
+ const promise = new Promise<void>((resolvePromise) => {
50
+ resolve = resolvePromise;
51
+ });
52
+ return { promise, resolve };
53
+ }
54
+
55
+ function signalDispatch(vars: DispatcherVars) {
56
+ vars.requestedGeneration++;
57
+ vars.drainSignal.resolve();
58
+ vars.drainSignal = createDispatchSignal();
59
+ }
60
+
61
+ export type DispatchContext = Ctx & {
62
+ key: readonly string[];
63
+ vars: DispatcherVars;
64
+ keepAwake<T>(promise: Promise<T>): Promise<T>;
65
+ schedule: {
66
+ after(
67
+ delayMs: number,
68
+ actionName: string,
69
+ ...args: unknown[]
70
+ ): Promise<unknown>;
71
+ at(timestampMs: number, actionName: string, ...args: unknown[]): Promise<unknown>;
72
+ };
73
+ armNextWake?: () => Promise<void>;
74
+ };
75
+
76
+ function assertPartitionKey(
77
+ c: { key: readonly string[] },
78
+ partition: string,
79
+ ) {
80
+ const actorPartition = c.key[0];
81
+ if (!actorPartition || actorPartition !== partition) {
82
+ throw new Error(
83
+ `workflowRun actor key mismatch: expected ${actorPartition ?? "<missing>"}, received ${partition}`,
84
+ );
85
+ }
86
+ }
87
+
88
+ function envInteger(name: string, fallback: number, min: number) {
89
+ const value = Number(process.env[name]);
90
+ if (!Number.isFinite(value)) return fallback;
91
+ return Math.max(min, Math.floor(value));
92
+ }
93
+
94
+ const MAX_DISPATCH_ATTEMPTS = envInteger(
95
+ "RIVET_WORLD_RIVET_MAX_DISPATCH_ATTEMPTS",
96
+ 256,
97
+ 1,
98
+ );
99
+ const RETRY_DELAY_MS = envInteger(
100
+ "RIVET_WORLD_RIVET_RETRY_DELAY_MS",
101
+ 5_000,
102
+ 0,
103
+ );
104
+ export const STALE_INFLIGHT_MS = envInteger(
105
+ "RIVET_WORLD_RIVET_STALE_INFLIGHT_MS",
106
+ 60_000,
107
+ 1,
108
+ );
109
+ const configuredDispatchTimeoutMs = Number(
110
+ process.env.RIVET_WORLD_RIVET_DISPATCH_TIMEOUT_MS,
111
+ );
112
+ const DISPATCH_TIMEOUT_MS = Number.isFinite(configuredDispatchTimeoutMs)
113
+ ? Math.max(1, Math.floor(configuredDispatchTimeoutMs))
114
+ : null;
115
+ const MAX_TIMER_MS = 2_147_483_647;
116
+ const MAX_CONCURRENT_DISPATCHES = 32;
117
+
118
+ // Field names that must never reach the log (defense-in-depth — the call sites
119
+ // already avoid passing the bearer secret, but redact by key in case a future
120
+ // call site passes headers/tokens through).
121
+ const REDACTED_LOG_KEYS = new Set([
122
+ "authorization",
123
+ "secret",
124
+ "token",
125
+ "headers",
126
+ "password",
127
+ ]);
128
+
129
+ /**
130
+ * Structured, single-line JSON logging gated behind RIVET_WORLD_RIVET_DEBUG. A
131
+ * trace id (message id, falling back to partition/run id) ties together all log
132
+ * lines for one dispatch so a run can be followed end to end. Sensitive keys are
133
+ * redacted so the `.well-known` bearer secret can never be logged.
134
+ */
135
+ export function debugLog(event: string, data?: Record<string, unknown>) {
136
+ if (process.env.RIVET_WORLD_RIVET_DEBUG !== "1") return;
137
+ const safe: Record<string, unknown> = {};
138
+ for (const [key, value] of Object.entries(data ?? {})) {
139
+ safe[key] = REDACTED_LOG_KEYS.has(key.toLowerCase()) ? "[redacted]" : value;
140
+ }
141
+ const traceId = data?.messageId ?? data?.partition ?? data?.runId;
142
+ console.error(
143
+ JSON.stringify({
144
+ scope: "@rivet-dev/vercel-world",
145
+ event,
146
+ ...(traceId == null ? {} : { traceId }),
147
+ ...safe,
148
+ }),
149
+ );
150
+ }
151
+
152
+ function normalizeRuntimeUrl(value: string) {
153
+ return value.replace(/\/+$/, "");
154
+ }
155
+
156
+ function queueRoute(queueName: string): "flow" | "step" {
157
+ return parseQueueName(queueName).kind === "workflow" ? "flow" : "step";
158
+ }
159
+
160
+ export async function claimNextDispatch(c: Ctx): Promise<DispatchRow | null> {
161
+ const now = Date.now();
162
+ const rows = await c.db.execute(
163
+ `
164
+ UPDATE dispatcher_queue
165
+ SET status = 'inflight',
166
+ attempt = attempt + 1,
167
+ started_at = ?,
168
+ finished_at = NULL,
169
+ http_status = NULL,
170
+ error = NULL,
171
+ updated_at = ?
172
+ WHERE message_id = (
173
+ SELECT message_id
174
+ FROM dispatcher_queue
175
+ WHERE status = 'ready' AND next_at <= ?
176
+ ORDER BY created_at ASC
177
+ LIMIT 1
178
+ )
179
+ RETURNING message_id, queue_name, route, runtime_url, body, headers, attempt
180
+ `,
181
+ now,
182
+ now,
183
+ now,
184
+ );
185
+ const row = rows[0];
186
+ if (!row) return null;
187
+ debugLog("dispatcher.claim", {
188
+ messageId: row.message_id,
189
+ queueName: row.queue_name,
190
+ attempt: row.attempt,
191
+ });
192
+ return {
193
+ message_id: String(row.message_id),
194
+ queue_name: String(row.queue_name),
195
+ route: row.route === "step" ? "step" : "flow",
196
+ runtime_url: String(row.runtime_url),
197
+ body: String(row.body),
198
+ headers:
199
+ row.headers == null
200
+ ? {}
201
+ : (JSON.parse(String(row.headers)) as Record<string, string>),
202
+ attempt: Number(row.attempt),
203
+ };
204
+ }
205
+
206
+ export async function reclaimStaleInflight(
207
+ c: Ctx,
208
+ locallyActiveMessageIds: ReadonlySet<string> = new Set(),
209
+ ) {
210
+ const now = Date.now();
211
+ const locallyActive = [...locallyActiveMessageIds];
212
+ const excludeLocallyActive =
213
+ locallyActive.length === 0
214
+ ? ""
215
+ : `AND message_id NOT IN (${locallyActive.map(() => "?").join(", ")})`;
216
+ // Transient reclaim: the row is going back to 'ready' for redelivery, so it
217
+ // is NOT finished — leave finished_at NULL and don't stamp a terminal error.
218
+ await c.db.execute(
219
+ `
220
+ UPDATE dispatcher_queue
221
+ SET status = 'ready',
222
+ next_at = ?,
223
+ updated_at = ?
224
+ WHERE status = 'inflight'
225
+ AND started_at IS NOT NULL
226
+ AND started_at <= ?
227
+ AND attempt < ?
228
+ ${excludeLocallyActive}
229
+ `,
230
+ now,
231
+ now,
232
+ now - STALE_INFLIGHT_MS,
233
+ MAX_DISPATCH_ATTEMPTS,
234
+ ...locallyActive,
235
+ );
236
+ await c.db.execute(
237
+ `
238
+ UPDATE dispatcher_queue
239
+ SET status = 'failed',
240
+ finished_at = ?,
241
+ error = 'stale inflight exceeded max attempts',
242
+ updated_at = ?
243
+ WHERE status = 'inflight'
244
+ AND started_at IS NOT NULL
245
+ AND started_at <= ?
246
+ AND attempt >= ?
247
+ ${excludeLocallyActive}
248
+ `,
249
+ now,
250
+ now,
251
+ now - STALE_INFLIGHT_MS,
252
+ MAX_DISPATCH_ATTEMPTS,
253
+ ...locallyActive,
254
+ );
255
+ }
256
+
257
+ export async function nextDispatchAt(c: Ctx) {
258
+ const row = (
259
+ await c.db.execute(
260
+ `
261
+ SELECT next_at
262
+ FROM dispatcher_queue
263
+ WHERE status = 'ready'
264
+ ORDER BY next_at ASC
265
+ LIMIT 1
266
+ `,
267
+ )
268
+ )[0];
269
+ return row?.next_at == null ? null : Number(row.next_at);
270
+ }
271
+
272
+ export async function nextInflightRecoveryAt(
273
+ c: Ctx,
274
+ locallyActiveMessageIds: ReadonlySet<string> = new Set(),
275
+ ) {
276
+ const locallyActive = [...locallyActiveMessageIds];
277
+ const excludeLocallyActive =
278
+ locallyActive.length === 0
279
+ ? ""
280
+ : `AND message_id NOT IN (${locallyActive.map(() => "?").join(", ")})`;
281
+ const row = (
282
+ await c.db.execute(
283
+ `
284
+ SELECT MIN(started_at) AS started_at
285
+ FROM dispatcher_queue
286
+ WHERE status = 'inflight' AND started_at IS NOT NULL
287
+ ${excludeLocallyActive}
288
+ `,
289
+ ...locallyActive,
290
+ )
291
+ )[0];
292
+ const persistedRecoveryAt = row?.started_at == null
293
+ ? null
294
+ : Number(row.started_at) + STALE_INFLIGHT_MS;
295
+ // Keep a durable recovery alarm while a request is active, but move it into
296
+ // the future after each check so a legitimate long request cannot hot-loop
297
+ // immediate actor wakes. After a crash this in-memory set disappears, and the
298
+ // next alarm reclaims the persisted inflight row at its original stale time.
299
+ const localRecoveryAt = locallyActive.length > 0
300
+ ? Date.now() + STALE_INFLIGHT_MS
301
+ : null;
302
+ return persistedRecoveryAt == null
303
+ ? localRecoveryAt
304
+ : localRecoveryAt == null
305
+ ? persistedRecoveryAt
306
+ : Math.min(persistedRecoveryAt, localRecoveryAt);
307
+ }
308
+
309
+ export async function markDispatchDone(
310
+ c: Ctx,
311
+ messageId: string,
312
+ attempt: number,
313
+ status: number,
314
+ ) {
315
+ await c.db.execute(
316
+ `
317
+ UPDATE dispatcher_queue
318
+ SET status = 'done',
319
+ finished_at = ?,
320
+ http_status = ?,
321
+ error = NULL,
322
+ updated_at = ?
323
+ WHERE message_id = ? AND status = 'inflight' AND attempt = ?
324
+ `,
325
+ Date.now(),
326
+ status,
327
+ Date.now(),
328
+ messageId,
329
+ attempt,
330
+ );
331
+ }
332
+
333
+ export async function rescheduleDispatch(
334
+ c: Ctx,
335
+ messageId: string,
336
+ attempt: number,
337
+ delayMs: number,
338
+ status: number | null,
339
+ error: string | null,
340
+ ) {
341
+ const now = Date.now();
342
+ await c.db.execute(
343
+ `
344
+ UPDATE dispatcher_queue
345
+ SET status = 'ready',
346
+ next_at = ?,
347
+ finished_at = ?,
348
+ http_status = ?,
349
+ error = ?,
350
+ updated_at = ?
351
+ WHERE message_id = ? AND status = 'inflight' AND attempt = ?
352
+ `,
353
+ now + Math.max(0, delayMs),
354
+ now,
355
+ status,
356
+ error,
357
+ now,
358
+ messageId,
359
+ attempt,
360
+ );
361
+ }
362
+
363
+ export async function failDispatch(
364
+ c: Ctx,
365
+ messageId: string,
366
+ attempt: number,
367
+ status: number | null,
368
+ error: string,
369
+ ) {
370
+ const now = Date.now();
371
+ await c.db.execute(
372
+ `
373
+ UPDATE dispatcher_queue
374
+ SET status = 'failed',
375
+ finished_at = ?,
376
+ http_status = ?,
377
+ error = ?,
378
+ updated_at = ?
379
+ WHERE message_id = ? AND status = 'inflight' AND attempt = ?
380
+ `,
381
+ now,
382
+ status,
383
+ error,
384
+ now,
385
+ messageId,
386
+ attempt,
387
+ );
388
+ }
389
+
390
+ export async function inspectQueue(c: Ctx) {
391
+ const countRows = await c.db.execute(
392
+ `
393
+ SELECT status, COUNT(*) AS count
394
+ FROM dispatcher_queue
395
+ GROUP BY status
396
+ `,
397
+ );
398
+ const rowRows = await c.db.execute(
399
+ `
400
+ SELECT message_id, queue_name, route, status, attempt, next_at,
401
+ started_at, finished_at, http_status, error
402
+ FROM dispatcher_queue
403
+ ORDER BY created_at ASC
404
+ LIMIT 100
405
+ `,
406
+ );
407
+ const counts: Record<string, number> = {};
408
+ for (const row of countRows) {
409
+ counts[String(row.status)] = Number(row.count);
410
+ }
411
+ const rows: QueueSnapshotRow[] = rowRows.map((row) => ({
412
+ messageId: String(row.message_id),
413
+ queueName: String(row.queue_name),
414
+ route: String(row.route),
415
+ status: String(row.status),
416
+ attempt: Number(row.attempt),
417
+ nextAt: Number(row.next_at),
418
+ startedAt: row.started_at == null ? null : Number(row.started_at),
419
+ finishedAt: row.finished_at == null ? null : Number(row.finished_at),
420
+ httpStatus: row.http_status == null ? null : Number(row.http_status),
421
+ error: row.error == null ? null : String(row.error),
422
+ }));
423
+ return {
424
+ counts,
425
+ rows,
426
+ maxAttempts: MAX_DISPATCH_ATTEMPTS,
427
+ retryDelayMs: RETRY_DELAY_MS,
428
+ staleInflightMs: STALE_INFLIGHT_MS,
429
+ };
430
+ }
431
+
432
+ async function deliverDispatch(
433
+ c: DispatchContext,
434
+ partition: string,
435
+ row: DispatchRow,
436
+ ) {
437
+ debugLog("dispatcher.deliver", {
438
+ messageId: row.message_id,
439
+ route: row.route,
440
+ runtimeUrl: row.runtime_url,
441
+ attempt: row.attempt,
442
+ });
443
+ const response = await fetch(
444
+ `${normalizeRuntimeUrl(row.runtime_url)}/.well-known/workflow/v1/${row.route}`,
445
+ {
446
+ method: "POST",
447
+ // Workflow handlers may legitimately hold this request for the entire
448
+ // duration of a long-running step. Keep it open by default; operators can
449
+ // opt into a transport timeout when their runtime has a known upper bound.
450
+ ...(DISPATCH_TIMEOUT_MS == null
451
+ ? {}
452
+ : { signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS) }),
453
+ duplex: "half",
454
+ headers: {
455
+ ...row.headers,
456
+ "content-type": "application/json",
457
+ "x-vqs-queue-name": row.queue_name,
458
+ "x-vqs-message-id": row.message_id,
459
+ "x-vqs-message-attempt": String(row.attempt),
460
+ },
461
+ body: row.body,
462
+ } as RequestInit,
463
+ );
464
+ const text = await response.text();
465
+ debugLog("dispatcher.response", {
466
+ messageId: row.message_id,
467
+ status: response.status,
468
+ body: text.slice(0, 500),
469
+ });
470
+ if (response.ok) {
471
+ let parsed: unknown;
472
+ let parseOk = false;
473
+ try {
474
+ parsed = JSON.parse(text);
475
+ parseOk = true;
476
+ } catch {}
477
+ if (
478
+ parseOk &&
479
+ parsed != null &&
480
+ typeof parsed === "object" &&
481
+ "timeoutSeconds" in parsed
482
+ ) {
483
+ const timeoutSeconds = Number(
484
+ (parsed as { timeoutSeconds: unknown }).timeoutSeconds,
485
+ );
486
+ if (Number.isFinite(timeoutSeconds) && timeoutSeconds >= 0) {
487
+ const delayMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
488
+ await rescheduleDispatch(
489
+ c,
490
+ row.message_id,
491
+ row.attempt,
492
+ delayMs,
493
+ response.status,
494
+ null,
495
+ );
496
+ await scheduleDispatchWake(c, partition, delayMs);
497
+ return;
498
+ }
499
+ // A `timeoutSeconds` field that isn't a valid delay is a malformed
500
+ // runtime response — retry rather than silently acking the message.
501
+ if (row.attempt >= MAX_DISPATCH_ATTEMPTS) {
502
+ await failDispatch(
503
+ c,
504
+ row.message_id,
505
+ row.attempt,
506
+ response.status,
507
+ `malformed timeoutSeconds: ${text.slice(0, 200)}`,
508
+ );
509
+ return;
510
+ }
511
+ await rescheduleDispatch(
512
+ c,
513
+ row.message_id,
514
+ row.attempt,
515
+ RETRY_DELAY_MS,
516
+ response.status,
517
+ `malformed timeoutSeconds: ${text.slice(0, 200)}`,
518
+ );
519
+ await scheduleDispatchWake(c, partition, RETRY_DELAY_MS);
520
+ return;
521
+ }
522
+ // A 2xx means the runtime accepted this delivery, but a crash before this
523
+ // durable update causes redelivery. This is intentional at-least-once
524
+ // delivery: Workflow requires the same message id across attempts for
525
+ // replay ownership recovery. It does not promise exactly-once external
526
+ // side effects; steps must use their stable step id as an idempotency key.
527
+ await markDispatchDone(c, row.message_id, row.attempt, response.status);
528
+ return;
529
+ }
530
+
531
+ if (row.attempt >= MAX_DISPATCH_ATTEMPTS) {
532
+ await failDispatch(
533
+ c,
534
+ row.message_id,
535
+ row.attempt,
536
+ response.status,
537
+ `HTTP ${response.status}: ${text}`,
538
+ );
539
+ return;
540
+ }
541
+ await rescheduleDispatch(
542
+ c,
543
+ row.message_id,
544
+ row.attempt,
545
+ RETRY_DELAY_MS,
546
+ response.status,
547
+ `HTTP ${response.status}: ${text}`,
548
+ );
549
+ await scheduleDispatchWake(c, partition, RETRY_DELAY_MS);
550
+ }
551
+
552
+ async function scheduleDispatchWake(
553
+ c: DispatchContext,
554
+ partition: string,
555
+ delayMs: number,
556
+ ) {
557
+ if (c.armNextWake) {
558
+ await c.armNextWake();
559
+ return;
560
+ }
561
+ const clampedDelayMs = Math.min(Math.max(0, delayMs), MAX_TIMER_MS);
562
+ debugLog("dispatcher.schedule", { partition, delayMs: clampedDelayMs });
563
+ if (clampedDelayMs === 0) {
564
+ startDispatchLoop(c, partition);
565
+ return;
566
+ }
567
+ // Coalesce superseded wakes: `c.schedule` has no cancel, so we avoid arming a
568
+ // redundant later-or-equal wake when one is already pending (it will re-scan
569
+ // and re-arm). This bounds schedule growth.
570
+ const target = Date.now() + clampedDelayMs;
571
+ if (c.vars.wakeArmedAt != null && c.vars.wakeArmedAt <= target) return;
572
+ await c.schedule.after(clampedDelayMs, "wake");
573
+ c.vars.wakeArmedAt = target;
574
+ }
575
+
576
+ export async function drainDispatchQueue(c: DispatchContext, partition: string) {
577
+ // NOTE: do not gate this loop on the originating action's `c.aborted`. The
578
+ // loop is launched via `c.keepAwake(...)` from whatever action (enqueue /
579
+ // wake / onWake) first observed work, but it must outlive that action: the
580
+ // action's context aborts as soon as it returns its response, which would
581
+ // otherwise strand any rows enqueued after the first delivery. `keepAwake`
582
+ // keeps the *actor* alive for the promise; genuine actor shutdown surfaces as
583
+ // a thrown db error that unwinds the loop and clears the latch.
584
+ const active = new Set<Promise<void>>();
585
+ while (true) {
586
+ if (active.size >= MAX_CONCURRENT_DISPATCHES) {
587
+ await Promise.race(active);
588
+ continue;
589
+ }
590
+ await reclaimStaleInflight(c, c.vars.activeMessageIds);
591
+ const drainSignal = c.vars.drainSignal.promise;
592
+ const row = await claimNextDispatch(c);
593
+ if (!row) {
594
+ if (active.size > 0) {
595
+ // Workflow turns can enqueue their continuations before the current HTTP
596
+ // delivery returns. Wake on either event so those continuations can run
597
+ // concurrently instead of head-of-line blocking the active turn.
598
+ await Promise.race([drainSignal, Promise.race(active)]);
599
+ continue;
600
+ }
601
+ const [nextReadyAt, nextRecoveryAt] = await Promise.all([
602
+ nextDispatchAt(c),
603
+ nextInflightRecoveryAt(c, c.vars.activeMessageIds),
604
+ ]);
605
+ const nextAt =
606
+ nextReadyAt == null
607
+ ? nextRecoveryAt
608
+ : nextRecoveryAt == null
609
+ ? nextReadyAt
610
+ : Math.min(nextReadyAt, nextRecoveryAt);
611
+ debugLog("dispatcher.loop.idle", { partition, nextAt });
612
+ if (nextAt == null) return;
613
+ if (nextAt <= Date.now()) continue;
614
+ await scheduleDispatchWake(c, partition, nextAt - Date.now());
615
+ return;
616
+ }
617
+ // A claimed row must always have a durable future wake before external I/O.
618
+ // If the process dies during fetch, that wake reclaims the expired claim.
619
+ await scheduleDispatchWake(c, partition, STALE_INFLIGHT_MS);
620
+ let delivery!: Promise<void>;
621
+ c.vars.activeMessageIds.add(row.message_id);
622
+ delivery = (async () => {
623
+ try {
624
+ await deliverDispatch(c, partition, row);
625
+ } catch (error) {
626
+ const message = error instanceof Error ? error.message : String(error);
627
+ if (row.attempt >= MAX_DISPATCH_ATTEMPTS) {
628
+ await failDispatch(c, row.message_id, row.attempt, null, message);
629
+ } else {
630
+ await rescheduleDispatch(
631
+ c,
632
+ row.message_id,
633
+ row.attempt,
634
+ RETRY_DELAY_MS,
635
+ null,
636
+ message,
637
+ );
638
+ await scheduleDispatchWake(c, partition, RETRY_DELAY_MS);
639
+ }
640
+ } finally {
641
+ active.delete(delivery);
642
+ c.vars.activeMessageIds.delete(row.message_id);
643
+ }
644
+ })();
645
+ active.add(delivery);
646
+ }
647
+ }
648
+
649
+ export function startDispatchLoop(c: DispatchContext, partition: string) {
650
+ signalDispatch(c.vars);
651
+ // Single-consumer latch (SPEC §3.3/§5): actions run concurrently per actor
652
+ // (Fact 1a), so concurrent enqueue/wake calls must not each spawn a drain
653
+ // loop — that would create competing claim loops. One loop may keep multiple
654
+ // claimed HTTP deliveries in flight because Workflow continuations can depend
655
+ // on another delivery for the same run. The
656
+ // check-and-set below has no `await` between read and write, so it is atomic
657
+ // across concurrent JS turns; losers no-op and rely on the running loop to
658
+ // pick up the rows they enqueued.
659
+ if (c.vars.draining) {
660
+ debugLog("dispatcher.start.latched", { partition });
661
+ return;
662
+ }
663
+ const launch = () => {
664
+ const generation = c.vars.requestedGeneration;
665
+ c.vars.draining = true;
666
+ debugLog("dispatcher.start", { partition, generation });
667
+ void c
668
+ .keepAwake(
669
+ (async () => {
670
+ try {
671
+ await drainDispatchQueue(c, partition);
672
+ } finally {
673
+ c.vars.draining = false;
674
+ // An enqueue can commit after the drain's final empty query but
675
+ // before the latch clears. A newer generation forces one more pass.
676
+ if (c.vars.requestedGeneration !== generation) launch();
677
+ }
678
+ })(),
679
+ )
680
+ .catch((error) => {
681
+ debugLog("dispatcher.loop.error", {
682
+ partition,
683
+ error: error instanceof Error ? error.message : String(error),
684
+ });
685
+ });
686
+ };
687
+ launch();
688
+ }
689
+
690
+ export const createDispatcherVars = (): DispatcherVars => ({
691
+ draining: false,
692
+ requestedGeneration: 0,
693
+ drainSignal: createDispatchSignal(),
694
+ activeMessageIds: new Set(),
695
+ wakeArmedAt: null,
696
+ serialTail: Promise.resolve(),
697
+ });
698
+
699
+ export async function insertDispatch(
700
+ c: Ctx,
701
+ messageId: string,
702
+ queueName: string,
703
+ runtimeUrl: string,
704
+ body: string,
705
+ headers?: Record<string, string>,
706
+ delaySeconds?: number,
707
+ idempotencyKey?: string,
708
+ ) {
709
+ MessageId.parse(messageId);
710
+ QueuePayloadSchema.parse(JSON.parse(body));
711
+ const now = Date.now();
712
+ await c.db.execute(
713
+ `INSERT OR IGNORE INTO dispatcher_queue (
714
+ message_id, queue_name, route, runtime_url, body, headers,
715
+ idem_key, status, attempt, next_at, created_at, updated_at
716
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'ready', 0, ?, ?, ?)`,
717
+ messageId,
718
+ queueName,
719
+ queueRoute(queueName),
720
+ normalizeRuntimeUrl(runtimeUrl),
721
+ body,
722
+ headers == null ? null : JSON.stringify(headers),
723
+ idempotencyKey ?? null,
724
+ now + Math.max(0, delaySeconds ?? 0) * 1000,
725
+ now,
726
+ now,
727
+ );
728
+ const stored = await c.db.execute(
729
+ `SELECT message_id FROM dispatcher_queue
730
+ WHERE message_id = ? OR (? IS NOT NULL AND idem_key = ?) LIMIT 1`,
731
+ messageId,
732
+ idempotencyKey ?? null,
733
+ idempotencyKey ?? null,
734
+ );
735
+ return { messageId: String(stored[0]?.message_id ?? messageId) };
736
+ }
737
+
738
+ export async function enqueueDispatch(
739
+ c: DispatchContext,
740
+ partition: string,
741
+ messageId: string,
742
+ queueName: string,
743
+ runtimeUrl: string,
744
+ body: string,
745
+ headers?: Record<string, string>,
746
+ delaySeconds?: number,
747
+ idempotencyKey?: string,
748
+ ) {
749
+ assertPartitionKey(c, partition);
750
+ // Pre-arm before the durable insert. If the process dies after the insert but
751
+ // before the normal drain starts, the actor wake still discovers the row.
752
+ await c.schedule.after(0, "wake");
753
+ const stored = await insertDispatch(
754
+ c,
755
+ messageId,
756
+ queueName,
757
+ runtimeUrl,
758
+ body,
759
+ headers,
760
+ delaySeconds,
761
+ idempotencyKey,
762
+ );
763
+ startDispatchLoop(c, partition);
764
+ return stored;
765
+ }
766
+
767
+ export async function forceStaleInflightForTesting(
768
+ c: DispatchContext,
769
+ partition: string,
770
+ messageId: string,
771
+ ageMs = STALE_INFLIGHT_MS + 1_000,
772
+ ) {
773
+ assertPartitionKey(c, partition);
774
+ if (process.env.RIVET_WORLD_RIVET_TESTING !== "1") {
775
+ throw new Error("forceStaleInflightForTesting requires test mode");
776
+ }
777
+ const now = Date.now();
778
+ await c.db.execute(
779
+ `UPDATE dispatcher_queue SET status = 'inflight', started_at = ?,
780
+ finished_at = NULL, updated_at = ? WHERE message_id = ?`,
781
+ now - Math.max(0, ageMs),
782
+ now,
783
+ messageId,
784
+ );
785
+ await reclaimStaleInflight(c);
786
+ startDispatchLoop(c, partition);
787
+ }