@ewhauser/world-celld 0.8.1 → 0.9.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 +17 -0
- package/README.md +40 -15
- package/celld-worker/README.md +9 -0
- package/celld-worker/wrangler.jsonc +3 -0
- package/dist/index.d.ts +6 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/queue-delivery-wvPp6_Hh.d.ts.map +1 -1
- package/dist/queue-protocol-8iiZ8o_1.d.ts +42 -0
- package/dist/queue-protocol-8iiZ8o_1.d.ts.map +1 -0
- package/dist/{run-list-DGsdv6Jf.js → run-list-CHRaDRvU.js} +39 -2
- package/dist/run-list-CHRaDRvU.js.map +1 -0
- package/dist/testing.d.ts +17 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +433 -12
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +14 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +268 -96
- package/dist/worker.js.map +1 -1
- package/package.json +5 -4
- package/dist/run-list-DGsdv6Jf.js.map +0 -1
package/dist/testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { B as MAX_STREAM_BATCH_BYTES, C as parseDeploymentUrls, D as LIFECYCLE_COMPACTION_RETRY_MS, E as HOOK_CLAIM_LEASE_MS, F as isNonNegativeSafeInteger, I as isPositiveSafeInteger, J as STREAM_CHUNK_LIMIT_HEADER, L as isRecord$1, M as QUEUE_CLAIM_STALE_MS, Q as encodeStreamReadResult, R as queueDelayDeadline, S as configuredDeploymentUrl, U as MAX_STREAM_LONG_POLL_MS, V as MAX_STREAM_CHUNK_BYTES, W as MAX_STREAM_READ_BYTES, X as decodeStreamWriteBatch, _ as rpcStringify, a as QUEUE_PUBLICATION_LEASE_MS, b as createWorkflowIndex, c as nativeQueueDelaySeconds, d as queuePayloadObjectKey, et as encodeStreamWriteResult, f as queuePayloadRegistryKey, g as rpcParse, h as compact, i as QUEUE_PAYLOAD_REGISTRY_PREFIX, it as validateStreamWriteChunks, j as MAX_RUN_INDEX_PUBLICATION_LIFETIME_MS, l as queueClaimName, m as validateNativeQueueSendOptions, n as listRunsPage, nt as normalizeStreamError, o as QUEUE_RESERVATION_GRACE_MS, p as validateNativeQueueEnvelope, q as STREAM_BATCH_CONTENT_TYPE, r as NATIVE_QUEUE_MAX_MESSAGE_BYTES, rt as validateStreamReadRequest, s as isInlineRunBody, t as MAX_RUN_LIST_LIMIT, u as queueOrphanName, v as parse, x as runIndexMetadata, y as stringify } from "./run-list-CHRaDRvU.js";
|
|
2
2
|
import { CreateEventSchema, EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, applyAttributeChanges, eventIdToSlot, getMaxEventsPerRun, isChildEntityCreationEvent, isTerminalStepStatus, isTerminalWorkflowRunStatus, slotToEventId, validateAttributeChanges } from "@workflow/world";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
//#region src/testing/fake-cell.ts
|
|
@@ -1361,6 +1361,11 @@ async function authenticate(request, secret) {
|
|
|
1361
1361
|
}
|
|
1362
1362
|
//#endregion
|
|
1363
1363
|
//#region src/worker/queue-claims.ts
|
|
1364
|
+
/**
|
|
1365
|
+
* Idempotency claims for Queue messages. A claim lives either in its own
|
|
1366
|
+
* claim cell (named by queue and idempotency key) or, for run-bearing
|
|
1367
|
+
* messages whose envelope says so, as an entry in the run's own cell.
|
|
1368
|
+
*/
|
|
1364
1369
|
function cellQueueClaim(cell) {
|
|
1365
1370
|
return {
|
|
1366
1371
|
claim: (messageId, staleMs) => cell.claimInflight({
|
|
@@ -1413,6 +1418,12 @@ function runQueueClaim(run, claimName) {
|
|
|
1413
1418
|
})
|
|
1414
1419
|
};
|
|
1415
1420
|
}
|
|
1421
|
+
/** The claim a delivered envelope was reserved under, if it has an idempotency key. */
|
|
1422
|
+
function deliveryQueueClaim(envelope, runStub) {
|
|
1423
|
+
if (!envelope.idempotencyKey) return void 0;
|
|
1424
|
+
const claimName = queueClaimName(envelope.queueName, envelope.idempotencyKey);
|
|
1425
|
+
return envelope.claimScope === "run" && envelope.runId ? runQueueClaim(runStub(envelope.runId), claimName) : cellQueueClaim(runStub(claimName));
|
|
1426
|
+
}
|
|
1416
1427
|
//#endregion
|
|
1417
1428
|
//#region src/worker/index-validation.ts
|
|
1418
1429
|
function invalid(message) {
|
|
@@ -2276,6 +2287,70 @@ async function resolveExpiredHookClaim(env, owner, claimId, token) {
|
|
|
2276
2287
|
token
|
|
2277
2288
|
});
|
|
2278
2289
|
}
|
|
2290
|
+
/** Read-only owner check used by index repair; unlike the resolver it never fences. */
|
|
2291
|
+
async function getCommittedHook(env, owner, token) {
|
|
2292
|
+
const namespace = env.WORKFLOW_DB;
|
|
2293
|
+
if (!namespace) throw new Error("world-celld hook index missing WORKFLOW_DB binding");
|
|
2294
|
+
return await namespace.get(namespace.idFromName(owner.runId)).getCommittedHook({
|
|
2295
|
+
hookId: owner.hookId,
|
|
2296
|
+
token
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
function warnRepair(action, owner) {
|
|
2300
|
+
console.warn(`world-celld ${action} hook index record for run ${owner.runId} hook ${owner.hookId}`);
|
|
2301
|
+
}
|
|
2302
|
+
function warnRepublished(owner) {
|
|
2303
|
+
warnRepair("republished", owner);
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Publish the record for a claim whose hook committed in its run but whose
|
|
2307
|
+
* creating client stopped before finalizing. The write applies only while the
|
|
2308
|
+
* observed claim is unchanged, so a concurrent release or finalize wins.
|
|
2309
|
+
* Returns the record now stored for the key, if any.
|
|
2310
|
+
*/
|
|
2311
|
+
async function publishCommittedClaim(storage, env, keys, token) {
|
|
2312
|
+
const observed = await storage.get(keys.claim);
|
|
2313
|
+
if (!observed) return null;
|
|
2314
|
+
const hook = await getCommittedHook(env, observed.owner, token);
|
|
2315
|
+
if (!hook) return null;
|
|
2316
|
+
const serialized = stringify(hook);
|
|
2317
|
+
const published = await storage.transaction(async (txn) => {
|
|
2318
|
+
const values = await txn.get([keys.record, keys.claim]);
|
|
2319
|
+
const indexed = values.get(keys.record);
|
|
2320
|
+
if (indexed !== void 0) return {
|
|
2321
|
+
raw: indexed,
|
|
2322
|
+
written: false
|
|
2323
|
+
};
|
|
2324
|
+
const claim = values.get(keys.claim);
|
|
2325
|
+
if (claim?.claimId !== observed.claimId) return {
|
|
2326
|
+
raw: null,
|
|
2327
|
+
written: false
|
|
2328
|
+
};
|
|
2329
|
+
await txn.put(keys.record, serialized);
|
|
2330
|
+
await txn.delete([keys.claim, claim.deadlineKey]);
|
|
2331
|
+
return {
|
|
2332
|
+
raw: serialized,
|
|
2333
|
+
written: true
|
|
2334
|
+
};
|
|
2335
|
+
});
|
|
2336
|
+
if (!published.written) return published.raw;
|
|
2337
|
+
warnRepublished(observed.owner);
|
|
2338
|
+
return await confirmRepublished(storage, env, keys.record, observed.owner, token) ? serialized : null;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Re-ask the owner after a repair write. A hook disposed between the owner's
|
|
2342
|
+
* answer and the write would otherwise keep a record whose index release was
|
|
2343
|
+
* already spent, so withdraw it.
|
|
2344
|
+
*/
|
|
2345
|
+
async function confirmRepublished(storage, env, record, owner, token) {
|
|
2346
|
+
if (await getCommittedHook(env, owner, token)) return true;
|
|
2347
|
+
await storage.transaction(async (txn) => {
|
|
2348
|
+
const indexed = await txn.get(record);
|
|
2349
|
+
if (indexed !== void 0 && sameOwner(ownerFromSerializedHook(indexed), owner)) await txn.delete(record);
|
|
2350
|
+
});
|
|
2351
|
+
warnRepair("withdrew", owner);
|
|
2352
|
+
return false;
|
|
2353
|
+
}
|
|
2279
2354
|
//#endregion
|
|
2280
2355
|
//#region src/worker/durable-objects/HookIdDO.ts
|
|
2281
2356
|
const MAX_RELEASE_BATCH$1 = 64;
|
|
@@ -2298,7 +2373,10 @@ var HookIdDO = class extends DurableObject {
|
|
|
2298
2373
|
return typeof this.env.clock === "function" ? this.env.clock() : Date.now();
|
|
2299
2374
|
}
|
|
2300
2375
|
async get(hookId) {
|
|
2301
|
-
const raw = await this.ctx.storage.get(recordKey$1(hookId))
|
|
2376
|
+
const raw = await this.ctx.storage.get(recordKey$1(hookId)) ?? await publishCommittedClaim(this.ctx.storage, this.env, {
|
|
2377
|
+
record: recordKey$1(hookId),
|
|
2378
|
+
claim: claimKey$1(hookId)
|
|
2379
|
+
});
|
|
2302
2380
|
if (!raw) return null;
|
|
2303
2381
|
const owner = ownerFromSerializedHook(raw);
|
|
2304
2382
|
return await runIsActive(this.env, owner.runId) ? raw : null;
|
|
@@ -2463,8 +2541,9 @@ var HookIdDO = class extends DurableObject {
|
|
|
2463
2541
|
const claim = observed.get(claimKey$1(item.hookId));
|
|
2464
2542
|
if (!claim || claim.claimId !== item.claimId || claim.expiresAt > now) return;
|
|
2465
2543
|
const hook = await resolveExpiredHookClaim(this.env, claim.owner, claim.claimId);
|
|
2466
|
-
committed.set(claimKey$1(item.hookId), hook
|
|
2544
|
+
committed.set(claimKey$1(item.hookId), hook);
|
|
2467
2545
|
}));
|
|
2546
|
+
const published = [];
|
|
2468
2547
|
await this.ctx.storage.transaction(async (txn) => {
|
|
2469
2548
|
const claims = await txn.get(page.map(([, item]) => claimKey$1(item.hookId)));
|
|
2470
2549
|
const deletes = [];
|
|
@@ -2474,8 +2553,15 @@ var HookIdDO = class extends DurableObject {
|
|
|
2474
2553
|
const snapshot = observed.get(key);
|
|
2475
2554
|
if (claim?.claimId !== item.claimId || claim.expiresAt > now) deletes.push(deadline);
|
|
2476
2555
|
else if (claim.claimId === snapshot?.claimId && claim.expiresAt === snapshot.expiresAt) {
|
|
2477
|
-
|
|
2478
|
-
|
|
2556
|
+
const hook = committed.get(key);
|
|
2557
|
+
if (hook) {
|
|
2558
|
+
await txn.put(recordKey$1(item.hookId), stringify(hook));
|
|
2559
|
+
published.push({
|
|
2560
|
+
hookId: item.hookId,
|
|
2561
|
+
owner: claim.owner
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
deletes.push(deadline, key);
|
|
2479
2565
|
}
|
|
2480
2566
|
}
|
|
2481
2567
|
for (let offset = 0; offset < deletes.length; offset += 128) await txn.delete(deletes.slice(offset, offset + 128));
|
|
@@ -2490,6 +2576,10 @@ var HookIdDO = class extends DurableObject {
|
|
|
2490
2576
|
if (nextAt === void 0) await txn.deleteAlarm();
|
|
2491
2577
|
else await txn.setAlarm(Math.max(now + 1, nextAt));
|
|
2492
2578
|
});
|
|
2579
|
+
for (const { hookId, owner } of published) {
|
|
2580
|
+
warnRepublished(owner);
|
|
2581
|
+
await confirmRepublished(this.ctx.storage, this.env, recordKey$1(hookId), owner);
|
|
2582
|
+
}
|
|
2493
2583
|
}
|
|
2494
2584
|
};
|
|
2495
2585
|
//#endregion
|
|
@@ -2514,7 +2604,10 @@ var HookTokenDO = class extends DurableObject {
|
|
|
2514
2604
|
return typeof this.env.clock === "function" ? this.env.clock() : Date.now();
|
|
2515
2605
|
}
|
|
2516
2606
|
async get(token) {
|
|
2517
|
-
const raw = await this.ctx.storage.get(recordKey(token))
|
|
2607
|
+
const raw = await this.ctx.storage.get(recordKey(token)) ?? await publishCommittedClaim(this.ctx.storage, this.env, {
|
|
2608
|
+
record: recordKey(token),
|
|
2609
|
+
claim: claimKey(token)
|
|
2610
|
+
}, token);
|
|
2518
2611
|
if (!raw) return null;
|
|
2519
2612
|
const owner = ownerFromSerializedHook(raw);
|
|
2520
2613
|
return await runIsActive(this.env, owner.runId) ? raw : null;
|
|
@@ -2678,8 +2771,9 @@ var HookTokenDO = class extends DurableObject {
|
|
|
2678
2771
|
const claim = observed.get(claimKey(item.token));
|
|
2679
2772
|
if (!claim || claim.claimId !== item.claimId || claim.expiresAt > now) return;
|
|
2680
2773
|
const hook = await resolveExpiredHookClaim(this.env, claim.owner, claim.claimId, item.token);
|
|
2681
|
-
committed.set(claimKey(item.token), hook
|
|
2774
|
+
committed.set(claimKey(item.token), hook);
|
|
2682
2775
|
}));
|
|
2776
|
+
const published = [];
|
|
2683
2777
|
await this.ctx.storage.transaction(async (txn) => {
|
|
2684
2778
|
const claims = await txn.get(page.map(([, item]) => claimKey(item.token)));
|
|
2685
2779
|
const deletes = [];
|
|
@@ -2689,8 +2783,15 @@ var HookTokenDO = class extends DurableObject {
|
|
|
2689
2783
|
const snapshot = observed.get(key);
|
|
2690
2784
|
if (claim?.claimId !== item.claimId || claim.expiresAt > now) deletes.push(deadline);
|
|
2691
2785
|
else if (claim.claimId === snapshot?.claimId && claim.expiresAt === snapshot.expiresAt) {
|
|
2692
|
-
|
|
2693
|
-
|
|
2786
|
+
const hook = committed.get(key);
|
|
2787
|
+
if (hook) {
|
|
2788
|
+
await txn.put(recordKey(item.token), stringify(hook));
|
|
2789
|
+
published.push({
|
|
2790
|
+
token: item.token,
|
|
2791
|
+
owner: claim.owner
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
deletes.push(deadline, key);
|
|
2694
2795
|
}
|
|
2695
2796
|
}
|
|
2696
2797
|
for (let offset = 0; offset < deletes.length; offset += 128) await txn.delete(deletes.slice(offset, offset + 128));
|
|
@@ -2705,6 +2806,10 @@ var HookTokenDO = class extends DurableObject {
|
|
|
2705
2806
|
if (nextAt === void 0) await txn.deleteAlarm();
|
|
2706
2807
|
else await txn.setAlarm(Math.max(now + 1, nextAt));
|
|
2707
2808
|
});
|
|
2809
|
+
for (const { token, owner } of published) {
|
|
2810
|
+
warnRepublished(owner);
|
|
2811
|
+
await confirmRepublished(this.ctx.storage, this.env, recordKey(token), owner, token);
|
|
2812
|
+
}
|
|
2708
2813
|
}
|
|
2709
2814
|
};
|
|
2710
2815
|
//#endregion
|
|
@@ -3810,6 +3915,23 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
3810
3915
|
return null;
|
|
3811
3916
|
});
|
|
3812
3917
|
}
|
|
3918
|
+
/**
|
|
3919
|
+
* Read-only twin of resolveExpiredHookClaim for index repair: the committed
|
|
3920
|
+
* hook of a live run, or null. It fences nothing, so a shard may ask while
|
|
3921
|
+
* the hook's creation is still in flight.
|
|
3922
|
+
*/
|
|
3923
|
+
async getCommittedHook(request) {
|
|
3924
|
+
if (typeof request?.hookId !== "string" || request.hookId.length === 0 || request.token !== void 0 && typeof request.token !== "string") throw new TypeError("world-celld committed hook lookup is invalid");
|
|
3925
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
3926
|
+
const { tombstone } = await this.retentionState(txn);
|
|
3927
|
+
if (tombstone) return null;
|
|
3928
|
+
const run = await txn.get("run");
|
|
3929
|
+
if (!run || isTerminalWorkflowRunStatus(run.status)) return null;
|
|
3930
|
+
const hook = await txn.get(`${HOOK_KEY_PREFIX}${request.hookId}`);
|
|
3931
|
+
if (!hook || request.token !== void 0 && hook.token !== request.token) return null;
|
|
3932
|
+
return hook;
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3813
3935
|
/** Single authoritative lifecycle answer used after derivative compaction. */
|
|
3814
3936
|
async getLifecycleStatus() {
|
|
3815
3937
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
@@ -4801,6 +4923,157 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
4801
4923
|
}
|
|
4802
4924
|
};
|
|
4803
4925
|
//#endregion
|
|
4926
|
+
//#region src/worker/queue-delivery.ts
|
|
4927
|
+
/** Internal delivery contract. No Request/Response objects cross the RPC boundary. */
|
|
4928
|
+
const MAX_QUEUE_DELIVERY_RESPONSE_BYTES = 65536;
|
|
4929
|
+
const QUEUE_ORPHAN_GRACE_MS = 432e6;
|
|
4930
|
+
const PERMANENT_QUEUE_STATUSES = /* @__PURE__ */ new Set([
|
|
4931
|
+
404,
|
|
4932
|
+
409,
|
|
4933
|
+
410,
|
|
4934
|
+
422
|
|
4935
|
+
]);
|
|
4936
|
+
async function readResponseText(response) {
|
|
4937
|
+
if (!response.body) return "";
|
|
4938
|
+
const reader = response.body.getReader();
|
|
4939
|
+
const decoder = new TextDecoder();
|
|
4940
|
+
let total = 0;
|
|
4941
|
+
let text = "";
|
|
4942
|
+
try {
|
|
4943
|
+
for (;;) {
|
|
4944
|
+
const { done, value } = await reader.read();
|
|
4945
|
+
if (done) break;
|
|
4946
|
+
total += value.byteLength;
|
|
4947
|
+
if (total > MAX_QUEUE_DELIVERY_RESPONSE_BYTES) {
|
|
4948
|
+
await reader.cancel("queue delivery response is too large").catch(() => void 0);
|
|
4949
|
+
throw new Error("workflow queue handler response exceeds configured limit");
|
|
4950
|
+
}
|
|
4951
|
+
text += decoder.decode(value, { stream: true });
|
|
4952
|
+
}
|
|
4953
|
+
return text + decoder.decode();
|
|
4954
|
+
} finally {
|
|
4955
|
+
reader.releaseLock();
|
|
4956
|
+
}
|
|
4957
|
+
}
|
|
4958
|
+
/** Validate at the service boundary; callers cannot bypass auth via RPC. */
|
|
4959
|
+
async function deliverQueueMessage(env, secret, input, attempt) {
|
|
4960
|
+
if (!env.WORLD_SECRET) throw new Error("WORLD_SECRET is not configured");
|
|
4961
|
+
if (typeof secret !== "string" || !secret || !await timingSafeEqual(secret, env.WORLD_SECRET)) throw new Error("Unauthorized Queue delivery");
|
|
4962
|
+
const envelope = validateNativeQueueEnvelope(input);
|
|
4963
|
+
if (!Number.isSafeInteger(attempt) || attempt < 1) throw new TypeError("queue delivery attempt must be a positive safe integer");
|
|
4964
|
+
if (!env.WORKFLOW_DB) throw new Error("missing binding: WORKFLOW_DB");
|
|
4965
|
+
const namespace = env.WORKFLOW_DB;
|
|
4966
|
+
const payloadStore = env.WORKFLOW_QUEUE_PAYLOADS;
|
|
4967
|
+
const runStub = (name) => namespace.get(namespace.idFromName(name));
|
|
4968
|
+
const claim = deliveryQueueClaim(envelope, runStub);
|
|
4969
|
+
let claimed = false;
|
|
4970
|
+
async function releaseClaim() {
|
|
4971
|
+
if (claimed) await claim.release(envelope.messageId);
|
|
4972
|
+
}
|
|
4973
|
+
async function unregisterPayload() {
|
|
4974
|
+
if (envelope.runId) {
|
|
4975
|
+
await runStub(envelope.runId).unregisterQueuePayload(envelope.messageId);
|
|
4976
|
+
await runStub(queueOrphanName(envelope.messageId)).cancelQueuePayloadOrphan(envelope.messageId);
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
try {
|
|
4980
|
+
let admission;
|
|
4981
|
+
if (envelope.runId && !envelope.payloadKey && envelope.claimScope !== "run") {
|
|
4982
|
+
admission = runStub(envelope.runId).getQueueAdmission();
|
|
4983
|
+
admission.catch(() => void 0);
|
|
4984
|
+
}
|
|
4985
|
+
if (claim) {
|
|
4986
|
+
const result = await claim.claim(envelope.messageId, QUEUE_CLAIM_STALE_MS);
|
|
4987
|
+
if (result.expired) return { kind: "complete" };
|
|
4988
|
+
if (!result.claimed) {
|
|
4989
|
+
const now = Date.now();
|
|
4990
|
+
if (result.retryAt !== void 0 && result.retryAt > now) return {
|
|
4991
|
+
kind: "suspend",
|
|
4992
|
+
timeoutSeconds: Math.max(1, Math.ceil((result.retryAt - now) / 1e3))
|
|
4993
|
+
};
|
|
4994
|
+
return { kind: "complete" };
|
|
4995
|
+
}
|
|
4996
|
+
claimed = true;
|
|
4997
|
+
}
|
|
4998
|
+
if (admission && !(await admission).ok) {
|
|
4999
|
+
await claim?.complete(envelope.messageId);
|
|
5000
|
+
return { kind: "complete" };
|
|
5001
|
+
}
|
|
5002
|
+
let body = envelope.body;
|
|
5003
|
+
if (envelope.payloadKey) {
|
|
5004
|
+
if (!payloadStore) throw new Error("missing binding: WORKFLOW_QUEUE_PAYLOADS");
|
|
5005
|
+
body = await payloadStore.read(envelope.payloadKey) ?? void 0;
|
|
5006
|
+
if (body === void 0) {
|
|
5007
|
+
await unregisterPayload();
|
|
5008
|
+
await claim?.complete(envelope.messageId);
|
|
5009
|
+
return { kind: "complete" };
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
const deploymentUrls = parseDeploymentUrls(env.WORKFLOW_DEPLOYMENT_URLS);
|
|
5013
|
+
let targetBaseUrl = envelope.targetBaseUrl;
|
|
5014
|
+
if (deploymentUrls && envelope.runId) {
|
|
5015
|
+
const stored = await runStub(envelope.runId).getRun();
|
|
5016
|
+
if (!stored.ok) throw new Error("world-celld: cannot route an expired run");
|
|
5017
|
+
const initialId = parse(body).runInput?.deploymentId;
|
|
5018
|
+
const pinnedId = stored.value?.deploymentId ?? initialId;
|
|
5019
|
+
if (typeof pinnedId !== "string" || pinnedId.length === 0) throw new Error(`world-celld: no pinned deployment for run ${envelope.runId}`);
|
|
5020
|
+
if (stored.value && initialId !== void 0 && initialId !== pinnedId) throw new Error(`world-celld: queue run deployment hints disagree for ${envelope.runId}`);
|
|
5021
|
+
targetBaseUrl = configuredDeploymentUrl(deploymentUrls, pinnedId);
|
|
5022
|
+
}
|
|
5023
|
+
const headers = {
|
|
5024
|
+
"content-type": "application/json",
|
|
5025
|
+
"x-vqs-queue-name": envelope.queueName,
|
|
5026
|
+
"x-vqs-message-id": envelope.messageId,
|
|
5027
|
+
"x-vqs-message-attempt": String(attempt)
|
|
5028
|
+
};
|
|
5029
|
+
if (env.WORKFLOW_CALLBACK_SECRET) headers["x-workflow-callback-secret"] = env.WORKFLOW_CALLBACK_SECRET;
|
|
5030
|
+
const callback = await fetch(`${targetBaseUrl.replace(/\/$/, "")}/.well-known/workflow/v1/flow`, {
|
|
5031
|
+
method: "POST",
|
|
5032
|
+
headers,
|
|
5033
|
+
body,
|
|
5034
|
+
signal: AbortSignal.timeout(3e5)
|
|
5035
|
+
});
|
|
5036
|
+
if (callback.ok || !deploymentUrls && PERMANENT_QUEUE_STATUSES.has(callback.status) || !envelope.runId && PERMANENT_QUEUE_STATUSES.has(callback.status)) {
|
|
5037
|
+
await callback.body?.cancel().catch(() => void 0);
|
|
5038
|
+
if (envelope.payloadKey) {
|
|
5039
|
+
await payloadStore.delete(envelope.payloadKey);
|
|
5040
|
+
await unregisterPayload();
|
|
5041
|
+
}
|
|
5042
|
+
await claim?.complete(envelope.messageId);
|
|
5043
|
+
return { kind: "complete" };
|
|
5044
|
+
}
|
|
5045
|
+
if (callback.status === 503) {
|
|
5046
|
+
const responseBody = await readResponseText(callback);
|
|
5047
|
+
const timeoutSeconds = JSON.parse(responseBody).timeoutSeconds;
|
|
5048
|
+
const notBefore = queueDelayDeadline(Date.now(), timeoutSeconds, 1, QUEUE_CLAIM_STALE_MS);
|
|
5049
|
+
if (notBefore === null) throw new Error("workflow queue suspension deadline is invalid");
|
|
5050
|
+
if (envelope.payloadKey && envelope.runId) await runStub(queueOrphanName(envelope.messageId)).scheduleQueuePayloadOrphan({
|
|
5051
|
+
messageId: envelope.messageId,
|
|
5052
|
+
runId: envelope.runId,
|
|
5053
|
+
key: envelope.payloadKey,
|
|
5054
|
+
expiresAt: notBefore + QUEUE_ORPHAN_GRACE_MS
|
|
5055
|
+
});
|
|
5056
|
+
if (claim) await claim.hold({
|
|
5057
|
+
messageId: envelope.messageId,
|
|
5058
|
+
retryAt: notBefore,
|
|
5059
|
+
expiresAt: notBefore + QUEUE_CLAIM_STALE_MS,
|
|
5060
|
+
reservationExpiresAt: notBefore + QUEUE_ORPHAN_GRACE_MS
|
|
5061
|
+
});
|
|
5062
|
+
return {
|
|
5063
|
+
kind: "suspend",
|
|
5064
|
+
timeoutSeconds
|
|
5065
|
+
};
|
|
5066
|
+
}
|
|
5067
|
+
await callback.body?.cancel().catch(() => void 0);
|
|
5068
|
+
await releaseClaim();
|
|
5069
|
+
claimed = false;
|
|
5070
|
+
return { kind: "retry" };
|
|
5071
|
+
} catch (error) {
|
|
5072
|
+
await releaseClaim();
|
|
5073
|
+
throw error;
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
//#endregion
|
|
4804
5077
|
//#region src/testing/http-harness.ts
|
|
4805
5078
|
/**
|
|
4806
5079
|
* Boots the real worker router over real cell classes (on FakeFleet state)
|
|
@@ -4809,6 +5082,7 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
4809
5082
|
async function startHarness(options = {}) {
|
|
4810
5083
|
const cellEnv = { ...options.cellEnv };
|
|
4811
5084
|
const queueMessages = [];
|
|
5085
|
+
const queuePublications = [];
|
|
4812
5086
|
const queuePayloads = /* @__PURE__ */ new Map();
|
|
4813
5087
|
const queuePayloadBucket = {
|
|
4814
5088
|
async put(key, value) {
|
|
@@ -4822,8 +5096,12 @@ async function startHarness(options = {}) {
|
|
|
4822
5096
|
for (const key of Array.isArray(keys) ? keys : [keys]) queuePayloads.delete(key);
|
|
4823
5097
|
}
|
|
4824
5098
|
};
|
|
4825
|
-
const nativeQueue = { async send(body) {
|
|
5099
|
+
const nativeQueue = { async send(body, sendOptions) {
|
|
4826
5100
|
queueMessages.push(body);
|
|
5101
|
+
queuePublications.push({
|
|
5102
|
+
body,
|
|
5103
|
+
delaySeconds: sendOptions?.delaySeconds ?? 0
|
|
5104
|
+
});
|
|
4827
5105
|
} };
|
|
4828
5106
|
const fleet = new FakeFleet({
|
|
4829
5107
|
runs: WorkflowRunDO,
|
|
@@ -4842,7 +5120,7 @@ async function startHarness(options = {}) {
|
|
|
4842
5120
|
WORKFLOW_HOOK_IDS: fleet.namespace("hook-ids"),
|
|
4843
5121
|
WORKFLOW_QUEUE_PAYLOADS: queuePayloadBucket
|
|
4844
5122
|
});
|
|
4845
|
-
const
|
|
5123
|
+
const env = {
|
|
4846
5124
|
WORKFLOW_DB: fleet.namespace("runs"),
|
|
4847
5125
|
WORKFLOW_STREAMS: fleet.namespace("streams"),
|
|
4848
5126
|
WORKFLOW_RUN_CATALOG: fleet.namespace("run-catalog"),
|
|
@@ -4851,7 +5129,8 @@ async function startHarness(options = {}) {
|
|
|
4851
5129
|
WORKFLOW_QUEUE: nativeQueue,
|
|
4852
5130
|
WORKFLOW_QUEUE_PAYLOADS: createQueuePayloadStore(queuePayloadBucket),
|
|
4853
5131
|
WORLD_SECRET: options.secret
|
|
4854
|
-
}
|
|
5132
|
+
};
|
|
5133
|
+
const router = createRouter(env);
|
|
4855
5134
|
const server = http.createServer(async (req, res) => {
|
|
4856
5135
|
const requestAbort = new AbortController();
|
|
4857
5136
|
const abortRequest = () => {
|
|
@@ -4894,7 +5173,9 @@ async function startHarness(options = {}) {
|
|
|
4894
5173
|
url: `http://127.0.0.1:${port}`,
|
|
4895
5174
|
fleet,
|
|
4896
5175
|
queueMessages,
|
|
5176
|
+
queuePublications,
|
|
4897
5177
|
queuePayloads,
|
|
5178
|
+
deliverQueueMessage: (secret, envelope, attempt) => deliverQueueMessage(env, secret, envelope, attempt),
|
|
4898
5179
|
close: () => new Promise((resolve, reject) => {
|
|
4899
5180
|
server.close((err) => err ? reject(err) : resolve());
|
|
4900
5181
|
server.closeAllConnections();
|
|
@@ -4902,11 +5183,148 @@ async function startHarness(options = {}) {
|
|
|
4902
5183
|
};
|
|
4903
5184
|
}
|
|
4904
5185
|
//#endregion
|
|
5186
|
+
//#region src/worker/queue-consumer.ts
|
|
5187
|
+
function backoffSeconds(attempt) {
|
|
5188
|
+
return Math.min(60, 2 ** Math.min(attempt, 30));
|
|
5189
|
+
}
|
|
5190
|
+
async function republish(env, envelope, now = Date.now()) {
|
|
5191
|
+
await env.WORKFLOW_QUEUE.send(JSON.stringify(envelope), {
|
|
5192
|
+
contentType: "text",
|
|
5193
|
+
delaySeconds: nativeQueueDelaySeconds(envelope.notBefore, now)
|
|
5194
|
+
});
|
|
5195
|
+
}
|
|
5196
|
+
async function consume(message, env) {
|
|
5197
|
+
let envelope;
|
|
5198
|
+
try {
|
|
5199
|
+
if (typeof message.body !== "string") throw new TypeError("native Queue message body must be text");
|
|
5200
|
+
envelope = validateNativeQueueEnvelope(JSON.parse(message.body));
|
|
5201
|
+
} catch (error) {
|
|
5202
|
+
console.error("world-celld native Queue rejected malformed broker message", error);
|
|
5203
|
+
message.retry({ delaySeconds: backoffSeconds(message.attempts) });
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5206
|
+
const now = Date.now();
|
|
5207
|
+
if (envelope.notBefore !== void 0 && envelope.notBefore > now) {
|
|
5208
|
+
try {
|
|
5209
|
+
await republish(env, envelope, now);
|
|
5210
|
+
message.ack();
|
|
5211
|
+
} catch (error) {
|
|
5212
|
+
console.error("world-celld native Queue failed to continue a long delay", error);
|
|
5213
|
+
message.retry({ delaySeconds: backoffSeconds(message.attempts) });
|
|
5214
|
+
}
|
|
5215
|
+
return;
|
|
5216
|
+
}
|
|
5217
|
+
const attempt = (envelope.deliveryFailures ?? 0) + message.attempts;
|
|
5218
|
+
if (!env.WORLD_SECRET) {
|
|
5219
|
+
console.error("world-celld Queue consumer missing WORLD_SECRET");
|
|
5220
|
+
message.retry({ delaySeconds: backoffSeconds(attempt) });
|
|
5221
|
+
return;
|
|
5222
|
+
}
|
|
5223
|
+
let response;
|
|
5224
|
+
try {
|
|
5225
|
+
response = await env.WORLD_SERVICE.deliver(env.WORLD_SECRET, envelope, attempt);
|
|
5226
|
+
} catch (error) {
|
|
5227
|
+
console.error("world-celld Queue consumer could not reach the world service", error);
|
|
5228
|
+
message.retry({ delaySeconds: backoffSeconds(attempt) });
|
|
5229
|
+
return;
|
|
5230
|
+
}
|
|
5231
|
+
if (response?.kind === "complete") {
|
|
5232
|
+
message.ack();
|
|
5233
|
+
return;
|
|
5234
|
+
}
|
|
5235
|
+
if (response?.kind === "suspend") try {
|
|
5236
|
+
const notBefore = queueDelayDeadline(Date.now(), response.timeoutSeconds, 1, QUEUE_CLAIM_STALE_MS);
|
|
5237
|
+
const suspensionCount = (envelope.suspensionCount ?? 0) + 1;
|
|
5238
|
+
if (notBefore !== null && suspensionCount <= 256) {
|
|
5239
|
+
await republish(env, {
|
|
5240
|
+
...envelope,
|
|
5241
|
+
notBefore,
|
|
5242
|
+
deliveryFailures: attempt - 1,
|
|
5243
|
+
suspensionCount
|
|
5244
|
+
});
|
|
5245
|
+
message.ack();
|
|
5246
|
+
return;
|
|
5247
|
+
}
|
|
5248
|
+
} catch (error) {
|
|
5249
|
+
console.error("world-celld Queue consumer received an invalid suspension response", error);
|
|
5250
|
+
}
|
|
5251
|
+
message.retry({ delaySeconds: backoffSeconds(attempt) });
|
|
5252
|
+
}
|
|
5253
|
+
var queue_consumer_default = { async queue(batch, env) {
|
|
5254
|
+
await Promise.all(batch.messages.map((message) => consume(message, env)));
|
|
5255
|
+
} };
|
|
5256
|
+
//#endregion
|
|
4905
5257
|
//#region src/testing/index.ts
|
|
4906
5258
|
/** Alarm-pump interval for the dev fleet (real time). */
|
|
4907
5259
|
const PUMP_INTERVAL_MS = 25;
|
|
4908
5260
|
async function startDevFleet(options = {}) {
|
|
4909
5261
|
const harness = await startHarness(options);
|
|
5262
|
+
const pending = [];
|
|
5263
|
+
const deliveries = /* @__PURE__ */ new Set();
|
|
5264
|
+
let publicationCursor = 0;
|
|
5265
|
+
let stopped = false;
|
|
5266
|
+
const queueEnv = {
|
|
5267
|
+
WORKFLOW_QUEUE: { async send(body, sendOptions) {
|
|
5268
|
+
harness.queueMessages.push(body);
|
|
5269
|
+
harness.queuePublications.push({
|
|
5270
|
+
body,
|
|
5271
|
+
delaySeconds: sendOptions?.delaySeconds ?? 0
|
|
5272
|
+
});
|
|
5273
|
+
} },
|
|
5274
|
+
WORLD_SERVICE: { deliver: (secret, envelope, attempt) => harness.deliverQueueMessage(secret, envelope, attempt) },
|
|
5275
|
+
WORLD_SECRET: options.secret
|
|
5276
|
+
};
|
|
5277
|
+
function dispatch(message) {
|
|
5278
|
+
let settled = false;
|
|
5279
|
+
const delivery = queue_consumer_default.queue({ messages: [{
|
|
5280
|
+
body: message.body,
|
|
5281
|
+
attempts: message.attempts,
|
|
5282
|
+
ack() {
|
|
5283
|
+
settled = true;
|
|
5284
|
+
},
|
|
5285
|
+
retry(retryOptions) {
|
|
5286
|
+
settled = true;
|
|
5287
|
+
if (!stopped) pending.push({
|
|
5288
|
+
body: message.body,
|
|
5289
|
+
attempts: message.attempts + 1,
|
|
5290
|
+
dueAt: Date.now() + (retryOptions?.delaySeconds ?? 0) * 1e3
|
|
5291
|
+
});
|
|
5292
|
+
}
|
|
5293
|
+
}] }, queueEnv).then(() => {
|
|
5294
|
+
if (!settled) {
|
|
5295
|
+
console.error("[world-celld dev fleet] Queue delivery was not settled");
|
|
5296
|
+
if (!stopped) pending.push({
|
|
5297
|
+
body: message.body,
|
|
5298
|
+
attempts: message.attempts + 1,
|
|
5299
|
+
dueAt: Date.now() + 1e3
|
|
5300
|
+
});
|
|
5301
|
+
}
|
|
5302
|
+
}).catch((error) => {
|
|
5303
|
+
console.error("[world-celld dev fleet] Queue consumer failed", error);
|
|
5304
|
+
if (!settled && !stopped) pending.push({
|
|
5305
|
+
body: message.body,
|
|
5306
|
+
attempts: message.attempts + 1,
|
|
5307
|
+
dueAt: Date.now() + 1e3
|
|
5308
|
+
});
|
|
5309
|
+
});
|
|
5310
|
+
deliveries.add(delivery);
|
|
5311
|
+
delivery.finally(() => deliveries.delete(delivery));
|
|
5312
|
+
}
|
|
5313
|
+
const queuePump = setInterval(() => {
|
|
5314
|
+
if (stopped) return;
|
|
5315
|
+
while (publicationCursor < harness.queuePublications.length) {
|
|
5316
|
+
const publication = harness.queuePublications[publicationCursor++];
|
|
5317
|
+
pending.push({
|
|
5318
|
+
body: publication.body,
|
|
5319
|
+
attempts: 1,
|
|
5320
|
+
dueAt: Date.now() + publication.delaySeconds * 1e3
|
|
5321
|
+
});
|
|
5322
|
+
}
|
|
5323
|
+
const now = Date.now();
|
|
5324
|
+
for (let index = 0; index < pending.length;) if (pending[index].dueAt <= now) dispatch(pending.splice(index, 1)[0]);
|
|
5325
|
+
else index++;
|
|
5326
|
+
}, PUMP_INTERVAL_MS);
|
|
5327
|
+
queuePump.unref?.();
|
|
4910
5328
|
let pumping = false;
|
|
4911
5329
|
const pump = setInterval(async () => {
|
|
4912
5330
|
if (pumping) return;
|
|
@@ -4924,7 +5342,10 @@ async function startDevFleet(options = {}) {
|
|
|
4924
5342
|
return {
|
|
4925
5343
|
...harness,
|
|
4926
5344
|
async stop() {
|
|
5345
|
+
stopped = true;
|
|
5346
|
+
clearInterval(queuePump);
|
|
4927
5347
|
clearInterval(pump);
|
|
5348
|
+
await Promise.allSettled(deliveries);
|
|
4928
5349
|
await harness.close();
|
|
4929
5350
|
}
|
|
4930
5351
|
};
|