@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,160 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SPEC_VERSION_CURRENT } from "@workflow/world";
|
|
3
|
+
import { setup } from "rivetkit";
|
|
4
|
+
import { setupTest } from "rivetkit/test";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { hookToken } from "../../src/actors/hook-token.ts";
|
|
7
|
+
import { workflowRun } from "../../src/actors/workflow-run.ts";
|
|
8
|
+
|
|
9
|
+
const registry = setup({ use: { hookToken, workflowRun } });
|
|
10
|
+
|
|
11
|
+
const uid = () => randomUUID().slice(0, 8);
|
|
12
|
+
type Client = Awaited<ReturnType<typeof setupTest>>["client"];
|
|
13
|
+
|
|
14
|
+
function tokenActor(client: Client, token: string) {
|
|
15
|
+
return client.hookToken.getOrCreate([token]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("hookToken ownership generations", () => {
|
|
19
|
+
test("reserve, confirm, and release are idempotent for one owner", async (c) => {
|
|
20
|
+
const { client } = await setupTest(c, registry);
|
|
21
|
+
const token = `token-${uid()}`;
|
|
22
|
+
const owner = { runId: `wrun_${uid()}`, hookId: `hook-${uid()}` };
|
|
23
|
+
const handle = tokenActor(client, token);
|
|
24
|
+
|
|
25
|
+
const first = await handle.reserve(token, owner.runId, owner.hookId);
|
|
26
|
+
expect(first.ok).toBe(true);
|
|
27
|
+
const repeated = await handle.reserve(token, owner.runId, owner.hookId);
|
|
28
|
+
expect(repeated.generation).toBe(first.generation);
|
|
29
|
+
|
|
30
|
+
expect(
|
|
31
|
+
await handle.confirm(
|
|
32
|
+
token,
|
|
33
|
+
first.generation,
|
|
34
|
+
owner.runId,
|
|
35
|
+
owner.hookId,
|
|
36
|
+
),
|
|
37
|
+
).toEqual({ ok: true });
|
|
38
|
+
expect(
|
|
39
|
+
await handle.confirm(
|
|
40
|
+
token,
|
|
41
|
+
first.generation,
|
|
42
|
+
owner.runId,
|
|
43
|
+
owner.hookId,
|
|
44
|
+
),
|
|
45
|
+
).toEqual({ ok: true });
|
|
46
|
+
expect(await handle.get(token)).toMatchObject({
|
|
47
|
+
...owner,
|
|
48
|
+
generation: first.generation,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
await handle.release(
|
|
53
|
+
token,
|
|
54
|
+
first.generation,
|
|
55
|
+
owner.runId,
|
|
56
|
+
owner.hookId,
|
|
57
|
+
),
|
|
58
|
+
).toEqual({ ok: true });
|
|
59
|
+
expect(
|
|
60
|
+
await handle.release(
|
|
61
|
+
token,
|
|
62
|
+
first.generation,
|
|
63
|
+
owner.runId,
|
|
64
|
+
owner.hookId,
|
|
65
|
+
),
|
|
66
|
+
).toEqual({ ok: true });
|
|
67
|
+
expect(await handle.get(token)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("operations from an older generation cannot change a replacement owner", async (c) => {
|
|
71
|
+
const { client } = await setupTest(c, registry);
|
|
72
|
+
const token = `token-${uid()}`;
|
|
73
|
+
const handle = tokenActor(client, token);
|
|
74
|
+
const oldOwner = { runId: `wrun_${uid()}`, hookId: `hook-${uid()}` };
|
|
75
|
+
const newOwner = { runId: `wrun_${uid()}`, hookId: `hook-${uid()}` };
|
|
76
|
+
|
|
77
|
+
const oldReservation = await handle.reserve(
|
|
78
|
+
token,
|
|
79
|
+
oldOwner.runId,
|
|
80
|
+
oldOwner.hookId,
|
|
81
|
+
);
|
|
82
|
+
await handle.confirm(
|
|
83
|
+
token,
|
|
84
|
+
oldReservation.generation,
|
|
85
|
+
oldOwner.runId,
|
|
86
|
+
oldOwner.hookId,
|
|
87
|
+
);
|
|
88
|
+
await handle.release(
|
|
89
|
+
token,
|
|
90
|
+
oldReservation.generation,
|
|
91
|
+
oldOwner.runId,
|
|
92
|
+
oldOwner.hookId,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const replacement = await handle.reserve(
|
|
96
|
+
token,
|
|
97
|
+
newOwner.runId,
|
|
98
|
+
newOwner.hookId,
|
|
99
|
+
);
|
|
100
|
+
expect(replacement.generation).toBeGreaterThan(oldReservation.generation);
|
|
101
|
+
await handle.confirm(
|
|
102
|
+
token,
|
|
103
|
+
replacement.generation,
|
|
104
|
+
newOwner.runId,
|
|
105
|
+
newOwner.hookId,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
await handle.confirm(
|
|
110
|
+
token,
|
|
111
|
+
oldReservation.generation,
|
|
112
|
+
oldOwner.runId,
|
|
113
|
+
oldOwner.hookId,
|
|
114
|
+
),
|
|
115
|
+
).toEqual({ ok: false });
|
|
116
|
+
expect(
|
|
117
|
+
await handle.release(
|
|
118
|
+
token,
|
|
119
|
+
oldReservation.generation,
|
|
120
|
+
oldOwner.runId,
|
|
121
|
+
oldOwner.hookId,
|
|
122
|
+
),
|
|
123
|
+
).toEqual({ ok: false });
|
|
124
|
+
expect(await handle.get(token)).toMatchObject(newOwner);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("pending lookup verifies canonical workflow ownership before exposure", async (c) => {
|
|
128
|
+
const { client } = await setupTest(c, registry);
|
|
129
|
+
const token = `token-${uid()}`;
|
|
130
|
+
const runId = `wrun_${uid()}`;
|
|
131
|
+
const hookId = `hook-${uid()}`;
|
|
132
|
+
const handle = tokenActor(client, token);
|
|
133
|
+
|
|
134
|
+
const reservation = await handle.reserve(token, runId, hookId);
|
|
135
|
+
expect(await handle.get(token)).toBeNull();
|
|
136
|
+
|
|
137
|
+
const workflow = client.workflowRun.getOrCreate([runId]);
|
|
138
|
+
await workflow.appendEvent(runId, {
|
|
139
|
+
eventType: "run_created",
|
|
140
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
141
|
+
eventData: {
|
|
142
|
+
deploymentId: "rivet",
|
|
143
|
+
workflowName: "pending-hook-verification",
|
|
144
|
+
input: {},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
await workflow.appendEvent(runId, {
|
|
148
|
+
eventType: "hook_created",
|
|
149
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
150
|
+
correlationId: hookId,
|
|
151
|
+
eventData: { token, metadata: {} },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(await handle.get(token)).toMatchObject({
|
|
155
|
+
runId,
|
|
156
|
+
hookId,
|
|
157
|
+
generation: reservation.generation,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setupTest } from "rivetkit/test";
|
|
3
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { SPEC_VERSION_CURRENT } from "@workflow/world";
|
|
5
|
+
import { registry } from "../../src/actors.ts";
|
|
6
|
+
import { RivetClientWorld } from "../../src/index.ts";
|
|
7
|
+
|
|
8
|
+
const uid = () => randomUUID().slice(0, 8);
|
|
9
|
+
|
|
10
|
+
async function createRun(world: RivetClientWorld, name: string) {
|
|
11
|
+
const created = await world.events.create(null, {
|
|
12
|
+
eventType: "run_created",
|
|
13
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
14
|
+
eventData: { deploymentId: "rivet", workflowName: name, input: {} },
|
|
15
|
+
});
|
|
16
|
+
const runId = created.run?.runId;
|
|
17
|
+
if (!runId) throw new Error("expected run id");
|
|
18
|
+
return runId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("hook token reserve/append orphan (P0 #3)", () => {
|
|
22
|
+
let world: RivetClientWorld | undefined;
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
if (world) await world.close();
|
|
25
|
+
world = undefined;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("releases a reserved token when the hook_created append fails", async (c) => {
|
|
29
|
+
const { client } = await setupTest(c, registry);
|
|
30
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
31
|
+
const token = `token-${uid()}`;
|
|
32
|
+
const runA = await createRun(world, "orphanProbeA");
|
|
33
|
+
const runB = await createRun(world, "orphanProbeB");
|
|
34
|
+
|
|
35
|
+
// Drive runA terminal first: a hook_created against a terminal run reserves
|
|
36
|
+
// the token (World step 1) but then throws EntityConflictError inside the
|
|
37
|
+
// append (workflow-run step 2) — a real failure path that strands the
|
|
38
|
+
// reservation unless events.create releases it on failure.
|
|
39
|
+
await world.events.create(runA, {
|
|
40
|
+
eventType: "run_completed",
|
|
41
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
42
|
+
eventData: { output: { ok: true } },
|
|
43
|
+
});
|
|
44
|
+
await expect(
|
|
45
|
+
world.events.create(runA, {
|
|
46
|
+
eventType: "hook_created",
|
|
47
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
48
|
+
correlationId: `hook-${uid()}`,
|
|
49
|
+
eventData: { token, metadata: {} },
|
|
50
|
+
}),
|
|
51
|
+
).rejects.toThrow();
|
|
52
|
+
|
|
53
|
+
// The reservation must have been rolled back, so a different run can now
|
|
54
|
+
// claim the same token without hitting a (stale) conflict.
|
|
55
|
+
const second = await world.events.create(runB, {
|
|
56
|
+
eventType: "hook_created",
|
|
57
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
58
|
+
correlationId: `hook-${uid()}`,
|
|
59
|
+
eventData: { token, metadata: {} },
|
|
60
|
+
});
|
|
61
|
+
expect(second.event?.eventType).toBe("hook_created");
|
|
62
|
+
expect(second.hook?.runId).toBe(runB);
|
|
63
|
+
expect(second.hook?.token).toBe(token);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a genuine concurrent token claim still surfaces as hook_conflict", async (c) => {
|
|
67
|
+
const { client } = await setupTest(c, registry);
|
|
68
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
69
|
+
const token = `token-${uid()}`;
|
|
70
|
+
const runA = await createRun(world, "conflictProbeA");
|
|
71
|
+
const runB = await createRun(world, "conflictProbeB");
|
|
72
|
+
|
|
73
|
+
const first = await world.events.create(runA, {
|
|
74
|
+
eventType: "hook_created",
|
|
75
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
76
|
+
correlationId: `hook-${uid()}`,
|
|
77
|
+
eventData: { token, metadata: {} },
|
|
78
|
+
});
|
|
79
|
+
expect(first.hook?.runId).toBe(runA);
|
|
80
|
+
|
|
81
|
+
const conflict = await world.events.create(runB, {
|
|
82
|
+
eventType: "hook_created",
|
|
83
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
84
|
+
correlationId: `hook-${uid()}`,
|
|
85
|
+
eventData: { token, metadata: {} },
|
|
86
|
+
});
|
|
87
|
+
expect(conflict.event?.eventType).toBe("hook_conflict");
|
|
88
|
+
expect(conflict.event?.eventData?.conflictingRunId).toBe(runA);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a duplicate hook id cannot confirm a different token", async (c) => {
|
|
92
|
+
const { client } = await setupTest(c, registry);
|
|
93
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
94
|
+
const runA = await createRun(world, "duplicateHookA");
|
|
95
|
+
const runB = await createRun(world, "duplicateHookB");
|
|
96
|
+
const hookId = `hook-${uid()}`;
|
|
97
|
+
const firstToken = `token-${uid()}`;
|
|
98
|
+
const secondToken = `token-${uid()}`;
|
|
99
|
+
|
|
100
|
+
await world.events.create(runA, {
|
|
101
|
+
eventType: "hook_created",
|
|
102
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
103
|
+
correlationId: hookId,
|
|
104
|
+
eventData: { token: firstToken },
|
|
105
|
+
});
|
|
106
|
+
await expect(
|
|
107
|
+
world.events.create(runA, {
|
|
108
|
+
eventType: "hook_created",
|
|
109
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
110
|
+
correlationId: hookId,
|
|
111
|
+
eventData: { token: secondToken },
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toThrow("different token");
|
|
114
|
+
|
|
115
|
+
const created = await world.events.create(runB, {
|
|
116
|
+
eventType: "hook_created",
|
|
117
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
118
|
+
correlationId: `hook-${uid()}`,
|
|
119
|
+
eventData: { token: secondToken },
|
|
120
|
+
});
|
|
121
|
+
expect(created.hook?.token).toBe(secondToken);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setupTest } from "rivetkit/test";
|
|
3
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { registry } from "../../src/actors.ts";
|
|
5
|
+
import { RivetClientWorld } from "../../src/index.ts";
|
|
6
|
+
|
|
7
|
+
const uid = () => randomUUID().slice(0, 8);
|
|
8
|
+
const ENV = "RIVET_WORLD_RIVET_INJECT_BROADCAST_DROP_RATE";
|
|
9
|
+
const READ_DELAY = "RIVET_WORLD_RIVET_INJECT_STREAM_READ_DELAY_MS";
|
|
10
|
+
|
|
11
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
|
|
13
|
+
async function readN(
|
|
14
|
+
stream: ReadableStream<Uint8Array>,
|
|
15
|
+
n: number,
|
|
16
|
+
timeoutMs = 8_000,
|
|
17
|
+
) {
|
|
18
|
+
const reader = stream.getReader();
|
|
19
|
+
const out: string[] = [];
|
|
20
|
+
const deadline = Date.now() + timeoutMs;
|
|
21
|
+
while (out.length < n) {
|
|
22
|
+
const remaining = deadline - Date.now();
|
|
23
|
+
if (remaining <= 0) {
|
|
24
|
+
void reader.cancel();
|
|
25
|
+
throw new Error(`readN timed out after ${out.length}/${n} chunks`);
|
|
26
|
+
}
|
|
27
|
+
const result = await Promise.race([
|
|
28
|
+
reader.read(),
|
|
29
|
+
new Promise<{ timeout: true }>((r) =>
|
|
30
|
+
setTimeout(() => r({ timeout: true }), remaining),
|
|
31
|
+
),
|
|
32
|
+
]);
|
|
33
|
+
if ("timeout" in result) {
|
|
34
|
+
void reader.cancel();
|
|
35
|
+
throw new Error(`readN timed out after ${out.length}/${n} chunks`);
|
|
36
|
+
}
|
|
37
|
+
if (result.done) break;
|
|
38
|
+
out.push(new TextDecoder().decode(result.value));
|
|
39
|
+
}
|
|
40
|
+
void reader.cancel();
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readAll(stream: ReadableStream<Uint8Array>, timeoutMs = 8_000) {
|
|
45
|
+
const reader = stream.getReader();
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
const deadline = Date.now() + timeoutMs;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const remaining = deadline - Date.now();
|
|
50
|
+
if (remaining <= 0) throw new Error("readAll timed out");
|
|
51
|
+
const result = await Promise.race([
|
|
52
|
+
reader.read(),
|
|
53
|
+
new Promise<{ timeout: true }>((r) =>
|
|
54
|
+
setTimeout(() => r({ timeout: true }), remaining),
|
|
55
|
+
),
|
|
56
|
+
]);
|
|
57
|
+
if ("timeout" in result) throw new Error("readAll timed out");
|
|
58
|
+
if (result.done) return out;
|
|
59
|
+
out.push(new TextDecoder().decode(result.value));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("live stream broadcast-loss resilience", () => {
|
|
64
|
+
let world: RivetClientWorld | undefined;
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
delete process.env[ENV];
|
|
67
|
+
delete process.env[READ_DELAY];
|
|
68
|
+
if (world) await world.close();
|
|
69
|
+
world = undefined;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("re-drains a chunk that lands mid-drain on a still-open stream (no signal lost)", async (c) => {
|
|
73
|
+
const { client } = await setupTest(c, registry);
|
|
74
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
75
|
+
const name = `stream-race-${uid()}`;
|
|
76
|
+
const runId = `wrun_stream_${uid()}`;
|
|
77
|
+
|
|
78
|
+
// Hold each read open 150ms so a write can land *during* the in-flight
|
|
79
|
+
// drain. Its broadcast arrives while draining; without the pending re-drain
|
|
80
|
+
// guard the signal is coalesced away and the chunk is stranded (the stream
|
|
81
|
+
// is never closed, so nothing else recovers it).
|
|
82
|
+
process.env[READ_DELAY] = "150";
|
|
83
|
+
await world.writeToStream(name, runId, "c0");
|
|
84
|
+
const live = await world.readFromStream(runId, name, 0);
|
|
85
|
+
await sleep(50); // initial drain is mid-flight (in the 150ms read window)
|
|
86
|
+
await world.writeToStream(name, runId, "c1");
|
|
87
|
+
|
|
88
|
+
expect(await readN(live, 2)).toEqual(["c0", "c1"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("recovers every chunk via re-drain when ALL append broadcasts are dropped", async (c) => {
|
|
92
|
+
const { client } = await setupTest(c, registry);
|
|
93
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
94
|
+
const name = `stream-dropped-${uid()}`;
|
|
95
|
+
const runId = `wrun_stream_${uid()}`;
|
|
96
|
+
|
|
97
|
+
// Drop 100% of append broadcasts; only the EOF (close) broadcast survives,
|
|
98
|
+
// and its re-drain must catch up all chunks whose signals were lost.
|
|
99
|
+
process.env[ENV] = "1";
|
|
100
|
+
const live = await world.readFromStream(runId, name, 0);
|
|
101
|
+
const expected = ["a", "b", "c", "d", "e"];
|
|
102
|
+
for (const chunk of expected) {
|
|
103
|
+
await world.writeToStream(name, runId, chunk);
|
|
104
|
+
}
|
|
105
|
+
await world.closeStream(name, runId);
|
|
106
|
+
|
|
107
|
+
const received = await readAll(live);
|
|
108
|
+
expect(received).toEqual(expected);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("recovers all chunks under partial broadcast loss with no gaps or dupes", async (c) => {
|
|
112
|
+
const { client } = await setupTest(c, registry);
|
|
113
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
114
|
+
const name = `stream-partial-${uid()}`;
|
|
115
|
+
const runId = `wrun_stream_${uid()}`;
|
|
116
|
+
|
|
117
|
+
process.env[ENV] = "0.5"; // drop every other append broadcast
|
|
118
|
+
const live = await world.readFromStream(runId, name, 0);
|
|
119
|
+
const expected = Array.from({ length: 8 }, (_, i) => `chunk-${i}`);
|
|
120
|
+
for (const chunk of expected) {
|
|
121
|
+
await world.writeToStream(name, runId, chunk);
|
|
122
|
+
}
|
|
123
|
+
await world.closeStream(name, runId);
|
|
124
|
+
|
|
125
|
+
const received = await readAll(live);
|
|
126
|
+
expect(received).toEqual(expected);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("delivers chunks written before the reader connects (initial drain)", async (c) => {
|
|
130
|
+
const { client } = await setupTest(c, registry);
|
|
131
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
132
|
+
const name = `stream-preexisting-${uid()}`;
|
|
133
|
+
const runId = `wrun_stream_${uid()}`;
|
|
134
|
+
|
|
135
|
+
await world.writeToStream(name, runId, "first");
|
|
136
|
+
await world.writeToStream(name, runId, "second");
|
|
137
|
+
const live = await world.readFromStream(runId, name, 0);
|
|
138
|
+
await world.closeStream(name, runId);
|
|
139
|
+
|
|
140
|
+
expect(await readAll(live)).toEqual(["first", "second"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("catches up after the reader actor connection opens", async (c) => {
|
|
144
|
+
const { client } = await setupTest(c, registry);
|
|
145
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
146
|
+
const name = `stream-opening-${uid()}`;
|
|
147
|
+
const runId = `wrun_stream_${uid()}`;
|
|
148
|
+
|
|
149
|
+
// Drop the append broadcast to model a write that lands while the actor
|
|
150
|
+
// connection is still opening. The on-open drain must recover the chunk.
|
|
151
|
+
process.env[ENV] = "1";
|
|
152
|
+
const live = await world.readFromStream(runId, name, 0);
|
|
153
|
+
await world.writeToStream(name, runId, "during-open");
|
|
154
|
+
process.env[ENV] = "0";
|
|
155
|
+
await world.closeStream(name, runId);
|
|
156
|
+
|
|
157
|
+
expect(await readAll(live)).toEqual(["during-open"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("isolates the same stream name between workflow runs", async (c) => {
|
|
161
|
+
const { client } = await setupTest(c, registry);
|
|
162
|
+
world = new RivetClientWorld({ client, runtimeUrl: "http://127.0.0.1:1" });
|
|
163
|
+
const name = `shared-${uid()}`;
|
|
164
|
+
const firstRun = `wrun_stream_${uid()}`;
|
|
165
|
+
const secondRun = `wrun_stream_${uid()}`;
|
|
166
|
+
await world.writeToStream(name, firstRun, "first");
|
|
167
|
+
await world.closeStream(name, firstRun);
|
|
168
|
+
await world.writeToStream(name, secondRun, "second");
|
|
169
|
+
await world.closeStream(name, secondRun);
|
|
170
|
+
|
|
171
|
+
expect(await readAll(await world.readFromStream(firstRun, name))).toEqual([
|
|
172
|
+
"first",
|
|
173
|
+
]);
|
|
174
|
+
expect(await readAll(await world.readFromStream(secondRun, name))).toEqual([
|
|
175
|
+
"second",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
});
|