@ewhauser/world-celld 0.2.0 → 0.3.1
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/CHANGELOG.md +25 -0
- package/README.md +47 -19
- package/celld-worker/README.md +5 -3
- package/celld-worker/worker.ts +10 -1
- package/celld-worker/wrangler.jsonc +15 -4
- package/dist/index.d.ts +163 -60
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +524 -197
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +47 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1874 -582
- package/dist/testing.js.map +1 -1
- package/dist/util-DmO0XkFw.js +545 -0
- package/dist/util-DmO0XkFw.js.map +1 -0
- package/dist/worker.d.ts +223 -111
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +1960 -551
- package/dist/worker.js.map +1 -1
- package/package.json +7 -5
- package/dist/retention-bLY1vcOu.js +0 -164
- package/dist/retention-bLY1vcOu.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as
|
|
2
|
-
import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, parseQueueName } from "@workflow/world";
|
|
1
|
+
import { S as normalizeStreamError, a as stringify, b as encodeStreamWriteBatch, c as MAX_STREAM_BATCH_BYTES, d as MAX_STREAM_LONG_POLL_MS, f as MAX_STREAM_READ_BYTES, g as decodeStreamReadResult, h as STREAM_BATCH_CONTENT_TYPE, i as parse, l as MAX_STREAM_CHUNK_BYTES, m as MAX_STREAM_WRITE_CHUNKS, n as rpcParse, o as createWorkflowIndex, p as MAX_STREAM_READ_CHUNKS, r as rpcStringify, t as compact, u as MAX_STREAM_ERROR_BYTES, v as decodeStreamWriteResult } from "./util-DmO0XkFw.js";
|
|
2
|
+
import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, isTerminalWorkflowRunStatus, parseQueueName } from "@workflow/world";
|
|
3
3
|
import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
|
|
4
4
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
5
5
|
import { monotonicFactory } from "ulid";
|
|
@@ -8,6 +8,13 @@ function resolveConfig(config) {
|
|
|
8
8
|
const retentionRaw = config?.runRetentionMs ?? process.env.CELLD_RUN_RETENTION_MS ?? 0;
|
|
9
9
|
const runRetentionMs = typeof retentionRaw === "number" ? retentionRaw : Number.parseInt(retentionRaw, 10);
|
|
10
10
|
if (!Number.isSafeInteger(runRetentionMs) || runRetentionMs < 0) throw new Error("world-celld: runRetentionMs must be a non-negative safe integer");
|
|
11
|
+
const rpcTimeoutMs = config?.rpcTimeoutMs ?? 3e4;
|
|
12
|
+
if (!Number.isSafeInteger(rpcTimeoutMs) || rpcTimeoutMs < 1) throw new Error("world-celld: rpcTimeoutMs must be a positive safe integer");
|
|
13
|
+
const streamLongPollMs = config?.streamLongPollMs ?? Math.min(2e4, Math.max(1, rpcTimeoutMs - 1e3));
|
|
14
|
+
if (!Number.isSafeInteger(streamLongPollMs) || streamLongPollMs < 1 || streamLongPollMs > 2e4) throw new Error(`world-celld: streamLongPollMs must be between 1 and ${MAX_STREAM_LONG_POLL_MS}`);
|
|
15
|
+
if (streamLongPollMs >= rpcTimeoutMs) throw new Error("world-celld: streamLongPollMs must be less than rpcTimeoutMs");
|
|
16
|
+
const streamFlushIntervalMs = config?.streamFlushIntervalMs ?? 0;
|
|
17
|
+
if (!Number.isSafeInteger(streamFlushIntervalMs) || streamFlushIntervalMs < 0) throw new Error("world-celld: streamFlushIntervalMs must be a non-negative safe integer");
|
|
11
18
|
return {
|
|
12
19
|
fleetUrl: config?.fleetUrl ?? process.env.CELLD_FLEET_URL,
|
|
13
20
|
secret: config?.secret ?? process.env.CELLD_WORLD_SECRET,
|
|
@@ -16,8 +23,9 @@ function resolveConfig(config) {
|
|
|
16
23
|
baseUrl: config?.baseUrl,
|
|
17
24
|
queueShards: config?.queueShards ?? 1,
|
|
18
25
|
runRetentionMs,
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
streamLongPollMs,
|
|
27
|
+
streamFlushIntervalMs,
|
|
28
|
+
rpcTimeoutMs
|
|
21
29
|
};
|
|
22
30
|
}
|
|
23
31
|
//#endregion
|
|
@@ -46,13 +54,13 @@ var FleetTransportError = class extends Error {
|
|
|
46
54
|
//#endregion
|
|
47
55
|
//#region src/remote/rpc-client.ts
|
|
48
56
|
/** Statuses that mean "the fleet couldn't take the request" (safe to retry reads). */
|
|
49
|
-
const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
57
|
+
const RETRYABLE_STATUSES$1 = /* @__PURE__ */ new Set([
|
|
50
58
|
502,
|
|
51
59
|
503,
|
|
52
60
|
504
|
|
53
61
|
]);
|
|
54
|
-
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
55
|
-
function delayMs(ms) {
|
|
62
|
+
const DEFAULT_TIMEOUT_MS$1 = 3e4;
|
|
63
|
+
function delayMs$1(ms) {
|
|
56
64
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
65
|
}
|
|
58
66
|
/**
|
|
@@ -64,12 +72,16 @@ function delayMs(ms) {
|
|
|
64
72
|
* via queue redelivery. Read-only calls retry on transport failures.
|
|
65
73
|
*/
|
|
66
74
|
async function callDO(transport, binding, name, method, args, opts) {
|
|
75
|
+
return await callFleetRoute(transport, `/v1/rpc/${binding}/${encodeURIComponent(name)}/${method}`, args, opts);
|
|
76
|
+
}
|
|
77
|
+
/** Invoke a fixed authenticated worker route using the generic RPC codec. */
|
|
78
|
+
async function callFleetRoute(transport, path, args, opts) {
|
|
67
79
|
const doFetch = transport.fetchImpl ?? fetch;
|
|
68
|
-
const url = `${transport.fleetUrl.replace(/\/$/, "")}
|
|
80
|
+
const url = `${transport.fleetUrl.replace(/\/$/, "")}${path}`;
|
|
69
81
|
const attempts = opts?.idempotent ? 3 : 1;
|
|
70
82
|
let lastError;
|
|
71
83
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
72
|
-
if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
|
|
84
|
+
if (attempt > 1) await delayMs$1(100 * attempt + Math.random() * 200);
|
|
73
85
|
let response;
|
|
74
86
|
try {
|
|
75
87
|
response = await doFetch(url, {
|
|
@@ -79,7 +91,7 @@ async function callDO(transport, binding, name, method, args, opts) {
|
|
|
79
91
|
authorization: `Bearer ${transport.secret}`
|
|
80
92
|
},
|
|
81
93
|
body: rpcStringify(args),
|
|
82
|
-
signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS))
|
|
94
|
+
signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS$1))
|
|
83
95
|
});
|
|
84
96
|
} catch (error) {
|
|
85
97
|
lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url}`, error);
|
|
@@ -99,7 +111,7 @@ async function callDO(transport, binding, name, method, args, opts) {
|
|
|
99
111
|
} catch {
|
|
100
112
|
wire = { message: text || `HTTP ${response.status}` };
|
|
101
113
|
}
|
|
102
|
-
if (RETRYABLE_STATUSES.has(response.status) && attempt < attempts) {
|
|
114
|
+
if (RETRYABLE_STATUSES$1.has(response.status) && attempt < attempts) {
|
|
103
115
|
lastError = reconstructError(wire, response.status);
|
|
104
116
|
continue;
|
|
105
117
|
}
|
|
@@ -108,6 +120,140 @@ async function callDO(transport, binding, name, method, args, opts) {
|
|
|
108
120
|
throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: rpc failed", lastError);
|
|
109
121
|
}
|
|
110
122
|
//#endregion
|
|
123
|
+
//#region src/remote/stream-client.ts
|
|
124
|
+
const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
125
|
+
502,
|
|
126
|
+
503,
|
|
127
|
+
504
|
|
128
|
+
]);
|
|
129
|
+
const READ_ATTEMPTS = 3;
|
|
130
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
131
|
+
const MAX_WRITE_RESPONSE_BYTES = 64;
|
|
132
|
+
const MAX_READ_RESPONSE_BYTES = MAX_STREAM_READ_BYTES + 65536;
|
|
133
|
+
const MAX_ERROR_RESPONSE_BYTES = 65536;
|
|
134
|
+
function delayMs(ms) {
|
|
135
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
136
|
+
}
|
|
137
|
+
function requestSignal(timeoutMs, signal) {
|
|
138
|
+
const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
|
|
139
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
140
|
+
}
|
|
141
|
+
function aborted(signal) {
|
|
142
|
+
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
143
|
+
}
|
|
144
|
+
async function readBoundedResponse(response, maxBytes) {
|
|
145
|
+
if (Number(response.headers.get("content-length") ?? "0") > maxBytes) {
|
|
146
|
+
await response.body?.cancel("stream response exceeds configured limit").catch(() => void 0);
|
|
147
|
+
throw new FleetTransportError(`world-celld: stream response exceeds ${maxBytes} bytes`);
|
|
148
|
+
}
|
|
149
|
+
if (!response.body) return /* @__PURE__ */ new Uint8Array();
|
|
150
|
+
const reader = response.body.getReader();
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let total = 0;
|
|
153
|
+
try {
|
|
154
|
+
for (;;) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
total += value.byteLength;
|
|
158
|
+
if (total > maxBytes) {
|
|
159
|
+
await reader.cancel("stream response exceeds configured limit").catch(() => void 0);
|
|
160
|
+
throw new FleetTransportError(`world-celld: stream response exceeds ${maxBytes} bytes`);
|
|
161
|
+
}
|
|
162
|
+
chunks.push(value);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
reader.releaseLock();
|
|
166
|
+
}
|
|
167
|
+
const body = new Uint8Array(total);
|
|
168
|
+
let offset = 0;
|
|
169
|
+
for (const chunk of chunks) {
|
|
170
|
+
body.set(chunk, offset);
|
|
171
|
+
offset += chunk.byteLength;
|
|
172
|
+
}
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
async function errorFromResponse(response) {
|
|
176
|
+
const text = new TextDecoder().decode(await readBoundedResponse(response, MAX_ERROR_RESPONSE_BYTES));
|
|
177
|
+
let wire;
|
|
178
|
+
try {
|
|
179
|
+
wire = JSON.parse(text).error;
|
|
180
|
+
} catch {
|
|
181
|
+
wire = { message: text || `HTTP ${response.status}` };
|
|
182
|
+
}
|
|
183
|
+
return reconstructError(wire, response.status);
|
|
184
|
+
}
|
|
185
|
+
function streamUrl(transport, name) {
|
|
186
|
+
return `${transport.fleetUrl.replace(/\/$/, "")}/v1/streams/${encodeURIComponent(name)}/chunks`;
|
|
187
|
+
}
|
|
188
|
+
async function writeStreamChunks(transport, name, runId, chunks) {
|
|
189
|
+
const body = encodeStreamWriteBatch(chunks);
|
|
190
|
+
if (body.byteLength > 8389632) throw new Error("world-celld: encoded stream batch exceeds configured limit");
|
|
191
|
+
const url = new URL(streamUrl(transport, name));
|
|
192
|
+
url.searchParams.set("runId", runId);
|
|
193
|
+
const doFetch = transport.fetchImpl ?? fetch;
|
|
194
|
+
let response;
|
|
195
|
+
try {
|
|
196
|
+
response = await doFetch(url, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
"content-type": STREAM_BATCH_CONTENT_TYPE,
|
|
200
|
+
authorization: `Bearer ${transport.secret}`
|
|
201
|
+
},
|
|
202
|
+
body,
|
|
203
|
+
signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok) throw await errorFromResponse(response);
|
|
209
|
+
try {
|
|
210
|
+
return decodeStreamWriteResult(await readBoundedResponse(response, MAX_WRITE_RESPONSE_BYTES));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof FleetTransportError) throw error;
|
|
213
|
+
throw new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function readStreamChunks(transport, name, request, signal) {
|
|
217
|
+
const url = new URL(streamUrl(transport, name));
|
|
218
|
+
url.searchParams.set("runId", request.runId);
|
|
219
|
+
url.searchParams.set("startIndex", String(request.startIndex));
|
|
220
|
+
url.searchParams.set("maxChunks", String(request.maxChunks));
|
|
221
|
+
url.searchParams.set("maxBytes", String(request.maxBytes));
|
|
222
|
+
url.searchParams.set("waitMs", String(request.waitMs));
|
|
223
|
+
const doFetch = transport.fetchImpl ?? fetch;
|
|
224
|
+
let lastError;
|
|
225
|
+
for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
|
|
226
|
+
if (signal?.aborted) throw aborted(signal);
|
|
227
|
+
if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
|
|
228
|
+
let response;
|
|
229
|
+
try {
|
|
230
|
+
response = await doFetch(url, {
|
|
231
|
+
method: "GET",
|
|
232
|
+
headers: { authorization: `Bearer ${transport.secret}` },
|
|
233
|
+
signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal)
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (signal?.aborted) throw aborted(signal);
|
|
237
|
+
lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (response.ok) try {
|
|
241
|
+
return decodeStreamReadResult(await readBoundedResponse(response, MAX_READ_RESPONSE_BYTES));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
lastError = new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
|
|
244
|
+
if (attempt < READ_ATTEMPTS) continue;
|
|
245
|
+
throw lastError;
|
|
246
|
+
}
|
|
247
|
+
const error = await errorFromResponse(response);
|
|
248
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < READ_ATTEMPTS) {
|
|
249
|
+
lastError = error;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: stream read failed", lastError);
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
111
257
|
//#region src/remote/namespaces.ts
|
|
112
258
|
const RUNS = {
|
|
113
259
|
methods: [
|
|
@@ -121,7 +267,8 @@ const RUNS = {
|
|
|
121
267
|
"getCleanupStatus",
|
|
122
268
|
"scheduleCleanup",
|
|
123
269
|
"cleanupNow",
|
|
124
|
-
"rearmCleanup"
|
|
270
|
+
"rearmCleanup",
|
|
271
|
+
"resolveHookTokenClaim"
|
|
125
272
|
],
|
|
126
273
|
mutating: /* @__PURE__ */ new Set([
|
|
127
274
|
"applyEvent",
|
|
@@ -132,10 +279,8 @@ const RUNS = {
|
|
|
132
279
|
};
|
|
133
280
|
const STREAMS = {
|
|
134
281
|
methods: [
|
|
135
|
-
"writeChunk",
|
|
136
282
|
"closeStream",
|
|
137
|
-
"
|
|
138
|
-
"getInfo",
|
|
283
|
+
"failStream",
|
|
139
284
|
"registerStream",
|
|
140
285
|
"listStreams",
|
|
141
286
|
"expireRegistry",
|
|
@@ -143,14 +288,28 @@ const STREAMS = {
|
|
|
143
288
|
"expireStream"
|
|
144
289
|
],
|
|
145
290
|
mutating: /* @__PURE__ */ new Set([
|
|
146
|
-
"writeChunk",
|
|
147
291
|
"closeStream",
|
|
292
|
+
"failStream",
|
|
148
293
|
"registerStream",
|
|
149
294
|
"expireRegistry",
|
|
150
295
|
"finalizeRegistry",
|
|
151
296
|
"expireStream"
|
|
152
297
|
])
|
|
153
298
|
};
|
|
299
|
+
function makeStreamNamespace(transport) {
|
|
300
|
+
return {
|
|
301
|
+
idFromName(name) {
|
|
302
|
+
return { toString: () => name };
|
|
303
|
+
},
|
|
304
|
+
get(id) {
|
|
305
|
+
const name = id.toString();
|
|
306
|
+
const control = makeStub(transport, "streams", name, STREAMS);
|
|
307
|
+
control.writeChunks = (runId, chunks) => writeStreamChunks(transport, name, runId, chunks);
|
|
308
|
+
control.readChunks = (request, signal) => readStreamChunks(transport, name, request, signal);
|
|
309
|
+
return control;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
154
313
|
const QUEUE = {
|
|
155
314
|
methods: [
|
|
156
315
|
"enqueue",
|
|
@@ -184,41 +343,76 @@ function makeNamespace(transport, binding, spec) {
|
|
|
184
343
|
}
|
|
185
344
|
};
|
|
186
345
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
346
|
+
const RUN_CATALOG = {
|
|
347
|
+
methods: [
|
|
348
|
+
"upsertRun",
|
|
349
|
+
"list",
|
|
350
|
+
"expireRun"
|
|
351
|
+
],
|
|
352
|
+
mutating: /* @__PURE__ */ new Set()
|
|
353
|
+
};
|
|
354
|
+
const RUN_FENCES = {
|
|
355
|
+
methods: [
|
|
356
|
+
"getStatus",
|
|
357
|
+
"fenceTerminal",
|
|
358
|
+
"fenceExpired"
|
|
359
|
+
],
|
|
360
|
+
mutating: /* @__PURE__ */ new Set()
|
|
361
|
+
};
|
|
362
|
+
const HOOK_TOKENS = {
|
|
363
|
+
methods: [
|
|
364
|
+
"get",
|
|
365
|
+
"reserve",
|
|
366
|
+
"finalize",
|
|
367
|
+
"releaseClaim",
|
|
368
|
+
"releaseBatch"
|
|
369
|
+
],
|
|
370
|
+
mutating: /* @__PURE__ */ new Set()
|
|
371
|
+
};
|
|
372
|
+
const HOOK_IDS = {
|
|
373
|
+
methods: [
|
|
374
|
+
"get",
|
|
375
|
+
"reserve",
|
|
376
|
+
"publish",
|
|
377
|
+
"releaseClaim",
|
|
378
|
+
"releaseBatch"
|
|
379
|
+
],
|
|
380
|
+
mutating: /* @__PURE__ */ new Set()
|
|
381
|
+
};
|
|
217
382
|
function createRemoteEnv(transport) {
|
|
383
|
+
const runCatalog = makeNamespace(transport, "run-catalog", RUN_CATALOG);
|
|
384
|
+
const runFences = makeNamespace(transport, "run-fences", RUN_FENCES);
|
|
385
|
+
const hookTokens = makeNamespace(transport, "hook-tokens", HOOK_TOKENS);
|
|
386
|
+
const hookIds = makeNamespace(transport, "hook-ids", HOOK_IDS);
|
|
387
|
+
const workflowIndex = createWorkflowIndex({
|
|
388
|
+
runCatalog,
|
|
389
|
+
runFences,
|
|
390
|
+
hookTokens,
|
|
391
|
+
hookIds
|
|
392
|
+
});
|
|
218
393
|
return {
|
|
219
394
|
WORKFLOW_DB: makeNamespace(transport, "runs", RUNS),
|
|
220
|
-
WORKFLOW_STREAMS:
|
|
221
|
-
WORKFLOW_INDEX:
|
|
395
|
+
WORKFLOW_STREAMS: makeStreamNamespace(transport),
|
|
396
|
+
WORKFLOW_INDEX: {
|
|
397
|
+
...workflowIndex,
|
|
398
|
+
commitRun: (run, serializedMetadata) => callFleetRoute(transport, "/v1/index/runs/commit", [run, serializedMetadata], { idempotent: true }),
|
|
399
|
+
listRuns: (options) => callFleetRoute(transport, "/v1/index/runs/list", [options], { idempotent: true }),
|
|
400
|
+
expireRun: (request) => callFleetRoute(transport, "/v1/index/runs/expire", [request], { idempotent: true }),
|
|
401
|
+
reserveHook: (token, owner) => callFleetRoute(transport, "/v1/index/hooks/reserve", [token, owner], { idempotent: true }),
|
|
402
|
+
finalizeHookIndexes: (token, hookId, serializedHook, owner, reservation) => callFleetRoute(transport, "/v1/index/hooks/finalize", [
|
|
403
|
+
token,
|
|
404
|
+
hookId,
|
|
405
|
+
serializedHook,
|
|
406
|
+
owner,
|
|
407
|
+
reservation
|
|
408
|
+
], { idempotent: true }),
|
|
409
|
+
releaseHookReservation: (token, owner, reservation) => callFleetRoute(transport, "/v1/index/hooks/release-reservation", [
|
|
410
|
+
token,
|
|
411
|
+
owner,
|
|
412
|
+
reservation
|
|
413
|
+
], { idempotent: true }),
|
|
414
|
+
releaseHookIndexes: (request) => callFleetRoute(transport, "/v1/index/hooks/release", [request], { idempotent: true })
|
|
415
|
+
},
|
|
222
416
|
WORKFLOW_QUEUE: makeNamespace(transport, "queue", QUEUE)
|
|
223
417
|
};
|
|
224
418
|
}
|
|
@@ -294,10 +488,7 @@ function isPermanentError(err) {
|
|
|
294
488
|
function computeBackoff(attempt) {
|
|
295
489
|
return Math.min(60, 2 ** attempt);
|
|
296
490
|
}
|
|
297
|
-
const
|
|
298
|
-
workflow: "flow",
|
|
299
|
-
step: "step"
|
|
300
|
-
};
|
|
491
|
+
const QUEUE_PATHNAME = "flow";
|
|
301
492
|
function isTestMode() {
|
|
302
493
|
if (process.env.CELLD_QUEUE_MODE === "cells") return false;
|
|
303
494
|
return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
|
|
@@ -321,9 +512,8 @@ function shardFor(key, shards) {
|
|
|
321
512
|
return (hash >>> 0) % shards;
|
|
322
513
|
}
|
|
323
514
|
/**
|
|
324
|
-
* In-process test pump. Holds
|
|
325
|
-
*
|
|
326
|
-
* `${baseUrl}/.well-known/workflow/v1/{flow|step}`.
|
|
515
|
+
* In-process test pump. Holds an in-memory FIFO and HTTP-dispatches envelopes
|
|
516
|
+
* to the user's server at `${baseUrl}/.well-known/workflow/v1/flow`.
|
|
327
517
|
*
|
|
328
518
|
* Mirrors world-local's idempotency semantics: messages are deduplicated on
|
|
329
519
|
* `idempotencyKey` while a message with the same key is in flight, and the
|
|
@@ -337,14 +527,8 @@ function createTestPump(config) {
|
|
|
337
527
|
const httpTimeoutMs = config.httpTimeoutMs ?? 3e5;
|
|
338
528
|
const maxAttempts = config.maxAttempts ?? 5;
|
|
339
529
|
const baseBackoffMs = config.backoffDelayMs ?? 1e3;
|
|
340
|
-
const queues = {
|
|
341
|
-
|
|
342
|
-
step: []
|
|
343
|
-
};
|
|
344
|
-
const wakers = {
|
|
345
|
-
flow: [],
|
|
346
|
-
step: []
|
|
347
|
-
};
|
|
530
|
+
const queues = { flow: [] };
|
|
531
|
+
const wakers = { flow: [] };
|
|
348
532
|
/** Inflight messageIds by idempotencyKey (world-local queue.js semantics). */
|
|
349
533
|
const inflightMessages = /* @__PURE__ */ new Map();
|
|
350
534
|
let running = false;
|
|
@@ -376,6 +560,9 @@ function createTestPump(config) {
|
|
|
376
560
|
signal: AbortSignal.timeout(httpTimeoutMs)
|
|
377
561
|
});
|
|
378
562
|
if (response.ok) {
|
|
563
|
+
try {
|
|
564
|
+
await response.body?.cancel();
|
|
565
|
+
} catch {}
|
|
379
566
|
release(envelope);
|
|
380
567
|
return;
|
|
381
568
|
}
|
|
@@ -437,7 +624,6 @@ function createTestPump(config) {
|
|
|
437
624
|
if (running) return;
|
|
438
625
|
running = true;
|
|
439
626
|
loop("flow");
|
|
440
|
-
loop("step");
|
|
441
627
|
},
|
|
442
628
|
stop() {
|
|
443
629
|
running = false;
|
|
@@ -456,13 +642,13 @@ function createQueue(config) {
|
|
|
456
642
|
};
|
|
457
643
|
return {
|
|
458
644
|
async queue(queueName, message, opts) {
|
|
459
|
-
|
|
460
|
-
const runId = "runId" in message
|
|
645
|
+
parseQueueName(queueName);
|
|
646
|
+
const runId = "runId" in message && typeof message.runId === "string" ? message.runId : void 0;
|
|
461
647
|
if (isTestMode()) {
|
|
462
648
|
const existing = testPump.inflight(opts?.idempotencyKey);
|
|
463
649
|
if (existing) return { messageId: existing };
|
|
464
650
|
const messageId = MessageId.parse(`msg_${generateMessageId()}`);
|
|
465
|
-
testPump.push(
|
|
651
|
+
testPump.push(QUEUE_PATHNAME, {
|
|
466
652
|
messageId,
|
|
467
653
|
queueName,
|
|
468
654
|
attempt: 1,
|
|
@@ -476,7 +662,7 @@ function createQueue(config) {
|
|
|
476
662
|
const outcome = await getQueueStub(shard).enqueue({
|
|
477
663
|
messageId,
|
|
478
664
|
queueName,
|
|
479
|
-
pathname:
|
|
665
|
+
pathname: QUEUE_PATHNAME,
|
|
480
666
|
body: stringify(message),
|
|
481
667
|
runId,
|
|
482
668
|
idempotencyKey: opts?.idempotencyKey,
|
|
@@ -510,7 +696,7 @@ function createQueue(config) {
|
|
|
510
696
|
status: 503,
|
|
511
697
|
headers: { "Retry-After": String(result.timeoutSeconds) }
|
|
512
698
|
});
|
|
513
|
-
return Response
|
|
699
|
+
return new Response(null, { status: 204 });
|
|
514
700
|
} catch (error) {
|
|
515
701
|
if (isPermanentError(error)) {
|
|
516
702
|
debug("[world-celld queue handler] permanent error:", {
|
|
@@ -589,11 +775,12 @@ function throwOutcomeError(outcome, runId, data) {
|
|
|
589
775
|
case "RUN_NOT_FOUND": throw new WorkflowRunNotFoundError(runId);
|
|
590
776
|
case "STEP_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
|
|
591
777
|
case "HOOK_NOT_FOUND": throw new HookNotFoundError(data.correlationId ?? runId);
|
|
778
|
+
case "WAIT_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
|
|
592
779
|
case "ENTITY_CONFLICT": throw new EntityConflictError(outcome.message);
|
|
780
|
+
case "HOOK_CLAIM_CANCELLED": throw new WorkflowWorldError(outcome.message, { status: 503 });
|
|
593
781
|
case "RUN_EXPIRED": throw new RunExpiredError(outcome.message);
|
|
594
782
|
case "TOO_EARLY": throw new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds });
|
|
595
783
|
case "RUN_NOT_SUPPORTED": throw new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT);
|
|
596
|
-
case "LEGACY_RUN_NOT_SUPPORTED": throw new WorkflowWorldError(outcome.message, { status: 422 });
|
|
597
784
|
}
|
|
598
785
|
}
|
|
599
786
|
const parseRun = (run) => WorkflowRunSchema.parse(compact(run));
|
|
@@ -604,6 +791,19 @@ function unwrapRead(outcome) {
|
|
|
604
791
|
if (!outcome.ok) throw new RunExpiredError(outcome.message);
|
|
605
792
|
return outcome.value;
|
|
606
793
|
}
|
|
794
|
+
/** Bound cross-run fanout so large list pages cannot create an RPC burst. */
|
|
795
|
+
const RUN_LIST_CONCURRENCY = 8;
|
|
796
|
+
/**
|
|
797
|
+
* Run statuses only move pending -> running -> terminal, and terminal statuses
|
|
798
|
+
* are immutable. An older index value may therefore safely exclude a filter
|
|
799
|
+
* only when it is already terminal, or when the caller asks for pending and
|
|
800
|
+
* the index has advanced beyond pending. Earlier non-terminal metadata cannot
|
|
801
|
+
* exclude a later status because a post-commit index write may need replay.
|
|
802
|
+
*/
|
|
803
|
+
function indexStatusExcludes(indexed, requested) {
|
|
804
|
+
if (requested === void 0 || indexed === requested) return false;
|
|
805
|
+
return isTerminalWorkflowRunStatus(indexed) || requested === "pending";
|
|
806
|
+
}
|
|
607
807
|
function createStorage(config) {
|
|
608
808
|
const { env } = config;
|
|
609
809
|
const cleanup = {
|
|
@@ -631,7 +831,7 @@ function createStorage(config) {
|
|
|
631
831
|
let scanCursor = params?.pagination?.cursor;
|
|
632
832
|
let exhausted = false;
|
|
633
833
|
while (matches.length <= limit && !exhausted) {
|
|
634
|
-
const kvList = await env.WORKFLOW_INDEX.
|
|
834
|
+
const kvList = await env.WORKFLOW_INDEX.listRuns({
|
|
635
835
|
prefix,
|
|
636
836
|
limit: Math.min(1e3, Math.max(50, limit)),
|
|
637
837
|
cursor: scanCursor,
|
|
@@ -641,22 +841,32 @@ function createStorage(config) {
|
|
|
641
841
|
exhausted = true;
|
|
642
842
|
break;
|
|
643
843
|
}
|
|
844
|
+
const candidates = [];
|
|
644
845
|
for (const key of kvList.keys) {
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
846
|
+
const metadata = JSON.parse(key.value);
|
|
847
|
+
if (indexStatusExcludes(metadata.status, params?.status)) continue;
|
|
848
|
+
candidates.push({
|
|
849
|
+
key: key.name,
|
|
850
|
+
metadata
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
for (let offset = 0; offset < candidates.length && matches.length <= limit;) {
|
|
854
|
+
const needed = limit + 1 - matches.length;
|
|
855
|
+
const batch = candidates.slice(offset, offset + Math.min(RUN_LIST_CONCURRENCY, needed));
|
|
856
|
+
const resolved = await Promise.all(batch.map(async ({ key, metadata }) => {
|
|
857
|
+
try {
|
|
858
|
+
const run = await runsGet(metadata.runId, { resolveData: params?.resolveData });
|
|
859
|
+
return !params?.status || run.status === params.status ? {
|
|
860
|
+
key,
|
|
653
861
|
run
|
|
654
|
-
}
|
|
655
|
-
|
|
862
|
+
} : null;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
if (!WorkflowRunNotFoundError.is(error) && !RunExpiredError.is(error)) throw error;
|
|
865
|
+
return null;
|
|
656
866
|
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
|
|
867
|
+
}));
|
|
868
|
+
for (const match of resolved) if (match) matches.push(match);
|
|
869
|
+
offset += batch.length;
|
|
660
870
|
}
|
|
661
871
|
exhausted = kvList.list_complete;
|
|
662
872
|
scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
|
|
@@ -671,36 +881,57 @@ function createStorage(config) {
|
|
|
671
881
|
}
|
|
672
882
|
},
|
|
673
883
|
events: {
|
|
674
|
-
async create(runId, data,
|
|
884
|
+
async create(runId, data, params) {
|
|
675
885
|
let effectiveRunId;
|
|
676
886
|
if (data.eventType === "run_created" && (!runId || runId === "")) effectiveRunId = `wrun_${ulid()}`;
|
|
677
887
|
else if (!runId) throw new WorkflowWorldError("runId is required for non-run_created events", { status: 400 });
|
|
678
888
|
else effectiveRunId = runId;
|
|
679
889
|
const stub = getRunDO(effectiveRunId);
|
|
680
890
|
let tokenHolder;
|
|
681
|
-
let
|
|
891
|
+
let hookAdmission;
|
|
682
892
|
if (data.eventType === "hook_created") {
|
|
683
|
-
|
|
893
|
+
const owner = {
|
|
684
894
|
runId: effectiveRunId,
|
|
685
895
|
hookId: data.correlationId
|
|
686
896
|
};
|
|
687
|
-
const
|
|
688
|
-
|
|
897
|
+
const admission = await env.WORKFLOW_INDEX.reserveHook(data.eventData.token, owner);
|
|
898
|
+
if (admission.admitted) {
|
|
899
|
+
tokenHolder = null;
|
|
900
|
+
hookAdmission = {
|
|
901
|
+
owner,
|
|
902
|
+
reservation: admission.reservation,
|
|
903
|
+
token: data.eventData.token
|
|
904
|
+
};
|
|
905
|
+
} else tokenHolder = admission.holder;
|
|
689
906
|
}
|
|
690
907
|
let outcome;
|
|
691
908
|
try {
|
|
692
909
|
outcome = await stub.applyEvent({
|
|
693
910
|
runId: effectiveRunId,
|
|
694
911
|
data,
|
|
912
|
+
params,
|
|
695
913
|
tokenHolder,
|
|
914
|
+
hookClaimId: hookAdmission?.reservation.claimId,
|
|
696
915
|
cleanup
|
|
697
916
|
});
|
|
698
917
|
} catch (error) {
|
|
699
|
-
if (
|
|
918
|
+
if (hookAdmission) {
|
|
919
|
+
let resolution;
|
|
920
|
+
try {
|
|
921
|
+
resolution = await stub.resolveHookTokenClaim({
|
|
922
|
+
hookId: hookAdmission.owner.hookId,
|
|
923
|
+
token: hookAdmission.token,
|
|
924
|
+
claimId: hookAdmission.reservation.claimId
|
|
925
|
+
});
|
|
926
|
+
} catch {
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
if (!resolution.committed) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
|
|
930
|
+
}
|
|
700
931
|
throw error;
|
|
701
932
|
}
|
|
702
933
|
if (!outcome.ok) {
|
|
703
|
-
if (
|
|
934
|
+
if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
|
|
704
935
|
throwOutcomeError(outcome, effectiveRunId, data);
|
|
705
936
|
}
|
|
706
937
|
if (outcome.run) {
|
|
@@ -709,30 +940,32 @@ function createStorage(config) {
|
|
|
709
940
|
createdAt: outcome.run.createdAt.toISOString(),
|
|
710
941
|
status: outcome.run.status
|
|
711
942
|
});
|
|
712
|
-
await env.WORKFLOW_INDEX.
|
|
713
|
-
await env.WORKFLOW_INDEX.putOwned(effectiveRunId, globalRunIndexKey(outcome.run), meta);
|
|
943
|
+
await env.WORKFLOW_INDEX.commitRun(outcome.run, meta);
|
|
714
944
|
}
|
|
715
945
|
if (outcome.hookToIndex) {
|
|
716
946
|
const serialized = stringify(outcome.hookToIndex);
|
|
717
|
-
await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex));
|
|
718
|
-
} else if (
|
|
719
|
-
|
|
947
|
+
await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex), hookAdmission?.reservation);
|
|
948
|
+
} else if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
|
|
949
|
+
const terminal = Boolean(outcome.run && isTerminalWorkflowRunStatus(outcome.run.status));
|
|
950
|
+
if (outcome.releasedHooks.length > 0) await env.WORKFLOW_INDEX.releaseHookIndexes({
|
|
720
951
|
runId: effectiveRunId,
|
|
721
|
-
|
|
952
|
+
hooks: outcome.releasedHooks,
|
|
953
|
+
terminal
|
|
722
954
|
});
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}));
|
|
729
|
-
}
|
|
955
|
+
const eventPage = outcome.events === void 0 ? {} : {
|
|
956
|
+
events: outcome.events,
|
|
957
|
+
cursor: outcome.cursor ?? null,
|
|
958
|
+
hasMore: outcome.hasMore ?? false
|
|
959
|
+
};
|
|
730
960
|
return {
|
|
731
961
|
event: outcome.event,
|
|
732
962
|
run: outcome.run,
|
|
733
963
|
step: outcome.step,
|
|
734
964
|
hook: outcome.hook,
|
|
735
|
-
|
|
965
|
+
wait: outcome.wait,
|
|
966
|
+
stepCreated: outcome.stepCreated,
|
|
967
|
+
maxEvents: outcome.maxEvents,
|
|
968
|
+
...eventPage
|
|
736
969
|
};
|
|
737
970
|
},
|
|
738
971
|
async get(runId, eventId, _params) {
|
|
@@ -756,23 +989,30 @@ function createStorage(config) {
|
|
|
756
989
|
},
|
|
757
990
|
async listByCorrelationId(params) {
|
|
758
991
|
const limit = params.pagination?.limit ?? 100;
|
|
759
|
-
const
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
992
|
+
const stub = getRunDO(params.runId);
|
|
993
|
+
const matches = [];
|
|
994
|
+
let scanCursor = params.pagination?.cursor;
|
|
995
|
+
let exhausted = false;
|
|
996
|
+
while (matches.length <= limit && !exhausted) {
|
|
997
|
+
const page = unwrapRead(await stub.listEvents({
|
|
998
|
+
limit: Math.min(1e3, Math.max(50, limit + 1)),
|
|
999
|
+
cursor: scanCursor,
|
|
1000
|
+
sortOrder: params.pagination?.sortOrder ?? "asc"
|
|
1001
|
+
}));
|
|
1002
|
+
for (const event of page.data) if (event.correlationId === params.correlationId) {
|
|
1003
|
+
matches.push(event);
|
|
1004
|
+
if (matches.length > limit) break;
|
|
1005
|
+
}
|
|
1006
|
+
exhausted = !page.hasMore;
|
|
1007
|
+
scanCursor = page.cursor ?? page.data.at(-1)?.eventId;
|
|
1008
|
+
if (page.data.length === 0) exhausted = true;
|
|
1009
|
+
}
|
|
1010
|
+
const hasMore = matches.length > limit;
|
|
1011
|
+
const data = matches.slice(0, limit);
|
|
766
1012
|
return {
|
|
767
|
-
data:
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const { runId, eventId } = JSON.parse(raw);
|
|
771
|
-
const outcome = await getRunDO(runId).getEvent(eventId);
|
|
772
|
-
return outcome.ok ? outcome.value : null;
|
|
773
|
-
}))).filter((event) => event !== null).map(parseEvent),
|
|
774
|
-
cursor: listed.list_complete ? null : listed.cursor ?? null,
|
|
775
|
-
hasMore: !listed.list_complete
|
|
1013
|
+
data: data.map(parseEvent),
|
|
1014
|
+
cursor: hasMore ? data.at(-1)?.eventId ?? null : null,
|
|
1015
|
+
hasMore
|
|
776
1016
|
};
|
|
777
1017
|
}
|
|
778
1018
|
},
|
|
@@ -800,12 +1040,12 @@ function createStorage(config) {
|
|
|
800
1040
|
},
|
|
801
1041
|
hooks: {
|
|
802
1042
|
async get(hookId, params) {
|
|
803
|
-
const raw = await env.WORKFLOW_INDEX.
|
|
1043
|
+
const raw = await env.WORKFLOW_INDEX.getHookById(hookId);
|
|
804
1044
|
if (!raw) throw new HookNotFoundError(hookId);
|
|
805
1045
|
return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
|
|
806
1046
|
},
|
|
807
1047
|
async getByToken(token, params) {
|
|
808
|
-
const raw = await env.WORKFLOW_INDEX.
|
|
1048
|
+
const raw = await env.WORKFLOW_INDEX.getHookByToken(token);
|
|
809
1049
|
if (!raw) throw new HookNotFoundError(token);
|
|
810
1050
|
return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
|
|
811
1051
|
},
|
|
@@ -829,19 +1069,27 @@ function createStorage(config) {
|
|
|
829
1069
|
}
|
|
830
1070
|
//#endregion
|
|
831
1071
|
//#region src/streamer.ts
|
|
832
|
-
|
|
833
|
-
const READ_POLL_MS = 100;
|
|
834
|
-
/** Chunks fetched per DO round-trip while reading. */
|
|
835
|
-
const READ_BATCH_SIZE = 32;
|
|
836
|
-
/** Default / maximum page sizes for getStreamChunks. */
|
|
1072
|
+
const DEFAULT_LONG_POLL_MS = MAX_STREAM_LONG_POLL_MS;
|
|
837
1073
|
const DEFAULT_CHUNK_PAGE_SIZE = 32;
|
|
838
1074
|
const MAX_CHUNK_PAGE_SIZE = 32;
|
|
1075
|
+
const REGISTERED_STREAM_CACHE_SIZE = 4096;
|
|
839
1076
|
function toBytes(chunk) {
|
|
840
1077
|
return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
841
1078
|
}
|
|
1079
|
+
function throwTerminal(result) {
|
|
1080
|
+
if (result.state === "expired") throw new Error("Stream has expired");
|
|
1081
|
+
if (result.state === "errored") {
|
|
1082
|
+
const error = new Error(result.error?.message ?? "Stream failed");
|
|
1083
|
+
error.name = result.error?.name ?? "Error";
|
|
1084
|
+
throw error;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
842
1087
|
function createStreamer(config) {
|
|
843
1088
|
const { env } = config;
|
|
844
|
-
const
|
|
1089
|
+
const streamLongPollMs = config.streamLongPollMs ?? DEFAULT_LONG_POLL_MS;
|
|
1090
|
+
if (!Number.isSafeInteger(streamLongPollMs) || streamLongPollMs < 1 || streamLongPollMs > 2e4) throw new Error(`streamLongPollMs must be between 1 and ${MAX_STREAM_LONG_POLL_MS}`);
|
|
1091
|
+
const streamFlushIntervalMs = config.streamFlushIntervalMs ?? 0;
|
|
1092
|
+
if (!Number.isSafeInteger(streamFlushIntervalMs) || streamFlushIntervalMs < 0) throw new Error("streamFlushIntervalMs must be a non-negative safe integer");
|
|
845
1093
|
const getStreamDO = (streamName) => {
|
|
846
1094
|
const id = env.WORKFLOW_STREAMS.idFromName(`stream:${streamName}`);
|
|
847
1095
|
return env.WORKFLOW_STREAMS.get(id);
|
|
@@ -850,83 +1098,161 @@ function createStreamer(config) {
|
|
|
850
1098
|
const id = env.WORKFLOW_STREAMS.idFromName(`run-streams:${runId}`);
|
|
851
1099
|
return env.WORKFLOW_STREAMS.get(id);
|
|
852
1100
|
};
|
|
853
|
-
/**
|
|
1101
|
+
/** Bounded per-isolate cache; eviction only repeats idempotent registration. */
|
|
854
1102
|
const registeredStreams = /* @__PURE__ */ new Set();
|
|
855
1103
|
async function registerStreamForRun(name, runId) {
|
|
856
1104
|
const cacheKey = `${runId}\0${name}`;
|
|
857
1105
|
if (registeredStreams.has(cacheKey)) return;
|
|
858
1106
|
await getRunRegistryDO(runId).registerStream(runId, name);
|
|
1107
|
+
if (registeredStreams.size >= REGISTERED_STREAM_CACHE_SIZE) {
|
|
1108
|
+
const oldest = registeredStreams.values().next().value;
|
|
1109
|
+
if (oldest !== void 0) registeredStreams.delete(oldest);
|
|
1110
|
+
}
|
|
859
1111
|
registeredStreams.add(cacheKey);
|
|
860
1112
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1113
|
+
const writeChunksToStream = async (name, runId, input) => {
|
|
1114
|
+
if (input.length === 0) return;
|
|
1115
|
+
const resolvedRunId = await runId;
|
|
1116
|
+
const chunks = input.map(toBytes);
|
|
1117
|
+
for (const chunk of chunks) if (chunk.byteLength > 1048576) throw new Error(`Stream chunk exceeds ${MAX_STREAM_CHUNK_BYTES} bytes`);
|
|
1118
|
+
await registerStreamForRun(name, resolvedRunId);
|
|
1119
|
+
let batch = [];
|
|
1120
|
+
let batchBytes = 0;
|
|
1121
|
+
for (const chunk of chunks) {
|
|
1122
|
+
if (batch.length > 0 && (batch.length === 32 || batchBytes + chunk.byteLength > 8388608)) {
|
|
1123
|
+
await getStreamDO(name).writeChunks(resolvedRunId, batch);
|
|
1124
|
+
batch = [];
|
|
1125
|
+
batchBytes = 0;
|
|
1126
|
+
}
|
|
1127
|
+
batch.push(chunk);
|
|
1128
|
+
batchBytes += chunk.byteLength;
|
|
1129
|
+
}
|
|
1130
|
+
if (batch.length > 0) await getStreamDO(name).writeChunks(resolvedRunId, batch);
|
|
1131
|
+
};
|
|
1132
|
+
const writeToStream = (name, runId, chunk) => writeChunksToStream(name, runId, [chunk]);
|
|
1133
|
+
const closeStream = async (name, runId) => {
|
|
1134
|
+
const resolvedRunId = await runId;
|
|
1135
|
+
await registerStreamForRun(name, resolvedRunId);
|
|
1136
|
+
await getStreamDO(name).closeStream(resolvedRunId);
|
|
1137
|
+
};
|
|
1138
|
+
const errorStream = async (name, runId, error) => {
|
|
1139
|
+
const resolvedRunId = await runId;
|
|
1140
|
+
await registerStreamForRun(name, resolvedRunId);
|
|
1141
|
+
await getStreamDO(name).failStream(resolvedRunId, normalizeStreamError(error));
|
|
1142
|
+
};
|
|
1143
|
+
const readFromStream = async (name, runId, startIndex = 0) => {
|
|
1144
|
+
const stub = getStreamDO(name);
|
|
1145
|
+
let nextIndex;
|
|
1146
|
+
if (startIndex < 0) {
|
|
1147
|
+
const info = await stub.readChunks({
|
|
1148
|
+
runId,
|
|
1149
|
+
startIndex: 0,
|
|
1150
|
+
maxChunks: 0,
|
|
1151
|
+
maxBytes: MAX_STREAM_READ_BYTES,
|
|
1152
|
+
waitMs: 0
|
|
1153
|
+
});
|
|
1154
|
+
throwTerminal(info);
|
|
1155
|
+
nextIndex = Math.max(0, info.tailIndex + 1 + startIndex);
|
|
1156
|
+
} else nextIndex = startIndex;
|
|
1157
|
+
let cancelled = false;
|
|
1158
|
+
const abortController = new AbortController();
|
|
1159
|
+
return new ReadableStream({
|
|
1160
|
+
async pull(controller) {
|
|
1161
|
+
for (;;) {
|
|
1162
|
+
if (cancelled) return;
|
|
1163
|
+
let result;
|
|
1164
|
+
try {
|
|
1165
|
+
result = await stub.readChunks({
|
|
1166
|
+
runId,
|
|
885
1167
|
startIndex: nextIndex,
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1168
|
+
maxChunks: 32,
|
|
1169
|
+
maxBytes: MAX_STREAM_READ_BYTES,
|
|
1170
|
+
waitMs: streamLongPollMs
|
|
1171
|
+
}, abortController.signal);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (cancelled || abortController.signal.aborted) return;
|
|
1174
|
+
throw error;
|
|
1175
|
+
}
|
|
1176
|
+
if (result.chunks.length > 0) {
|
|
1177
|
+
for (const chunk of result.chunks) controller.enqueue(chunk);
|
|
1178
|
+
nextIndex += result.chunks.length;
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
throwTerminal(result);
|
|
1182
|
+
if (result.state === "closed") {
|
|
1183
|
+
controller.close();
|
|
1184
|
+
return;
|
|
898
1185
|
}
|
|
899
|
-
},
|
|
900
|
-
cancel() {
|
|
901
|
-
cancelled = true;
|
|
902
1186
|
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1187
|
+
},
|
|
1188
|
+
cancel() {
|
|
1189
|
+
cancelled = true;
|
|
1190
|
+
abortController.abort(new DOMException("Stream reader cancelled", "AbortError"));
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
};
|
|
1194
|
+
const listStreamsByRunId = (runId) => getRunRegistryDO(runId).listStreams();
|
|
1195
|
+
const getStreamChunks = async (name, runId, options) => {
|
|
1196
|
+
const requestedLimit = options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE;
|
|
1197
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) throw new Error("Stream chunk limit must be a positive safe integer");
|
|
1198
|
+
const limit = Math.min(requestedLimit, MAX_CHUNK_PAGE_SIZE);
|
|
1199
|
+
const startIndex = options?.cursor ? Number.parseInt(options.cursor, 10) : 0;
|
|
1200
|
+
if (Number.isNaN(startIndex) || startIndex < 0) throw new Error(`Invalid stream cursor: ${options?.cursor}`);
|
|
1201
|
+
const result = await getStreamDO(name).readChunks({
|
|
1202
|
+
runId,
|
|
1203
|
+
startIndex,
|
|
1204
|
+
maxChunks: limit,
|
|
1205
|
+
maxBytes: MAX_STREAM_READ_BYTES,
|
|
1206
|
+
waitMs: 0
|
|
1207
|
+
});
|
|
1208
|
+
throwTerminal(result);
|
|
1209
|
+
const data = result.chunks.map((chunk, offset) => ({
|
|
1210
|
+
index: startIndex + offset,
|
|
1211
|
+
data: chunk
|
|
1212
|
+
}));
|
|
1213
|
+
const nextIndex = startIndex + data.length;
|
|
1214
|
+
const hasMore = nextIndex <= result.tailIndex;
|
|
1215
|
+
return {
|
|
1216
|
+
data,
|
|
1217
|
+
cursor: hasMore ? String(nextIndex) : null,
|
|
1218
|
+
hasMore,
|
|
1219
|
+
done: result.state === "closed"
|
|
1220
|
+
};
|
|
1221
|
+
};
|
|
1222
|
+
const getStreamInfo = async (name, runId) => {
|
|
1223
|
+
const result = await getStreamDO(name).readChunks({
|
|
1224
|
+
runId,
|
|
1225
|
+
startIndex: 0,
|
|
1226
|
+
maxChunks: 0,
|
|
1227
|
+
maxBytes: MAX_STREAM_READ_BYTES,
|
|
1228
|
+
waitMs: 0
|
|
1229
|
+
});
|
|
1230
|
+
throwTerminal(result);
|
|
1231
|
+
return {
|
|
1232
|
+
tailIndex: result.tailIndex,
|
|
1233
|
+
done: result.state === "closed"
|
|
1234
|
+
};
|
|
1235
|
+
};
|
|
1236
|
+
return {
|
|
1237
|
+
streamFlushIntervalMs,
|
|
1238
|
+
streams: {
|
|
1239
|
+
write: (runId, name, chunk) => writeToStream(name, runId, chunk),
|
|
1240
|
+
writeMulti: (runId, name, chunks) => writeChunksToStream(name, runId, chunks),
|
|
1241
|
+
close: (runId, name) => closeStream(name, runId),
|
|
1242
|
+
fail: (runId, name, error) => errorStream(name, runId, error),
|
|
1243
|
+
get: (runId, name, startIndex) => readFromStream(name, runId, startIndex),
|
|
1244
|
+
list: listStreamsByRunId,
|
|
1245
|
+
getChunks: (runId, name, options) => getStreamChunks(name, runId, options),
|
|
1246
|
+
getInfo: (runId, name) => getStreamInfo(name, runId)
|
|
926
1247
|
},
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1248
|
+
writeToStream,
|
|
1249
|
+
writeChunksToStream,
|
|
1250
|
+
closeStream,
|
|
1251
|
+
errorStream,
|
|
1252
|
+
readFromStream,
|
|
1253
|
+
listStreamsByRunId,
|
|
1254
|
+
getStreamChunks,
|
|
1255
|
+
getStreamInfo
|
|
930
1256
|
};
|
|
931
1257
|
}
|
|
932
1258
|
//#endregion
|
|
@@ -971,7 +1297,8 @@ function createCelldWorld(config) {
|
|
|
971
1297
|
});
|
|
972
1298
|
const streamer = createStreamer({
|
|
973
1299
|
env: { WORKFLOW_STREAMS: env.WORKFLOW_STREAMS },
|
|
974
|
-
|
|
1300
|
+
streamLongPollMs: resolved.streamLongPollMs,
|
|
1301
|
+
streamFlushIntervalMs: resolved.streamFlushIntervalMs
|
|
975
1302
|
});
|
|
976
1303
|
const runStub = (runId) => env.WORKFLOW_DB.get(env.WORKFLOW_DB.idFromName(runId));
|
|
977
1304
|
const retention = {
|
|
@@ -998,6 +1325,6 @@ function createCelldWorld(config) {
|
|
|
998
1325
|
};
|
|
999
1326
|
}
|
|
1000
1327
|
//#endregion
|
|
1001
|
-
export { createCelldWorld, createCelldWorld as createWorld };
|
|
1328
|
+
export { MAX_STREAM_BATCH_BYTES, MAX_STREAM_CHUNK_BYTES, MAX_STREAM_ERROR_BYTES, MAX_STREAM_LONG_POLL_MS, MAX_STREAM_READ_BYTES, MAX_STREAM_READ_CHUNKS, MAX_STREAM_WRITE_CHUNKS, createCelldWorld, createCelldWorld as createWorld };
|
|
1002
1329
|
|
|
1003
1330
|
//# sourceMappingURL=index.js.map
|