@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.
- package/.turbo/turbo-build.log +4 -0
- package/README.md +31 -0
- package/dist/actors/coordinator.d.ts +114 -0
- package/dist/actors/coordinator.d.ts.map +1 -0
- package/dist/actors/coordinator.js +232 -0
- package/dist/actors/coordinator.js.map +1 -0
- package/dist/actors/db.d.ts +8 -0
- package/dist/actors/db.d.ts.map +1 -0
- package/dist/actors/db.js +282 -0
- package/dist/actors/db.js.map +1 -0
- package/dist/actors/dispatcher.d.ts +84 -0
- package/dist/actors/dispatcher.d.ts.map +1 -0
- package/dist/actors/dispatcher.js +498 -0
- package/dist/actors/dispatcher.js.map +1 -0
- package/dist/actors/hook-token.d.ts +26 -0
- package/dist/actors/hook-token.d.ts.map +1 -0
- package/dist/actors/hook-token.js +151 -0
- package/dist/actors/hook-token.js.map +1 -0
- package/dist/actors/shared.d.ts +366 -0
- package/dist/actors/shared.d.ts.map +1 -0
- package/dist/actors/shared.js +112 -0
- package/dist/actors/shared.js.map +1 -0
- package/dist/actors/streams.d.ts +26 -0
- package/dist/actors/streams.d.ts.map +1 -0
- package/dist/actors/streams.js +127 -0
- package/dist/actors/streams.js.map +1 -0
- package/dist/actors/workflow-run.d.ts +890 -0
- package/dist/actors/workflow-run.d.ts.map +1 -0
- package/dist/actors/workflow-run.js +863 -0
- package/dist/actors/workflow-run.js.map +1 -0
- package/dist/actors.d.ts +2204 -0
- package/dist/actors.d.ts.map +1 -0
- package/dist/actors.js +16 -0
- package/dist/actors.js.map +1 -0
- package/dist/codec.d.ts +4 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +27 -0
- package/dist/codec.js.map +1 -0
- package/dist/index.d.ts +843 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +553 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +24 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +51 -0
- package/scripts/conformance/run.ts +278 -0
- package/scripts/integration/run.ts +935 -0
- package/src/actors/coordinator.ts +360 -0
- package/src/actors/db.ts +291 -0
- package/src/actors/dispatcher.ts +787 -0
- package/src/actors/hook-token.ts +239 -0
- package/src/actors/shared.ts +153 -0
- package/src/actors/streams.ts +215 -0
- package/src/actors/workflow-run.ts +1466 -0
- package/src/actors.ts +18 -0
- package/src/codec.ts +28 -0
- package/src/index.ts +768 -0
- package/src/runtime.ts +29 -0
- package/tests/conformance.test.ts +8 -0
- package/tests/helpers/db.ts +62 -0
- package/tests/helpers/dispatcher-driver.ts +71 -0
- package/tests/helpers/harness.ts +161 -0
- package/tests/integration/crash-restart.test.ts +145 -0
- package/tests/integration/dispatcher-loop.test.ts +144 -0
- package/tests/integration/hook-token.test.ts +160 -0
- package/tests/integration/hooks.test.ts +123 -0
- package/tests/integration/streams.test.ts +178 -0
- package/tests/integration/workflow-events.test.ts +326 -0
- package/tests/setup.ts +10 -0
- package/tests/unit/codec.test.ts +73 -0
- package/tests/unit/coordinator-record.test.ts +177 -0
- package/tests/unit/db-migrations.test.ts +65 -0
- package/tests/unit/dispatcher-queue.test.ts +274 -0
- package/tests/unit/logging.test.ts +49 -0
- package/tests/unit/readiness.test.ts +102 -0
- package/tests/unit/transaction.test.ts +76 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +12 -0
- 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
|
+
}
|