@ewhauser/world-celld 0.7.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 +17 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +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-CtH7UUCO.js → queue-protocol-DKYt8qZJ.js} +25 -2
- package/dist/queue-protocol-DKYt8qZJ.js.map +1 -0
- package/dist/{run-list-B__jKuE7.js → run-list-C987PkJ2.js} +25 -2
- package/dist/{run-list-B__jKuE7.js.map → run-list-C987PkJ2.js.map} +1 -1
- package/dist/testing.d.ts +2 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +557 -92
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +66 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +581 -103
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/queue-delivery-DzNOtCYA.d.ts.map +0 -1
- package/dist/queue-protocol-CtH7UUCO.js.map +0 -1
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,
|
|
@@ -1854,6 +1911,18 @@ const INDEX_OPERATIONS = /* @__PURE__ */ new Set([
|
|
|
1854
1911
|
*/
|
|
1855
1912
|
const WORLD_NAME = "world-celld";
|
|
1856
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
|
+
}
|
|
1857
1926
|
function workflowIndex(env) {
|
|
1858
1927
|
return createWorkflowIndex({
|
|
1859
1928
|
runCatalog: env.WORKFLOW_RUN_CATALOG,
|
|
@@ -2091,31 +2160,71 @@ function createRouter(env) {
|
|
|
2091
2160
|
try {
|
|
2092
2161
|
const namespace = env.WORKFLOW_DB;
|
|
2093
2162
|
const payloadStore = env.WORKFLOW_QUEUE_PAYLOADS;
|
|
2094
|
-
const
|
|
2095
|
-
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 ? {
|
|
2096
2166
|
...envelope,
|
|
2097
2167
|
payloadKey,
|
|
2098
2168
|
body: void 0
|
|
2099
2169
|
} : envelope;
|
|
2100
|
-
const
|
|
2101
|
-
|
|
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`);
|
|
2102
2175
|
const now = Date.now();
|
|
2103
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;
|
|
2104
2178
|
let claim;
|
|
2179
|
+
let admission;
|
|
2105
2180
|
if (envelope.idempotencyKey) {
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
+
}
|
|
2111
2213
|
if (!reservation.admitted) return new Response(rpcStringify({ messageId: reservation.messageId }), {
|
|
2112
2214
|
status: 200,
|
|
2113
2215
|
headers: { "content-type": "application/json" }
|
|
2114
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
|
+
}
|
|
2115
2225
|
}
|
|
2116
|
-
if (envelope.runId && payloadKey) {
|
|
2226
|
+
if (run && envelope.runId && payloadKey) {
|
|
2117
2227
|
if (!payloadStore) return errorResponse(500, "WorldMisconfigured", "missing binding: WORKFLOW_QUEUE_PAYLOADS");
|
|
2118
|
-
const run = namespace.get(namespace.idFromName(envelope.runId));
|
|
2119
2228
|
const orphanExpiresAt = reservationExpiresAt;
|
|
2120
2229
|
const registered = await run.registerQueuePayload({
|
|
2121
2230
|
messageId: envelope.messageId,
|
|
@@ -2123,7 +2232,7 @@ function createRouter(env) {
|
|
|
2123
2232
|
orphanExpiresAt
|
|
2124
2233
|
});
|
|
2125
2234
|
if (!registered.ok) {
|
|
2126
|
-
await claim?.
|
|
2235
|
+
await claim?.complete(envelope.messageId);
|
|
2127
2236
|
return errorResponse(410, "RunExpiredError", registered.message);
|
|
2128
2237
|
}
|
|
2129
2238
|
const orphan = namespace.get(namespace.idFromName(queueOrphanName(envelope.messageId)));
|
|
@@ -2136,7 +2245,7 @@ function createRouter(env) {
|
|
|
2136
2245
|
});
|
|
2137
2246
|
} catch (error) {
|
|
2138
2247
|
await run.unregisterQueuePayload(envelope.messageId);
|
|
2139
|
-
await claim?.
|
|
2248
|
+
await claim?.complete(envelope.messageId);
|
|
2140
2249
|
throw error;
|
|
2141
2250
|
}
|
|
2142
2251
|
try {
|
|
@@ -2145,21 +2254,21 @@ function createRouter(env) {
|
|
|
2145
2254
|
messageId: envelope.messageId
|
|
2146
2255
|
});
|
|
2147
2256
|
} catch (error) {
|
|
2148
|
-
await claim?.
|
|
2257
|
+
await claim?.complete(envelope.messageId);
|
|
2149
2258
|
throw error;
|
|
2150
2259
|
}
|
|
2151
2260
|
let finalized;
|
|
2152
2261
|
try {
|
|
2153
2262
|
finalized = await run.finalizeQueuePayload(envelope.messageId);
|
|
2154
2263
|
} catch (error) {
|
|
2155
|
-
await claim?.
|
|
2264
|
+
await claim?.complete(envelope.messageId);
|
|
2156
2265
|
throw error;
|
|
2157
2266
|
}
|
|
2158
2267
|
if (!finalized.ok) {
|
|
2159
2268
|
await payloadStore.delete(payloadKey);
|
|
2160
2269
|
await run.unregisterQueuePayload(envelope.messageId);
|
|
2161
2270
|
await orphan.cancelQueuePayloadOrphan(envelope.messageId);
|
|
2162
|
-
await claim?.
|
|
2271
|
+
await claim?.complete(envelope.messageId);
|
|
2163
2272
|
return errorResponse(410, "RunExpiredError", finalized.message);
|
|
2164
2273
|
}
|
|
2165
2274
|
}
|
|
@@ -3049,6 +3158,13 @@ const DELETE_BATCH_KEYS = 128;
|
|
|
3049
3158
|
const DEFAULT_EXPIRE_BYTE_LIMIT = 16777216;
|
|
3050
3159
|
const MAX_EXPIRE_BYTE_LIMIT = DEFAULT_EXPIRE_BYTE_LIMIT;
|
|
3051
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;
|
|
3052
3168
|
/** One storage get or put call accepts at most 128 keys. */
|
|
3053
3169
|
const STORAGE_BATCH_KEYS = 128;
|
|
3054
3170
|
function boundedLimit(value, fallback, maximum) {
|
|
@@ -3069,7 +3185,7 @@ function validateMeta(meta) {
|
|
|
3069
3185
|
"closed",
|
|
3070
3186
|
"errored",
|
|
3071
3187
|
"expired"
|
|
3072
|
-
].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");
|
|
3073
3189
|
const error = candidate.error;
|
|
3074
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");
|
|
3075
3191
|
return candidate;
|
|
@@ -3081,6 +3197,80 @@ function chunkKey(index) {
|
|
|
3081
3197
|
function chunkSizeKey(index) {
|
|
3082
3198
|
return `${CHUNK_SIZE_KEY_PREFIX}${index.toString().padStart(12, "0")}`;
|
|
3083
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
|
+
}
|
|
3084
3274
|
function indexFromChunkSizeKey(key) {
|
|
3085
3275
|
const suffix = key.slice(11);
|
|
3086
3276
|
const index = Number(suffix);
|
|
@@ -3097,6 +3287,9 @@ function validateChunkSize(size, index) {
|
|
|
3097
3287
|
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > 1048576) throw new Error(`Invalid persisted stream chunk size at index ${index}`);
|
|
3098
3288
|
return size;
|
|
3099
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
|
+
}
|
|
3100
3293
|
function compactChunk(chunk) {
|
|
3101
3294
|
return chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength ? chunk : new Uint8Array(chunk);
|
|
3102
3295
|
}
|
|
@@ -3193,29 +3386,8 @@ var StreamDO = class extends DurableObject {
|
|
|
3193
3386
|
const meta = await this.getMeta();
|
|
3194
3387
|
this.assertOwner(meta, request.runId);
|
|
3195
3388
|
const available = meta.state === "expired" ? 0 : Math.max(0, Math.min(request.maxChunks, meta.count - request.startIndex));
|
|
3196
|
-
|
|
3197
|
-
if (available > 0)
|
|
3198
|
-
const indexes = Array.from({ length: available }, (_, offset) => request.startIndex + offset);
|
|
3199
|
-
const sizeKeys = indexes.map(chunkSizeKey);
|
|
3200
|
-
const storedSizes = await this.getMany(sizeKeys);
|
|
3201
|
-
const selectedIndexes = [];
|
|
3202
|
-
const selectedSizes = [];
|
|
3203
|
-
let bytes = 0;
|
|
3204
|
-
for (const index of indexes) {
|
|
3205
|
-
const size = validateChunkSize(storedSizes.get(chunkSizeKey(index)), index);
|
|
3206
|
-
if (bytes + size > request.maxBytes) break;
|
|
3207
|
-
selectedIndexes.push(index);
|
|
3208
|
-
selectedSizes.push(size);
|
|
3209
|
-
bytes += size;
|
|
3210
|
-
}
|
|
3211
|
-
const payloadKeys = selectedIndexes.map(chunkKey);
|
|
3212
|
-
const storedChunks = await this.getMany(payloadKeys);
|
|
3213
|
-
for (let offset = 0; offset < payloadKeys.length; offset++) {
|
|
3214
|
-
const chunk = storedChunks.get(payloadKeys[offset]);
|
|
3215
|
-
if (!(chunk instanceof Uint8Array) || chunk.byteLength !== selectedSizes[offset]) throw new Error(`Invalid persisted stream chunk at index ${selectedIndexes[offset]}`);
|
|
3216
|
-
chunks.push(chunk);
|
|
3217
|
-
}
|
|
3218
|
-
}
|
|
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);
|
|
3219
3391
|
return {
|
|
3220
3392
|
startIndex: request.startIndex,
|
|
3221
3393
|
tailIndex: meta.count - 1,
|
|
@@ -3225,10 +3397,91 @@ var StreamDO = class extends DurableObject {
|
|
|
3225
3397
|
error: meta.error
|
|
3226
3398
|
};
|
|
3227
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
|
+
}
|
|
3228
3482
|
/** Append one bounded binary batch with contiguous indexes. */
|
|
3229
3483
|
async writeChunks(runId, chunks) {
|
|
3230
3484
|
validateStreamWriteChunks(chunks);
|
|
3231
|
-
const storedChunks = chunks.map(compactChunk);
|
|
3232
3485
|
return await this.runMutation(async () => {
|
|
3233
3486
|
const committed = await this.ctx.storage.transaction(async (txn) => {
|
|
3234
3487
|
const stored = await txn.get(META_KEY);
|
|
@@ -3236,16 +3489,28 @@ var StreamDO = class extends DurableObject {
|
|
|
3236
3489
|
this.assertWritable(meta, runId);
|
|
3237
3490
|
const startIndex = meta.count;
|
|
3238
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;
|
|
3239
3493
|
const nextMeta = {
|
|
3240
3494
|
count: startIndex + chunks.length,
|
|
3241
3495
|
state: "open",
|
|
3242
|
-
ownerRunId: runId
|
|
3496
|
+
ownerRunId: runId,
|
|
3497
|
+
...layout === void 0 ? {} : { layout }
|
|
3243
3498
|
};
|
|
3244
3499
|
const entries = [[META_KEY, nextMeta]];
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
entries.push([
|
|
3248
|
-
|
|
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
|
+
}
|
|
3249
3514
|
}
|
|
3250
3515
|
for (let offset = 0; offset < entries.length; offset += STORAGE_BATCH_KEYS) await txn.put(Object.fromEntries(entries.slice(offset, offset + STORAGE_BATCH_KEYS)));
|
|
3251
3516
|
return {
|
|
@@ -3381,6 +3646,102 @@ var StreamDO = class extends DurableObject {
|
|
|
3381
3646
|
};
|
|
3382
3647
|
});
|
|
3383
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
|
+
}
|
|
3384
3745
|
/**
|
|
3385
3746
|
* Fence the stream and delete one bounded page. Listing size keys avoids
|
|
3386
3747
|
* loading payloads, and the transaction makes a crash retry resume safely.
|
|
@@ -3394,50 +3755,8 @@ var StreamDO = class extends DurableObject {
|
|
|
3394
3755
|
const meta = stored === void 0 ? emptyMeta() : validateMeta(stored);
|
|
3395
3756
|
this.assertOwner(meta, runId);
|
|
3396
3757
|
const firstFence = meta.state !== "expired";
|
|
3397
|
-
const
|
|
3398
|
-
|
|
3399
|
-
limit: limit + 1
|
|
3400
|
-
});
|
|
3401
|
-
const page = [];
|
|
3402
|
-
let bytes = 0;
|
|
3403
|
-
for (const [key, storedSize] of Array.from(candidates).slice(0, limit)) {
|
|
3404
|
-
const index = indexFromChunkSizeKey(key);
|
|
3405
|
-
const size = validateChunkSize(storedSize, index);
|
|
3406
|
-
if (page.length > 0 && bytes + size > byteLimit) break;
|
|
3407
|
-
page.push({
|
|
3408
|
-
index,
|
|
3409
|
-
size
|
|
3410
|
-
});
|
|
3411
|
-
bytes += size;
|
|
3412
|
-
}
|
|
3413
|
-
let payloadFallback = false;
|
|
3414
|
-
if (candidates.size === 0) {
|
|
3415
|
-
const first = (await txn.list({
|
|
3416
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3417
|
-
limit: 1
|
|
3418
|
-
})).entries().next().value;
|
|
3419
|
-
if (first) {
|
|
3420
|
-
const [key, payload] = first;
|
|
3421
|
-
page.push({
|
|
3422
|
-
index: indexFromChunkKey(key),
|
|
3423
|
-
size: payload instanceof Uint8Array ? payload.byteLength : 0
|
|
3424
|
-
});
|
|
3425
|
-
bytes = page[0].size;
|
|
3426
|
-
payloadFallback = true;
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
3429
|
-
const keys = page.flatMap(({ index }) => [chunkKey(index), chunkSizeKey(index)]);
|
|
3430
|
-
for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_KEYS) await txn.delete(keys.slice(offset, offset + DELETE_BATCH_KEYS));
|
|
3431
|
-
let done = candidates.size === page.length;
|
|
3432
|
-
if (payloadFallback) done = (await txn.list({
|
|
3433
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3434
|
-
limit: 1
|
|
3435
|
-
})).size === 0;
|
|
3436
|
-
else if (done && candidates.size > 0) done = (await txn.list({
|
|
3437
|
-
prefix: CHUNK_KEY_PREFIX,
|
|
3438
|
-
limit: 1
|
|
3439
|
-
})).size === 0;
|
|
3440
|
-
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 {
|
|
3441
3760
|
meta,
|
|
3442
3761
|
result: {
|
|
3443
3762
|
deleted: false,
|
|
@@ -3461,7 +3780,7 @@ var StreamDO = class extends DurableObject {
|
|
|
3461
3780
|
meta: nextMeta,
|
|
3462
3781
|
result: {
|
|
3463
3782
|
deleted: firstFence,
|
|
3464
|
-
chunks
|
|
3783
|
+
chunks,
|
|
3465
3784
|
bytes,
|
|
3466
3785
|
done
|
|
3467
3786
|
}
|
|
@@ -3491,6 +3810,14 @@ const CLEANUP_RETRY_MAX_MS = 36e5;
|
|
|
3491
3810
|
const MAX_DATE_MS = 864e13;
|
|
3492
3811
|
const QUEUE_ORPHAN_KEY = "queue-orphan";
|
|
3493
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";
|
|
3494
3821
|
function validateCleanupRequest(request, allowDisabled) {
|
|
3495
3822
|
if (!isRecord$1(request)) throw new Error("world-celld retention request must be an object");
|
|
3496
3823
|
if (!(allowDisabled ? isNonNegativeSafeInteger(request.retentionMs) : isPositiveSafeInteger(request.retentionMs))) throw new Error(`world-celld retentionMs must be a ${allowDisabled ? "non-negative" : "positive"} safe integer`);
|
|
@@ -3544,6 +3871,12 @@ async function putStorageEntries(storage, entries) {
|
|
|
3544
3871
|
const all = Array.from(entries);
|
|
3545
3872
|
for (let offset = 0; offset < all.length; offset += STORAGE_BATCH_SIZE) await storage.put(Object.fromEntries(all.slice(offset, offset + STORAGE_BATCH_SIZE)));
|
|
3546
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
|
+
}
|
|
3547
3880
|
/**
|
|
3548
3881
|
* Durable Object for managing a single workflow run's state.
|
|
3549
3882
|
*
|
|
@@ -3624,6 +3957,7 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
3624
3957
|
});
|
|
3625
3958
|
if (!outcome.ok) return outcome;
|
|
3626
3959
|
if (eventSequence !== storedSequence) await txn.put(EVENT_SEQUENCE_KEY, eventSequence);
|
|
3960
|
+
if (outcome.runCreated) await txn.put(RUN_QUEUE_CLAIM_SCOPE_KEY, "run");
|
|
3627
3961
|
await putStorageEntries(txn, (outcome.hookToIndex ? [{
|
|
3628
3962
|
hookId: outcome.hookToIndex.hookId,
|
|
3629
3963
|
token: outcome.hookToIndex.token
|
|
@@ -4290,6 +4624,150 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
4290
4624
|
await txn.setAlarm(this.now() + delay);
|
|
4291
4625
|
});
|
|
4292
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
|
+
}
|
|
4293
4771
|
async registerQueuePayload(registration) {
|
|
4294
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");
|
|
4295
4773
|
return await this.ctx.storage.transaction(async (txn) => {
|