@rivet-dev/vercel-world 0.0.0-http-body-streaming.064e07d

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 +891 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +872 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2244 -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 +567 -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 +1477 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +788 -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
package/src/runtime.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { registry } from "./actors.js";
2
+
3
+ const endpoint = process.env.RIVET_ENDPOINT;
4
+ const namespace = process.env.RIVET_NAMESPACE;
5
+ const poolName = process.env.RIVET_POOL;
6
+
7
+ if (!endpoint || !namespace || !poolName) {
8
+ throw new Error(
9
+ "RIVET_ENDPOINT, RIVET_NAMESPACE, and RIVET_POOL are required",
10
+ );
11
+ }
12
+
13
+ registry.config.test = {
14
+ ...registry.config.test,
15
+ enabled: true,
16
+ sqliteBackend: "local",
17
+ };
18
+ registry.config.runtime = "native";
19
+ registry.config.startEngine = false;
20
+ registry.config.endpoint = endpoint;
21
+ registry.config.token = process.env.RIVET_TOKEN;
22
+ registry.config.namespace = namespace;
23
+ registry.config.envoy = {
24
+ ...registry.config.envoy,
25
+ poolName,
26
+ };
27
+ registry.config.noWelcome = true;
28
+
29
+ await registry.startAndWait();
@@ -0,0 +1,8 @@
1
+ import { createTestSuite } from "@workflow/world-testing";
2
+ import { describe } from "vitest";
3
+
4
+ if (process.env.RUN_WORKFLOW_CONFORMANCE === "1") {
5
+ createTestSuite("./dist/index.js");
6
+ } else {
7
+ describe.skip("@workflow/world-testing conformance", () => {});
8
+ }
@@ -0,0 +1,62 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { Ctx } from "../../src/actors/shared.ts";
3
+
4
+ /**
5
+ * A node:sqlite-backed implementation of the actor `Ctx.db` surface used by the
6
+ * extracted SQL helpers (dispatcher queue ops, event materialization, hook and
7
+ * coordinator index transitions). Lets those helpers be unit-tested without an
8
+ * engine, against real SQLite semantics (RETURNING, partial unique indexes,
9
+ * BLOB round-trips).
10
+ */
11
+ export type TestDb = Ctx["db"];
12
+
13
+ export type MigrateFn = (database: {
14
+ execute(sql: string, ...args: unknown[]): Promise<unknown>;
15
+ }) => Promise<void> | void;
16
+
17
+ export type TestCtx = Ctx & { db: TestDb; raw: DatabaseSync; close(): void };
18
+
19
+ function makeExecute(raw: DatabaseSync) {
20
+ return async (sql: string, ...args: unknown[]) => {
21
+ const stmt = raw.prepare(sql);
22
+ // node:sqlite `.all()` works for SELECT, RETURNING, and DML alike
23
+ // (returning [] when there are no result rows).
24
+ return stmt.all(...(args as never[])) as Record<string, unknown>[];
25
+ };
26
+ }
27
+
28
+ export async function makeCtx(onMigrate: MigrateFn): Promise<TestCtx> {
29
+ const raw = new DatabaseSync(":memory:");
30
+ const execute = makeExecute(raw);
31
+ await onMigrate({ execute });
32
+ let transactionTail = Promise.resolve();
33
+ const db: TestDb = {
34
+ execute,
35
+ transaction: async (callback) => {
36
+ const previous = transactionTail;
37
+ let release!: () => void;
38
+ transactionTail = new Promise<void>((resolve) => {
39
+ release = resolve;
40
+ });
41
+ await previous;
42
+ try {
43
+ raw.exec("BEGIN IMMEDIATE");
44
+ try {
45
+ const result = await callback(db);
46
+ raw.exec("COMMIT");
47
+ return result;
48
+ } catch (error) {
49
+ raw.exec("ROLLBACK");
50
+ throw error;
51
+ }
52
+ } finally {
53
+ release();
54
+ }
55
+ },
56
+ };
57
+ return {
58
+ db,
59
+ raw,
60
+ close: () => raw.close(),
61
+ };
62
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ createDispatcherVars,
3
+ startDispatchLoop,
4
+ type DispatchContext,
5
+ } from "../../src/actors/dispatcher.ts";
6
+ import { migrateWorkflowDb } from "../../src/actors/db.ts";
7
+ import { makeCtx, type TestCtx } from "./db.ts";
8
+
9
+ /**
10
+ * A deterministic stand-in for the dispatcher actor's runtime context. It wires
11
+ * the *real* drain loop (`startDispatchLoop` / `drainDispatchQueue` and the
12
+ * delivery/retry/dead-letter logic) to a node:sqlite-backed queue and a real
13
+ * `setTimeout`-driven schedule, so loop behavior is exercised faithfully without
14
+ * the `setupTest` runner's autonomous-loop provisioning flakiness.
15
+ *
16
+ * `schedule.after` mirrors the actor: it fires a `wake` that clears the
17
+ * coalescing flag and restarts the loop — exactly the production wiring.
18
+ */
19
+ export type DispatcherDriver = {
20
+ ctx: DispatchContext;
21
+ /** Simulate an actor crash + restart: drop in-flight timers and rebuild the
22
+ * context with fresh vars (latch cleared) over the SAME persisted db. */
23
+ restart(): DispatchContext;
24
+ close(): void;
25
+ };
26
+
27
+ export async function makeDispatcherDriver(
28
+ partition: string,
29
+ ): Promise<DispatcherDriver> {
30
+ const base: TestCtx = await makeCtx(migrateWorkflowDb);
31
+ let timers = new Set<ReturnType<typeof setTimeout>>();
32
+
33
+ const build = (): DispatchContext => {
34
+ const ctx = {
35
+ key: [partition],
36
+ db: base.db,
37
+ vars: createDispatcherVars(),
38
+ keepAwake: <T>(p: Promise<T>) => p,
39
+ schedule: {
40
+ after: (delayMs: number, _action: string, ...args: unknown[]) => {
41
+ const t = setTimeout(() => {
42
+ timers.delete(t);
43
+ ctx.vars.wakeArmedAt = null;
44
+ startDispatchLoop(ctx, String(args[0] ?? partition));
45
+ }, delayMs);
46
+ timers.add(t);
47
+ return Promise.resolve();
48
+ },
49
+ at: (timestampMs: number, action: string, ...args: unknown[]) =>
50
+ ctx.schedule.after(Math.max(0, timestampMs - Date.now()), action, ...args),
51
+ },
52
+ } as unknown as DispatchContext;
53
+ return ctx;
54
+ };
55
+
56
+ const driver: DispatcherDriver = {
57
+ ctx: build(),
58
+ restart() {
59
+ for (const t of timers) clearTimeout(t);
60
+ timers = new Set();
61
+ driver.ctx = build();
62
+ return driver.ctx;
63
+ },
64
+ close: () => {
65
+ for (const t of timers) clearTimeout(t);
66
+ timers.clear();
67
+ base.close();
68
+ },
69
+ };
70
+ return driver;
71
+ }
@@ -0,0 +1,161 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+
4
+ export type Delivery = {
5
+ route: string;
6
+ queueName: string | null;
7
+ messageId: string | null;
8
+ attempt: number;
9
+ body: any;
10
+ };
11
+
12
+ export type MockApp = {
13
+ url: string;
14
+ deliveries: Delivery[];
15
+ /** runId -> remaining forced 503 failures before success. */
16
+ failuresBeforeSuccess: Map<string, number>;
17
+ /** runIds that always respond 503. */
18
+ alwaysFail: Set<string>;
19
+ /** runId -> { timeoutSeconds } body to return on next success (long-step). */
20
+ timeoutResponse: Map<string, number>;
21
+ /** runIds whose success body is malformed/non-JSON. */
22
+ malformedSuccess: Set<string>;
23
+ /** Artificial delay (ms) before responding, to widen concurrency windows. */
24
+ responseDelayMs: number;
25
+ /** Peak number of simultaneously in-flight deliveries observed. */
26
+ maxConcurrent: number;
27
+ count(runId: string): number;
28
+ close(): Promise<void>;
29
+ };
30
+
31
+ function runIdOf(body: unknown): string | null {
32
+ if (body && typeof body === "object" && "runId" in body) {
33
+ return String((body as { runId: unknown }).runId);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Stands up a stand-in for the app's `.well-known/workflow/v1/{flow,step}`
40
+ * runtime endpoints with scriptable failure / long-step / malformed behavior so
41
+ * dispatcher-loop tests are deterministic. Mirrors the integration mock but is
42
+ * reusable from native (`setupTest`) actor tests.
43
+ */
44
+ export async function startMockApp(): Promise<MockApp> {
45
+ const deliveries: Delivery[] = [];
46
+ const failuresBeforeSuccess = new Map<string, number>();
47
+ const alwaysFail = new Set<string>();
48
+ const timeoutResponse = new Map<string, number>();
49
+ const malformedSuccess = new Set<string>();
50
+ const state = { responseDelayMs: 0, maxConcurrent: 0, inFlight: 0 };
51
+
52
+ const server = createServer(async (req, res) => {
53
+ const path = req.url ?? "";
54
+ if (req.method === "GET" && path.includes("__health")) {
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(JSON.stringify({ healthy: true }));
57
+ return;
58
+ }
59
+ if (req.method !== "POST" || !path.startsWith("/.well-known/workflow/v1/")) {
60
+ res.writeHead(404);
61
+ res.end("not found");
62
+ return;
63
+ }
64
+ state.inFlight++;
65
+ state.maxConcurrent = Math.max(state.maxConcurrent, state.inFlight);
66
+ const finish = (status: number, payload: string) => {
67
+ const send = () => {
68
+ state.inFlight--;
69
+ res.writeHead(status, { "content-type": "application/json" });
70
+ res.end(payload);
71
+ };
72
+ if (state.responseDelayMs > 0) setTimeout(send, state.responseDelayMs);
73
+ else send();
74
+ };
75
+ const chunks: Buffer[] = [];
76
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
77
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
78
+ deliveries.push({
79
+ route: path.split("/").at(-1)?.split("?")[0] ?? "",
80
+ queueName: req.headers["x-vqs-queue-name"]
81
+ ? String(req.headers["x-vqs-queue-name"])
82
+ : null,
83
+ messageId: req.headers["x-vqs-message-id"]
84
+ ? String(req.headers["x-vqs-message-id"])
85
+ : null,
86
+ attempt: Number(req.headers["x-vqs-message-attempt"]),
87
+ body,
88
+ });
89
+ const runId = runIdOf(body);
90
+ if (runId && alwaysFail.has(runId)) {
91
+ finish(503, JSON.stringify({ error: "forced failure" }));
92
+ return;
93
+ }
94
+ if (runId) {
95
+ const remaining = failuresBeforeSuccess.get(runId) ?? 0;
96
+ if (remaining > 0) {
97
+ failuresBeforeSuccess.set(runId, remaining - 1);
98
+ finish(503, JSON.stringify({ error: "forced retry" }));
99
+ return;
100
+ }
101
+ }
102
+ if (runId && timeoutResponse.has(runId)) {
103
+ const seconds = timeoutResponse.get(runId)!;
104
+ timeoutResponse.delete(runId);
105
+ finish(200, JSON.stringify({ timeoutSeconds: seconds }));
106
+ return;
107
+ }
108
+ if (runId && malformedSuccess.has(runId)) {
109
+ finish(200, JSON.stringify({ timeoutSeconds: "not-a-number" }));
110
+ return;
111
+ }
112
+ finish(200, JSON.stringify({ ok: true }));
113
+ });
114
+
115
+ await new Promise<void>((resolve) =>
116
+ server.listen(0, "127.0.0.1", () => resolve()),
117
+ );
118
+ const port = (server.address() as AddressInfo).port;
119
+
120
+ return {
121
+ url: `http://127.0.0.1:${port}`,
122
+ deliveries,
123
+ failuresBeforeSuccess,
124
+ alwaysFail,
125
+ timeoutResponse,
126
+ malformedSuccess,
127
+ get responseDelayMs() {
128
+ return state.responseDelayMs;
129
+ },
130
+ set responseDelayMs(value: number) {
131
+ state.responseDelayMs = value;
132
+ },
133
+ get maxConcurrent() {
134
+ return state.maxConcurrent;
135
+ },
136
+ count: (runId: string) =>
137
+ deliveries.filter((d) => runIdOf(d.body) === runId).length,
138
+ close: () =>
139
+ new Promise<void>((resolve, reject) =>
140
+ (server as Server).close((err) => (err ? reject(err) : resolve())),
141
+ ),
142
+ };
143
+ }
144
+
145
+ export function sleep(ms: number) {
146
+ return new Promise((resolve) => setTimeout(resolve, ms));
147
+ }
148
+
149
+ export async function waitFor(
150
+ label: string,
151
+ predicate: () => boolean | Promise<boolean>,
152
+ timeoutMs = 5_000,
153
+ intervalMs = 25,
154
+ ) {
155
+ const deadline = Date.now() + timeoutMs;
156
+ while (Date.now() < deadline) {
157
+ if (await predicate()) return;
158
+ await sleep(intervalMs);
159
+ }
160
+ throw new Error(`Timed out waiting for ${label}`);
161
+ }
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { afterEach, describe, expect, test } from "vitest";
3
+ import {
4
+ startDispatchLoop,
5
+ type DispatchContext,
6
+ } from "../../src/actors/dispatcher.ts";
7
+ import {
8
+ makeDispatcherDriver,
9
+ type DispatcherDriver,
10
+ } from "../helpers/dispatcher-driver.ts";
11
+ import { startMockApp, waitFor, type MockApp } from "../helpers/harness.ts";
12
+
13
+ const uid = () => randomUUID().slice(0, 8);
14
+
15
+ async function insert(
16
+ ctx: DispatchContext,
17
+ runtimeUrl: string,
18
+ runId: string,
19
+ messageId: string,
20
+ status: string,
21
+ startedAtAgoMs: number | null,
22
+ ) {
23
+ const now = Date.now();
24
+ await ctx.db.execute(
25
+ `
26
+ INSERT INTO dispatcher_queue (
27
+ message_id, queue_name, route, runtime_url, body, headers, idem_key,
28
+ status, attempt, next_at, created_at, updated_at, started_at
29
+ )
30
+ VALUES (?, ?, 'flow', ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)
31
+ `,
32
+ messageId,
33
+ `__wkf_workflow_${runId}`,
34
+ runtimeUrl,
35
+ JSON.stringify({ runId }),
36
+ status,
37
+ status === "inflight" ? 1 : 0,
38
+ now,
39
+ now,
40
+ now,
41
+ startedAtAgoMs == null ? null : now - startedAtAgoMs,
42
+ );
43
+ }
44
+
45
+ function snapshot(ctx: DispatchContext) {
46
+ return ctx.db.execute(
47
+ "SELECT message_id, status, attempt FROM dispatcher_queue ORDER BY message_id",
48
+ );
49
+ }
50
+
51
+ describe("crash/restart dispatch reconciliation", () => {
52
+ let driver: DispatcherDriver | undefined;
53
+ let app: MockApp | undefined;
54
+ afterEach(async () => {
55
+ driver?.close();
56
+ if (app) await app.close();
57
+ driver = undefined;
58
+ app = undefined;
59
+ });
60
+
61
+ test("restart reconciles stranded ready and stale-inflight rows to done, each delivered", async () => {
62
+ app = await startMockApp();
63
+ driver = await makeDispatcherDriver("wrun_crash");
64
+ const runId = `wrun_crash_${uid()}`;
65
+
66
+ // Crash signature left in the durable queue:
67
+ // - msg_ready: enqueued but the drain loop never started (crash before loop)
68
+ // - msg_stale: claimed inflight, then the process died mid-delivery
69
+ // - msg_done2: already completed before the crash (must NOT redeliver)
70
+ await insert(driver.ctx, app.url, runId, "msg_ready", "ready", null);
71
+ await insert(driver.ctx, app.url, runId, "msg_stale", "inflight", 60_000);
72
+ await insert(driver.ctx, app.url, runId, "msg_done2", "done", 60_000);
73
+
74
+ // Restart: fresh actor context (latch cleared) over the same db, then the
75
+ // onWake/self-heal equivalent kicks the drain loop.
76
+ const ctx = driver.restart();
77
+ startDispatchLoop(ctx, runId);
78
+
79
+ await waitFor(
80
+ "ready + stale reconciled to done",
81
+ async () => {
82
+ const rows = await snapshot(ctx);
83
+ const ready = rows.find((r) => r.message_id === "msg_ready");
84
+ const stale = rows.find((r) => r.message_id === "msg_stale");
85
+ return ready?.status === "done" && stale?.status === "done";
86
+ },
87
+ 8_000,
88
+ );
89
+
90
+ const rows = await snapshot(ctx);
91
+ // No row left stranded in ready/inflight.
92
+ expect(rows.every((r) => r.status === "done")).toBe(true);
93
+ // The already-done row was never redelivered.
94
+ expect(app.count(runId)).toBe(2);
95
+ expect(app.deliveries.some((d) => d.messageId === "msg_done2")).toBe(false);
96
+ });
97
+
98
+ test("restart schedules a wake for inflight work that is not stale yet", async () => {
99
+ app = await startMockApp();
100
+ driver = await makeDispatcherDriver("wrun_fresh");
101
+ const runId = `wrun_fresh_${uid()}`;
102
+ await insert(driver.ctx, app.url, runId, "msg_fresh", "inflight", 0);
103
+
104
+ const ctx = driver.restart();
105
+ startDispatchLoop(ctx, runId);
106
+ expect((await snapshot(ctx))[0]?.status).toBe("inflight");
107
+
108
+ await waitFor(
109
+ "fresh inflight row to reach its stale deadline and redeliver",
110
+ async () => (await snapshot(ctx))[0]?.status === "done",
111
+ 3_000,
112
+ );
113
+ expect(app.count(runId)).toBe(1);
114
+ });
115
+
116
+ test("repeated restarts mid-cycle never strand or duplicate a completed delivery", async () => {
117
+ app = await startMockApp();
118
+ driver = await makeDispatcherDriver("wrun_chaos");
119
+ const runId = `wrun_chaos_${uid()}`;
120
+ await insert(driver.ctx, app.url, runId, "msg_chaos", "ready", null);
121
+
122
+ // Bombard with restarts at varied points; each restart clears the latch and
123
+ // re-kicks the loop. Stale reclaim (>400ms) + the single-consumer latch must
124
+ // converge to exactly one completed delivery with the row marked done.
125
+ for (let i = 0; i < 5; i++) {
126
+ const ctx = driver.restart();
127
+ startDispatchLoop(ctx, runId);
128
+ await new Promise((r) => setTimeout(r, 120));
129
+ }
130
+ const finalCtx = driver.restart();
131
+ startDispatchLoop(finalCtx, runId);
132
+
133
+ await waitFor(
134
+ "converged to done",
135
+ async () =>
136
+ (await snapshot(finalCtx)).some((r) => r.status === "done"),
137
+ 8_000,
138
+ );
139
+ // At-least-once is the contract; with a fast healthy app it must converge to
140
+ // exactly one successful delivery (no thrash, no duplicate completion).
141
+ expect(app.count(runId)).toBe(1);
142
+ const rows = await snapshot(finalCtx);
143
+ expect(rows.filter((r) => r.status === "done")).toHaveLength(1);
144
+ });
145
+ });
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { afterEach, describe, expect, test } from "vitest";
3
+ import {
4
+ startDispatchLoop,
5
+ type DispatchContext,
6
+ } from "../../src/actors/dispatcher.ts";
7
+ import {
8
+ makeDispatcherDriver,
9
+ type DispatcherDriver,
10
+ } from "../helpers/dispatcher-driver.ts";
11
+ import { startMockApp, waitFor, type MockApp } from "../helpers/harness.ts";
12
+
13
+ const uid = () => randomUUID().slice(0, 8);
14
+
15
+ async function enqueue(
16
+ ctx: DispatchContext,
17
+ runtimeUrl: string,
18
+ runId: string,
19
+ messageId: string,
20
+ overrides: Partial<{ next_at: number; created_at: number }> = {},
21
+ ) {
22
+ const now = Date.now();
23
+ await ctx.db.execute(
24
+ `
25
+ INSERT INTO dispatcher_queue (
26
+ message_id, queue_name, route, runtime_url, body, headers, idem_key,
27
+ status, attempt, next_at, created_at, updated_at
28
+ )
29
+ VALUES (?, ?, 'flow', ?, ?, NULL, NULL, 'ready', 0, ?, ?, ?)
30
+ `,
31
+ messageId,
32
+ `__wkf_workflow_${runId}`,
33
+ runtimeUrl,
34
+ JSON.stringify({ runId }),
35
+ overrides.next_at ?? now,
36
+ overrides.created_at ?? now,
37
+ now,
38
+ );
39
+ }
40
+
41
+ function rows(ctx: DispatchContext) {
42
+ return ctx.db.execute(
43
+ "SELECT message_id, status, attempt FROM dispatcher_queue ORDER BY created_at ASC",
44
+ );
45
+ }
46
+
47
+ describe("dispatcher drain loop (deterministic driver)", () => {
48
+ let driver: DispatcherDriver | undefined;
49
+ let app: MockApp | undefined;
50
+ afterEach(async () => {
51
+ driver?.close();
52
+ if (app) await app.close();
53
+ driver = undefined;
54
+ app = undefined;
55
+ });
56
+
57
+ test("single drain loop claims each message once while deliveries overlap", async () => {
58
+ app = await startMockApp();
59
+ app.responseDelayMs = 60; // widen the window so parallel loops would overlap
60
+ driver = await makeDispatcherDriver("wrun_latch");
61
+ const runId = `wrun_latch_${uid()}`;
62
+ await enqueue(driver.ctx, app.url, runId, "msg_a", { created_at: 1 });
63
+ await enqueue(driver.ctx, app.url, runId, "msg_b", { created_at: 2 });
64
+
65
+ // Two concurrent start signals must still produce one claim loop and one
66
+ // delivery per message. HTTP deliveries intentionally overlap because a
67
+ // workflow turn can wait for a continuation enqueued behind it.
68
+ startDispatchLoop(driver.ctx, runId);
69
+ startDispatchLoop(driver.ctx, runId);
70
+
71
+ await waitFor("both delivered", () => app!.count(runId) === 2, 8_000);
72
+ expect(app.maxConcurrent).toBe(2);
73
+ });
74
+
75
+ test("delivery failures retry with backoff then succeed", async () => {
76
+ app = await startMockApp();
77
+ driver = await makeDispatcherDriver("wrun_retry");
78
+ const runId = `wrun_retry_${uid()}`;
79
+ app.failuresBeforeSuccess.set(runId, 2);
80
+ await enqueue(driver.ctx, app.url, runId, "msg_retry");
81
+ startDispatchLoop(driver.ctx, runId);
82
+ await waitFor("3 attempts", () => app!.count(runId) === 3, 8_000);
83
+ await waitFor(
84
+ "row done on attempt 3",
85
+ async () =>
86
+ (await rows(driver!.ctx)).some(
87
+ (r) => r.status === "done" && Number(r.attempt) === 3,
88
+ ),
89
+ 2_000,
90
+ );
91
+ });
92
+
93
+ test("permanently failing delivery dead-letters at the attempt cap", async () => {
94
+ app = await startMockApp();
95
+ driver = await makeDispatcherDriver("wrun_dead");
96
+ const runId = `wrun_dead_${uid()}`;
97
+ app.alwaysFail.add(runId);
98
+ await enqueue(driver.ctx, app.url, runId, "msg_dead");
99
+ startDispatchLoop(driver.ctx, runId);
100
+ await waitFor(
101
+ "failed at cap",
102
+ async () =>
103
+ (await rows(driver!.ctx)).some(
104
+ (r) => r.status === "failed" && Number(r.attempt) === 3,
105
+ ),
106
+ 8_000,
107
+ );
108
+ expect(app.count(runId)).toBe(3);
109
+ });
110
+
111
+ test("long-step {timeoutSeconds} response defers redelivery instead of acking", async () => {
112
+ app = await startMockApp();
113
+ driver = await makeDispatcherDriver("wrun_timeout");
114
+ const runId = `wrun_timeout_${uid()}`;
115
+ app.timeoutResponse.set(runId, 0); // first response carries timeoutSeconds
116
+ await enqueue(driver.ctx, app.url, runId, "msg_timeout");
117
+ startDispatchLoop(driver.ctx, runId);
118
+ // timeoutSeconds reschedules (not done); redelivered, then acks normally.
119
+ await waitFor("redelivered", () => app!.count(runId) === 2, 8_000);
120
+ await waitFor(
121
+ "eventually done",
122
+ async () => (await rows(driver!.ctx)).some((r) => r.status === "done"),
123
+ 2_000,
124
+ );
125
+ });
126
+
127
+ test("malformed timeoutSeconds is retried, not silently acked", async () => {
128
+ app = await startMockApp();
129
+ driver = await makeDispatcherDriver("wrun_malformed");
130
+ const runId = `wrun_malformed_${uid()}`;
131
+ app.malformedSuccess.add(runId);
132
+ await enqueue(driver.ctx, app.url, runId, "msg_malformed");
133
+ startDispatchLoop(driver.ctx, runId);
134
+ await waitFor(
135
+ "retried to cap then failed",
136
+ async () =>
137
+ (await rows(driver!.ctx)).some(
138
+ (r) => r.status === "failed" && Number(r.attempt) === 3,
139
+ ),
140
+ 8_000,
141
+ );
142
+ expect(app.count(runId)).toBe(3);
143
+ });
144
+ });