@ewhauser/world-celld 0.1.0 → 0.2.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 +22 -0
- package/README.md +60 -22
- package/dist/index.d.ts +112 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -39
- package/dist/index.js.map +1 -1
- package/dist/{util-BvWlkqgV.js → retention-bLY1vcOu.js} +44 -2
- package/dist/retention-bLY1vcOu.js.map +1 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +557 -78
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +120 -57
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +585 -75
- package/dist/worker.js.map +1 -1
- package/package.json +6 -6
- package/dist/util-BvWlkqgV.js.map +0 -1
package/dist/worker.js
CHANGED
|
@@ -162,7 +162,11 @@ const BINDINGS = {
|
|
|
162
162
|
"getEvent",
|
|
163
163
|
"listEvents",
|
|
164
164
|
"listSteps",
|
|
165
|
-
"listHooks"
|
|
165
|
+
"listHooks",
|
|
166
|
+
"getCleanupStatus",
|
|
167
|
+
"scheduleCleanup",
|
|
168
|
+
"cleanupNow",
|
|
169
|
+
"rearmCleanup"
|
|
166
170
|
])
|
|
167
171
|
},
|
|
168
172
|
streams: {
|
|
@@ -173,7 +177,10 @@ const BINDINGS = {
|
|
|
173
177
|
"getChunks",
|
|
174
178
|
"getInfo",
|
|
175
179
|
"registerStream",
|
|
176
|
-
"listStreams"
|
|
180
|
+
"listStreams",
|
|
181
|
+
"expireRegistry",
|
|
182
|
+
"finalizeRegistry",
|
|
183
|
+
"expireStream"
|
|
177
184
|
])
|
|
178
185
|
},
|
|
179
186
|
index: {
|
|
@@ -182,6 +189,8 @@ const BINDINGS = {
|
|
|
182
189
|
"get",
|
|
183
190
|
"put",
|
|
184
191
|
"delete",
|
|
192
|
+
"putOwned",
|
|
193
|
+
"expireRun",
|
|
185
194
|
"list",
|
|
186
195
|
"reserveHookToken",
|
|
187
196
|
"finalizeHookIndexes",
|
|
@@ -197,7 +206,8 @@ const BINDINGS = {
|
|
|
197
206
|
"listDeadLetters",
|
|
198
207
|
"redriveDeadLetter",
|
|
199
208
|
"purgeDeadLetters",
|
|
200
|
-
"rearmAlarm"
|
|
209
|
+
"rearmAlarm",
|
|
210
|
+
"expireRun"
|
|
201
211
|
])
|
|
202
212
|
}
|
|
203
213
|
};
|
|
@@ -320,6 +330,14 @@ var IndexDO = class extends DurableObject {
|
|
|
320
330
|
async put(key, value) {
|
|
321
331
|
await this.ctx.storage.put(key, value);
|
|
322
332
|
}
|
|
333
|
+
/** Publish a derived index only while its owning run is not expired. */
|
|
334
|
+
async putOwned(runId, key, value) {
|
|
335
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
336
|
+
if (await txn.get(`expired:${runId}`) !== void 0) return { stored: false };
|
|
337
|
+
await txn.put(key, value);
|
|
338
|
+
return { stored: true };
|
|
339
|
+
});
|
|
340
|
+
}
|
|
323
341
|
async delete(key) {
|
|
324
342
|
await this.ctx.storage.delete(key);
|
|
325
343
|
}
|
|
@@ -365,6 +383,11 @@ var IndexDO = class extends DurableObject {
|
|
|
365
383
|
}
|
|
366
384
|
async finalizeHookIndexes(token, hookId, serializedHook, owner) {
|
|
367
385
|
await this.ctx.storage.transaction(async (txn) => {
|
|
386
|
+
if (await txn.get(`expired:${owner.runId}`) !== void 0) {
|
|
387
|
+
const claim = await txn.get(`hookclaim:${token}`);
|
|
388
|
+
if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(`hookclaim:${token}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
368
391
|
const indexedHook = await txn.get(`hook:${token}`);
|
|
369
392
|
if (indexedHook !== void 0 && !sameOwner(ownerFromHook(indexedHook), owner)) throw new Error(`Hook token ${token} is owned by another hook`);
|
|
370
393
|
const claim = await txn.get(`hookclaim:${token}`);
|
|
@@ -396,6 +419,42 @@ var IndexDO = class extends DurableObject {
|
|
|
396
419
|
if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(claimKey);
|
|
397
420
|
});
|
|
398
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Fence a run and remove all of its known derived indexes atomically.
|
|
424
|
+
* Once the fence exists, putOwned() and hook finalization cannot resurrect
|
|
425
|
+
* entries from a delayed request.
|
|
426
|
+
*/
|
|
427
|
+
async expireRun(request) {
|
|
428
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
429
|
+
await txn.put(`expired:${request.runId}`, request.expiredAt);
|
|
430
|
+
let deleted = 0;
|
|
431
|
+
for (const key of new Set(request.keys)) if (await txn.delete(key)) deleted++;
|
|
432
|
+
for (const hook of request.hooks) {
|
|
433
|
+
const owner = {
|
|
434
|
+
runId: request.runId,
|
|
435
|
+
hookId: hook.hookId
|
|
436
|
+
};
|
|
437
|
+
const tokenKey = `hook:${hook.token}`;
|
|
438
|
+
const idKey = `hookid:${hook.hookId}`;
|
|
439
|
+
const claimKey = `hookclaim:${hook.token}`;
|
|
440
|
+
const [byToken, byId, claim] = await Promise.all([
|
|
441
|
+
txn.get(tokenKey),
|
|
442
|
+
txn.get(idKey),
|
|
443
|
+
txn.get(claimKey)
|
|
444
|
+
]);
|
|
445
|
+
if (byToken !== void 0 && sameOwner(ownerFromHook(byToken), owner)) {
|
|
446
|
+
if (await txn.delete(tokenKey)) deleted++;
|
|
447
|
+
}
|
|
448
|
+
if (byId !== void 0 && sameOwner(ownerFromHook(byId), owner)) {
|
|
449
|
+
if (await txn.delete(idKey)) deleted++;
|
|
450
|
+
}
|
|
451
|
+
if (claim !== void 0 && sameOwner(claim.owner, owner)) {
|
|
452
|
+
if (await txn.delete(claimKey)) deleted++;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { deleted };
|
|
456
|
+
});
|
|
457
|
+
}
|
|
399
458
|
};
|
|
400
459
|
//#endregion
|
|
401
460
|
//#region src/worker/durable-objects/QueueDO.ts
|
|
@@ -418,6 +477,12 @@ function pad(ms) {
|
|
|
418
477
|
function dueKey(atMs, messageId) {
|
|
419
478
|
return `due:${pad(atMs)}:${messageId}`;
|
|
420
479
|
}
|
|
480
|
+
function runReferenceKey(runId, messageId) {
|
|
481
|
+
return `run:${runId}:${messageId}`;
|
|
482
|
+
}
|
|
483
|
+
function expiredRunKey(runId) {
|
|
484
|
+
return `expired-run:${runId}`;
|
|
485
|
+
}
|
|
421
486
|
function backoffSeconds(attempt) {
|
|
422
487
|
return Math.min(60, 2 ** attempt);
|
|
423
488
|
}
|
|
@@ -439,6 +504,11 @@ var QueueDO = class extends DurableObject {
|
|
|
439
504
|
const storage = this.ctx.storage;
|
|
440
505
|
const now = this.#now();
|
|
441
506
|
return await storage.transaction(async (txn) => {
|
|
507
|
+
if (request.runId && await txn.get(expiredRunKey(request.runId)) !== void 0) return {
|
|
508
|
+
ok: false,
|
|
509
|
+
code: "RUN_EXPIRED",
|
|
510
|
+
message: `Workflow run "${request.runId}" has expired`
|
|
511
|
+
};
|
|
442
512
|
const pinned = await txn.get("cfg");
|
|
443
513
|
if (!pinned) await txn.put("cfg", {
|
|
444
514
|
queueShards: request.config.queueShards,
|
|
@@ -469,14 +539,21 @@ var QueueDO = class extends DurableObject {
|
|
|
469
539
|
queueName: request.queueName,
|
|
470
540
|
pathname: request.pathname,
|
|
471
541
|
body: request.body,
|
|
542
|
+
runId: request.runId,
|
|
472
543
|
targetBaseUrl: request.config.targetBaseUrl,
|
|
473
544
|
idempotencyKey: request.idempotencyKey,
|
|
474
545
|
attempt: 0,
|
|
475
546
|
enqueuedAt: now
|
|
476
547
|
};
|
|
477
548
|
const dueAt = now + Math.max(0, request.delaySeconds ?? 0) * 1e3;
|
|
549
|
+
const scheduleKey = dueKey(dueAt, row.messageId);
|
|
478
550
|
await txn.put(`msg:${row.messageId}`, row);
|
|
479
|
-
await txn.put(
|
|
551
|
+
await txn.put(scheduleKey, row.messageId);
|
|
552
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
553
|
+
messageId: row.messageId,
|
|
554
|
+
dueKey: scheduleKey,
|
|
555
|
+
idempotencyKey: row.idempotencyKey
|
|
556
|
+
});
|
|
480
557
|
await this.#armAlarmAtMost(txn, dueAt, now);
|
|
481
558
|
return {
|
|
482
559
|
ok: true,
|
|
@@ -514,7 +591,16 @@ var QueueDO = class extends DurableObject {
|
|
|
514
591
|
for (const [key, deadline] of inflight) if (deadline <= now) {
|
|
515
592
|
const messageId = key.slice(9);
|
|
516
593
|
await txn.delete(key);
|
|
517
|
-
|
|
594
|
+
const row = await txn.get(`msg:${messageId}`);
|
|
595
|
+
if (row) {
|
|
596
|
+
const scheduleKey = dueKey(now, messageId);
|
|
597
|
+
await txn.put(scheduleKey, messageId);
|
|
598
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
599
|
+
messageId: row.messageId,
|
|
600
|
+
dueKey: scheduleKey,
|
|
601
|
+
idempotencyKey: row.idempotencyKey
|
|
602
|
+
});
|
|
603
|
+
}
|
|
518
604
|
} else inflightCount++;
|
|
519
605
|
const rows = [];
|
|
520
606
|
if (inflightCount < maxInflight) {
|
|
@@ -569,8 +655,25 @@ var QueueDO = class extends DurableObject {
|
|
|
569
655
|
timeoutSeconds = void 0;
|
|
570
656
|
}
|
|
571
657
|
if (typeof timeoutSeconds === "number") await storage.transaction(async (txn) => {
|
|
658
|
+
if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
|
|
659
|
+
await txn.delete(`msg:${row.messageId}`);
|
|
660
|
+
await txn.delete(`inflight:${row.messageId}`);
|
|
661
|
+
await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
662
|
+
if (row.idempotencyKey) {
|
|
663
|
+
const dedupKey = `key:${row.idempotencyKey}`;
|
|
664
|
+
if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
|
|
665
|
+
}
|
|
666
|
+
await this.#scheduleNextAlarm(txn, now);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
572
669
|
await txn.delete(`inflight:${row.messageId}`);
|
|
573
|
-
|
|
670
|
+
const scheduleKey = dueKey(now + timeoutSeconds * 1e3, row.messageId);
|
|
671
|
+
await txn.put(scheduleKey, row.messageId);
|
|
672
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
673
|
+
messageId: row.messageId,
|
|
674
|
+
dueKey: scheduleKey,
|
|
675
|
+
idempotencyKey: row.idempotencyKey
|
|
676
|
+
});
|
|
574
677
|
await this.#scheduleNextAlarm(txn, now);
|
|
575
678
|
});
|
|
576
679
|
else await this.#retry(row, "HTTP 503");
|
|
@@ -587,6 +690,7 @@ var QueueDO = class extends DurableObject {
|
|
|
587
690
|
if (row.idempotencyKey) {
|
|
588
691
|
if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
|
|
589
692
|
}
|
|
693
|
+
if (row.runId) await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
590
694
|
await this.#scheduleNextAlarm(txn, this.#now());
|
|
591
695
|
});
|
|
592
696
|
}
|
|
@@ -596,6 +700,17 @@ var QueueDO = class extends DurableObject {
|
|
|
596
700
|
const maxAttempts = this.#intVar("QUEUE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS);
|
|
597
701
|
const attempt = row.attempt + 1;
|
|
598
702
|
await storage.transaction(async (txn) => {
|
|
703
|
+
if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
|
|
704
|
+
await txn.delete(`msg:${row.messageId}`);
|
|
705
|
+
await txn.delete(`inflight:${row.messageId}`);
|
|
706
|
+
await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
707
|
+
if (row.idempotencyKey) {
|
|
708
|
+
const dedupKey = `key:${row.idempotencyKey}`;
|
|
709
|
+
if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
|
|
710
|
+
}
|
|
711
|
+
await this.#scheduleNextAlarm(txn, now);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
599
714
|
await txn.delete(`inflight:${row.messageId}`);
|
|
600
715
|
if (attempt >= maxAttempts) {
|
|
601
716
|
const dead = {
|
|
@@ -604,11 +719,17 @@ var QueueDO = class extends DurableObject {
|
|
|
604
719
|
lastError: reason,
|
|
605
720
|
failedAt: now
|
|
606
721
|
};
|
|
607
|
-
|
|
722
|
+
const deadKey = `dlq:${pad(now)}:${row.messageId}`;
|
|
723
|
+
await txn.put(deadKey, dead);
|
|
608
724
|
await txn.delete(`msg:${row.messageId}`);
|
|
609
725
|
if (row.idempotencyKey) {
|
|
610
726
|
if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
|
|
611
727
|
}
|
|
728
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
729
|
+
messageId: row.messageId,
|
|
730
|
+
dlqKey: deadKey,
|
|
731
|
+
idempotencyKey: row.idempotencyKey
|
|
732
|
+
});
|
|
612
733
|
await this.#scheduleNextAlarm(txn, now);
|
|
613
734
|
return;
|
|
614
735
|
}
|
|
@@ -617,8 +738,14 @@ var QueueDO = class extends DurableObject {
|
|
|
617
738
|
attempt,
|
|
618
739
|
lastError: reason
|
|
619
740
|
};
|
|
741
|
+
const scheduleKey = dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId);
|
|
620
742
|
await txn.put(`msg:${row.messageId}`, updated);
|
|
621
|
-
await txn.put(
|
|
743
|
+
await txn.put(scheduleKey, row.messageId);
|
|
744
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
745
|
+
messageId: row.messageId,
|
|
746
|
+
dueKey: scheduleKey,
|
|
747
|
+
idempotencyKey: row.idempotencyKey
|
|
748
|
+
});
|
|
622
749
|
await this.#scheduleNextAlarm(txn, now);
|
|
623
750
|
});
|
|
624
751
|
}
|
|
@@ -702,8 +829,14 @@ var QueueDO = class extends DurableObject {
|
|
|
702
829
|
await txn.put(dedupKey, row.messageId);
|
|
703
830
|
}
|
|
704
831
|
await txn.put(`msg:${row.messageId}`, row);
|
|
705
|
-
|
|
832
|
+
const scheduleKey = dueKey(now, row.messageId);
|
|
833
|
+
await txn.put(scheduleKey, row.messageId);
|
|
706
834
|
await txn.delete(key);
|
|
835
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
836
|
+
messageId: row.messageId,
|
|
837
|
+
dueKey: scheduleKey,
|
|
838
|
+
idempotencyKey: row.idempotencyKey
|
|
839
|
+
});
|
|
707
840
|
await this.#armAlarmAtMost(txn, now, now);
|
|
708
841
|
return { ok: true };
|
|
709
842
|
}
|
|
@@ -713,7 +846,11 @@ var QueueDO = class extends DurableObject {
|
|
|
713
846
|
async purgeDeadLetters() {
|
|
714
847
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
715
848
|
const dlq = await txn.list({ prefix: "dlq:" });
|
|
716
|
-
for (const key of dlq
|
|
849
|
+
for (const [key, value] of dlq) {
|
|
850
|
+
await txn.delete(key);
|
|
851
|
+
const dead = value;
|
|
852
|
+
if (dead.runId) await txn.delete(runReferenceKey(dead.runId, dead.messageId));
|
|
853
|
+
}
|
|
717
854
|
return { purged: dlq.size };
|
|
718
855
|
});
|
|
719
856
|
}
|
|
@@ -725,6 +862,32 @@ var QueueDO = class extends DurableObject {
|
|
|
725
862
|
await this.#rearm();
|
|
726
863
|
return { alarmAt: await this.ctx.storage.getAlarm() };
|
|
727
864
|
}
|
|
865
|
+
/** Fence a run and purge every live, inflight, and dead-letter message it owns. */
|
|
866
|
+
async expireRun(runId, expiredAt) {
|
|
867
|
+
return { deleted: await this.ctx.storage.transaction(async (txn) => {
|
|
868
|
+
const existing = await txn.get(expiredRunKey(runId));
|
|
869
|
+
const references = await txn.list({ prefix: `run:${runId}:` });
|
|
870
|
+
let count = existing?.deleted ?? 0;
|
|
871
|
+
for (const [key, reference] of references) {
|
|
872
|
+
await txn.delete(`msg:${reference.messageId}`);
|
|
873
|
+
await txn.delete(`inflight:${reference.messageId}`);
|
|
874
|
+
if (reference.dueKey) await txn.delete(reference.dueKey);
|
|
875
|
+
if (reference.dlqKey) await txn.delete(reference.dlqKey);
|
|
876
|
+
if (reference.idempotencyKey) {
|
|
877
|
+
const dedupKey = `key:${reference.idempotencyKey}`;
|
|
878
|
+
if (await txn.get(dedupKey) === reference.messageId) await txn.delete(dedupKey);
|
|
879
|
+
}
|
|
880
|
+
await txn.delete(key);
|
|
881
|
+
count++;
|
|
882
|
+
}
|
|
883
|
+
await txn.put(expiredRunKey(runId), {
|
|
884
|
+
expiredAt,
|
|
885
|
+
deleted: count
|
|
886
|
+
});
|
|
887
|
+
await this.#scheduleNextAlarm(txn, this.#now());
|
|
888
|
+
return count;
|
|
889
|
+
}) };
|
|
890
|
+
}
|
|
728
891
|
};
|
|
729
892
|
//#endregion
|
|
730
893
|
//#region src/worker/durable-objects/StreamDO.ts
|
|
@@ -732,6 +895,8 @@ const META_KEY = "meta";
|
|
|
732
895
|
const CHUNK_KEY_PREFIX = "chunk:";
|
|
733
896
|
/** Registry keys used when this DO instance acts as a per-run stream index. */
|
|
734
897
|
const STREAM_REGISTRY_PREFIX = "stream:";
|
|
898
|
+
const REGISTRY_OWNER_KEY = "registry:owner";
|
|
899
|
+
const REGISTRY_EXPIRED_KEY = "registry:expired";
|
|
735
900
|
/**
|
|
736
901
|
* Zero-pad chunk indexes so `storage.list({ prefix })` returns chunks in
|
|
737
902
|
* write order. 12 digits comfortably exceeds any realistic chunk count.
|
|
@@ -756,22 +921,28 @@ var StreamDO = class extends DurableObject {
|
|
|
756
921
|
closed: false
|
|
757
922
|
};
|
|
758
923
|
}
|
|
924
|
+
assertOwner(meta, runId) {
|
|
925
|
+
if (meta.expiredAt !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
|
|
926
|
+
if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
|
|
927
|
+
}
|
|
759
928
|
/**
|
|
760
929
|
* Append a chunk. The index is allocated inside the transaction, so
|
|
761
930
|
* concurrent writers can never collide or skip.
|
|
762
931
|
*/
|
|
763
|
-
async writeChunk(data) {
|
|
932
|
+
async writeChunk(runId, data) {
|
|
764
933
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
765
934
|
const meta = await txn.get(META_KEY) ?? {
|
|
766
935
|
count: 0,
|
|
767
936
|
closed: false
|
|
768
937
|
};
|
|
938
|
+
this.assertOwner(meta, runId);
|
|
769
939
|
if (meta.closed) throw new Error("Cannot write to a closed stream");
|
|
770
940
|
const index = meta.count;
|
|
771
941
|
await txn.put(chunkKey(index), data);
|
|
772
942
|
await txn.put(META_KEY, {
|
|
773
943
|
count: index + 1,
|
|
774
|
-
closed: false
|
|
944
|
+
closed: false,
|
|
945
|
+
ownerRunId: runId
|
|
775
946
|
});
|
|
776
947
|
return index;
|
|
777
948
|
});
|
|
@@ -780,14 +951,16 @@ var StreamDO = class extends DurableObject {
|
|
|
780
951
|
* Close the stream (idempotent). Readers observe `done: true` once all
|
|
781
952
|
* previously written chunks have been consumed.
|
|
782
953
|
*/
|
|
783
|
-
async closeStream() {
|
|
954
|
+
async closeStream(runId) {
|
|
784
955
|
await this.ctx.storage.transaction(async (txn) => {
|
|
785
956
|
const meta = await txn.get(META_KEY) ?? {
|
|
786
957
|
count: 0,
|
|
787
958
|
closed: false
|
|
788
959
|
};
|
|
960
|
+
this.assertOwner(meta, runId);
|
|
789
961
|
await txn.put(META_KEY, {
|
|
790
962
|
...meta,
|
|
963
|
+
ownerRunId: runId,
|
|
791
964
|
closed: true
|
|
792
965
|
});
|
|
793
966
|
});
|
|
@@ -797,6 +970,7 @@ var StreamDO = class extends DurableObject {
|
|
|
797
970
|
*/
|
|
798
971
|
async getChunks(params) {
|
|
799
972
|
const meta = await this.getMeta();
|
|
973
|
+
if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
|
|
800
974
|
const start = Math.max(0, params.startIndex);
|
|
801
975
|
const entries = await this.ctx.storage.list({
|
|
802
976
|
prefix: CHUNK_KEY_PREFIX,
|
|
@@ -814,6 +988,7 @@ var StreamDO = class extends DurableObject {
|
|
|
814
988
|
*/
|
|
815
989
|
async getInfo() {
|
|
816
990
|
const meta = await this.getMeta();
|
|
991
|
+
if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
|
|
817
992
|
return {
|
|
818
993
|
tailIndex: meta.count - 1,
|
|
819
994
|
done: meta.closed
|
|
@@ -822,16 +997,66 @@ var StreamDO = class extends DurableObject {
|
|
|
822
997
|
/**
|
|
823
998
|
* Register a stream name against this per-run registry instance.
|
|
824
999
|
*/
|
|
825
|
-
async registerStream(name) {
|
|
826
|
-
await this.ctx.storage.
|
|
1000
|
+
async registerStream(runId, name) {
|
|
1001
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1002
|
+
if (await txn.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
|
|
1003
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1004
|
+
if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
|
|
1005
|
+
await txn.put(REGISTRY_OWNER_KEY, runId);
|
|
1006
|
+
await txn.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
|
|
1007
|
+
});
|
|
827
1008
|
}
|
|
828
1009
|
/**
|
|
829
1010
|
* List stream names registered against this per-run registry instance.
|
|
830
1011
|
*/
|
|
831
1012
|
async listStreams() {
|
|
1013
|
+
if (await this.ctx.storage.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error("Workflow run streams have expired");
|
|
832
1014
|
const entries = await this.ctx.storage.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
833
1015
|
return Array.from(entries.keys()).map((key) => key.slice(7));
|
|
834
1016
|
}
|
|
1017
|
+
/** Fence a run's registry before any stream cells are removed. */
|
|
1018
|
+
async expireRegistry(runId, expiredAt) {
|
|
1019
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1020
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1021
|
+
if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
|
|
1022
|
+
await txn.put(REGISTRY_OWNER_KEY, runId);
|
|
1023
|
+
await txn.put(REGISTRY_EXPIRED_KEY, expiredAt);
|
|
1024
|
+
const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
1025
|
+
return { streams: Array.from(entries.keys()).map((key) => key.slice(7)) };
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
/** Remove the registry payload after every referenced stream was fenced. */
|
|
1029
|
+
async finalizeRegistry(runId) {
|
|
1030
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1031
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1032
|
+
const expiredAt = await txn.get(REGISTRY_EXPIRED_KEY);
|
|
1033
|
+
if (owner !== runId || expiredAt === void 0) throw new Error(`Stream registry for workflow run "${runId}" is not expired`);
|
|
1034
|
+
const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
1035
|
+
for (const key of entries.keys()) await txn.delete(key);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
/** Delete stream payload while leaving a small ownership tombstone. */
|
|
1039
|
+
async expireStream(runId, expiredAt) {
|
|
1040
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1041
|
+
const meta = await txn.get(META_KEY) ?? {
|
|
1042
|
+
count: 0,
|
|
1043
|
+
closed: false
|
|
1044
|
+
};
|
|
1045
|
+
if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
|
|
1046
|
+
const chunks = await txn.list({ prefix: CHUNK_KEY_PREFIX });
|
|
1047
|
+
for (const key of chunks.keys()) await txn.delete(key);
|
|
1048
|
+
await txn.put(META_KEY, {
|
|
1049
|
+
count: 0,
|
|
1050
|
+
closed: true,
|
|
1051
|
+
ownerRunId: runId,
|
|
1052
|
+
expiredAt
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
deleted: true,
|
|
1056
|
+
chunks: chunks.size
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
835
1060
|
};
|
|
836
1061
|
//#endregion
|
|
837
1062
|
//#region src/apply-event.ts
|
|
@@ -1367,7 +1592,52 @@ async function applyEvent(store, ctx) {
|
|
|
1367
1592
|
}
|
|
1368
1593
|
}
|
|
1369
1594
|
//#endregion
|
|
1595
|
+
//#region src/retention.ts
|
|
1596
|
+
const CLEANUP_RECORD_KEY = "retention:cleanup";
|
|
1597
|
+
const TOMBSTONE_KEY = "retention:tombstone";
|
|
1598
|
+
const INDEX_MARKER_PREFIX = "retention:index:";
|
|
1599
|
+
const HOOK_MARKER_PREFIX = "retention:hook:";
|
|
1600
|
+
function sortableTimestamp(date) {
|
|
1601
|
+
return date.getTime().toString().padStart(13, "0");
|
|
1602
|
+
}
|
|
1603
|
+
function workflowRunIndexKey(run) {
|
|
1604
|
+
return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
|
|
1605
|
+
}
|
|
1606
|
+
function globalRunIndexKey(run) {
|
|
1607
|
+
return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
|
|
1608
|
+
}
|
|
1609
|
+
function correlationIndexKey(correlationId, createdAt, eventId, runId) {
|
|
1610
|
+
return `correlation:${encodeURIComponent(correlationId)}:${sortableTimestamp(createdAt)}:${eventId}:${runId}`;
|
|
1611
|
+
}
|
|
1612
|
+
function indexMarkerKey(indexKey) {
|
|
1613
|
+
return `${INDEX_MARKER_PREFIX}${encodeURIComponent(indexKey)}`;
|
|
1614
|
+
}
|
|
1615
|
+
function hookMarkerKey(reference) {
|
|
1616
|
+
return `${HOOK_MARKER_PREFIX}${encodeURIComponent(reference.hookId)}:${encodeURIComponent(reference.token)}`;
|
|
1617
|
+
}
|
|
1618
|
+
function cleanupTombstone(record, now) {
|
|
1619
|
+
return {
|
|
1620
|
+
version: 1,
|
|
1621
|
+
runId: record.runId,
|
|
1622
|
+
terminalStatus: record.terminalStatus,
|
|
1623
|
+
completedAt: record.completedAt,
|
|
1624
|
+
expiredAt: record.dueAt,
|
|
1625
|
+
tombstonedAt: now
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function expiredRead(tombstone) {
|
|
1629
|
+
return {
|
|
1630
|
+
ok: false,
|
|
1631
|
+
code: "RUN_EXPIRED",
|
|
1632
|
+
message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`,
|
|
1633
|
+
tombstone
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
//#endregion
|
|
1370
1637
|
//#region src/worker/durable-objects/WorkflowRunDO.ts
|
|
1638
|
+
const PAYLOAD_DELETE_BATCH = 256;
|
|
1639
|
+
const STREAM_DELETE_CONCURRENCY = 16;
|
|
1640
|
+
const CLEANUP_RETRY_MAX_MS = 36e5;
|
|
1371
1641
|
/**
|
|
1372
1642
|
* Current schema version for DO storage.
|
|
1373
1643
|
* Increment when making breaking changes to the storage layout.
|
|
@@ -1452,104 +1722,347 @@ function storeFrom(storage) {
|
|
|
1452
1722
|
*/
|
|
1453
1723
|
var WorkflowRunDO = class extends DurableObject {
|
|
1454
1724
|
schemaReady = null;
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1725
|
+
now() {
|
|
1726
|
+
const clock = this.env?.clock;
|
|
1727
|
+
return typeof clock === "function" ? clock() : Date.now();
|
|
1728
|
+
}
|
|
1459
1729
|
ensureSchema() {
|
|
1460
1730
|
if (!this.schemaReady) this.schemaReady = ensureSchemaUpToDate(this.ctx.storage);
|
|
1461
1731
|
return this.schemaReady;
|
|
1462
1732
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1733
|
+
async expiredTombstone(storage, now = this.now()) {
|
|
1734
|
+
const tombstone = await storage.get(TOMBSTONE_KEY);
|
|
1735
|
+
if (tombstone) return tombstone;
|
|
1736
|
+
const cleanup = await storage.get(CLEANUP_RECORD_KEY);
|
|
1737
|
+
if (!cleanup || cleanup.phase === "retained" && cleanup.dueAt.getTime() > now) return null;
|
|
1738
|
+
return cleanupTombstone(cleanup, cleanup.tombstonedAt ?? cleanup.dueAt);
|
|
1739
|
+
}
|
|
1740
|
+
async read(reader) {
|
|
1741
|
+
await this.ensureSchema();
|
|
1742
|
+
const tombstone = await this.expiredTombstone(this.ctx.storage);
|
|
1743
|
+
if (tombstone) return expiredRead(tombstone);
|
|
1744
|
+
return {
|
|
1745
|
+
ok: true,
|
|
1746
|
+
value: await reader()
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
/** Apply an event and capture all retention metadata in the same transaction. */
|
|
1469
1750
|
async applyEvent(request) {
|
|
1470
1751
|
await this.ensureSchema();
|
|
1471
1752
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
1753
|
+
const now = new Date(this.now());
|
|
1754
|
+
const tombstone = await this.expiredTombstone(txn, now.getTime());
|
|
1755
|
+
if (tombstone) return {
|
|
1756
|
+
ok: false,
|
|
1757
|
+
code: "RUN_EXPIRED",
|
|
1758
|
+
message: `Workflow run "${request.runId}" expired at ${tombstone.expiredAt.toISOString()}`
|
|
1759
|
+
};
|
|
1472
1760
|
let eventSequence = await txn.get("event_sequence") ?? 0;
|
|
1473
1761
|
const outcome = await applyEvent(storeFrom(txn), {
|
|
1474
1762
|
...request,
|
|
1475
1763
|
nextEventId: () => `wevt_z${String(++eventSequence).padStart(20, "0")}`,
|
|
1476
|
-
now
|
|
1764
|
+
now
|
|
1477
1765
|
});
|
|
1478
|
-
if (outcome.ok)
|
|
1766
|
+
if (!outcome.ok) return outcome;
|
|
1767
|
+
await txn.put("event_sequence", eventSequence);
|
|
1768
|
+
if (outcome.event?.correlationId) {
|
|
1769
|
+
const key = correlationIndexKey(outcome.event.correlationId, outcome.event.createdAt, outcome.event.eventId, request.runId);
|
|
1770
|
+
await txn.put(indexMarkerKey(key), key);
|
|
1771
|
+
}
|
|
1772
|
+
if (outcome.hookToIndex) {
|
|
1773
|
+
const reference = {
|
|
1774
|
+
hookId: outcome.hookToIndex.hookId,
|
|
1775
|
+
token: outcome.hookToIndex.token
|
|
1776
|
+
};
|
|
1777
|
+
await txn.put(hookMarkerKey(reference), reference);
|
|
1778
|
+
}
|
|
1779
|
+
for (const reference of outcome.releasedHooks) await txn.put(hookMarkerKey(reference), reference);
|
|
1780
|
+
const retentionMs = request.cleanup?.retentionMs ?? 0;
|
|
1781
|
+
if (retentionMs > 0 && outcome.run && isTerminalWorkflowRunStatus(outcome.run.status) && !await txn.get("retention:cleanup")) {
|
|
1782
|
+
const terminalRun = outcome.run;
|
|
1783
|
+
const dueAt = new Date(terminalRun.completedAt.getTime() + retentionMs);
|
|
1784
|
+
const run = WorkflowRunSchema.parse({
|
|
1785
|
+
...terminalRun,
|
|
1786
|
+
expiredAt: dueAt
|
|
1787
|
+
});
|
|
1788
|
+
const cleanup = {
|
|
1789
|
+
version: 1,
|
|
1790
|
+
runId: run.runId,
|
|
1791
|
+
workflowName: run.workflowName,
|
|
1792
|
+
createdAt: run.createdAt,
|
|
1793
|
+
completedAt: run.completedAt,
|
|
1794
|
+
terminalStatus: run.status,
|
|
1795
|
+
dueAt,
|
|
1796
|
+
queueShards: request.cleanup?.queueShards ?? 1,
|
|
1797
|
+
phase: "retained",
|
|
1798
|
+
attempts: 0,
|
|
1799
|
+
deletedPayloadKeys: 0,
|
|
1800
|
+
deletedStreams: 0,
|
|
1801
|
+
deletedQueueMessages: 0
|
|
1802
|
+
};
|
|
1803
|
+
await txn.put("run", run);
|
|
1804
|
+
await txn.put(CLEANUP_RECORD_KEY, cleanup);
|
|
1805
|
+
await txn.setAlarm(dueAt);
|
|
1806
|
+
outcome.run = run;
|
|
1807
|
+
}
|
|
1479
1808
|
return outcome;
|
|
1480
1809
|
});
|
|
1481
1810
|
}
|
|
1482
|
-
/**
|
|
1483
|
-
* Get the workflow run
|
|
1484
|
-
*/
|
|
1485
1811
|
async getRun() {
|
|
1486
|
-
await this.
|
|
1487
|
-
return await this.ctx.storage.get("run") ?? null;
|
|
1812
|
+
return this.read(async () => await this.ctx.storage.get("run") ?? null);
|
|
1488
1813
|
}
|
|
1489
|
-
/**
|
|
1490
|
-
* Get a specific step
|
|
1491
|
-
*/
|
|
1492
1814
|
async getStep(stepId) {
|
|
1493
|
-
await this.
|
|
1494
|
-
return await this.ctx.storage.get(`step:${stepId}`) ?? null;
|
|
1815
|
+
return this.read(async () => await this.ctx.storage.get(`step:${stepId}`) ?? null);
|
|
1495
1816
|
}
|
|
1496
|
-
/**
|
|
1497
|
-
* List steps with real limit/cursor pagination (cursor = last stepId).
|
|
1498
|
-
*/
|
|
1499
1817
|
async listSteps(params) {
|
|
1500
|
-
|
|
1501
|
-
return listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
|
|
1818
|
+
return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
|
|
1502
1819
|
limit: params?.limit ?? 20,
|
|
1503
1820
|
cursor: params?.cursor,
|
|
1504
1821
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1505
|
-
});
|
|
1822
|
+
}));
|
|
1506
1823
|
}
|
|
1507
|
-
/**
|
|
1508
|
-
* Get a specific event
|
|
1509
|
-
*/
|
|
1510
1824
|
async getEvent(eventId) {
|
|
1511
|
-
await this.
|
|
1512
|
-
return await this.ctx.storage.get(`event:${eventId}`) ?? null;
|
|
1825
|
+
return this.read(async () => await this.ctx.storage.get(`event:${eventId}`) ?? null);
|
|
1513
1826
|
}
|
|
1514
|
-
/**
|
|
1515
|
-
* List events with real limit/cursor pagination. Events are keyed by their
|
|
1516
|
-
* monotonic ULID eventId, so storage order == event order.
|
|
1517
|
-
*/
|
|
1518
1827
|
async listEvents(params) {
|
|
1519
|
-
|
|
1520
|
-
return listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
|
|
1828
|
+
return this.read(() => listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
|
|
1521
1829
|
limit: params?.limit ?? 100,
|
|
1522
1830
|
cursor: params?.cursor,
|
|
1523
1831
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1524
|
-
}, (event) => event.eventId);
|
|
1832
|
+
}, (event) => event.eventId));
|
|
1525
1833
|
}
|
|
1526
|
-
/**
|
|
1527
|
-
* List hooks with real limit/cursor pagination (cursor = last hookId).
|
|
1528
|
-
*/
|
|
1529
1834
|
async listHooks(params) {
|
|
1530
|
-
|
|
1531
|
-
return listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
|
|
1835
|
+
return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
|
|
1532
1836
|
limit: params?.limit ?? 100,
|
|
1533
1837
|
cursor: params?.cursor,
|
|
1534
1838
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1839
|
+
}));
|
|
1840
|
+
}
|
|
1841
|
+
async getCleanupStatus() {
|
|
1842
|
+
await this.ensureSchema();
|
|
1843
|
+
return await this.ctx.storage.get("retention:cleanup") ?? null;
|
|
1844
|
+
}
|
|
1845
|
+
/** Explicitly schedule an existing terminal run, used for opt-in backfills. */
|
|
1846
|
+
async scheduleCleanup(request) {
|
|
1847
|
+
await this.ensureSchema();
|
|
1848
|
+
if (request.retentionMs <= 0) return this.getCleanupStatus();
|
|
1849
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1850
|
+
const existing = await txn.get(CLEANUP_RECORD_KEY);
|
|
1851
|
+
if (existing) return existing;
|
|
1852
|
+
if (await txn.get("retention:tombstone")) return null;
|
|
1853
|
+
const current = await txn.get("run");
|
|
1854
|
+
if (!current || !isTerminalWorkflowRunStatus(current.status)) return null;
|
|
1855
|
+
const terminalRun = current;
|
|
1856
|
+
const dueAt = new Date(terminalRun.completedAt.getTime() + request.retentionMs);
|
|
1857
|
+
const run = WorkflowRunSchema.parse({
|
|
1858
|
+
...terminalRun,
|
|
1859
|
+
expiredAt: dueAt
|
|
1860
|
+
});
|
|
1861
|
+
const cleanup = {
|
|
1862
|
+
version: 1,
|
|
1863
|
+
runId: run.runId,
|
|
1864
|
+
workflowName: run.workflowName,
|
|
1865
|
+
createdAt: run.createdAt,
|
|
1866
|
+
completedAt: run.completedAt,
|
|
1867
|
+
terminalStatus: run.status,
|
|
1868
|
+
dueAt,
|
|
1869
|
+
queueShards: request.queueShards,
|
|
1870
|
+
phase: "retained",
|
|
1871
|
+
attempts: 0,
|
|
1872
|
+
deletedPayloadKeys: 0,
|
|
1873
|
+
deletedStreams: 0,
|
|
1874
|
+
deletedQueueMessages: 0
|
|
1875
|
+
};
|
|
1876
|
+
await txn.put("run", run);
|
|
1877
|
+
await txn.put(CLEANUP_RECORD_KEY, cleanup);
|
|
1878
|
+
await txn.setAlarm(dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
|
|
1879
|
+
return cleanup;
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
async cleanupNow(request) {
|
|
1883
|
+
await this.ensureSchema();
|
|
1884
|
+
if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
|
|
1885
|
+
...request,
|
|
1886
|
+
retentionMs: Math.max(1, request.retentionMs)
|
|
1887
|
+
});
|
|
1888
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1889
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
1890
|
+
if (!cleanup || cleanup.phase === "tombstoned") return;
|
|
1891
|
+
const dueAt = new Date(this.now());
|
|
1892
|
+
const run = await txn.get("run");
|
|
1893
|
+
if (run) await txn.put("run", WorkflowRunSchema.parse({
|
|
1894
|
+
...run,
|
|
1895
|
+
expiredAt: dueAt
|
|
1896
|
+
}));
|
|
1897
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
1898
|
+
...cleanup,
|
|
1899
|
+
dueAt
|
|
1900
|
+
});
|
|
1901
|
+
await txn.setAlarm(this.now() + 1);
|
|
1902
|
+
});
|
|
1903
|
+
try {
|
|
1904
|
+
await this.executeCleanup();
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
await this.recordCleanupFailure(error);
|
|
1907
|
+
}
|
|
1908
|
+
return this.getCleanupStatus();
|
|
1909
|
+
}
|
|
1910
|
+
async rearmCleanup() {
|
|
1911
|
+
await this.ensureSchema();
|
|
1912
|
+
const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
|
|
1913
|
+
if (!cleanup) return null;
|
|
1914
|
+
if (cleanup.phase === "tombstoned") await this.ctx.storage.deleteAlarm();
|
|
1915
|
+
else await this.ctx.storage.setAlarm(Math.max(this.now() + 1, cleanup.dueAt.getTime()));
|
|
1916
|
+
return cleanup;
|
|
1917
|
+
}
|
|
1918
|
+
async alarm() {
|
|
1919
|
+
await this.ensureSchema();
|
|
1920
|
+
try {
|
|
1921
|
+
await this.executeCleanup();
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
await this.recordCleanupFailure(error);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
namespace(name) {
|
|
1927
|
+
const namespace = this.env?.[name];
|
|
1928
|
+
if (!namespace || typeof namespace === "function") throw new Error(`world-celld retention missing binding ${name}`);
|
|
1929
|
+
return namespace;
|
|
1930
|
+
}
|
|
1931
|
+
async setCleanupPhase(expected, next, updates = {}) {
|
|
1932
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1933
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
1934
|
+
if (!cleanup || cleanup.phase !== expected) return;
|
|
1935
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
1936
|
+
...cleanup,
|
|
1937
|
+
...updates,
|
|
1938
|
+
phase: next,
|
|
1939
|
+
attempts: 0,
|
|
1940
|
+
lastError: void 0
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
async executeCleanup() {
|
|
1945
|
+
for (;;) {
|
|
1946
|
+
const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
|
|
1947
|
+
if (!cleanup) {
|
|
1948
|
+
await this.ctx.storage.deleteAlarm();
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (cleanup.phase === "tombstoned") {
|
|
1952
|
+
await this.ctx.storage.deleteAlarm();
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (cleanup.phase === "retained") {
|
|
1956
|
+
if (cleanup.dueAt.getTime() > this.now()) {
|
|
1957
|
+
await this.ctx.storage.setAlarm(cleanup.dueAt);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
await this.setCleanupPhase("retained", "index");
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
if (cleanup.phase === "index") {
|
|
1964
|
+
const [indexMarkers, hookMarkers] = await Promise.all([this.ctx.storage.list({ prefix: INDEX_MARKER_PREFIX }), this.ctx.storage.list({ prefix: HOOK_MARKER_PREFIX })]);
|
|
1965
|
+
const indexNamespace = this.namespace("WORKFLOW_INDEX");
|
|
1966
|
+
await indexNamespace.get(indexNamespace.idFromName("index")).expireRun({
|
|
1967
|
+
runId: cleanup.runId,
|
|
1968
|
+
keys: [
|
|
1969
|
+
workflowRunIndexKey(cleanup),
|
|
1970
|
+
globalRunIndexKey(cleanup),
|
|
1971
|
+
...indexMarkers.values()
|
|
1972
|
+
],
|
|
1973
|
+
hooks: Array.from(hookMarkers.values()),
|
|
1974
|
+
expiredAt: cleanup.dueAt.getTime()
|
|
1975
|
+
});
|
|
1976
|
+
await this.setCleanupPhase("index", "streams");
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1979
|
+
if (cleanup.phase === "streams") {
|
|
1980
|
+
const streamsNamespace = this.namespace("WORKFLOW_STREAMS");
|
|
1981
|
+
const registry = streamsNamespace.get(streamsNamespace.idFromName(`run-streams:${cleanup.runId}`));
|
|
1982
|
+
const { streams } = await registry.expireRegistry(cleanup.runId, cleanup.dueAt.getTime());
|
|
1983
|
+
let deletedStreams = streams.length === 0 ? cleanup.deletedStreams : 0;
|
|
1984
|
+
for (let offset = 0; offset < streams.length; offset += STREAM_DELETE_CONCURRENCY) {
|
|
1985
|
+
const batch = streams.slice(offset, offset + STREAM_DELETE_CONCURRENCY);
|
|
1986
|
+
const results = await Promise.all(batch.map((name) => {
|
|
1987
|
+
return streamsNamespace.get(streamsNamespace.idFromName(`stream:${name}`)).expireStream(cleanup.runId, cleanup.dueAt.getTime());
|
|
1988
|
+
}));
|
|
1989
|
+
deletedStreams += results.filter((result) => result.deleted).length;
|
|
1990
|
+
}
|
|
1991
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1992
|
+
const current = await txn.get(CLEANUP_RECORD_KEY);
|
|
1993
|
+
if (current?.phase === "streams") await txn.put(CLEANUP_RECORD_KEY, {
|
|
1994
|
+
...current,
|
|
1995
|
+
deletedStreams
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
await registry.finalizeRegistry(cleanup.runId);
|
|
1999
|
+
await this.setCleanupPhase("streams", "queues");
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
if (cleanup.phase === "queues") {
|
|
2003
|
+
const queues = this.namespace("WORKFLOW_QUEUE");
|
|
2004
|
+
let deletedQueueMessages = 0;
|
|
2005
|
+
for (let shard = 0; shard < cleanup.queueShards; shard++) {
|
|
2006
|
+
const result = await queues.get(queues.idFromName(`q:${shard}`)).expireRun(cleanup.runId, cleanup.dueAt.getTime());
|
|
2007
|
+
deletedQueueMessages += result.deleted;
|
|
2008
|
+
}
|
|
2009
|
+
await this.setCleanupPhase("queues", "payload", { deletedQueueMessages });
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
if (cleanup.phase === "payload") {
|
|
2013
|
+
if (!await this.deletePayloadBatch()) return;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
async deletePayloadBatch() {
|
|
2018
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
2019
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2020
|
+
if (!cleanup || cleanup.phase !== "payload") return true;
|
|
2021
|
+
const entries = await txn.list({ limit: 259 });
|
|
2022
|
+
const keys = Array.from(entries.keys()).filter((key) => key !== "retention:cleanup" && key !== "retention:tombstone" && key !== "schema_version").slice(0, PAYLOAD_DELETE_BATCH);
|
|
2023
|
+
const now = new Date(this.now());
|
|
2024
|
+
await txn.put(TOMBSTONE_KEY, cleanupTombstone(cleanup, now));
|
|
2025
|
+
for (const key of keys) await txn.delete(key);
|
|
2026
|
+
if (keys.length > 0) {
|
|
2027
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2028
|
+
...cleanup,
|
|
2029
|
+
deletedPayloadKeys: cleanup.deletedPayloadKeys + keys.length,
|
|
2030
|
+
lastError: void 0
|
|
2031
|
+
});
|
|
2032
|
+
await txn.setAlarm(this.now() + 1);
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2036
|
+
...cleanup,
|
|
2037
|
+
phase: "tombstoned",
|
|
2038
|
+
attempts: 0,
|
|
2039
|
+
lastError: void 0,
|
|
2040
|
+
tombstonedAt: now
|
|
2041
|
+
});
|
|
2042
|
+
await txn.deleteAlarm();
|
|
2043
|
+
return true;
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
async recordCleanupFailure(error) {
|
|
2047
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
2048
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2049
|
+
if (!cleanup || cleanup.phase === "tombstoned") return;
|
|
2050
|
+
const attempts = cleanup.attempts + 1;
|
|
2051
|
+
const delay = Math.min(CLEANUP_RETRY_MAX_MS, 1e3 * 2 ** Math.min(attempts - 1, 12));
|
|
2052
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2053
|
+
...cleanup,
|
|
2054
|
+
attempts,
|
|
2055
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
2056
|
+
lastAttemptAt: new Date(this.now())
|
|
2057
|
+
});
|
|
2058
|
+
await txn.setAlarm(this.now() + delay);
|
|
1535
2059
|
});
|
|
1536
2060
|
}
|
|
1537
|
-
/**
|
|
1538
|
-
* Claim a queue-message dedup slot. Used on DO instances dedicated to a
|
|
1539
|
-
* single idempotency key (`claim:<queueName>:<idempotencyKey>`).
|
|
1540
|
-
*
|
|
1541
|
-
* - Unclaimed -> claim and proceed.
|
|
1542
|
-
* - Claimed by the SAME messageId -> redelivery of the same message
|
|
1543
|
-
* (previous attempt failed transiently) -> proceed.
|
|
1544
|
-
* - Claimed by a DIFFERENT messageId -> concurrent duplicate -> reject,
|
|
1545
|
-
* unless the claim is stale (holder crashed / went to the DLQ without
|
|
1546
|
-
* releasing), in which case the claim is stolen.
|
|
1547
|
-
*/
|
|
1548
2061
|
async claimInflight(params) {
|
|
1549
2062
|
await this.ensureSchema();
|
|
1550
2063
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
1551
2064
|
const existing = await txn.get("claim");
|
|
1552
|
-
const now =
|
|
2065
|
+
const now = this.now();
|
|
1553
2066
|
if (existing && existing.messageId !== params.messageId && now - existing.claimedAt < params.staleMs) return { claimed: false };
|
|
1554
2067
|
await txn.put("claim", {
|
|
1555
2068
|
messageId: params.messageId,
|
|
@@ -1558,9 +2071,6 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
1558
2071
|
return { claimed: true };
|
|
1559
2072
|
});
|
|
1560
2073
|
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Release a queue-message dedup claim after the message was fully handled.
|
|
1563
|
-
*/
|
|
1564
2074
|
async releaseInflight() {
|
|
1565
2075
|
await this.ensureSchema();
|
|
1566
2076
|
await this.ctx.storage.delete("claim");
|