@ewhauser/world-celld 0.6.0 → 0.8.0
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 +35 -0
- package/README.md +32 -15
- package/celld-queue-worker/README.md +2 -2
- package/celld-queue-worker/wrangler.jsonc +3 -1
- package/celld-worker/README.md +3 -3
- package/dist/index.d.ts +54 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -100
- package/dist/index.js.map +1 -1
- package/dist/queue-consumer.d.ts +1 -1
- package/dist/queue-consumer.js +1 -1
- package/dist/{queue-delivery-DzNOtCYA.d.ts → queue-delivery-BdeNUbRN.d.ts} +29 -3
- package/dist/queue-delivery-BdeNUbRN.d.ts.map +1 -0
- package/dist/{queue-protocol-Ds44uuND.js → queue-protocol-DKYt8qZJ.js} +26 -3
- package/dist/queue-protocol-DKYt8qZJ.js.map +1 -0
- package/dist/{queue-protocol-CmJNMH_5.js → run-list-C987PkJ2.js} +147 -18
- package/dist/run-list-C987PkJ2.js.map +1 -0
- package/dist/testing.d.ts +4 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +762 -111
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +85 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +888 -130
- package/dist/worker.js.map +1 -1
- package/docs/celld-v0.6-upgrade.md +121 -0
- package/package.json +4 -2
- package/dist/queue-delivery-DzNOtCYA.d.ts.map +0 -1
- package/dist/queue-protocol-CmJNMH_5.js.map +0 -1
- package/dist/queue-protocol-Ds44uuND.js.map +0 -1
- package/docs/celld-v0.5-upgrade.md +0 -130
package/dist/worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as isPositiveSafeInteger, a as queueClaimName, b as strictIntegerSetting, c as queuePayloadRegistryKey, f as HOOK_CLAIM_LEASE_MS, g as isNonNegativeSafeInteger, h as QUEUE_CLAIM_STALE_MS, l as validateNativeQueueEnvelope, m as MAX_RUN_INDEX_PUBLICATION_LIFETIME_MS, n as QUEUE_PAYLOAD_REGISTRY_PREFIX, o as queueOrphanName, p as LIFECYCLE_COMPACTION_RETRY_MS, r as isInlineRunBody, s as queuePayloadObjectKey, t as NATIVE_QUEUE_MAX_MESSAGE_BYTES, u as validateNativeQueueSendOptions, v as isRecord$1, y as queueDelayDeadline } from "./queue-protocol-DKYt8qZJ.js";
|
|
2
2
|
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
|
3
3
|
import { CreateEventSchema, EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, applyAttributeChanges, eventIdToSlot, getMaxEventsPerRun, isChildEntityCreationEvent, isTerminalStepStatus, isTerminalWorkflowRunStatus, slotToEventId, validateAttributeChanges } from "@workflow/world";
|
|
4
4
|
//#region src/worker/auth.ts
|
|
@@ -42,6 +42,57 @@ async function authenticate(request, secret) {
|
|
|
42
42
|
return { ok: true };
|
|
43
43
|
}
|
|
44
44
|
//#endregion
|
|
45
|
+
//#region src/worker/queue-claims.ts
|
|
46
|
+
/**
|
|
47
|
+
* Idempotency claims for Queue messages. A claim lives either in its own
|
|
48
|
+
* claim cell (named by queue and idempotency key) or, for run-bearing
|
|
49
|
+
* messages whose envelope says so, as an entry in the run's own cell.
|
|
50
|
+
*/
|
|
51
|
+
function cellQueueClaim(cell) {
|
|
52
|
+
return {
|
|
53
|
+
claim: (messageId, staleMs) => cell.claimInflight({
|
|
54
|
+
messageId,
|
|
55
|
+
staleMs
|
|
56
|
+
}),
|
|
57
|
+
hold: (params) => cell.holdInflight(params),
|
|
58
|
+
release: (messageId) => cell.releaseInflight(messageId),
|
|
59
|
+
complete: (messageId) => cell.completeQueueMessage(messageId)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function runQueueClaim(run, claimName) {
|
|
63
|
+
return {
|
|
64
|
+
claim: async (messageId, staleMs) => {
|
|
65
|
+
const result = await run.claimRunQueueMessage({
|
|
66
|
+
claimName,
|
|
67
|
+
messageId,
|
|
68
|
+
staleMs
|
|
69
|
+
});
|
|
70
|
+
return result.expired ? {
|
|
71
|
+
expired: true,
|
|
72
|
+
claimed: false
|
|
73
|
+
} : result;
|
|
74
|
+
},
|
|
75
|
+
hold: (params) => run.holdRunQueueMessage({
|
|
76
|
+
claimName,
|
|
77
|
+
...params
|
|
78
|
+
}),
|
|
79
|
+
release: (messageId) => run.releaseRunQueueMessage({
|
|
80
|
+
claimName,
|
|
81
|
+
messageId
|
|
82
|
+
}),
|
|
83
|
+
complete: (messageId) => run.completeRunQueueMessage({
|
|
84
|
+
claimName,
|
|
85
|
+
messageId
|
|
86
|
+
})
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** The claim a delivered envelope was reserved under, if it has an idempotency key. */
|
|
90
|
+
function deliveryQueueClaim(envelope, runStub) {
|
|
91
|
+
if (!envelope.idempotencyKey) return void 0;
|
|
92
|
+
const claimName = queueClaimName(envelope.queueName, envelope.idempotencyKey);
|
|
93
|
+
return envelope.claimScope === "run" && envelope.runId ? runQueueClaim(runStub(envelope.runId), claimName) : cellQueueClaim(runStub(claimName));
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
45
96
|
//#region src/worker/queue-delivery.ts
|
|
46
97
|
/** Internal delivery contract. No Request/Response objects cross the RPC boundary. */
|
|
47
98
|
const MAX_QUEUE_DELIVERY_RESPONSE_BYTES = 65536;
|
|
@@ -84,10 +135,10 @@ async function deliverQueueMessage(env, secret, input, attempt) {
|
|
|
84
135
|
const namespace = env.WORKFLOW_DB;
|
|
85
136
|
const payloadStore = env.WORKFLOW_QUEUE_PAYLOADS;
|
|
86
137
|
const runStub = (name) => namespace.get(namespace.idFromName(name));
|
|
87
|
-
|
|
138
|
+
const claim = deliveryQueueClaim(envelope, runStub);
|
|
88
139
|
let claimed = false;
|
|
89
140
|
async function releaseClaim() {
|
|
90
|
-
if (claimed) await claim.
|
|
141
|
+
if (claimed) await claim.release(envelope.messageId);
|
|
91
142
|
}
|
|
92
143
|
async function unregisterPayload() {
|
|
93
144
|
if (envelope.runId) {
|
|
@@ -96,12 +147,14 @@ async function deliverQueueMessage(env, secret, input, attempt) {
|
|
|
96
147
|
}
|
|
97
148
|
}
|
|
98
149
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
150
|
+
let admission;
|
|
151
|
+
if (envelope.runId && !envelope.payloadKey && envelope.claimScope !== "run") {
|
|
152
|
+
admission = runStub(envelope.runId).getQueueAdmission();
|
|
153
|
+
admission.catch(() => void 0);
|
|
154
|
+
}
|
|
155
|
+
if (claim) {
|
|
156
|
+
const result = await claim.claim(envelope.messageId, QUEUE_CLAIM_STALE_MS);
|
|
157
|
+
if (result.expired) return { kind: "complete" };
|
|
105
158
|
if (!result.claimed) {
|
|
106
159
|
const now = Date.now();
|
|
107
160
|
if (result.retryAt !== void 0 && result.retryAt > now) return {
|
|
@@ -112,13 +165,17 @@ async function deliverQueueMessage(env, secret, input, attempt) {
|
|
|
112
165
|
}
|
|
113
166
|
claimed = true;
|
|
114
167
|
}
|
|
168
|
+
if (admission && !(await admission).ok) {
|
|
169
|
+
await claim?.complete(envelope.messageId);
|
|
170
|
+
return { kind: "complete" };
|
|
171
|
+
}
|
|
115
172
|
let body = envelope.body;
|
|
116
173
|
if (envelope.payloadKey) {
|
|
117
174
|
if (!payloadStore) throw new Error("missing binding: WORKFLOW_QUEUE_PAYLOADS");
|
|
118
175
|
body = await payloadStore.read(envelope.payloadKey) ?? void 0;
|
|
119
176
|
if (body === void 0) {
|
|
120
177
|
await unregisterPayload();
|
|
121
|
-
await claim?.
|
|
178
|
+
await claim?.complete(envelope.messageId);
|
|
122
179
|
return { kind: "complete" };
|
|
123
180
|
}
|
|
124
181
|
}
|
|
@@ -141,7 +198,7 @@ async function deliverQueueMessage(env, secret, input, attempt) {
|
|
|
141
198
|
await payloadStore.delete(envelope.payloadKey);
|
|
142
199
|
await unregisterPayload();
|
|
143
200
|
}
|
|
144
|
-
await claim?.
|
|
201
|
+
await claim?.complete(envelope.messageId);
|
|
145
202
|
return { kind: "complete" };
|
|
146
203
|
}
|
|
147
204
|
if (callback.status === 503) {
|
|
@@ -155,7 +212,7 @@ async function deliverQueueMessage(env, secret, input, attempt) {
|
|
|
155
212
|
key: envelope.payloadKey,
|
|
156
213
|
expiresAt: notBefore + QUEUE_ORPHAN_GRACE_MS$1
|
|
157
214
|
});
|
|
158
|
-
if (claim) await claim.
|
|
215
|
+
if (claim) await claim.hold({
|
|
159
216
|
messageId: envelope.messageId,
|
|
160
217
|
retryAt: notBefore,
|
|
161
218
|
expiresAt: notBefore + QUEUE_CLAIM_STALE_MS,
|
|
@@ -197,22 +254,34 @@ function dateReviver(key, value) {
|
|
|
197
254
|
return value;
|
|
198
255
|
}
|
|
199
256
|
/**
|
|
200
|
-
* Base64 helpers. Modified vs upstream: implemented
|
|
201
|
-
*
|
|
202
|
-
*
|
|
257
|
+
* Base64 helpers. Modified vs upstream: implemented without Buffer so this
|
|
258
|
+
* module is safe inside the celld worker bundle (workerd has no Buffer
|
|
259
|
+
* global). Uses the native Uint8Array base64 methods where the runtime has
|
|
260
|
+
* them (Node >= 25, current V8) and falls back to atob/btoa elsewhere.
|
|
203
261
|
*/
|
|
204
|
-
|
|
262
|
+
const nativeToBase64 = Uint8Array.prototype.toBase64;
|
|
263
|
+
const nativeFromBase64 = Uint8Array.fromBase64;
|
|
264
|
+
/** Small enough to stay far below engine argument-count limits and fast to spread. */
|
|
265
|
+
const FALLBACK_ENCODE_CHUNK = 4096;
|
|
266
|
+
/** @internal Exported for tests; use b64encode. */
|
|
267
|
+
function b64encodeFallback(bytes) {
|
|
205
268
|
let bin = "";
|
|
206
|
-
|
|
207
|
-
for (let i = 0; i < bytes.length; i += CHUNK) bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
269
|
+
for (let i = 0; i < bytes.length; i += FALLBACK_ENCODE_CHUNK) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + FALLBACK_ENCODE_CHUNK));
|
|
208
270
|
return btoa(bin);
|
|
209
271
|
}
|
|
210
|
-
|
|
272
|
+
/** @internal Exported for tests; use b64decode. */
|
|
273
|
+
function b64decodeFallback(text) {
|
|
211
274
|
const bin = atob(text);
|
|
212
275
|
const out = new Uint8Array(bin.length);
|
|
213
276
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
214
277
|
return out;
|
|
215
278
|
}
|
|
279
|
+
function b64encode(bytes) {
|
|
280
|
+
return nativeToBase64 ? nativeToBase64.call(bytes) : b64encodeFallback(bytes);
|
|
281
|
+
}
|
|
282
|
+
function b64decode(text) {
|
|
283
|
+
return nativeFromBase64 ? nativeFromBase64(text) : b64decodeFallback(text);
|
|
284
|
+
}
|
|
216
285
|
/**
|
|
217
286
|
* JSON reviver that decodes tagged Uint8Array objects and ISO date strings.
|
|
218
287
|
* Supports both:
|
|
@@ -1068,6 +1137,7 @@ async function finalizeEventPage(store, outcome, params) {
|
|
|
1068
1137
|
maxEvents: getMaxEventsPerRun()
|
|
1069
1138
|
} : outcome;
|
|
1070
1139
|
if (!result.event) return result;
|
|
1140
|
+
if (params?.preloadEvents && result.event.eventType === "hook_received") return await attachHookReplayPreload(store, result);
|
|
1071
1141
|
if (typeof params?.sinceCursor === "string") {
|
|
1072
1142
|
const page = await listByPrefix(store, EVENT_KEY_PREFIX, {
|
|
1073
1143
|
limit: 100,
|
|
@@ -1097,6 +1167,32 @@ async function finalizeEventPage(store, outcome, params) {
|
|
|
1097
1167
|
hasMore: events.length < skippedCount
|
|
1098
1168
|
};
|
|
1099
1169
|
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Lazy hook resume preload: return the run and its complete replay log with
|
|
1172
|
+
* the hook_received write, read in the same transaction, so the runtime can
|
|
1173
|
+
* skip its run_started write and the index publication that follows it. The
|
|
1174
|
+
* runtime trusts only a complete log with a non-null cursor, so a run that is
|
|
1175
|
+
* not started, not running, or over the event ceiling gets the plain result.
|
|
1176
|
+
*/
|
|
1177
|
+
async function attachHookReplayPreload(store, result) {
|
|
1178
|
+
const run = await store.get(RUN_KEY);
|
|
1179
|
+
if (!run?.startedAt || run.status !== "running") return result;
|
|
1180
|
+
const maxEvents = getMaxEventsPerRun();
|
|
1181
|
+
const log = await listByPrefix(store, EVENT_KEY_PREFIX, {
|
|
1182
|
+
limit: maxEvents,
|
|
1183
|
+
sortOrder: "asc"
|
|
1184
|
+
}, (event) => event.eventId);
|
|
1185
|
+
const last = log.data.at(-1);
|
|
1186
|
+
if (log.hasMore || last === void 0) return result;
|
|
1187
|
+
return {
|
|
1188
|
+
...result,
|
|
1189
|
+
replayRun: run,
|
|
1190
|
+
maxEvents,
|
|
1191
|
+
events: log.data,
|
|
1192
|
+
cursor: last.eventId,
|
|
1193
|
+
hasMore: false
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1100
1196
|
//#endregion
|
|
1101
1197
|
//#region src/codec.ts
|
|
1102
1198
|
/**
|
|
@@ -1110,7 +1206,8 @@ async function finalizeEventPage(store, outcome, params) {
|
|
|
1110
1206
|
* `{__type:'Date', iso}`.
|
|
1111
1207
|
*
|
|
1112
1208
|
* Runs on both sides of the wire, including inside the celld worker — so no
|
|
1113
|
-
* Node built-ins (Buffer): base64
|
|
1209
|
+
* Node built-ins (Buffer): base64 uses the native Uint8Array methods or
|
|
1210
|
+
* atob/btoa.
|
|
1114
1211
|
*/
|
|
1115
1212
|
function rpcReplacer(key, value) {
|
|
1116
1213
|
const original = this && typeof this === "object" ? this[key] : void 0;
|
|
@@ -1150,6 +1247,79 @@ function rpcStringify(value) {
|
|
|
1150
1247
|
function rpcParse(text) {
|
|
1151
1248
|
return JSON.parse(text, rpcReviver);
|
|
1152
1249
|
}
|
|
1250
|
+
/** Largest page the fleet-side list accepts, matching the catalog page limit. */
|
|
1251
|
+
const MAX_RUN_LIST_LIMIT = 1e3;
|
|
1252
|
+
/**
|
|
1253
|
+
* Run statuses only move pending -> running -> terminal, and terminal statuses
|
|
1254
|
+
* are immutable. An older index value may therefore safely exclude a filter
|
|
1255
|
+
* only when it is already terminal, or when the caller asks for pending and
|
|
1256
|
+
* the index has advanced beyond pending. Earlier non-terminal metadata cannot
|
|
1257
|
+
* exclude a later status because a post-commit index write may need replay.
|
|
1258
|
+
*/
|
|
1259
|
+
function indexStatusExcludes(indexed, requested) {
|
|
1260
|
+
if (requested === void 0 || indexed === requested) return false;
|
|
1261
|
+
return isTerminalWorkflowRunStatus(indexed) || requested === "pending";
|
|
1262
|
+
}
|
|
1263
|
+
function stripRunData(run) {
|
|
1264
|
+
const { input: _input, output: _output, ...rest } = run;
|
|
1265
|
+
return rest;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Page through the run catalog, verifying every candidate against its
|
|
1269
|
+
* authoritative run cell. Runs on the client against remote bindings, or in
|
|
1270
|
+
* the worker router against fleet bindings.
|
|
1271
|
+
*/
|
|
1272
|
+
async function listRunsPage(deps, request) {
|
|
1273
|
+
const { statuses, limit } = request;
|
|
1274
|
+
const prefix = request.workflowName ? `run:${request.workflowName}:` : "runall:";
|
|
1275
|
+
const reverse = request.sortOrder === "desc";
|
|
1276
|
+
const matches = [];
|
|
1277
|
+
let scanCursor = request.cursor;
|
|
1278
|
+
let exhausted = false;
|
|
1279
|
+
while (matches.length <= limit && !exhausted) {
|
|
1280
|
+
const kvList = await deps.listRuns({
|
|
1281
|
+
prefix,
|
|
1282
|
+
limit: Math.min(MAX_RUN_LIST_LIMIT, Math.max(50, limit)),
|
|
1283
|
+
cursor: scanCursor,
|
|
1284
|
+
reverse
|
|
1285
|
+
});
|
|
1286
|
+
if (kvList.keys.length === 0) {
|
|
1287
|
+
exhausted = true;
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
const candidates = [];
|
|
1291
|
+
for (const key of kvList.keys) {
|
|
1292
|
+
const metadata = JSON.parse(key.value);
|
|
1293
|
+
if (statuses?.every((status) => indexStatusExcludes(metadata.status, status))) continue;
|
|
1294
|
+
candidates.push({
|
|
1295
|
+
key: key.name,
|
|
1296
|
+
metadata
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
for (let offset = 0; offset < candidates.length && matches.length <= limit;) {
|
|
1300
|
+
const needed = limit + 1 - matches.length;
|
|
1301
|
+
const batch = candidates.slice(offset, offset + Math.min(8, needed));
|
|
1302
|
+
const resolved = await Promise.all(batch.map(async ({ key, metadata }) => {
|
|
1303
|
+
const run = await deps.readRun(metadata.runId);
|
|
1304
|
+
return run && (statuses === void 0 || statuses.includes(run.status)) ? {
|
|
1305
|
+
key,
|
|
1306
|
+
run
|
|
1307
|
+
} : null;
|
|
1308
|
+
}));
|
|
1309
|
+
for (const match of resolved) if (match) matches.push(match);
|
|
1310
|
+
offset += batch.length;
|
|
1311
|
+
}
|
|
1312
|
+
exhausted = kvList.list_complete;
|
|
1313
|
+
scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
|
|
1314
|
+
}
|
|
1315
|
+
const hasMore = matches.length > limit;
|
|
1316
|
+
const page = matches.slice(0, limit);
|
|
1317
|
+
return {
|
|
1318
|
+
data: page.map(({ run }) => request.resolveData === "none" ? stripRunData(run) : run),
|
|
1319
|
+
cursor: hasMore ? page.at(-1)?.key ?? null : null,
|
|
1320
|
+
hasMore
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1153
1323
|
const INDEX_PROTOCOL_VERSION = "v1";
|
|
1154
1324
|
const HOOK_RELEASE_BATCH = 64;
|
|
1155
1325
|
/** Stable FNV-1a routing hash. Collisions share a shard but never a storage key. */
|
|
@@ -1178,6 +1348,14 @@ function hookIdShardName(hookId) {
|
|
|
1178
1348
|
function allRunCatalogShardNames() {
|
|
1179
1349
|
return Array.from({ length: 16 }, (_, shard) => `run-catalog:${INDEX_PROTOCOL_VERSION}:${shard.toString(16).padStart(2, "0")}`);
|
|
1180
1350
|
}
|
|
1351
|
+
/** Catalog metadata for a run; the client and the worker router must agree on it. */
|
|
1352
|
+
function runIndexMetadata(run) {
|
|
1353
|
+
return JSON.stringify({
|
|
1354
|
+
runId: run.runId,
|
|
1355
|
+
createdAt: run.createdAt.toISOString(),
|
|
1356
|
+
status: run.status
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1181
1359
|
function stub(namespace, name) {
|
|
1182
1360
|
return namespace.get(namespace.idFromName(name));
|
|
1183
1361
|
}
|
|
@@ -1343,6 +1521,8 @@ const STREAM_BATCH_CONTENT_TYPE = "application/vnd.world-celld.stream-batch.v1";
|
|
|
1343
1521
|
const MAX_STREAM_CHUNK_BYTES = 1048576;
|
|
1344
1522
|
/** Keeps serialized RPC bodies well below the 32 MiB Workers RPC limit. */
|
|
1345
1523
|
const MAX_STREAM_BATCH_BYTES = 8388608;
|
|
1524
|
+
/** Response header on stream requests: the per-request chunk limit this worker accepts. */
|
|
1525
|
+
const STREAM_CHUNK_LIMIT_HEADER = "x-world-celld-stream-chunks";
|
|
1346
1526
|
const MAX_STREAM_READ_BYTES = 8388608;
|
|
1347
1527
|
const MAX_STREAM_ERROR_BYTES = 16384;
|
|
1348
1528
|
/** Must remain below the default 30 second fleet request deadline. */
|
|
@@ -1370,7 +1550,7 @@ function requireSafeIndex(value, field) {
|
|
|
1370
1550
|
}
|
|
1371
1551
|
function validateStreamWriteChunks(chunks) {
|
|
1372
1552
|
if (chunks.length === 0) throw protocolError("batch must contain at least one chunk");
|
|
1373
|
-
if (chunks.length >
|
|
1553
|
+
if (chunks.length > 512) throw protocolError(`batch exceeds 512 chunks`);
|
|
1374
1554
|
let totalBytes = 0;
|
|
1375
1555
|
for (const chunk of chunks) {
|
|
1376
1556
|
if (!(chunk instanceof Uint8Array)) throw protocolError("chunk is not binary data");
|
|
@@ -1383,7 +1563,7 @@ function validateStreamWriteChunks(chunks) {
|
|
|
1383
1563
|
function validateStreamReadRequest(request) {
|
|
1384
1564
|
if (!request.runId) throw protocolError("runId is required");
|
|
1385
1565
|
if (!Number.isSafeInteger(request.startIndex) || request.startIndex < 0 || request.startIndex > 2147483647) throw protocolError("startIndex must be between 0 and 2147483647");
|
|
1386
|
-
if (!Number.isSafeInteger(request.maxChunks) || request.maxChunks < 0 || request.maxChunks >
|
|
1566
|
+
if (!Number.isSafeInteger(request.maxChunks) || request.maxChunks < 0 || request.maxChunks > 512) throw protocolError(`maxChunks must be between 0 and 512`);
|
|
1387
1567
|
if (!Number.isSafeInteger(request.maxBytes) || request.maxBytes < 1048576 || request.maxBytes > 8388608) throw protocolError(`maxBytes must be between ${MAX_STREAM_CHUNK_BYTES} and ${MAX_STREAM_READ_BYTES}`);
|
|
1388
1568
|
if (!Number.isSafeInteger(request.waitMs) || request.waitMs < 0 || request.waitMs > 2e4) throw protocolError(`waitMs must be between 0 and ${MAX_STREAM_LONG_POLL_MS}`);
|
|
1389
1569
|
}
|
|
@@ -1410,7 +1590,7 @@ function decodeStreamWriteBatch(encoded) {
|
|
|
1410
1590
|
function encodeStreamWriteResult(result) {
|
|
1411
1591
|
requireSafeIndex(result.startIndex, "startIndex");
|
|
1412
1592
|
requireSafeIndex(result.tailIndex, "tailIndex");
|
|
1413
|
-
if (!Number.isSafeInteger(result.count) || result.count < 1 || result.count >
|
|
1593
|
+
if (!Number.isSafeInteger(result.count) || result.count < 1 || result.count > 512) throw protocolError("write count is out of range");
|
|
1414
1594
|
const encoded = new Uint8Array(WRITE_ACK_BYTES);
|
|
1415
1595
|
const view = new DataView(encoded.buffer);
|
|
1416
1596
|
view.setUint32(0, WRITE_ACK_MAGIC);
|
|
@@ -1425,7 +1605,7 @@ function encodeStreamReadResult(result) {
|
|
|
1425
1605
|
requireSafeIndex(result.tailIndex, "tailIndex");
|
|
1426
1606
|
let chunkBytes = 0;
|
|
1427
1607
|
for (const chunk of result.chunks) chunkBytes += 4 + chunk.byteLength;
|
|
1428
|
-
if (result.chunks.length >
|
|
1608
|
+
if (result.chunks.length > 512 || chunkBytes > 8390656) throw protocolError("read result exceeds configured bounds");
|
|
1429
1609
|
const encoder = new TextEncoder();
|
|
1430
1610
|
const errorName = result.error ? encoder.encode(result.error.name) : /* @__PURE__ */ new Uint8Array();
|
|
1431
1611
|
const errorMessage = result.error ? encoder.encode(result.error.message) : /* @__PURE__ */ new Uint8Array();
|
|
@@ -1577,6 +1757,34 @@ function listOptions(value) {
|
|
|
1577
1757
|
}
|
|
1578
1758
|
return parsed;
|
|
1579
1759
|
}
|
|
1760
|
+
const RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
1761
|
+
"pending",
|
|
1762
|
+
"running",
|
|
1763
|
+
"completed",
|
|
1764
|
+
"failed",
|
|
1765
|
+
"cancelled"
|
|
1766
|
+
]);
|
|
1767
|
+
function runListRequest(value) {
|
|
1768
|
+
if (!isRecord$1(value)) invalid("run list request must be an object");
|
|
1769
|
+
const limit = value.limit;
|
|
1770
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) invalid(`request.limit must be an integer between 1 and ${MAX_RUN_LIST_LIMIT}`);
|
|
1771
|
+
const parsed = { limit };
|
|
1772
|
+
if (value.workflowName !== void 0) parsed.workflowName = nonEmptyString(value.workflowName, "request.workflowName");
|
|
1773
|
+
if (value.cursor !== void 0) parsed.cursor = stringValue(value.cursor, "request.cursor");
|
|
1774
|
+
if (value.statuses !== void 0) {
|
|
1775
|
+
if (!Array.isArray(value.statuses) || value.statuses.length === 0 || !value.statuses.every((status) => RUN_STATUSES.has(status))) invalid("request.statuses must be a non-empty array of run statuses");
|
|
1776
|
+
parsed.statuses = value.statuses;
|
|
1777
|
+
}
|
|
1778
|
+
if (value.sortOrder !== void 0) {
|
|
1779
|
+
if (value.sortOrder !== "asc" && value.sortOrder !== "desc") invalid("request.sortOrder must be 'asc' or 'desc'");
|
|
1780
|
+
parsed.sortOrder = value.sortOrder;
|
|
1781
|
+
}
|
|
1782
|
+
if (value.resolveData !== void 0) {
|
|
1783
|
+
if (value.resolveData !== "none" && value.resolveData !== "all") invalid("request.resolveData must be 'none' or 'all'");
|
|
1784
|
+
parsed.resolveData = value.resolveData;
|
|
1785
|
+
}
|
|
1786
|
+
return parsed;
|
|
1787
|
+
}
|
|
1580
1788
|
function runCommit(args) {
|
|
1581
1789
|
exactArgs(args, 3);
|
|
1582
1790
|
const parsedRun = WorkflowRunSchema.safeParse(args[0]);
|
|
@@ -1637,6 +1845,12 @@ function validateIndexRequest(operation, args) {
|
|
|
1637
1845
|
operation,
|
|
1638
1846
|
args: [listOptions(args[0])]
|
|
1639
1847
|
};
|
|
1848
|
+
case "runs.list-resolved":
|
|
1849
|
+
exactArgs(args, 1);
|
|
1850
|
+
return {
|
|
1851
|
+
operation,
|
|
1852
|
+
args: [runListRequest(args[0])]
|
|
1853
|
+
};
|
|
1640
1854
|
case "runs.commit": return runCommit(args);
|
|
1641
1855
|
case "runs.expire":
|
|
1642
1856
|
exactArgs(args, 1);
|
|
@@ -1672,6 +1886,7 @@ function validateIndexRequest(operation, args) {
|
|
|
1672
1886
|
}
|
|
1673
1887
|
const INDEX_OPERATIONS = /* @__PURE__ */ new Set([
|
|
1674
1888
|
"runs.list",
|
|
1889
|
+
"runs.list-resolved",
|
|
1675
1890
|
"runs.commit",
|
|
1676
1891
|
"runs.expire",
|
|
1677
1892
|
"hooks.reserve",
|
|
@@ -1696,6 +1911,18 @@ const INDEX_OPERATIONS = /* @__PURE__ */ new Set([
|
|
|
1696
1911
|
*/
|
|
1697
1912
|
const WORLD_NAME = "world-celld";
|
|
1698
1913
|
const WORLD_VERSION = "0.1.0";
|
|
1914
|
+
/**
|
|
1915
|
+
* Per-isolate record of runs whose Queue idempotency claims stay in claim
|
|
1916
|
+
* cells (created before run-scoped claims) or live in the run cell. A run's
|
|
1917
|
+
* scope never changes, so the cache only saves the run-cell question.
|
|
1918
|
+
*/
|
|
1919
|
+
const runClaimScopes = /* @__PURE__ */ new Map();
|
|
1920
|
+
const MAX_REMEMBERED_RUN_CLAIM_SCOPES = 4096;
|
|
1921
|
+
function rememberRunClaimScope(runId, scope) {
|
|
1922
|
+
if (runClaimScopes.has(runId)) return;
|
|
1923
|
+
if (runClaimScopes.size >= MAX_REMEMBERED_RUN_CLAIM_SCOPES) runClaimScopes.delete(runClaimScopes.keys().next().value);
|
|
1924
|
+
runClaimScopes.set(runId, scope);
|
|
1925
|
+
}
|
|
1699
1926
|
function workflowIndex(env) {
|
|
1700
1927
|
return createWorkflowIndex({
|
|
1701
1928
|
runCatalog: env.WORKFLOW_RUN_CATALOG,
|
|
@@ -1703,6 +1930,37 @@ function workflowIndex(env) {
|
|
|
1703
1930
|
hookIds: env.WORKFLOW_HOOK_IDS
|
|
1704
1931
|
});
|
|
1705
1932
|
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Publish a committed applyEvent outcome to the derivative indexes inside the
|
|
1935
|
+
* fleet, sparing the client a round trip per run mutation. Each index write is
|
|
1936
|
+
* idempotent under the outcome's publication lease. A failed write leaves its
|
|
1937
|
+
* flag unset, so the client publishes it itself exactly as it would without
|
|
1938
|
+
* this step; older clients ignore the flags and publish again.
|
|
1939
|
+
*/
|
|
1940
|
+
async function publishApplyEventIndexes(env, runId, outcome) {
|
|
1941
|
+
if (typeof outcome !== "object" || outcome === null) return;
|
|
1942
|
+
const success = outcome;
|
|
1943
|
+
if (success.ok !== true) return;
|
|
1944
|
+
const index = workflowIndex(env);
|
|
1945
|
+
const publications = [];
|
|
1946
|
+
if (success.run && typeof success.indexPublicationExpiresAt === "number") {
|
|
1947
|
+
const run = success.run;
|
|
1948
|
+
const lease = success.indexPublicationExpiresAt;
|
|
1949
|
+
publications.push((async () => {
|
|
1950
|
+
await index.commitRun(run, runIndexMetadata(run), lease);
|
|
1951
|
+
success.runIndexPublished = true;
|
|
1952
|
+
})());
|
|
1953
|
+
}
|
|
1954
|
+
const releasedHooks = success.releasedHooks;
|
|
1955
|
+
if (Array.isArray(releasedHooks) && releasedHooks.length > 0) publications.push((async () => {
|
|
1956
|
+
await index.releaseHookIndexes({
|
|
1957
|
+
runId,
|
|
1958
|
+
hooks: releasedHooks
|
|
1959
|
+
});
|
|
1960
|
+
success.hookIndexesReleased = true;
|
|
1961
|
+
})());
|
|
1962
|
+
await Promise.allSettled(publications);
|
|
1963
|
+
}
|
|
1706
1964
|
const BINDINGS = {
|
|
1707
1965
|
runs: {
|
|
1708
1966
|
env: "WORKFLOW_DB",
|
|
@@ -1745,7 +2003,7 @@ const BINDINGS = {
|
|
|
1745
2003
|
};
|
|
1746
2004
|
/** Request body cap: oversize payloads get a clear 413 instead of an OOM. */
|
|
1747
2005
|
const MAX_BODY_BYTES = 33554432;
|
|
1748
|
-
const MAX_STREAM_WRITE_BODY_BYTES =
|
|
2006
|
+
const MAX_STREAM_WRITE_BODY_BYTES = 8391680;
|
|
1749
2007
|
const QUEUE_ORPHAN_GRACE_MS = 432e6;
|
|
1750
2008
|
function errorResponse(status, name, message) {
|
|
1751
2009
|
return Response.json({ error: {
|
|
@@ -1862,7 +2120,10 @@ function parseBoundedInteger(url, name, minimum, maximum) {
|
|
|
1862
2120
|
function streamResponse(body) {
|
|
1863
2121
|
return new Response(body, {
|
|
1864
2122
|
status: 200,
|
|
1865
|
-
headers: {
|
|
2123
|
+
headers: {
|
|
2124
|
+
"content-type": STREAM_BATCH_CONTENT_TYPE,
|
|
2125
|
+
[STREAM_CHUNK_LIMIT_HEADER]: String(512)
|
|
2126
|
+
}
|
|
1866
2127
|
});
|
|
1867
2128
|
}
|
|
1868
2129
|
function createRouter(env) {
|
|
@@ -1899,31 +2160,71 @@ function createRouter(env) {
|
|
|
1899
2160
|
try {
|
|
1900
2161
|
const namespace = env.WORKFLOW_DB;
|
|
1901
2162
|
const payloadStore = env.WORKFLOW_QUEUE_PAYLOADS;
|
|
1902
|
-
const
|
|
1903
|
-
const
|
|
2163
|
+
const inlineRunBody = envelope.runId !== void 0 && isInlineRunBody(envelope.body);
|
|
2164
|
+
const payloadKey = envelope.runId && !inlineRunBody ? queuePayloadObjectKey(envelope.runId, envelope.messageId) : void 0;
|
|
2165
|
+
let brokerEnvelope = payloadKey ? {
|
|
1904
2166
|
...envelope,
|
|
1905
2167
|
payloadKey,
|
|
1906
2168
|
body: void 0
|
|
1907
2169
|
} : envelope;
|
|
1908
|
-
const
|
|
1909
|
-
|
|
2170
|
+
const sized = JSON.stringify({
|
|
2171
|
+
...brokerEnvelope,
|
|
2172
|
+
claimScope: "run"
|
|
2173
|
+
});
|
|
2174
|
+
if (new TextEncoder().encode(sized).byteLength > 128e3) return errorResponse(413, "PayloadTooLarge", `native queue envelope exceeds ${NATIVE_QUEUE_MAX_MESSAGE_BYTES} bytes`);
|
|
1910
2175
|
const now = Date.now();
|
|
1911
2176
|
const reservationExpiresAt = Math.max(now, envelope.notBefore ?? now) + QUEUE_ORPHAN_GRACE_MS;
|
|
2177
|
+
const run = envelope.runId ? namespace.get(namespace.idFromName(envelope.runId)) : void 0;
|
|
1912
2178
|
let claim;
|
|
2179
|
+
let admission;
|
|
1913
2180
|
if (envelope.idempotencyKey) {
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2181
|
+
const claimName = queueClaimName(envelope.queueName, envelope.idempotencyKey);
|
|
2182
|
+
let scope = run && envelope.runId ? runClaimScopes.get(envelope.runId) : "cell";
|
|
2183
|
+
let reservation;
|
|
2184
|
+
if (run && envelope.runId && scope !== "cell") {
|
|
2185
|
+
const reserved = await run.reserveRunQueueMessage({
|
|
2186
|
+
claimName,
|
|
2187
|
+
messageId: envelope.messageId,
|
|
2188
|
+
expiresAt: reservationExpiresAt
|
|
2189
|
+
});
|
|
2190
|
+
if (!reserved.ok) return errorResponse(410, "RunExpiredError", reserved.message);
|
|
2191
|
+
scope = reserved.scope;
|
|
2192
|
+
rememberRunClaimScope(envelope.runId, reserved.scope);
|
|
2193
|
+
if (reserved.scope === "run") {
|
|
2194
|
+
reservation = reserved;
|
|
2195
|
+
claim = runQueueClaim(run, claimName);
|
|
2196
|
+
brokerEnvelope = {
|
|
2197
|
+
...brokerEnvelope,
|
|
2198
|
+
claimScope: "run"
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
} else if (inlineRunBody && run) {
|
|
2202
|
+
admission = run.getQueueAdmission();
|
|
2203
|
+
admission.catch(() => void 0);
|
|
2204
|
+
}
|
|
2205
|
+
if (!reservation) {
|
|
2206
|
+
const cell = namespace.get(namespace.idFromName(claimName));
|
|
2207
|
+
reservation = await cell.reserveQueueMessage({
|
|
2208
|
+
messageId: envelope.messageId,
|
|
2209
|
+
expiresAt: reservationExpiresAt
|
|
2210
|
+
});
|
|
2211
|
+
claim = cellQueueClaim(cell);
|
|
2212
|
+
}
|
|
1919
2213
|
if (!reservation.admitted) return new Response(rpcStringify({ messageId: reservation.messageId }), {
|
|
1920
2214
|
status: 200,
|
|
1921
2215
|
headers: { "content-type": "application/json" }
|
|
1922
2216
|
});
|
|
2217
|
+
} else if (inlineRunBody && run) admission = run.getQueueAdmission();
|
|
2218
|
+
const encoded = JSON.stringify(brokerEnvelope);
|
|
2219
|
+
if (admission) {
|
|
2220
|
+
const admitted = await admission;
|
|
2221
|
+
if (!admitted.ok) {
|
|
2222
|
+
await claim?.complete(envelope.messageId);
|
|
2223
|
+
return errorResponse(410, "RunExpiredError", admitted.message);
|
|
2224
|
+
}
|
|
1923
2225
|
}
|
|
1924
|
-
if (envelope.runId && payloadKey) {
|
|
2226
|
+
if (run && envelope.runId && payloadKey) {
|
|
1925
2227
|
if (!payloadStore) return errorResponse(500, "WorldMisconfigured", "missing binding: WORKFLOW_QUEUE_PAYLOADS");
|
|
1926
|
-
const run = namespace.get(namespace.idFromName(envelope.runId));
|
|
1927
2228
|
const orphanExpiresAt = reservationExpiresAt;
|
|
1928
2229
|
const registered = await run.registerQueuePayload({
|
|
1929
2230
|
messageId: envelope.messageId,
|
|
@@ -1931,7 +2232,7 @@ function createRouter(env) {
|
|
|
1931
2232
|
orphanExpiresAt
|
|
1932
2233
|
});
|
|
1933
2234
|
if (!registered.ok) {
|
|
1934
|
-
await claim?.
|
|
2235
|
+
await claim?.complete(envelope.messageId);
|
|
1935
2236
|
return errorResponse(410, "RunExpiredError", registered.message);
|
|
1936
2237
|
}
|
|
1937
2238
|
const orphan = namespace.get(namespace.idFromName(queueOrphanName(envelope.messageId)));
|
|
@@ -1944,7 +2245,7 @@ function createRouter(env) {
|
|
|
1944
2245
|
});
|
|
1945
2246
|
} catch (error) {
|
|
1946
2247
|
await run.unregisterQueuePayload(envelope.messageId);
|
|
1947
|
-
await claim?.
|
|
2248
|
+
await claim?.complete(envelope.messageId);
|
|
1948
2249
|
throw error;
|
|
1949
2250
|
}
|
|
1950
2251
|
try {
|
|
@@ -1953,21 +2254,21 @@ function createRouter(env) {
|
|
|
1953
2254
|
messageId: envelope.messageId
|
|
1954
2255
|
});
|
|
1955
2256
|
} catch (error) {
|
|
1956
|
-
await claim?.
|
|
2257
|
+
await claim?.complete(envelope.messageId);
|
|
1957
2258
|
throw error;
|
|
1958
2259
|
}
|
|
1959
2260
|
let finalized;
|
|
1960
2261
|
try {
|
|
1961
2262
|
finalized = await run.finalizeQueuePayload(envelope.messageId);
|
|
1962
2263
|
} catch (error) {
|
|
1963
|
-
await claim?.
|
|
2264
|
+
await claim?.complete(envelope.messageId);
|
|
1964
2265
|
throw error;
|
|
1965
2266
|
}
|
|
1966
2267
|
if (!finalized.ok) {
|
|
1967
2268
|
await payloadStore.delete(payloadKey);
|
|
1968
2269
|
await run.unregisterQueuePayload(envelope.messageId);
|
|
1969
2270
|
await orphan.cancelQueuePayloadOrphan(envelope.messageId);
|
|
1970
|
-
await claim?.
|
|
2271
|
+
await claim?.complete(envelope.messageId);
|
|
1971
2272
|
return errorResponse(410, "RunExpiredError", finalized.message);
|
|
1972
2273
|
}
|
|
1973
2274
|
}
|
|
@@ -2026,6 +2327,18 @@ function createRouter(env) {
|
|
|
2026
2327
|
case "runs.list":
|
|
2027
2328
|
result = await index.listRuns(...validated.args);
|
|
2028
2329
|
break;
|
|
2330
|
+
case "runs.list-resolved": {
|
|
2331
|
+
const runs = env.WORKFLOW_DB;
|
|
2332
|
+
if (!runs) return errorResponse(500, "WorldMisconfigured", "missing binding: WORKFLOW_DB");
|
|
2333
|
+
result = await listRunsPage({
|
|
2334
|
+
listRuns: (options) => index.listRuns(options),
|
|
2335
|
+
readRun: async (runId) => {
|
|
2336
|
+
const outcome = await runs.get(runs.idFromName(runId)).getRun();
|
|
2337
|
+
return outcome.ok ? outcome.value : null;
|
|
2338
|
+
}
|
|
2339
|
+
}, ...validated.args);
|
|
2340
|
+
break;
|
|
2341
|
+
}
|
|
2029
2342
|
case "runs.commit":
|
|
2030
2343
|
result = await index.commitRun(...validated.args);
|
|
2031
2344
|
break;
|
|
@@ -2081,7 +2394,7 @@ function createRouter(env) {
|
|
|
2081
2394
|
const readRequest = {
|
|
2082
2395
|
runId,
|
|
2083
2396
|
startIndex: parseBoundedInteger(url, "startIndex", 0, 2147483647),
|
|
2084
|
-
maxChunks: parseBoundedInteger(url, "maxChunks", 0,
|
|
2397
|
+
maxChunks: parseBoundedInteger(url, "maxChunks", 0, 512),
|
|
2085
2398
|
maxBytes: parseBoundedInteger(url, "maxBytes", MAX_STREAM_CHUNK_BYTES, MAX_STREAM_READ_BYTES),
|
|
2086
2399
|
waitMs: parseBoundedInteger(url, "waitMs", 0, MAX_STREAM_LONG_POLL_MS)
|
|
2087
2400
|
};
|
|
@@ -2144,6 +2457,7 @@ function createRouter(env) {
|
|
|
2144
2457
|
}
|
|
2145
2458
|
try {
|
|
2146
2459
|
const result = await namespace.get(namespace.idFromName(name))[method](...args);
|
|
2460
|
+
if (bindingKey === "runs" && method === "applyEvent") await publishApplyEventIndexes(env, name, result);
|
|
2147
2461
|
return new Response(rpcStringify(result ?? null), {
|
|
2148
2462
|
status: 200,
|
|
2149
2463
|
headers: { "content-type": "application/json" }
|
|
@@ -2713,9 +3027,18 @@ var RunCatalogDO = class extends DurableObject {
|
|
|
2713
3027
|
async upsertRun(runId, keys, serializedMetadata, publicationExpiresAt) {
|
|
2714
3028
|
const canonicalKeys = canonicalCatalogKeys(runId, keys);
|
|
2715
3029
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
2716
|
-
|
|
3030
|
+
const recordKey = catalogKeysKey(runId);
|
|
3031
|
+
const existing = await txn.get([
|
|
3032
|
+
expiredKey(runId),
|
|
3033
|
+
canonicalKeys[0],
|
|
3034
|
+
canonicalKeys[1],
|
|
3035
|
+
recordKey
|
|
3036
|
+
]);
|
|
3037
|
+
if (existing.get(expiredKey(runId)) !== void 0) return { stored: false };
|
|
2717
3038
|
const now = this.now();
|
|
2718
3039
|
if (!Number.isSafeInteger(publicationExpiresAt) || publicationExpiresAt <= now || publicationExpiresAt > now + 1200900) return { stored: false };
|
|
3040
|
+
const record = existing.get(recordKey);
|
|
3041
|
+
if (existing.get(canonicalKeys[0]) === serializedMetadata && existing.get(canonicalKeys[1]) === serializedMetadata && record?.keys.length === canonicalKeys.length && record.keys.every((key, index) => key === canonicalKeys[index])) return { stored: true };
|
|
2719
3042
|
await txn.put({
|
|
2720
3043
|
[canonicalKeys[0]]: serializedMetadata,
|
|
2721
3044
|
[canonicalKeys[1]]: serializedMetadata,
|
|
@@ -2827,13 +3150,23 @@ const CHUNK_SIZE_KEY_PREFIX = "chunk-size:";
|
|
|
2827
3150
|
const STREAM_REGISTRY_PREFIX = "stream:";
|
|
2828
3151
|
const REGISTRY_OWNER_KEY = "registry:owner";
|
|
2829
3152
|
const REGISTRY_EXPIRED_KEY = "registry:expired";
|
|
2830
|
-
/** Each chunk occupies a payload key and a size key
|
|
3153
|
+
/** Each chunk occupies a payload key and a size key, deleted in calls of at most 128 keys. */
|
|
2831
3154
|
const DEFAULT_EXPIRE_CHUNK_LIMIT = 64;
|
|
2832
|
-
const MAX_EXPIRE_CHUNK_LIMIT =
|
|
3155
|
+
const MAX_EXPIRE_CHUNK_LIMIT = 256;
|
|
3156
|
+
const DELETE_BATCH_KEYS = 128;
|
|
2833
3157
|
/** Bound storage deletion work as well as item count; one oversized chunk still makes progress. */
|
|
2834
3158
|
const DEFAULT_EXPIRE_BYTE_LIMIT = 16777216;
|
|
2835
3159
|
const MAX_EXPIRE_BYTE_LIMIT = DEFAULT_EXPIRE_BYTE_LIMIT;
|
|
2836
3160
|
const MAX_STREAM_INDEX = 2147483647;
|
|
3161
|
+
const SEGMENT_LAYOUT = 2;
|
|
3162
|
+
const SEGMENT_KEY_PREFIX = "seg:";
|
|
3163
|
+
const SEGMENT_SIZE_KEY_PREFIX = "segsize:";
|
|
3164
|
+
/** Payload bytes per segment row, well below the 2 MiB per-value storage limit. */
|
|
3165
|
+
const MAX_SEGMENT_BYTES = 1048576;
|
|
3166
|
+
/** Layout of a stream's first write. Existing streams keep the layout they started with. */
|
|
3167
|
+
const NEW_STREAM_LAYOUT = SEGMENT_LAYOUT;
|
|
3168
|
+
/** One storage get or put call accepts at most 128 keys. */
|
|
3169
|
+
const STORAGE_BATCH_KEYS = 128;
|
|
2837
3170
|
function boundedLimit(value, fallback, maximum) {
|
|
2838
3171
|
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
2839
3172
|
return Math.max(1, Math.min(maximum, Math.floor(value)));
|
|
@@ -2852,7 +3185,7 @@ function validateMeta(meta) {
|
|
|
2852
3185
|
"closed",
|
|
2853
3186
|
"errored",
|
|
2854
3187
|
"expired"
|
|
2855
|
-
].includes(candidate.state) || candidate.ownerRunId !== void 0 && typeof candidate.ownerRunId !== "string" || candidate.payloadDeleted !== void 0 && typeof candidate.payloadDeleted !== "boolean") throw new Error("Invalid persisted stream metadata");
|
|
3188
|
+
].includes(candidate.state) || candidate.ownerRunId !== void 0 && typeof candidate.ownerRunId !== "string" || candidate.payloadDeleted !== void 0 && typeof candidate.payloadDeleted !== "boolean" || candidate.layout !== void 0 && candidate.layout !== SEGMENT_LAYOUT) throw new Error("Invalid persisted stream metadata");
|
|
2856
3189
|
const error = candidate.error;
|
|
2857
3190
|
if (error !== void 0 && (!error || typeof error !== "object" || typeof error.name !== "string" || typeof error.message !== "string") || candidate.state === "errored" !== (error !== void 0)) throw new Error("Invalid persisted stream error metadata");
|
|
2858
3191
|
return candidate;
|
|
@@ -2864,6 +3197,80 @@ function chunkKey(index) {
|
|
|
2864
3197
|
function chunkSizeKey(index) {
|
|
2865
3198
|
return `${CHUNK_SIZE_KEY_PREFIX}${index.toString().padStart(12, "0")}`;
|
|
2866
3199
|
}
|
|
3200
|
+
function segmentKey(start) {
|
|
3201
|
+
return `${SEGMENT_KEY_PREFIX}${start.toString().padStart(12, "0")}`;
|
|
3202
|
+
}
|
|
3203
|
+
function segmentSizeKey(start) {
|
|
3204
|
+
return `${SEGMENT_SIZE_KEY_PREFIX}${start.toString().padStart(12, "0")}`;
|
|
3205
|
+
}
|
|
3206
|
+
function startFromSegmentKey(key, prefix) {
|
|
3207
|
+
const suffix = key.slice(prefix.length);
|
|
3208
|
+
const start = Number(suffix);
|
|
3209
|
+
if (!key.startsWith(prefix) || !/^\d{12}$/.test(suffix) || !Number.isSafeInteger(start) || start > MAX_STREAM_INDEX) throw new Error(`Invalid persisted stream segment key "${key}"`);
|
|
3210
|
+
return start;
|
|
3211
|
+
}
|
|
3212
|
+
function validateSegmentSize(value, start) {
|
|
3213
|
+
const size = value;
|
|
3214
|
+
if (!size || !Number.isSafeInteger(size.count) || size.count < 1 || !Number.isSafeInteger(size.bytes) || size.bytes < 0) throw new Error(`Invalid persisted stream segment size at index ${start}`);
|
|
3215
|
+
return size;
|
|
3216
|
+
}
|
|
3217
|
+
/** Pack chunks as `[u32 count][u32 length]*count` followed by the payloads. */
|
|
3218
|
+
function encodeSegment(chunks) {
|
|
3219
|
+
const payloadBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
3220
|
+
const header = 4 + 4 * chunks.length;
|
|
3221
|
+
const encoded = new Uint8Array(header + payloadBytes);
|
|
3222
|
+
const view = new DataView(encoded.buffer);
|
|
3223
|
+
view.setUint32(0, chunks.length);
|
|
3224
|
+
let offset = header;
|
|
3225
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
3226
|
+
view.setUint32(4 + 4 * index, chunks[index].byteLength);
|
|
3227
|
+
encoded.set(chunks[index], offset);
|
|
3228
|
+
offset += chunks[index].byteLength;
|
|
3229
|
+
}
|
|
3230
|
+
return encoded;
|
|
3231
|
+
}
|
|
3232
|
+
function decodeSegment(value, start, size) {
|
|
3233
|
+
if (!(value instanceof Uint8Array) || value.byteLength < 4) throw new Error(`Invalid persisted stream segment at index ${start}`);
|
|
3234
|
+
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
|
|
3235
|
+
const count = view.getUint32(0);
|
|
3236
|
+
const header = 4 + 4 * count;
|
|
3237
|
+
if (count !== size.count || header + size.bytes !== value.byteLength) throw new Error(`Invalid persisted stream segment at index ${start}`);
|
|
3238
|
+
const chunks = [];
|
|
3239
|
+
let offset = header;
|
|
3240
|
+
for (let index = 0; index < count; index++) {
|
|
3241
|
+
const length = view.getUint32(4 + 4 * index);
|
|
3242
|
+
if (offset + length > value.byteLength) throw new Error(`Invalid persisted stream segment at index ${start}`);
|
|
3243
|
+
chunks.push(value.subarray(offset, offset + length));
|
|
3244
|
+
offset += length;
|
|
3245
|
+
}
|
|
3246
|
+
if (offset !== value.byteLength) throw new Error(`Invalid persisted stream segment at index ${start}`);
|
|
3247
|
+
return chunks;
|
|
3248
|
+
}
|
|
3249
|
+
/** Group one write's chunks into segments of at most MAX_SEGMENT_BYTES payload bytes. */
|
|
3250
|
+
function packSegments(startIndex, chunks) {
|
|
3251
|
+
const segments = [];
|
|
3252
|
+
let current = [];
|
|
3253
|
+
let bytes = 0;
|
|
3254
|
+
let start = startIndex;
|
|
3255
|
+
for (const chunk of chunks) {
|
|
3256
|
+
if (current.length > 0 && bytes + chunk.byteLength > MAX_SEGMENT_BYTES) {
|
|
3257
|
+
segments.push({
|
|
3258
|
+
start,
|
|
3259
|
+
chunks: current
|
|
3260
|
+
});
|
|
3261
|
+
start += current.length;
|
|
3262
|
+
current = [];
|
|
3263
|
+
bytes = 0;
|
|
3264
|
+
}
|
|
3265
|
+
current.push(chunk);
|
|
3266
|
+
bytes += chunk.byteLength;
|
|
3267
|
+
}
|
|
3268
|
+
if (current.length > 0) segments.push({
|
|
3269
|
+
start,
|
|
3270
|
+
chunks: current
|
|
3271
|
+
});
|
|
3272
|
+
return segments;
|
|
3273
|
+
}
|
|
2867
3274
|
function indexFromChunkSizeKey(key) {
|
|
2868
3275
|
const suffix = key.slice(11);
|
|
2869
3276
|
const index = Number(suffix);
|
|
@@ -2880,6 +3287,9 @@ function validateChunkSize(size, index) {
|
|
|
2880
3287
|
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > 1048576) throw new Error(`Invalid persisted stream chunk size at index ${index}`);
|
|
2881
3288
|
return size;
|
|
2882
3289
|
}
|
|
3290
|
+
async function deleteInBatches(txn, keys) {
|
|
3291
|
+
for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_KEYS) await txn.delete(keys.slice(offset, offset + DELETE_BATCH_KEYS));
|
|
3292
|
+
}
|
|
2883
3293
|
function compactChunk(chunk) {
|
|
2884
3294
|
return chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength ? chunk : new Uint8Array(chunk);
|
|
2885
3295
|
}
|
|
@@ -2899,6 +3309,15 @@ var StreamDO = class extends DurableObject {
|
|
|
2899
3309
|
mutationTail = Promise.resolve();
|
|
2900
3310
|
changeVersion = 0;
|
|
2901
3311
|
waiters = /* @__PURE__ */ new Set();
|
|
3312
|
+
/** Multi-key read split into calls within the per-call key limit. */
|
|
3313
|
+
async getMany(keys) {
|
|
3314
|
+
if (keys.length <= STORAGE_BATCH_KEYS) return await this.ctx.storage.get(keys);
|
|
3315
|
+
const groups = [];
|
|
3316
|
+
for (let offset = 0; offset < keys.length; offset += STORAGE_BATCH_KEYS) groups.push(keys.slice(offset, offset + STORAGE_BATCH_KEYS));
|
|
3317
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3318
|
+
for (const result of await Promise.all(groups.map((group) => this.ctx.storage.get(group)))) for (const [key, value] of result) merged.set(key, value);
|
|
3319
|
+
return merged;
|
|
3320
|
+
}
|
|
2902
3321
|
async getMeta() {
|
|
2903
3322
|
if (this.meta) return this.meta;
|
|
2904
3323
|
this.metaLoad ??= this.ctx.storage.get(META_KEY).then((meta) => {
|
|
@@ -2967,29 +3386,8 @@ var StreamDO = class extends DurableObject {
|
|
|
2967
3386
|
const meta = await this.getMeta();
|
|
2968
3387
|
this.assertOwner(meta, request.runId);
|
|
2969
3388
|
const available = meta.state === "expired" ? 0 : Math.max(0, Math.min(request.maxChunks, meta.count - request.startIndex));
|
|
2970
|
-
|
|
2971
|
-
if (available > 0)
|
|
2972
|
-
const indexes = Array.from({ length: available }, (_, offset) => request.startIndex + offset);
|
|
2973
|
-
const sizeKeys = indexes.map(chunkSizeKey);
|
|
2974
|
-
const storedSizes = await this.ctx.storage.get(sizeKeys);
|
|
2975
|
-
const selectedIndexes = [];
|
|
2976
|
-
const selectedSizes = [];
|
|
2977
|
-
let bytes = 0;
|
|
2978
|
-
for (const index of indexes) {
|
|
2979
|
-
const size = validateChunkSize(storedSizes.get(chunkSizeKey(index)), index);
|
|
2980
|
-
if (bytes + size > request.maxBytes) break;
|
|
2981
|
-
selectedIndexes.push(index);
|
|
2982
|
-
selectedSizes.push(size);
|
|
2983
|
-
bytes += size;
|
|
2984
|
-
}
|
|
2985
|
-
const payloadKeys = selectedIndexes.map(chunkKey);
|
|
2986
|
-
const storedChunks = await this.ctx.storage.get(payloadKeys);
|
|
2987
|
-
for (let offset = 0; offset < payloadKeys.length; offset++) {
|
|
2988
|
-
const chunk = storedChunks.get(payloadKeys[offset]);
|
|
2989
|
-
if (!(chunk instanceof Uint8Array) || chunk.byteLength !== selectedSizes[offset]) throw new Error(`Invalid persisted stream chunk at index ${selectedIndexes[offset]}`);
|
|
2990
|
-
chunks.push(chunk);
|
|
2991
|
-
}
|
|
2992
|
-
}
|
|
3389
|
+
let chunks = [];
|
|
3390
|
+
if (available > 0) chunks = meta.layout === SEGMENT_LAYOUT ? await this.readSegmentRows(request.startIndex, available, request.maxBytes) : await this.readChunkRows(request.startIndex, available, request.maxBytes);
|
|
2993
3391
|
return {
|
|
2994
3392
|
startIndex: request.startIndex,
|
|
2995
3393
|
tailIndex: meta.count - 1,
|
|
@@ -2999,10 +3397,91 @@ var StreamDO = class extends DurableObject {
|
|
|
2999
3397
|
error: meta.error
|
|
3000
3398
|
};
|
|
3001
3399
|
}
|
|
3400
|
+
async readChunkRows(startIndex, available, maxBytes) {
|
|
3401
|
+
const indexes = Array.from({ length: available }, (_, offset) => startIndex + offset);
|
|
3402
|
+
const sizeKeys = indexes.map(chunkSizeKey);
|
|
3403
|
+
const storedSizes = await this.getMany(sizeKeys);
|
|
3404
|
+
const selectedIndexes = [];
|
|
3405
|
+
const selectedSizes = [];
|
|
3406
|
+
let bytes = 0;
|
|
3407
|
+
for (const index of indexes) {
|
|
3408
|
+
const size = validateChunkSize(storedSizes.get(chunkSizeKey(index)), index);
|
|
3409
|
+
if (bytes + size > maxBytes) break;
|
|
3410
|
+
selectedIndexes.push(index);
|
|
3411
|
+
selectedSizes.push(size);
|
|
3412
|
+
bytes += size;
|
|
3413
|
+
}
|
|
3414
|
+
const payloadKeys = selectedIndexes.map(chunkKey);
|
|
3415
|
+
const storedChunks = await this.getMany(payloadKeys);
|
|
3416
|
+
const chunks = [];
|
|
3417
|
+
for (let offset = 0; offset < payloadKeys.length; offset++) {
|
|
3418
|
+
const chunk = storedChunks.get(payloadKeys[offset]);
|
|
3419
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength !== selectedSizes[offset]) throw new Error(`Invalid persisted stream chunk at index ${selectedIndexes[offset]}`);
|
|
3420
|
+
chunks.push(chunk);
|
|
3421
|
+
}
|
|
3422
|
+
return chunks;
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Plan from the small segment size rows, then load only the segments that
|
|
3426
|
+
* cover the requested range. A read that starts on a segment boundary (the
|
|
3427
|
+
* common sequential case) needs one list and one multi-get.
|
|
3428
|
+
*/
|
|
3429
|
+
async readSegmentRows(startIndex, available, maxBytes) {
|
|
3430
|
+
const endIndex = startIndex + available;
|
|
3431
|
+
const listed = await this.ctx.storage.list({
|
|
3432
|
+
prefix: SEGMENT_SIZE_KEY_PREFIX,
|
|
3433
|
+
start: segmentSizeKey(startIndex),
|
|
3434
|
+
end: segmentSizeKey(endIndex),
|
|
3435
|
+
limit: available
|
|
3436
|
+
});
|
|
3437
|
+
const planned = [];
|
|
3438
|
+
for (const [key, value] of listed) {
|
|
3439
|
+
const start = startFromSegmentKey(key, SEGMENT_SIZE_KEY_PREFIX);
|
|
3440
|
+
planned.push({
|
|
3441
|
+
start,
|
|
3442
|
+
size: validateSegmentSize(value, start)
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
if (planned[0]?.start !== startIndex) {
|
|
3446
|
+
const entry = (await this.ctx.storage.list({
|
|
3447
|
+
prefix: SEGMENT_SIZE_KEY_PREFIX,
|
|
3448
|
+
end: segmentSizeKey(startIndex + 1),
|
|
3449
|
+
reverse: true,
|
|
3450
|
+
limit: 1
|
|
3451
|
+
})).entries().next().value;
|
|
3452
|
+
if (!entry) throw new Error(`Missing persisted stream segment at index ${startIndex}`);
|
|
3453
|
+
const start = startFromSegmentKey(entry[0], SEGMENT_SIZE_KEY_PREFIX);
|
|
3454
|
+
planned.unshift({
|
|
3455
|
+
start,
|
|
3456
|
+
size: validateSegmentSize(entry[1], start)
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
const selected = [];
|
|
3460
|
+
let plannedBytes = 0;
|
|
3461
|
+
for (const segment of planned) {
|
|
3462
|
+
if (selected.length > 0 && plannedBytes >= maxBytes) break;
|
|
3463
|
+
selected.push(segment);
|
|
3464
|
+
plannedBytes += segment.size.bytes;
|
|
3465
|
+
}
|
|
3466
|
+
const values = await this.getMany(selected.map(({ start }) => segmentKey(start)));
|
|
3467
|
+
const chunks = [];
|
|
3468
|
+
let bytes = 0;
|
|
3469
|
+
let expected = startIndex;
|
|
3470
|
+
for (const { start, size } of selected) {
|
|
3471
|
+
if (start > expected) throw new Error(`Missing persisted stream segment at index ${expected}`);
|
|
3472
|
+
const segment = decodeSegment(values.get(segmentKey(start)), start, size);
|
|
3473
|
+
for (let offset = expected - start; offset < segment.length; offset++) {
|
|
3474
|
+
if (expected >= endIndex || bytes + segment[offset].byteLength > maxBytes) return chunks;
|
|
3475
|
+
chunks.push(segment[offset]);
|
|
3476
|
+
bytes += segment[offset].byteLength;
|
|
3477
|
+
expected++;
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
return chunks;
|
|
3481
|
+
}
|
|
3002
3482
|
/** Append one bounded binary batch with contiguous indexes. */
|
|
3003
3483
|
async writeChunks(runId, chunks) {
|
|
3004
3484
|
validateStreamWriteChunks(chunks);
|
|
3005
|
-
const storedChunks = chunks.map(compactChunk);
|
|
3006
3485
|
return await this.runMutation(async () => {
|
|
3007
3486
|
const committed = await this.ctx.storage.transaction(async (txn) => {
|
|
3008
3487
|
const stored = await txn.get(META_KEY);
|
|
@@ -3010,18 +3489,30 @@ var StreamDO = class extends DurableObject {
|
|
|
3010
3489
|
this.assertWritable(meta, runId);
|
|
3011
3490
|
const startIndex = meta.count;
|
|
3012
3491
|
if (startIndex + chunks.length > MAX_STREAM_INDEX) throw new Error("Stream offset limit exceeded");
|
|
3492
|
+
const layout = stored === void 0 ? NEW_STREAM_LAYOUT : meta.layout;
|
|
3013
3493
|
const nextMeta = {
|
|
3014
3494
|
count: startIndex + chunks.length,
|
|
3015
3495
|
state: "open",
|
|
3016
|
-
ownerRunId: runId
|
|
3496
|
+
ownerRunId: runId,
|
|
3497
|
+
...layout === void 0 ? {} : { layout }
|
|
3017
3498
|
};
|
|
3018
|
-
const entries =
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
entries[
|
|
3022
|
-
|
|
3499
|
+
const entries = [[META_KEY, nextMeta]];
|
|
3500
|
+
if (layout === SEGMENT_LAYOUT) for (const segment of packSegments(startIndex, chunks)) {
|
|
3501
|
+
entries.push([segmentKey(segment.start), encodeSegment(segment.chunks)]);
|
|
3502
|
+
entries.push([segmentSizeKey(segment.start), {
|
|
3503
|
+
count: segment.chunks.length,
|
|
3504
|
+
bytes: segment.chunks.reduce((total, chunk) => total + chunk.byteLength, 0)
|
|
3505
|
+
}]);
|
|
3506
|
+
}
|
|
3507
|
+
else {
|
|
3508
|
+
const storedChunks = chunks.map(compactChunk);
|
|
3509
|
+
for (let offset = 0; offset < storedChunks.length; offset++) {
|
|
3510
|
+
const index = startIndex + offset;
|
|
3511
|
+
entries.push([chunkKey(index), storedChunks[offset]]);
|
|
3512
|
+
entries.push([chunkSizeKey(index), storedChunks[offset].byteLength]);
|
|
3513
|
+
}
|
|
3023
3514
|
}
|
|
3024
|
-
await txn.put(entries);
|
|
3515
|
+
for (let offset = 0; offset < entries.length; offset += STORAGE_BATCH_KEYS) await txn.put(Object.fromEntries(entries.slice(offset, offset + STORAGE_BATCH_KEYS)));
|
|
3025
3516
|
return {
|
|
3026
3517
|
meta: nextMeta,
|
|
3027
3518
|
result: {
|
|
@@ -3155,6 +3646,102 @@ var StreamDO = class extends DurableObject {
|
|
|
3155
3646
|
};
|
|
3156
3647
|
});
|
|
3157
3648
|
}
|
|
3649
|
+
/** Delete one page of per-chunk rows, listing size rows to avoid loading payloads. */
|
|
3650
|
+
async expireChunkPage(txn, limit, byteLimit) {
|
|
3651
|
+
const candidates = await txn.list({
|
|
3652
|
+
prefix: CHUNK_SIZE_KEY_PREFIX,
|
|
3653
|
+
limit: limit + 1
|
|
3654
|
+
});
|
|
3655
|
+
const page = [];
|
|
3656
|
+
let bytes = 0;
|
|
3657
|
+
for (const [key, storedSize] of Array.from(candidates).slice(0, limit)) {
|
|
3658
|
+
const index = indexFromChunkSizeKey(key);
|
|
3659
|
+
const size = validateChunkSize(storedSize, index);
|
|
3660
|
+
if (page.length > 0 && bytes + size > byteLimit) break;
|
|
3661
|
+
page.push({
|
|
3662
|
+
index,
|
|
3663
|
+
size
|
|
3664
|
+
});
|
|
3665
|
+
bytes += size;
|
|
3666
|
+
}
|
|
3667
|
+
let payloadFallback = false;
|
|
3668
|
+
if (candidates.size === 0) {
|
|
3669
|
+
const first = (await txn.list({
|
|
3670
|
+
prefix: CHUNK_KEY_PREFIX,
|
|
3671
|
+
limit: 1
|
|
3672
|
+
})).entries().next().value;
|
|
3673
|
+
if (first) {
|
|
3674
|
+
const [key, payload] = first;
|
|
3675
|
+
page.push({
|
|
3676
|
+
index: indexFromChunkKey(key),
|
|
3677
|
+
size: payload instanceof Uint8Array ? payload.byteLength : 0
|
|
3678
|
+
});
|
|
3679
|
+
bytes = page[0].size;
|
|
3680
|
+
payloadFallback = true;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
await deleteInBatches(txn, page.flatMap(({ index }) => [chunkKey(index), chunkSizeKey(index)]));
|
|
3684
|
+
let done = candidates.size === page.length;
|
|
3685
|
+
if (payloadFallback) done = (await txn.list({
|
|
3686
|
+
prefix: CHUNK_KEY_PREFIX,
|
|
3687
|
+
limit: 1
|
|
3688
|
+
})).size === 0;
|
|
3689
|
+
else if (done && candidates.size > 0) done = (await txn.list({
|
|
3690
|
+
prefix: CHUNK_KEY_PREFIX,
|
|
3691
|
+
limit: 1
|
|
3692
|
+
})).size === 0;
|
|
3693
|
+
return {
|
|
3694
|
+
chunks: page.length,
|
|
3695
|
+
bytes,
|
|
3696
|
+
done
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Delete one page of segment rows, listing segment size rows to avoid
|
|
3701
|
+
* loading payloads. One segment always makes progress, even when it holds
|
|
3702
|
+
* more chunks or bytes than the page allows.
|
|
3703
|
+
*/
|
|
3704
|
+
async expireSegmentPage(txn, limit, byteLimit) {
|
|
3705
|
+
const candidates = await txn.list({
|
|
3706
|
+
prefix: SEGMENT_SIZE_KEY_PREFIX,
|
|
3707
|
+
limit: limit + 1
|
|
3708
|
+
});
|
|
3709
|
+
const page = [];
|
|
3710
|
+
let chunks = 0;
|
|
3711
|
+
let bytes = 0;
|
|
3712
|
+
for (const [key, value] of candidates) {
|
|
3713
|
+
const start = startFromSegmentKey(key, SEGMENT_SIZE_KEY_PREFIX);
|
|
3714
|
+
const size = validateSegmentSize(value, start);
|
|
3715
|
+
if (page.length > 0 && (chunks + size.count > limit || bytes + size.bytes > byteLimit)) break;
|
|
3716
|
+
page.push(start);
|
|
3717
|
+
chunks += size.count;
|
|
3718
|
+
bytes += size.bytes;
|
|
3719
|
+
}
|
|
3720
|
+
let orphan = false;
|
|
3721
|
+
if (candidates.size === 0) {
|
|
3722
|
+
const first = (await txn.list({
|
|
3723
|
+
prefix: SEGMENT_KEY_PREFIX,
|
|
3724
|
+
limit: 1
|
|
3725
|
+
})).entries().next().value;
|
|
3726
|
+
if (first) {
|
|
3727
|
+
page.push(startFromSegmentKey(first[0], SEGMENT_KEY_PREFIX));
|
|
3728
|
+
bytes = first[1] instanceof Uint8Array ? first[1].byteLength : 0;
|
|
3729
|
+
chunks = 1;
|
|
3730
|
+
orphan = true;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
await deleteInBatches(txn, page.flatMap((start) => [segmentKey(start), segmentSizeKey(start)]));
|
|
3734
|
+
let done = !orphan && candidates.size === page.length;
|
|
3735
|
+
if (orphan || done) done = (await txn.list({
|
|
3736
|
+
prefix: SEGMENT_KEY_PREFIX,
|
|
3737
|
+
limit: 1
|
|
3738
|
+
})).size === 0;
|
|
3739
|
+
return {
|
|
3740
|
+
chunks,
|
|
3741
|
+
bytes,
|
|
3742
|
+
done
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3158
3745
|
/**
|
|
3159
3746
|
* Fence the stream and delete one bounded page. Listing size keys avoids
|
|
3160
3747
|
* loading payloads, and the transaction makes a crash retry resume safely.
|
|
@@ -3168,50 +3755,8 @@ var StreamDO = class extends DurableObject {
|
|
|
3168
3755
|
const meta = stored === void 0 ? emptyMeta() : validateMeta(stored);
|
|
3169
3756
|
this.assertOwner(meta, runId);
|
|
3170
3757
|
const firstFence = meta.state !== "expired";
|
|
3171
|
-
const
|
|
3172
|
-
|
|
3173
|
-
limit: limit + 1
|
|
3174
|
-
});
|
|
3175
|
-
const page = [];
|
|
3176
|
-
let bytes = 0;
|
|
3177
|
-
for (const [key, storedSize] of Array.from(candidates).slice(0, limit)) {
|
|
3178
|
-
const index = indexFromChunkSizeKey(key);
|
|
3179
|
-
const size = validateChunkSize(storedSize, index);
|
|
3180
|
-
if (page.length > 0 && bytes + size > byteLimit) break;
|
|
3181
|
-
page.push({
|
|
3182
|
-
index,
|
|
3183
|
-
size
|
|
3184
|
-
});
|
|
3185
|
-
bytes += size;
|
|
3186
|
-
}
|
|
3187
|
-
let payloadFallback = false;
|
|
3188
|
-
if (candidates.size === 0) {
|
|
3189
|
-
const first = (await txn.list({
|
|
3190
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3191
|
-
limit: 1
|
|
3192
|
-
})).entries().next().value;
|
|
3193
|
-
if (first) {
|
|
3194
|
-
const [key, payload] = first;
|
|
3195
|
-
page.push({
|
|
3196
|
-
index: indexFromChunkKey(key),
|
|
3197
|
-
size: payload instanceof Uint8Array ? payload.byteLength : 0
|
|
3198
|
-
});
|
|
3199
|
-
bytes = page[0].size;
|
|
3200
|
-
payloadFallback = true;
|
|
3201
|
-
}
|
|
3202
|
-
}
|
|
3203
|
-
const keys = page.flatMap(({ index }) => [chunkKey(index), chunkSizeKey(index)]);
|
|
3204
|
-
if (keys.length > 0) await txn.delete(keys);
|
|
3205
|
-
let done = candidates.size === page.length;
|
|
3206
|
-
if (payloadFallback) done = (await txn.list({
|
|
3207
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3208
|
-
limit: 1
|
|
3209
|
-
})).size === 0;
|
|
3210
|
-
else if (done && candidates.size > 0) done = (await txn.list({
|
|
3211
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3212
|
-
limit: 1
|
|
3213
|
-
})).size === 0;
|
|
3214
|
-
if (done && page.length === 0 && meta.state === "expired" && meta.payloadDeleted) return {
|
|
3758
|
+
const { chunks, bytes, done } = meta.layout === SEGMENT_LAYOUT ? await this.expireSegmentPage(txn, limit, byteLimit) : await this.expireChunkPage(txn, limit, byteLimit);
|
|
3759
|
+
if (done && chunks === 0 && meta.state === "expired" && meta.payloadDeleted) return {
|
|
3215
3760
|
meta,
|
|
3216
3761
|
result: {
|
|
3217
3762
|
deleted: false,
|
|
@@ -3235,7 +3780,7 @@ var StreamDO = class extends DurableObject {
|
|
|
3235
3780
|
meta: nextMeta,
|
|
3236
3781
|
result: {
|
|
3237
3782
|
deleted: firstFence,
|
|
3238
|
-
chunks
|
|
3783
|
+
chunks,
|
|
3239
3784
|
bytes,
|
|
3240
3785
|
done
|
|
3241
3786
|
}
|
|
@@ -3250,19 +3795,29 @@ var StreamDO = class extends DurableObject {
|
|
|
3250
3795
|
};
|
|
3251
3796
|
//#endregion
|
|
3252
3797
|
//#region src/worker/durable-objects/WorkflowRunDO.ts
|
|
3798
|
+
const EVENT_SEQUENCE_KEY = "event_sequence";
|
|
3253
3799
|
const STORAGE_BATCH_SIZE = 128;
|
|
3254
3800
|
const PAYLOAD_DELETE_BATCH = STORAGE_BATCH_SIZE;
|
|
3255
3801
|
const HOOK_MARKER_PAGE_SIZE = 64;
|
|
3256
3802
|
const TERMINAL_HOOK_PAGE_SIZE = 64;
|
|
3257
3803
|
const TERMINAL_WAIT_PAGE_SIZE = STORAGE_BATCH_SIZE;
|
|
3258
3804
|
const STREAMS_PER_CLEANUP_PAGE = 16;
|
|
3259
|
-
|
|
3805
|
+
/** StreamDO deletes a page in 128-key calls within one transaction. */
|
|
3806
|
+
const STREAM_CHUNKS_PER_CLEANUP_PAGE = 256;
|
|
3260
3807
|
const STREAM_BYTES_PER_CLEANUP_PAGE = 16777216;
|
|
3261
3808
|
const QUEUE_PAYLOADS_PER_CLEANUP_PAGE = STORAGE_BATCH_SIZE;
|
|
3262
3809
|
const CLEANUP_RETRY_MAX_MS = 36e5;
|
|
3263
3810
|
const MAX_DATE_MS = 864e13;
|
|
3264
3811
|
const QUEUE_ORPHAN_KEY = "queue-orphan";
|
|
3265
3812
|
const QUEUE_RESERVATION_KEY = "queue-reservation";
|
|
3813
|
+
/** Run-scoped Queue idempotency state, one key per claim name, in the run's own cell. */
|
|
3814
|
+
const RUN_QUEUE_CLAIM_PREFIX = "queue-claim:";
|
|
3815
|
+
/**
|
|
3816
|
+
* Present when every idempotency claim of this run lives in the run's own
|
|
3817
|
+
* cell. Written only when the run is created, so a run keeps one claim scope
|
|
3818
|
+
* for its whole life and claims made before the marker existed stay valid.
|
|
3819
|
+
*/
|
|
3820
|
+
const RUN_QUEUE_CLAIM_SCOPE_KEY = "queue-claim-scope";
|
|
3266
3821
|
function validateCleanupRequest(request, allowDisabled) {
|
|
3267
3822
|
if (!isRecord$1(request)) throw new Error("world-celld retention request must be an object");
|
|
3268
3823
|
if (!(allowDisabled ? isNonNegativeSafeInteger(request.retentionMs) : isPositiveSafeInteger(request.retentionMs))) throw new Error(`world-celld retentionMs must be a ${allowDisabled ? "non-negative" : "positive"} safe integer`);
|
|
@@ -3316,6 +3871,12 @@ async function putStorageEntries(storage, entries) {
|
|
|
3316
3871
|
const all = Array.from(entries);
|
|
3317
3872
|
for (let offset = 0; offset < all.length; offset += STORAGE_BATCH_SIZE) await storage.put(Object.fromEntries(all.slice(offset, offset + STORAGE_BATCH_SIZE)));
|
|
3318
3873
|
}
|
|
3874
|
+
function runQueueClaimKey(claimName) {
|
|
3875
|
+
return `${RUN_QUEUE_CLAIM_PREFIX}${claimName}`;
|
|
3876
|
+
}
|
|
3877
|
+
function validRunQueueClaimName(claimName) {
|
|
3878
|
+
return typeof claimName === "string" && claimName.startsWith("claim:");
|
|
3879
|
+
}
|
|
3319
3880
|
/**
|
|
3320
3881
|
* Durable Object for managing a single workflow run's state.
|
|
3321
3882
|
*
|
|
@@ -3338,11 +3899,14 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
3338
3899
|
if (!cleanup || cleanup.phase === "retained" && cleanup.dueAt.getTime() > now) return null;
|
|
3339
3900
|
return cleanupTombstone(cleanup, cleanup.tombstonedAt ?? cleanup.dueAt);
|
|
3340
3901
|
}
|
|
3341
|
-
async retentionState(storage, now = this.now()) {
|
|
3342
|
-
const
|
|
3902
|
+
async retentionState(storage, now = this.now(), options) {
|
|
3903
|
+
const keys = [TOMBSTONE_KEY, CLEANUP_RECORD_KEY];
|
|
3904
|
+
if (options?.withEventSequence) keys.push(EVENT_SEQUENCE_KEY);
|
|
3905
|
+
const values = await storage.get(keys);
|
|
3343
3906
|
return {
|
|
3344
3907
|
cleanup: values.get(CLEANUP_RECORD_KEY),
|
|
3345
|
-
tombstone: this.tombstoneFrom(values, now)
|
|
3908
|
+
tombstone: this.tombstoneFrom(values, now),
|
|
3909
|
+
eventSequence: values.get(EVENT_SEQUENCE_KEY) ?? 0
|
|
3346
3910
|
};
|
|
3347
3911
|
}
|
|
3348
3912
|
async read(reader) {
|
|
@@ -3377,21 +3941,23 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
3377
3941
|
if (request.cleanup !== void 0) validateCleanupRequest(request.cleanup, true);
|
|
3378
3942
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
3379
3943
|
const now = new Date(this.now());
|
|
3380
|
-
const retention = await this.retentionState(txn, now.getTime());
|
|
3944
|
+
const retention = await this.retentionState(txn, now.getTime(), { withEventSequence: true });
|
|
3381
3945
|
const { tombstone } = retention;
|
|
3382
3946
|
if (tombstone) return {
|
|
3383
3947
|
ok: false,
|
|
3384
3948
|
code: "RUN_EXPIRED",
|
|
3385
3949
|
message: `Workflow run "${request.runId}" expired at ${tombstone.expiredAt.toISOString()}`
|
|
3386
3950
|
};
|
|
3387
|
-
|
|
3951
|
+
const storedSequence = retention.eventSequence;
|
|
3952
|
+
let eventSequence = storedSequence;
|
|
3388
3953
|
const outcome = await applyEvent(storeFrom(txn), {
|
|
3389
3954
|
...request,
|
|
3390
3955
|
nextEventId: () => slotToEventId(++eventSequence),
|
|
3391
3956
|
now
|
|
3392
3957
|
});
|
|
3393
3958
|
if (!outcome.ok) return outcome;
|
|
3394
|
-
await txn.put(
|
|
3959
|
+
if (eventSequence !== storedSequence) await txn.put(EVENT_SEQUENCE_KEY, eventSequence);
|
|
3960
|
+
if (outcome.runCreated) await txn.put(RUN_QUEUE_CLAIM_SCOPE_KEY, "run");
|
|
3395
3961
|
await putStorageEntries(txn, (outcome.hookToIndex ? [{
|
|
3396
3962
|
hookId: outcome.hookToIndex.hookId,
|
|
3397
3963
|
token: outcome.hookToIndex.token
|
|
@@ -3771,10 +4337,58 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
3771
4337
|
if (cleanup.phase === "payload") await this.deletePayloadBatch();
|
|
3772
4338
|
}
|
|
3773
4339
|
async executeTerminalCleanup(cleanup) {
|
|
4340
|
+
if (cleanup.phase === "hooks" && await this.completeSmallTerminalCleanup(cleanup)) return;
|
|
3774
4341
|
if (cleanup.phase === "hooks") await this.deleteTerminalHookPage(cleanup);
|
|
3775
4342
|
else if (cleanup.phase === "markers") await this.deleteTerminalHookMarkerPage(cleanup);
|
|
3776
4343
|
else await this.deleteTerminalWaitPage(cleanup);
|
|
3777
4344
|
}
|
|
4345
|
+
/**
|
|
4346
|
+
* Most runs end with a handful of hooks and waits. When every terminal
|
|
4347
|
+
* phase fits in one page, release the hook indexes once and delete hooks,
|
|
4348
|
+
* markers, and waits in a single transaction instead of one alarm and one
|
|
4349
|
+
* transaction per phase. Returns false, having changed nothing locally, when
|
|
4350
|
+
* any phase needs paging; the paged phases then run as before.
|
|
4351
|
+
*/
|
|
4352
|
+
async completeSmallTerminalCleanup(cleanup) {
|
|
4353
|
+
const [hookEntries, markerEntries] = await Promise.all([this.ctx.storage.list({
|
|
4354
|
+
prefix: HOOK_KEY_PREFIX,
|
|
4355
|
+
limit: 65
|
|
4356
|
+
}), this.ctx.storage.list({
|
|
4357
|
+
prefix: HOOK_MARKER_PREFIX,
|
|
4358
|
+
limit: 65
|
|
4359
|
+
})]);
|
|
4360
|
+
if (hookEntries.size > TERMINAL_HOOK_PAGE_SIZE || markerEntries.size > HOOK_MARKER_PAGE_SIZE) return false;
|
|
4361
|
+
const hooks = Array.from(hookEntries.values());
|
|
4362
|
+
const references = /* @__PURE__ */ new Map();
|
|
4363
|
+
for (const reference of [...hooks.map((hook) => ({
|
|
4364
|
+
hookId: hook.hookId,
|
|
4365
|
+
token: hook.token
|
|
4366
|
+
})), ...markerEntries.values()]) references.set(`${reference.hookId}\u0000${reference.token}`, reference);
|
|
4367
|
+
if (references.size > 0) await this.workflowIndex().releaseHookIndexes({
|
|
4368
|
+
runId: cleanup.runId,
|
|
4369
|
+
hooks: Array.from(references.values())
|
|
4370
|
+
});
|
|
4371
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
4372
|
+
const current = await txn.get(TERMINAL_CLEANUP_KEY);
|
|
4373
|
+
if (!current || current.phase !== cleanup.phase || current.generation !== cleanup.generation) return true;
|
|
4374
|
+
const waits = await txn.list({
|
|
4375
|
+
prefix: WAIT_KEY_PREFIX,
|
|
4376
|
+
limit: 129
|
|
4377
|
+
});
|
|
4378
|
+
if (waits.size > TERMINAL_WAIT_PAGE_SIZE) return false;
|
|
4379
|
+
const keys = [
|
|
4380
|
+
...hooks.flatMap((hook) => [`${HOOK_KEY_PREFIX}${hook.hookId}`, hookCreationIndexKey(hook)]),
|
|
4381
|
+
...markerEntries.keys(),
|
|
4382
|
+
...waits.keys()
|
|
4383
|
+
];
|
|
4384
|
+
for (let offset = 0; offset < keys.length; offset += STORAGE_BATCH_SIZE) await txn.delete(keys.slice(offset, offset + STORAGE_BATCH_SIZE));
|
|
4385
|
+
await txn.delete(TERMINAL_CLEANUP_KEY);
|
|
4386
|
+
const retention = await txn.get(CLEANUP_RECORD_KEY);
|
|
4387
|
+
if (retention && retention.phase !== "tombstoned") await txn.setAlarm(Math.max(this.now() + 1, retention.dueAt.getTime()));
|
|
4388
|
+
else await txn.deleteAlarm();
|
|
4389
|
+
return true;
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
3778
4392
|
async deleteTerminalHookPage(cleanup) {
|
|
3779
4393
|
const entries = await this.ctx.storage.list({
|
|
3780
4394
|
prefix: HOOK_KEY_PREFIX,
|
|
@@ -4010,6 +4624,150 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
4010
4624
|
await txn.setAlarm(this.now() + delay);
|
|
4011
4625
|
});
|
|
4012
4626
|
}
|
|
4627
|
+
/**
|
|
4628
|
+
* Read-only admission for a run-bearing Queue message whose body travels
|
|
4629
|
+
* inline in the broker envelope. It answers what payload registration does
|
|
4630
|
+
* for offloaded bodies, without writing anything.
|
|
4631
|
+
*/
|
|
4632
|
+
async getQueueAdmission() {
|
|
4633
|
+
const { tombstone } = await this.retentionState(this.ctx.storage);
|
|
4634
|
+
return tombstone ? {
|
|
4635
|
+
ok: false,
|
|
4636
|
+
message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`
|
|
4637
|
+
} : { ok: true };
|
|
4638
|
+
}
|
|
4639
|
+
/**
|
|
4640
|
+
* Enqueue-side admission and idempotency reservation in one transaction,
|
|
4641
|
+
* for a run-scoped claim. Replaces a separate claim cell's reservation plus
|
|
4642
|
+
* the run expiry check.
|
|
4643
|
+
*/
|
|
4644
|
+
async reserveRunQueueMessage(params) {
|
|
4645
|
+
if (!validRunQueueClaimName(params?.claimName) || typeof params.messageId !== "string" || params.messageId.length === 0 || !isNonNegativeSafeInteger(params.expiresAt)) throw new TypeError("world-celld run queue reservation is invalid");
|
|
4646
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
4647
|
+
const key = runQueueClaimKey(params.claimName);
|
|
4648
|
+
const values = await txn.get([
|
|
4649
|
+
TOMBSTONE_KEY,
|
|
4650
|
+
CLEANUP_RECORD_KEY,
|
|
4651
|
+
RUN_QUEUE_CLAIM_SCOPE_KEY,
|
|
4652
|
+
key
|
|
4653
|
+
]);
|
|
4654
|
+
const tombstone = this.tombstoneFrom(values);
|
|
4655
|
+
if (tombstone) return {
|
|
4656
|
+
ok: false,
|
|
4657
|
+
message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`
|
|
4658
|
+
};
|
|
4659
|
+
if (values.get("queue-claim-scope") !== "run") return {
|
|
4660
|
+
ok: true,
|
|
4661
|
+
scope: "cell"
|
|
4662
|
+
};
|
|
4663
|
+
const state = values.get(key) ?? {};
|
|
4664
|
+
const existing = state.reservation;
|
|
4665
|
+
if (existing && existing.expiresAt > this.now() && existing.messageId !== params.messageId) return {
|
|
4666
|
+
ok: true,
|
|
4667
|
+
scope: "run",
|
|
4668
|
+
admitted: false,
|
|
4669
|
+
messageId: existing.messageId
|
|
4670
|
+
};
|
|
4671
|
+
await txn.put(key, {
|
|
4672
|
+
...state,
|
|
4673
|
+
reservation: {
|
|
4674
|
+
messageId: params.messageId,
|
|
4675
|
+
expiresAt: Math.max(existing?.expiresAt ?? 0, params.expiresAt)
|
|
4676
|
+
}
|
|
4677
|
+
});
|
|
4678
|
+
return {
|
|
4679
|
+
ok: true,
|
|
4680
|
+
scope: "run",
|
|
4681
|
+
admitted: true,
|
|
4682
|
+
messageId: params.messageId
|
|
4683
|
+
};
|
|
4684
|
+
});
|
|
4685
|
+
}
|
|
4686
|
+
/** Delivery-side run expiry check and in-flight claim in one transaction. */
|
|
4687
|
+
async claimRunQueueMessage(params) {
|
|
4688
|
+
if (!validRunQueueClaimName(params?.claimName) || typeof params.messageId !== "string" || params.messageId.length === 0 || !isPositiveSafeInteger(params.staleMs)) throw new TypeError("world-celld run queue claim is invalid");
|
|
4689
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
4690
|
+
const key = runQueueClaimKey(params.claimName);
|
|
4691
|
+
const values = await txn.get([
|
|
4692
|
+
TOMBSTONE_KEY,
|
|
4693
|
+
CLEANUP_RECORD_KEY,
|
|
4694
|
+
key
|
|
4695
|
+
]);
|
|
4696
|
+
if (this.tombstoneFrom(values)) return { expired: true };
|
|
4697
|
+
const state = values.get(key) ?? {};
|
|
4698
|
+
const existing = state.claim;
|
|
4699
|
+
const now = this.now();
|
|
4700
|
+
if (existing && existing.expiresAt > now) {
|
|
4701
|
+
if (existing.messageId !== params.messageId) return {
|
|
4702
|
+
expired: false,
|
|
4703
|
+
claimed: false
|
|
4704
|
+
};
|
|
4705
|
+
if (existing.retryAt === void 0 || existing.retryAt > now) return {
|
|
4706
|
+
expired: false,
|
|
4707
|
+
claimed: false,
|
|
4708
|
+
retryAt: existing.retryAt ?? existing.expiresAt
|
|
4709
|
+
};
|
|
4710
|
+
}
|
|
4711
|
+
await txn.put(key, {
|
|
4712
|
+
...state,
|
|
4713
|
+
claim: {
|
|
4714
|
+
messageId: params.messageId,
|
|
4715
|
+
expiresAt: now + params.staleMs
|
|
4716
|
+
}
|
|
4717
|
+
});
|
|
4718
|
+
return {
|
|
4719
|
+
expired: false,
|
|
4720
|
+
claimed: true
|
|
4721
|
+
};
|
|
4722
|
+
});
|
|
4723
|
+
}
|
|
4724
|
+
async holdRunQueueMessage(params) {
|
|
4725
|
+
if (!validRunQueueClaimName(params?.claimName) || typeof params.messageId !== "string" || params.messageId.length === 0 || !isNonNegativeSafeInteger(params.retryAt) || !isNonNegativeSafeInteger(params.expiresAt) || params.reservationExpiresAt !== void 0 && !isNonNegativeSafeInteger(params.reservationExpiresAt)) throw new TypeError("world-celld run queue claim hold is invalid");
|
|
4726
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
4727
|
+
const key = runQueueClaimKey(params.claimName);
|
|
4728
|
+
const state = await txn.get(key) ?? {};
|
|
4729
|
+
const existing = state.claim;
|
|
4730
|
+
if (!existing || existing.messageId !== params.messageId) return { held: false };
|
|
4731
|
+
const next = {
|
|
4732
|
+
...state,
|
|
4733
|
+
claim: {
|
|
4734
|
+
...existing,
|
|
4735
|
+
retryAt: params.retryAt,
|
|
4736
|
+
expiresAt: Math.max(existing.expiresAt, params.expiresAt)
|
|
4737
|
+
}
|
|
4738
|
+
};
|
|
4739
|
+
if (state.reservation?.messageId === params.messageId && params.reservationExpiresAt !== void 0 && params.reservationExpiresAt > state.reservation.expiresAt) next.reservation = {
|
|
4740
|
+
...state.reservation,
|
|
4741
|
+
expiresAt: params.reservationExpiresAt
|
|
4742
|
+
};
|
|
4743
|
+
await txn.put(key, next);
|
|
4744
|
+
return { held: true };
|
|
4745
|
+
});
|
|
4746
|
+
}
|
|
4747
|
+
async releaseRunQueueMessage(params) {
|
|
4748
|
+
if (!validRunQueueClaimName(params?.claimName) || typeof params.messageId !== "string") throw new TypeError("world-celld run queue claim release is invalid");
|
|
4749
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
4750
|
+
const key = runQueueClaimKey(params.claimName);
|
|
4751
|
+
const state = await txn.get(key);
|
|
4752
|
+
if (state?.claim?.messageId !== params.messageId) return;
|
|
4753
|
+
const { claim: _released, ...rest } = state;
|
|
4754
|
+
if (rest.reservation) await txn.put(key, rest);
|
|
4755
|
+
else await txn.delete(key);
|
|
4756
|
+
});
|
|
4757
|
+
}
|
|
4758
|
+
async completeRunQueueMessage(params) {
|
|
4759
|
+
if (!validRunQueueClaimName(params?.claimName) || typeof params.messageId !== "string") throw new TypeError("world-celld run queue completion is invalid");
|
|
4760
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
4761
|
+
const key = runQueueClaimKey(params.claimName);
|
|
4762
|
+
const state = await txn.get(key);
|
|
4763
|
+
if (!state) return;
|
|
4764
|
+
const next = {};
|
|
4765
|
+
if (state.reservation && state.reservation.messageId !== params.messageId) next.reservation = state.reservation;
|
|
4766
|
+
if (state.claim && state.claim.messageId !== params.messageId) next.claim = state.claim;
|
|
4767
|
+
if (next.reservation || next.claim) await txn.put(key, next);
|
|
4768
|
+
else await txn.delete(key);
|
|
4769
|
+
});
|
|
4770
|
+
}
|
|
4013
4771
|
async registerQueuePayload(registration) {
|
|
4014
4772
|
if (!registration || typeof registration.messageId !== "string" || registration.messageId.length === 0 || typeof registration.key !== "string" || registration.key.length === 0 || !isNonNegativeSafeInteger(registration.orphanExpiresAt)) throw new TypeError("world-celld queue payload registration is invalid");
|
|
4015
4773
|
return await this.ctx.storage.transaction(async (txn) => {
|