@ewhauser/world-celld 0.1.1 → 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 +17 -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/testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as cleanupTombstone, c as globalRunIndexKey, d as workflowRunIndexKey, f as compact, h as parse, i as TOMBSTONE_KEY, l as hookMarkerKey, m as rpcStringify, n as HOOK_MARKER_PREFIX, o as correlationIndexKey, p as rpcParse, r as INDEX_MARKER_PREFIX, s as expiredRead, t as CLEANUP_RECORD_KEY, u as indexMarkerKey } from "./retention-bLY1vcOu.js";
|
|
2
2
|
import { EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, isLegacySpecVersion, isTerminalStepStatus, isTerminalWorkflowRunStatus, requiresNewerWorld } from "@workflow/world";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
//#region src/testing/fake-cell.ts
|
|
@@ -309,7 +309,11 @@ const BINDINGS = {
|
|
|
309
309
|
"getEvent",
|
|
310
310
|
"listEvents",
|
|
311
311
|
"listSteps",
|
|
312
|
-
"listHooks"
|
|
312
|
+
"listHooks",
|
|
313
|
+
"getCleanupStatus",
|
|
314
|
+
"scheduleCleanup",
|
|
315
|
+
"cleanupNow",
|
|
316
|
+
"rearmCleanup"
|
|
313
317
|
])
|
|
314
318
|
},
|
|
315
319
|
streams: {
|
|
@@ -320,7 +324,10 @@ const BINDINGS = {
|
|
|
320
324
|
"getChunks",
|
|
321
325
|
"getInfo",
|
|
322
326
|
"registerStream",
|
|
323
|
-
"listStreams"
|
|
327
|
+
"listStreams",
|
|
328
|
+
"expireRegistry",
|
|
329
|
+
"finalizeRegistry",
|
|
330
|
+
"expireStream"
|
|
324
331
|
])
|
|
325
332
|
},
|
|
326
333
|
index: {
|
|
@@ -329,6 +336,8 @@ const BINDINGS = {
|
|
|
329
336
|
"get",
|
|
330
337
|
"put",
|
|
331
338
|
"delete",
|
|
339
|
+
"putOwned",
|
|
340
|
+
"expireRun",
|
|
332
341
|
"list",
|
|
333
342
|
"reserveHookToken",
|
|
334
343
|
"finalizeHookIndexes",
|
|
@@ -344,7 +353,8 @@ const BINDINGS = {
|
|
|
344
353
|
"listDeadLetters",
|
|
345
354
|
"redriveDeadLetter",
|
|
346
355
|
"purgeDeadLetters",
|
|
347
|
-
"rearmAlarm"
|
|
356
|
+
"rearmAlarm",
|
|
357
|
+
"expireRun"
|
|
348
358
|
])
|
|
349
359
|
}
|
|
350
360
|
};
|
|
@@ -478,6 +488,14 @@ var IndexDO = class extends DurableObject {
|
|
|
478
488
|
async put(key, value) {
|
|
479
489
|
await this.ctx.storage.put(key, value);
|
|
480
490
|
}
|
|
491
|
+
/** Publish a derived index only while its owning run is not expired. */
|
|
492
|
+
async putOwned(runId, key, value) {
|
|
493
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
494
|
+
if (await txn.get(`expired:${runId}`) !== void 0) return { stored: false };
|
|
495
|
+
await txn.put(key, value);
|
|
496
|
+
return { stored: true };
|
|
497
|
+
});
|
|
498
|
+
}
|
|
481
499
|
async delete(key) {
|
|
482
500
|
await this.ctx.storage.delete(key);
|
|
483
501
|
}
|
|
@@ -523,6 +541,11 @@ var IndexDO = class extends DurableObject {
|
|
|
523
541
|
}
|
|
524
542
|
async finalizeHookIndexes(token, hookId, serializedHook, owner) {
|
|
525
543
|
await this.ctx.storage.transaction(async (txn) => {
|
|
544
|
+
if (await txn.get(`expired:${owner.runId}`) !== void 0) {
|
|
545
|
+
const claim = await txn.get(`hookclaim:${token}`);
|
|
546
|
+
if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(`hookclaim:${token}`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
526
549
|
const indexedHook = await txn.get(`hook:${token}`);
|
|
527
550
|
if (indexedHook !== void 0 && !sameOwner(ownerFromHook(indexedHook), owner)) throw new Error(`Hook token ${token} is owned by another hook`);
|
|
528
551
|
const claim = await txn.get(`hookclaim:${token}`);
|
|
@@ -554,6 +577,42 @@ var IndexDO = class extends DurableObject {
|
|
|
554
577
|
if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(claimKey);
|
|
555
578
|
});
|
|
556
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Fence a run and remove all of its known derived indexes atomically.
|
|
582
|
+
* Once the fence exists, putOwned() and hook finalization cannot resurrect
|
|
583
|
+
* entries from a delayed request.
|
|
584
|
+
*/
|
|
585
|
+
async expireRun(request) {
|
|
586
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
587
|
+
await txn.put(`expired:${request.runId}`, request.expiredAt);
|
|
588
|
+
let deleted = 0;
|
|
589
|
+
for (const key of new Set(request.keys)) if (await txn.delete(key)) deleted++;
|
|
590
|
+
for (const hook of request.hooks) {
|
|
591
|
+
const owner = {
|
|
592
|
+
runId: request.runId,
|
|
593
|
+
hookId: hook.hookId
|
|
594
|
+
};
|
|
595
|
+
const tokenKey = `hook:${hook.token}`;
|
|
596
|
+
const idKey = `hookid:${hook.hookId}`;
|
|
597
|
+
const claimKey = `hookclaim:${hook.token}`;
|
|
598
|
+
const [byToken, byId, claim] = await Promise.all([
|
|
599
|
+
txn.get(tokenKey),
|
|
600
|
+
txn.get(idKey),
|
|
601
|
+
txn.get(claimKey)
|
|
602
|
+
]);
|
|
603
|
+
if (byToken !== void 0 && sameOwner(ownerFromHook(byToken), owner)) {
|
|
604
|
+
if (await txn.delete(tokenKey)) deleted++;
|
|
605
|
+
}
|
|
606
|
+
if (byId !== void 0 && sameOwner(ownerFromHook(byId), owner)) {
|
|
607
|
+
if (await txn.delete(idKey)) deleted++;
|
|
608
|
+
}
|
|
609
|
+
if (claim !== void 0 && sameOwner(claim.owner, owner)) {
|
|
610
|
+
if (await txn.delete(claimKey)) deleted++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return { deleted };
|
|
614
|
+
});
|
|
615
|
+
}
|
|
557
616
|
};
|
|
558
617
|
//#endregion
|
|
559
618
|
//#region src/worker/durable-objects/QueueDO.ts
|
|
@@ -576,6 +635,12 @@ function pad(ms) {
|
|
|
576
635
|
function dueKey(atMs, messageId) {
|
|
577
636
|
return `due:${pad(atMs)}:${messageId}`;
|
|
578
637
|
}
|
|
638
|
+
function runReferenceKey(runId, messageId) {
|
|
639
|
+
return `run:${runId}:${messageId}`;
|
|
640
|
+
}
|
|
641
|
+
function expiredRunKey(runId) {
|
|
642
|
+
return `expired-run:${runId}`;
|
|
643
|
+
}
|
|
579
644
|
function backoffSeconds(attempt) {
|
|
580
645
|
return Math.min(60, 2 ** attempt);
|
|
581
646
|
}
|
|
@@ -597,6 +662,11 @@ var QueueDO = class extends DurableObject {
|
|
|
597
662
|
const storage = this.ctx.storage;
|
|
598
663
|
const now = this.#now();
|
|
599
664
|
return await storage.transaction(async (txn) => {
|
|
665
|
+
if (request.runId && await txn.get(expiredRunKey(request.runId)) !== void 0) return {
|
|
666
|
+
ok: false,
|
|
667
|
+
code: "RUN_EXPIRED",
|
|
668
|
+
message: `Workflow run "${request.runId}" has expired`
|
|
669
|
+
};
|
|
600
670
|
const pinned = await txn.get("cfg");
|
|
601
671
|
if (!pinned) await txn.put("cfg", {
|
|
602
672
|
queueShards: request.config.queueShards,
|
|
@@ -627,14 +697,21 @@ var QueueDO = class extends DurableObject {
|
|
|
627
697
|
queueName: request.queueName,
|
|
628
698
|
pathname: request.pathname,
|
|
629
699
|
body: request.body,
|
|
700
|
+
runId: request.runId,
|
|
630
701
|
targetBaseUrl: request.config.targetBaseUrl,
|
|
631
702
|
idempotencyKey: request.idempotencyKey,
|
|
632
703
|
attempt: 0,
|
|
633
704
|
enqueuedAt: now
|
|
634
705
|
};
|
|
635
706
|
const dueAt = now + Math.max(0, request.delaySeconds ?? 0) * 1e3;
|
|
707
|
+
const scheduleKey = dueKey(dueAt, row.messageId);
|
|
636
708
|
await txn.put(`msg:${row.messageId}`, row);
|
|
637
|
-
await txn.put(
|
|
709
|
+
await txn.put(scheduleKey, row.messageId);
|
|
710
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
711
|
+
messageId: row.messageId,
|
|
712
|
+
dueKey: scheduleKey,
|
|
713
|
+
idempotencyKey: row.idempotencyKey
|
|
714
|
+
});
|
|
638
715
|
await this.#armAlarmAtMost(txn, dueAt, now);
|
|
639
716
|
return {
|
|
640
717
|
ok: true,
|
|
@@ -672,7 +749,16 @@ var QueueDO = class extends DurableObject {
|
|
|
672
749
|
for (const [key, deadline] of inflight) if (deadline <= now) {
|
|
673
750
|
const messageId = key.slice(9);
|
|
674
751
|
await txn.delete(key);
|
|
675
|
-
|
|
752
|
+
const row = await txn.get(`msg:${messageId}`);
|
|
753
|
+
if (row) {
|
|
754
|
+
const scheduleKey = dueKey(now, messageId);
|
|
755
|
+
await txn.put(scheduleKey, messageId);
|
|
756
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
757
|
+
messageId: row.messageId,
|
|
758
|
+
dueKey: scheduleKey,
|
|
759
|
+
idempotencyKey: row.idempotencyKey
|
|
760
|
+
});
|
|
761
|
+
}
|
|
676
762
|
} else inflightCount++;
|
|
677
763
|
const rows = [];
|
|
678
764
|
if (inflightCount < maxInflight) {
|
|
@@ -727,8 +813,25 @@ var QueueDO = class extends DurableObject {
|
|
|
727
813
|
timeoutSeconds = void 0;
|
|
728
814
|
}
|
|
729
815
|
if (typeof timeoutSeconds === "number") await storage.transaction(async (txn) => {
|
|
816
|
+
if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
|
|
817
|
+
await txn.delete(`msg:${row.messageId}`);
|
|
818
|
+
await txn.delete(`inflight:${row.messageId}`);
|
|
819
|
+
await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
820
|
+
if (row.idempotencyKey) {
|
|
821
|
+
const dedupKey = `key:${row.idempotencyKey}`;
|
|
822
|
+
if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
|
|
823
|
+
}
|
|
824
|
+
await this.#scheduleNextAlarm(txn, now);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
730
827
|
await txn.delete(`inflight:${row.messageId}`);
|
|
731
|
-
|
|
828
|
+
const scheduleKey = dueKey(now + timeoutSeconds * 1e3, row.messageId);
|
|
829
|
+
await txn.put(scheduleKey, row.messageId);
|
|
830
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
831
|
+
messageId: row.messageId,
|
|
832
|
+
dueKey: scheduleKey,
|
|
833
|
+
idempotencyKey: row.idempotencyKey
|
|
834
|
+
});
|
|
732
835
|
await this.#scheduleNextAlarm(txn, now);
|
|
733
836
|
});
|
|
734
837
|
else await this.#retry(row, "HTTP 503");
|
|
@@ -745,6 +848,7 @@ var QueueDO = class extends DurableObject {
|
|
|
745
848
|
if (row.idempotencyKey) {
|
|
746
849
|
if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
|
|
747
850
|
}
|
|
851
|
+
if (row.runId) await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
748
852
|
await this.#scheduleNextAlarm(txn, this.#now());
|
|
749
853
|
});
|
|
750
854
|
}
|
|
@@ -754,6 +858,17 @@ var QueueDO = class extends DurableObject {
|
|
|
754
858
|
const maxAttempts = this.#intVar("QUEUE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS);
|
|
755
859
|
const attempt = row.attempt + 1;
|
|
756
860
|
await storage.transaction(async (txn) => {
|
|
861
|
+
if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
|
|
862
|
+
await txn.delete(`msg:${row.messageId}`);
|
|
863
|
+
await txn.delete(`inflight:${row.messageId}`);
|
|
864
|
+
await txn.delete(runReferenceKey(row.runId, row.messageId));
|
|
865
|
+
if (row.idempotencyKey) {
|
|
866
|
+
const dedupKey = `key:${row.idempotencyKey}`;
|
|
867
|
+
if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
|
|
868
|
+
}
|
|
869
|
+
await this.#scheduleNextAlarm(txn, now);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
757
872
|
await txn.delete(`inflight:${row.messageId}`);
|
|
758
873
|
if (attempt >= maxAttempts) {
|
|
759
874
|
const dead = {
|
|
@@ -762,11 +877,17 @@ var QueueDO = class extends DurableObject {
|
|
|
762
877
|
lastError: reason,
|
|
763
878
|
failedAt: now
|
|
764
879
|
};
|
|
765
|
-
|
|
880
|
+
const deadKey = `dlq:${pad(now)}:${row.messageId}`;
|
|
881
|
+
await txn.put(deadKey, dead);
|
|
766
882
|
await txn.delete(`msg:${row.messageId}`);
|
|
767
883
|
if (row.idempotencyKey) {
|
|
768
884
|
if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
|
|
769
885
|
}
|
|
886
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
887
|
+
messageId: row.messageId,
|
|
888
|
+
dlqKey: deadKey,
|
|
889
|
+
idempotencyKey: row.idempotencyKey
|
|
890
|
+
});
|
|
770
891
|
await this.#scheduleNextAlarm(txn, now);
|
|
771
892
|
return;
|
|
772
893
|
}
|
|
@@ -775,8 +896,14 @@ var QueueDO = class extends DurableObject {
|
|
|
775
896
|
attempt,
|
|
776
897
|
lastError: reason
|
|
777
898
|
};
|
|
899
|
+
const scheduleKey = dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId);
|
|
778
900
|
await txn.put(`msg:${row.messageId}`, updated);
|
|
779
|
-
await txn.put(
|
|
901
|
+
await txn.put(scheduleKey, row.messageId);
|
|
902
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
903
|
+
messageId: row.messageId,
|
|
904
|
+
dueKey: scheduleKey,
|
|
905
|
+
idempotencyKey: row.idempotencyKey
|
|
906
|
+
});
|
|
780
907
|
await this.#scheduleNextAlarm(txn, now);
|
|
781
908
|
});
|
|
782
909
|
}
|
|
@@ -860,8 +987,14 @@ var QueueDO = class extends DurableObject {
|
|
|
860
987
|
await txn.put(dedupKey, row.messageId);
|
|
861
988
|
}
|
|
862
989
|
await txn.put(`msg:${row.messageId}`, row);
|
|
863
|
-
|
|
990
|
+
const scheduleKey = dueKey(now, row.messageId);
|
|
991
|
+
await txn.put(scheduleKey, row.messageId);
|
|
864
992
|
await txn.delete(key);
|
|
993
|
+
if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
|
|
994
|
+
messageId: row.messageId,
|
|
995
|
+
dueKey: scheduleKey,
|
|
996
|
+
idempotencyKey: row.idempotencyKey
|
|
997
|
+
});
|
|
865
998
|
await this.#armAlarmAtMost(txn, now, now);
|
|
866
999
|
return { ok: true };
|
|
867
1000
|
}
|
|
@@ -871,7 +1004,11 @@ var QueueDO = class extends DurableObject {
|
|
|
871
1004
|
async purgeDeadLetters() {
|
|
872
1005
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
873
1006
|
const dlq = await txn.list({ prefix: "dlq:" });
|
|
874
|
-
for (const key of dlq
|
|
1007
|
+
for (const [key, value] of dlq) {
|
|
1008
|
+
await txn.delete(key);
|
|
1009
|
+
const dead = value;
|
|
1010
|
+
if (dead.runId) await txn.delete(runReferenceKey(dead.runId, dead.messageId));
|
|
1011
|
+
}
|
|
875
1012
|
return { purged: dlq.size };
|
|
876
1013
|
});
|
|
877
1014
|
}
|
|
@@ -883,6 +1020,32 @@ var QueueDO = class extends DurableObject {
|
|
|
883
1020
|
await this.#rearm();
|
|
884
1021
|
return { alarmAt: await this.ctx.storage.getAlarm() };
|
|
885
1022
|
}
|
|
1023
|
+
/** Fence a run and purge every live, inflight, and dead-letter message it owns. */
|
|
1024
|
+
async expireRun(runId, expiredAt) {
|
|
1025
|
+
return { deleted: await this.ctx.storage.transaction(async (txn) => {
|
|
1026
|
+
const existing = await txn.get(expiredRunKey(runId));
|
|
1027
|
+
const references = await txn.list({ prefix: `run:${runId}:` });
|
|
1028
|
+
let count = existing?.deleted ?? 0;
|
|
1029
|
+
for (const [key, reference] of references) {
|
|
1030
|
+
await txn.delete(`msg:${reference.messageId}`);
|
|
1031
|
+
await txn.delete(`inflight:${reference.messageId}`);
|
|
1032
|
+
if (reference.dueKey) await txn.delete(reference.dueKey);
|
|
1033
|
+
if (reference.dlqKey) await txn.delete(reference.dlqKey);
|
|
1034
|
+
if (reference.idempotencyKey) {
|
|
1035
|
+
const dedupKey = `key:${reference.idempotencyKey}`;
|
|
1036
|
+
if (await txn.get(dedupKey) === reference.messageId) await txn.delete(dedupKey);
|
|
1037
|
+
}
|
|
1038
|
+
await txn.delete(key);
|
|
1039
|
+
count++;
|
|
1040
|
+
}
|
|
1041
|
+
await txn.put(expiredRunKey(runId), {
|
|
1042
|
+
expiredAt,
|
|
1043
|
+
deleted: count
|
|
1044
|
+
});
|
|
1045
|
+
await this.#scheduleNextAlarm(txn, this.#now());
|
|
1046
|
+
return count;
|
|
1047
|
+
}) };
|
|
1048
|
+
}
|
|
886
1049
|
};
|
|
887
1050
|
//#endregion
|
|
888
1051
|
//#region src/worker/durable-objects/StreamDO.ts
|
|
@@ -890,6 +1053,8 @@ const META_KEY = "meta";
|
|
|
890
1053
|
const CHUNK_KEY_PREFIX = "chunk:";
|
|
891
1054
|
/** Registry keys used when this DO instance acts as a per-run stream index. */
|
|
892
1055
|
const STREAM_REGISTRY_PREFIX = "stream:";
|
|
1056
|
+
const REGISTRY_OWNER_KEY = "registry:owner";
|
|
1057
|
+
const REGISTRY_EXPIRED_KEY = "registry:expired";
|
|
893
1058
|
/**
|
|
894
1059
|
* Zero-pad chunk indexes so `storage.list({ prefix })` returns chunks in
|
|
895
1060
|
* write order. 12 digits comfortably exceeds any realistic chunk count.
|
|
@@ -914,22 +1079,28 @@ var StreamDO = class extends DurableObject {
|
|
|
914
1079
|
closed: false
|
|
915
1080
|
};
|
|
916
1081
|
}
|
|
1082
|
+
assertOwner(meta, runId) {
|
|
1083
|
+
if (meta.expiredAt !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
|
|
1084
|
+
if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
|
|
1085
|
+
}
|
|
917
1086
|
/**
|
|
918
1087
|
* Append a chunk. The index is allocated inside the transaction, so
|
|
919
1088
|
* concurrent writers can never collide or skip.
|
|
920
1089
|
*/
|
|
921
|
-
async writeChunk(data) {
|
|
1090
|
+
async writeChunk(runId, data) {
|
|
922
1091
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
923
1092
|
const meta = await txn.get(META_KEY) ?? {
|
|
924
1093
|
count: 0,
|
|
925
1094
|
closed: false
|
|
926
1095
|
};
|
|
1096
|
+
this.assertOwner(meta, runId);
|
|
927
1097
|
if (meta.closed) throw new Error("Cannot write to a closed stream");
|
|
928
1098
|
const index = meta.count;
|
|
929
1099
|
await txn.put(chunkKey(index), data);
|
|
930
1100
|
await txn.put(META_KEY, {
|
|
931
1101
|
count: index + 1,
|
|
932
|
-
closed: false
|
|
1102
|
+
closed: false,
|
|
1103
|
+
ownerRunId: runId
|
|
933
1104
|
});
|
|
934
1105
|
return index;
|
|
935
1106
|
});
|
|
@@ -938,14 +1109,16 @@ var StreamDO = class extends DurableObject {
|
|
|
938
1109
|
* Close the stream (idempotent). Readers observe `done: true` once all
|
|
939
1110
|
* previously written chunks have been consumed.
|
|
940
1111
|
*/
|
|
941
|
-
async closeStream() {
|
|
1112
|
+
async closeStream(runId) {
|
|
942
1113
|
await this.ctx.storage.transaction(async (txn) => {
|
|
943
1114
|
const meta = await txn.get(META_KEY) ?? {
|
|
944
1115
|
count: 0,
|
|
945
1116
|
closed: false
|
|
946
1117
|
};
|
|
1118
|
+
this.assertOwner(meta, runId);
|
|
947
1119
|
await txn.put(META_KEY, {
|
|
948
1120
|
...meta,
|
|
1121
|
+
ownerRunId: runId,
|
|
949
1122
|
closed: true
|
|
950
1123
|
});
|
|
951
1124
|
});
|
|
@@ -955,6 +1128,7 @@ var StreamDO = class extends DurableObject {
|
|
|
955
1128
|
*/
|
|
956
1129
|
async getChunks(params) {
|
|
957
1130
|
const meta = await this.getMeta();
|
|
1131
|
+
if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
|
|
958
1132
|
const start = Math.max(0, params.startIndex);
|
|
959
1133
|
const entries = await this.ctx.storage.list({
|
|
960
1134
|
prefix: CHUNK_KEY_PREFIX,
|
|
@@ -972,6 +1146,7 @@ var StreamDO = class extends DurableObject {
|
|
|
972
1146
|
*/
|
|
973
1147
|
async getInfo() {
|
|
974
1148
|
const meta = await this.getMeta();
|
|
1149
|
+
if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
|
|
975
1150
|
return {
|
|
976
1151
|
tailIndex: meta.count - 1,
|
|
977
1152
|
done: meta.closed
|
|
@@ -980,16 +1155,66 @@ var StreamDO = class extends DurableObject {
|
|
|
980
1155
|
/**
|
|
981
1156
|
* Register a stream name against this per-run registry instance.
|
|
982
1157
|
*/
|
|
983
|
-
async registerStream(name) {
|
|
984
|
-
await this.ctx.storage.
|
|
1158
|
+
async registerStream(runId, name) {
|
|
1159
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1160
|
+
if (await txn.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
|
|
1161
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1162
|
+
if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
|
|
1163
|
+
await txn.put(REGISTRY_OWNER_KEY, runId);
|
|
1164
|
+
await txn.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
|
|
1165
|
+
});
|
|
985
1166
|
}
|
|
986
1167
|
/**
|
|
987
1168
|
* List stream names registered against this per-run registry instance.
|
|
988
1169
|
*/
|
|
989
1170
|
async listStreams() {
|
|
1171
|
+
if (await this.ctx.storage.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error("Workflow run streams have expired");
|
|
990
1172
|
const entries = await this.ctx.storage.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
991
1173
|
return Array.from(entries.keys()).map((key) => key.slice(7));
|
|
992
1174
|
}
|
|
1175
|
+
/** Fence a run's registry before any stream cells are removed. */
|
|
1176
|
+
async expireRegistry(runId, expiredAt) {
|
|
1177
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1178
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1179
|
+
if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
|
|
1180
|
+
await txn.put(REGISTRY_OWNER_KEY, runId);
|
|
1181
|
+
await txn.put(REGISTRY_EXPIRED_KEY, expiredAt);
|
|
1182
|
+
const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
1183
|
+
return { streams: Array.from(entries.keys()).map((key) => key.slice(7)) };
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
/** Remove the registry payload after every referenced stream was fenced. */
|
|
1187
|
+
async finalizeRegistry(runId) {
|
|
1188
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
1189
|
+
const owner = await txn.get(REGISTRY_OWNER_KEY);
|
|
1190
|
+
const expiredAt = await txn.get(REGISTRY_EXPIRED_KEY);
|
|
1191
|
+
if (owner !== runId || expiredAt === void 0) throw new Error(`Stream registry for workflow run "${runId}" is not expired`);
|
|
1192
|
+
const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
|
|
1193
|
+
for (const key of entries.keys()) await txn.delete(key);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
/** Delete stream payload while leaving a small ownership tombstone. */
|
|
1197
|
+
async expireStream(runId, expiredAt) {
|
|
1198
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1199
|
+
const meta = await txn.get(META_KEY) ?? {
|
|
1200
|
+
count: 0,
|
|
1201
|
+
closed: false
|
|
1202
|
+
};
|
|
1203
|
+
if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
|
|
1204
|
+
const chunks = await txn.list({ prefix: CHUNK_KEY_PREFIX });
|
|
1205
|
+
for (const key of chunks.keys()) await txn.delete(key);
|
|
1206
|
+
await txn.put(META_KEY, {
|
|
1207
|
+
count: 0,
|
|
1208
|
+
closed: true,
|
|
1209
|
+
ownerRunId: runId,
|
|
1210
|
+
expiredAt
|
|
1211
|
+
});
|
|
1212
|
+
return {
|
|
1213
|
+
deleted: true,
|
|
1214
|
+
chunks: chunks.size
|
|
1215
|
+
};
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
993
1218
|
};
|
|
994
1219
|
//#endregion
|
|
995
1220
|
//#region src/apply-event.ts
|
|
@@ -1526,6 +1751,9 @@ async function applyEvent(store, ctx) {
|
|
|
1526
1751
|
}
|
|
1527
1752
|
//#endregion
|
|
1528
1753
|
//#region src/worker/durable-objects/WorkflowRunDO.ts
|
|
1754
|
+
const PAYLOAD_DELETE_BATCH = 256;
|
|
1755
|
+
const STREAM_DELETE_CONCURRENCY = 16;
|
|
1756
|
+
const CLEANUP_RETRY_MAX_MS = 36e5;
|
|
1529
1757
|
/**
|
|
1530
1758
|
* Current schema version for DO storage.
|
|
1531
1759
|
* Increment when making breaking changes to the storage layout.
|
|
@@ -1610,104 +1838,347 @@ function storeFrom(storage) {
|
|
|
1610
1838
|
*/
|
|
1611
1839
|
var WorkflowRunDO = class extends DurableObject {
|
|
1612
1840
|
schemaReady = null;
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1841
|
+
now() {
|
|
1842
|
+
const clock = this.env?.clock;
|
|
1843
|
+
return typeof clock === "function" ? clock() : Date.now();
|
|
1844
|
+
}
|
|
1617
1845
|
ensureSchema() {
|
|
1618
1846
|
if (!this.schemaReady) this.schemaReady = ensureSchemaUpToDate(this.ctx.storage);
|
|
1619
1847
|
return this.schemaReady;
|
|
1620
1848
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1849
|
+
async expiredTombstone(storage, now = this.now()) {
|
|
1850
|
+
const tombstone = await storage.get(TOMBSTONE_KEY);
|
|
1851
|
+
if (tombstone) return tombstone;
|
|
1852
|
+
const cleanup = await storage.get(CLEANUP_RECORD_KEY);
|
|
1853
|
+
if (!cleanup || cleanup.phase === "retained" && cleanup.dueAt.getTime() > now) return null;
|
|
1854
|
+
return cleanupTombstone(cleanup, cleanup.tombstonedAt ?? cleanup.dueAt);
|
|
1855
|
+
}
|
|
1856
|
+
async read(reader) {
|
|
1857
|
+
await this.ensureSchema();
|
|
1858
|
+
const tombstone = await this.expiredTombstone(this.ctx.storage);
|
|
1859
|
+
if (tombstone) return expiredRead(tombstone);
|
|
1860
|
+
return {
|
|
1861
|
+
ok: true,
|
|
1862
|
+
value: await reader()
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
/** Apply an event and capture all retention metadata in the same transaction. */
|
|
1627
1866
|
async applyEvent(request) {
|
|
1628
1867
|
await this.ensureSchema();
|
|
1629
1868
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
1869
|
+
const now = new Date(this.now());
|
|
1870
|
+
const tombstone = await this.expiredTombstone(txn, now.getTime());
|
|
1871
|
+
if (tombstone) return {
|
|
1872
|
+
ok: false,
|
|
1873
|
+
code: "RUN_EXPIRED",
|
|
1874
|
+
message: `Workflow run "${request.runId}" expired at ${tombstone.expiredAt.toISOString()}`
|
|
1875
|
+
};
|
|
1630
1876
|
let eventSequence = await txn.get("event_sequence") ?? 0;
|
|
1631
1877
|
const outcome = await applyEvent(storeFrom(txn), {
|
|
1632
1878
|
...request,
|
|
1633
1879
|
nextEventId: () => `wevt_z${String(++eventSequence).padStart(20, "0")}`,
|
|
1634
|
-
now
|
|
1880
|
+
now
|
|
1635
1881
|
});
|
|
1636
|
-
if (outcome.ok)
|
|
1882
|
+
if (!outcome.ok) return outcome;
|
|
1883
|
+
await txn.put("event_sequence", eventSequence);
|
|
1884
|
+
if (outcome.event?.correlationId) {
|
|
1885
|
+
const key = correlationIndexKey(outcome.event.correlationId, outcome.event.createdAt, outcome.event.eventId, request.runId);
|
|
1886
|
+
await txn.put(indexMarkerKey(key), key);
|
|
1887
|
+
}
|
|
1888
|
+
if (outcome.hookToIndex) {
|
|
1889
|
+
const reference = {
|
|
1890
|
+
hookId: outcome.hookToIndex.hookId,
|
|
1891
|
+
token: outcome.hookToIndex.token
|
|
1892
|
+
};
|
|
1893
|
+
await txn.put(hookMarkerKey(reference), reference);
|
|
1894
|
+
}
|
|
1895
|
+
for (const reference of outcome.releasedHooks) await txn.put(hookMarkerKey(reference), reference);
|
|
1896
|
+
const retentionMs = request.cleanup?.retentionMs ?? 0;
|
|
1897
|
+
if (retentionMs > 0 && outcome.run && isTerminalWorkflowRunStatus(outcome.run.status) && !await txn.get("retention:cleanup")) {
|
|
1898
|
+
const terminalRun = outcome.run;
|
|
1899
|
+
const dueAt = new Date(terminalRun.completedAt.getTime() + retentionMs);
|
|
1900
|
+
const run = WorkflowRunSchema.parse({
|
|
1901
|
+
...terminalRun,
|
|
1902
|
+
expiredAt: dueAt
|
|
1903
|
+
});
|
|
1904
|
+
const cleanup = {
|
|
1905
|
+
version: 1,
|
|
1906
|
+
runId: run.runId,
|
|
1907
|
+
workflowName: run.workflowName,
|
|
1908
|
+
createdAt: run.createdAt,
|
|
1909
|
+
completedAt: run.completedAt,
|
|
1910
|
+
terminalStatus: run.status,
|
|
1911
|
+
dueAt,
|
|
1912
|
+
queueShards: request.cleanup?.queueShards ?? 1,
|
|
1913
|
+
phase: "retained",
|
|
1914
|
+
attempts: 0,
|
|
1915
|
+
deletedPayloadKeys: 0,
|
|
1916
|
+
deletedStreams: 0,
|
|
1917
|
+
deletedQueueMessages: 0
|
|
1918
|
+
};
|
|
1919
|
+
await txn.put("run", run);
|
|
1920
|
+
await txn.put(CLEANUP_RECORD_KEY, cleanup);
|
|
1921
|
+
await txn.setAlarm(dueAt);
|
|
1922
|
+
outcome.run = run;
|
|
1923
|
+
}
|
|
1637
1924
|
return outcome;
|
|
1638
1925
|
});
|
|
1639
1926
|
}
|
|
1640
|
-
/**
|
|
1641
|
-
* Get the workflow run
|
|
1642
|
-
*/
|
|
1643
1927
|
async getRun() {
|
|
1644
|
-
await this.
|
|
1645
|
-
return await this.ctx.storage.get("run") ?? null;
|
|
1928
|
+
return this.read(async () => await this.ctx.storage.get("run") ?? null);
|
|
1646
1929
|
}
|
|
1647
|
-
/**
|
|
1648
|
-
* Get a specific step
|
|
1649
|
-
*/
|
|
1650
1930
|
async getStep(stepId) {
|
|
1651
|
-
await this.
|
|
1652
|
-
return await this.ctx.storage.get(`step:${stepId}`) ?? null;
|
|
1931
|
+
return this.read(async () => await this.ctx.storage.get(`step:${stepId}`) ?? null);
|
|
1653
1932
|
}
|
|
1654
|
-
/**
|
|
1655
|
-
* List steps with real limit/cursor pagination (cursor = last stepId).
|
|
1656
|
-
*/
|
|
1657
1933
|
async listSteps(params) {
|
|
1658
|
-
|
|
1659
|
-
return listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
|
|
1934
|
+
return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
|
|
1660
1935
|
limit: params?.limit ?? 20,
|
|
1661
1936
|
cursor: params?.cursor,
|
|
1662
1937
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1663
|
-
});
|
|
1938
|
+
}));
|
|
1664
1939
|
}
|
|
1665
|
-
/**
|
|
1666
|
-
* Get a specific event
|
|
1667
|
-
*/
|
|
1668
1940
|
async getEvent(eventId) {
|
|
1669
|
-
await this.
|
|
1670
|
-
return await this.ctx.storage.get(`event:${eventId}`) ?? null;
|
|
1941
|
+
return this.read(async () => await this.ctx.storage.get(`event:${eventId}`) ?? null);
|
|
1671
1942
|
}
|
|
1672
|
-
/**
|
|
1673
|
-
* List events with real limit/cursor pagination. Events are keyed by their
|
|
1674
|
-
* monotonic ULID eventId, so storage order == event order.
|
|
1675
|
-
*/
|
|
1676
1943
|
async listEvents(params) {
|
|
1677
|
-
|
|
1678
|
-
return listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
|
|
1944
|
+
return this.read(() => listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
|
|
1679
1945
|
limit: params?.limit ?? 100,
|
|
1680
1946
|
cursor: params?.cursor,
|
|
1681
1947
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1682
|
-
}, (event) => event.eventId);
|
|
1948
|
+
}, (event) => event.eventId));
|
|
1683
1949
|
}
|
|
1684
|
-
/**
|
|
1685
|
-
* List hooks with real limit/cursor pagination (cursor = last hookId).
|
|
1686
|
-
*/
|
|
1687
1950
|
async listHooks(params) {
|
|
1688
|
-
|
|
1689
|
-
return listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
|
|
1951
|
+
return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
|
|
1690
1952
|
limit: params?.limit ?? 100,
|
|
1691
1953
|
cursor: params?.cursor,
|
|
1692
1954
|
sortOrder: params?.sortOrder ?? "asc"
|
|
1955
|
+
}));
|
|
1956
|
+
}
|
|
1957
|
+
async getCleanupStatus() {
|
|
1958
|
+
await this.ensureSchema();
|
|
1959
|
+
return await this.ctx.storage.get("retention:cleanup") ?? null;
|
|
1960
|
+
}
|
|
1961
|
+
/** Explicitly schedule an existing terminal run, used for opt-in backfills. */
|
|
1962
|
+
async scheduleCleanup(request) {
|
|
1963
|
+
await this.ensureSchema();
|
|
1964
|
+
if (request.retentionMs <= 0) return this.getCleanupStatus();
|
|
1965
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
1966
|
+
const existing = await txn.get(CLEANUP_RECORD_KEY);
|
|
1967
|
+
if (existing) return existing;
|
|
1968
|
+
if (await txn.get("retention:tombstone")) return null;
|
|
1969
|
+
const current = await txn.get("run");
|
|
1970
|
+
if (!current || !isTerminalWorkflowRunStatus(current.status)) return null;
|
|
1971
|
+
const terminalRun = current;
|
|
1972
|
+
const dueAt = new Date(terminalRun.completedAt.getTime() + request.retentionMs);
|
|
1973
|
+
const run = WorkflowRunSchema.parse({
|
|
1974
|
+
...terminalRun,
|
|
1975
|
+
expiredAt: dueAt
|
|
1976
|
+
});
|
|
1977
|
+
const cleanup = {
|
|
1978
|
+
version: 1,
|
|
1979
|
+
runId: run.runId,
|
|
1980
|
+
workflowName: run.workflowName,
|
|
1981
|
+
createdAt: run.createdAt,
|
|
1982
|
+
completedAt: run.completedAt,
|
|
1983
|
+
terminalStatus: run.status,
|
|
1984
|
+
dueAt,
|
|
1985
|
+
queueShards: request.queueShards,
|
|
1986
|
+
phase: "retained",
|
|
1987
|
+
attempts: 0,
|
|
1988
|
+
deletedPayloadKeys: 0,
|
|
1989
|
+
deletedStreams: 0,
|
|
1990
|
+
deletedQueueMessages: 0
|
|
1991
|
+
};
|
|
1992
|
+
await txn.put("run", run);
|
|
1993
|
+
await txn.put(CLEANUP_RECORD_KEY, cleanup);
|
|
1994
|
+
await txn.setAlarm(dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
|
|
1995
|
+
return cleanup;
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
async cleanupNow(request) {
|
|
1999
|
+
await this.ensureSchema();
|
|
2000
|
+
if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
|
|
2001
|
+
...request,
|
|
2002
|
+
retentionMs: Math.max(1, request.retentionMs)
|
|
2003
|
+
});
|
|
2004
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
2005
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2006
|
+
if (!cleanup || cleanup.phase === "tombstoned") return;
|
|
2007
|
+
const dueAt = new Date(this.now());
|
|
2008
|
+
const run = await txn.get("run");
|
|
2009
|
+
if (run) await txn.put("run", WorkflowRunSchema.parse({
|
|
2010
|
+
...run,
|
|
2011
|
+
expiredAt: dueAt
|
|
2012
|
+
}));
|
|
2013
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2014
|
+
...cleanup,
|
|
2015
|
+
dueAt
|
|
2016
|
+
});
|
|
2017
|
+
await txn.setAlarm(this.now() + 1);
|
|
2018
|
+
});
|
|
2019
|
+
try {
|
|
2020
|
+
await this.executeCleanup();
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
await this.recordCleanupFailure(error);
|
|
2023
|
+
}
|
|
2024
|
+
return this.getCleanupStatus();
|
|
2025
|
+
}
|
|
2026
|
+
async rearmCleanup() {
|
|
2027
|
+
await this.ensureSchema();
|
|
2028
|
+
const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
|
|
2029
|
+
if (!cleanup) return null;
|
|
2030
|
+
if (cleanup.phase === "tombstoned") await this.ctx.storage.deleteAlarm();
|
|
2031
|
+
else await this.ctx.storage.setAlarm(Math.max(this.now() + 1, cleanup.dueAt.getTime()));
|
|
2032
|
+
return cleanup;
|
|
2033
|
+
}
|
|
2034
|
+
async alarm() {
|
|
2035
|
+
await this.ensureSchema();
|
|
2036
|
+
try {
|
|
2037
|
+
await this.executeCleanup();
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
await this.recordCleanupFailure(error);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
namespace(name) {
|
|
2043
|
+
const namespace = this.env?.[name];
|
|
2044
|
+
if (!namespace || typeof namespace === "function") throw new Error(`world-celld retention missing binding ${name}`);
|
|
2045
|
+
return namespace;
|
|
2046
|
+
}
|
|
2047
|
+
async setCleanupPhase(expected, next, updates = {}) {
|
|
2048
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
2049
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2050
|
+
if (!cleanup || cleanup.phase !== expected) return;
|
|
2051
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2052
|
+
...cleanup,
|
|
2053
|
+
...updates,
|
|
2054
|
+
phase: next,
|
|
2055
|
+
attempts: 0,
|
|
2056
|
+
lastError: void 0
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
async executeCleanup() {
|
|
2061
|
+
for (;;) {
|
|
2062
|
+
const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
|
|
2063
|
+
if (!cleanup) {
|
|
2064
|
+
await this.ctx.storage.deleteAlarm();
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (cleanup.phase === "tombstoned") {
|
|
2068
|
+
await this.ctx.storage.deleteAlarm();
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (cleanup.phase === "retained") {
|
|
2072
|
+
if (cleanup.dueAt.getTime() > this.now()) {
|
|
2073
|
+
await this.ctx.storage.setAlarm(cleanup.dueAt);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
await this.setCleanupPhase("retained", "index");
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (cleanup.phase === "index") {
|
|
2080
|
+
const [indexMarkers, hookMarkers] = await Promise.all([this.ctx.storage.list({ prefix: INDEX_MARKER_PREFIX }), this.ctx.storage.list({ prefix: HOOK_MARKER_PREFIX })]);
|
|
2081
|
+
const indexNamespace = this.namespace("WORKFLOW_INDEX");
|
|
2082
|
+
await indexNamespace.get(indexNamespace.idFromName("index")).expireRun({
|
|
2083
|
+
runId: cleanup.runId,
|
|
2084
|
+
keys: [
|
|
2085
|
+
workflowRunIndexKey(cleanup),
|
|
2086
|
+
globalRunIndexKey(cleanup),
|
|
2087
|
+
...indexMarkers.values()
|
|
2088
|
+
],
|
|
2089
|
+
hooks: Array.from(hookMarkers.values()),
|
|
2090
|
+
expiredAt: cleanup.dueAt.getTime()
|
|
2091
|
+
});
|
|
2092
|
+
await this.setCleanupPhase("index", "streams");
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (cleanup.phase === "streams") {
|
|
2096
|
+
const streamsNamespace = this.namespace("WORKFLOW_STREAMS");
|
|
2097
|
+
const registry = streamsNamespace.get(streamsNamespace.idFromName(`run-streams:${cleanup.runId}`));
|
|
2098
|
+
const { streams } = await registry.expireRegistry(cleanup.runId, cleanup.dueAt.getTime());
|
|
2099
|
+
let deletedStreams = streams.length === 0 ? cleanup.deletedStreams : 0;
|
|
2100
|
+
for (let offset = 0; offset < streams.length; offset += STREAM_DELETE_CONCURRENCY) {
|
|
2101
|
+
const batch = streams.slice(offset, offset + STREAM_DELETE_CONCURRENCY);
|
|
2102
|
+
const results = await Promise.all(batch.map((name) => {
|
|
2103
|
+
return streamsNamespace.get(streamsNamespace.idFromName(`stream:${name}`)).expireStream(cleanup.runId, cleanup.dueAt.getTime());
|
|
2104
|
+
}));
|
|
2105
|
+
deletedStreams += results.filter((result) => result.deleted).length;
|
|
2106
|
+
}
|
|
2107
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
2108
|
+
const current = await txn.get(CLEANUP_RECORD_KEY);
|
|
2109
|
+
if (current?.phase === "streams") await txn.put(CLEANUP_RECORD_KEY, {
|
|
2110
|
+
...current,
|
|
2111
|
+
deletedStreams
|
|
2112
|
+
});
|
|
2113
|
+
});
|
|
2114
|
+
await registry.finalizeRegistry(cleanup.runId);
|
|
2115
|
+
await this.setCleanupPhase("streams", "queues");
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
if (cleanup.phase === "queues") {
|
|
2119
|
+
const queues = this.namespace("WORKFLOW_QUEUE");
|
|
2120
|
+
let deletedQueueMessages = 0;
|
|
2121
|
+
for (let shard = 0; shard < cleanup.queueShards; shard++) {
|
|
2122
|
+
const result = await queues.get(queues.idFromName(`q:${shard}`)).expireRun(cleanup.runId, cleanup.dueAt.getTime());
|
|
2123
|
+
deletedQueueMessages += result.deleted;
|
|
2124
|
+
}
|
|
2125
|
+
await this.setCleanupPhase("queues", "payload", { deletedQueueMessages });
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
if (cleanup.phase === "payload") {
|
|
2129
|
+
if (!await this.deletePayloadBatch()) return;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
async deletePayloadBatch() {
|
|
2134
|
+
return await this.ctx.storage.transaction(async (txn) => {
|
|
2135
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2136
|
+
if (!cleanup || cleanup.phase !== "payload") return true;
|
|
2137
|
+
const entries = await txn.list({ limit: 259 });
|
|
2138
|
+
const keys = Array.from(entries.keys()).filter((key) => key !== "retention:cleanup" && key !== "retention:tombstone" && key !== "schema_version").slice(0, PAYLOAD_DELETE_BATCH);
|
|
2139
|
+
const now = new Date(this.now());
|
|
2140
|
+
await txn.put(TOMBSTONE_KEY, cleanupTombstone(cleanup, now));
|
|
2141
|
+
for (const key of keys) await txn.delete(key);
|
|
2142
|
+
if (keys.length > 0) {
|
|
2143
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2144
|
+
...cleanup,
|
|
2145
|
+
deletedPayloadKeys: cleanup.deletedPayloadKeys + keys.length,
|
|
2146
|
+
lastError: void 0
|
|
2147
|
+
});
|
|
2148
|
+
await txn.setAlarm(this.now() + 1);
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2152
|
+
...cleanup,
|
|
2153
|
+
phase: "tombstoned",
|
|
2154
|
+
attempts: 0,
|
|
2155
|
+
lastError: void 0,
|
|
2156
|
+
tombstonedAt: now
|
|
2157
|
+
});
|
|
2158
|
+
await txn.deleteAlarm();
|
|
2159
|
+
return true;
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
async recordCleanupFailure(error) {
|
|
2163
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
2164
|
+
const cleanup = await txn.get(CLEANUP_RECORD_KEY);
|
|
2165
|
+
if (!cleanup || cleanup.phase === "tombstoned") return;
|
|
2166
|
+
const attempts = cleanup.attempts + 1;
|
|
2167
|
+
const delay = Math.min(CLEANUP_RETRY_MAX_MS, 1e3 * 2 ** Math.min(attempts - 1, 12));
|
|
2168
|
+
await txn.put(CLEANUP_RECORD_KEY, {
|
|
2169
|
+
...cleanup,
|
|
2170
|
+
attempts,
|
|
2171
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
2172
|
+
lastAttemptAt: new Date(this.now())
|
|
2173
|
+
});
|
|
2174
|
+
await txn.setAlarm(this.now() + delay);
|
|
1693
2175
|
});
|
|
1694
2176
|
}
|
|
1695
|
-
/**
|
|
1696
|
-
* Claim a queue-message dedup slot. Used on DO instances dedicated to a
|
|
1697
|
-
* single idempotency key (`claim:<queueName>:<idempotencyKey>`).
|
|
1698
|
-
*
|
|
1699
|
-
* - Unclaimed -> claim and proceed.
|
|
1700
|
-
* - Claimed by the SAME messageId -> redelivery of the same message
|
|
1701
|
-
* (previous attempt failed transiently) -> proceed.
|
|
1702
|
-
* - Claimed by a DIFFERENT messageId -> concurrent duplicate -> reject,
|
|
1703
|
-
* unless the claim is stale (holder crashed / went to the DLQ without
|
|
1704
|
-
* releasing), in which case the claim is stolen.
|
|
1705
|
-
*/
|
|
1706
2177
|
async claimInflight(params) {
|
|
1707
2178
|
await this.ensureSchema();
|
|
1708
2179
|
return await this.ctx.storage.transaction(async (txn) => {
|
|
1709
2180
|
const existing = await txn.get("claim");
|
|
1710
|
-
const now =
|
|
2181
|
+
const now = this.now();
|
|
1711
2182
|
if (existing && existing.messageId !== params.messageId && now - existing.claimedAt < params.staleMs) return { claimed: false };
|
|
1712
2183
|
await txn.put("claim", {
|
|
1713
2184
|
messageId: params.messageId,
|
|
@@ -1716,9 +2187,6 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
1716
2187
|
return { claimed: true };
|
|
1717
2188
|
});
|
|
1718
2189
|
}
|
|
1719
|
-
/**
|
|
1720
|
-
* Release a queue-message dedup claim after the message was fully handled.
|
|
1721
|
-
*/
|
|
1722
2190
|
async releaseInflight() {
|
|
1723
2191
|
await this.ensureSchema();
|
|
1724
2192
|
await this.ctx.storage.delete("claim");
|
|
@@ -1731,13 +2199,21 @@ var WorkflowRunDO = class extends DurableObject {
|
|
|
1731
2199
|
* behind a node:http server — the full wire protocol without a celld binary.
|
|
1732
2200
|
*/
|
|
1733
2201
|
async function startHarness(options = {}) {
|
|
2202
|
+
const cellEnv = { ...options.cellEnv };
|
|
1734
2203
|
const fleet = new FakeFleet({
|
|
1735
2204
|
runs: WorkflowRunDO,
|
|
1736
2205
|
streams: StreamDO,
|
|
1737
2206
|
index: IndexDO,
|
|
1738
2207
|
queue: QueueDO,
|
|
1739
2208
|
...options.extraClasses
|
|
1740
|
-
},
|
|
2209
|
+
}, cellEnv);
|
|
2210
|
+
cellEnv.clock ??= options.virtualClock ? () => fleet.now : () => Date.now();
|
|
2211
|
+
Object.assign(cellEnv, {
|
|
2212
|
+
WORKFLOW_DB: fleet.namespace("runs"),
|
|
2213
|
+
WORKFLOW_STREAMS: fleet.namespace("streams"),
|
|
2214
|
+
WORKFLOW_INDEX: fleet.namespace("index"),
|
|
2215
|
+
WORKFLOW_QUEUE: fleet.namespace("queue")
|
|
2216
|
+
});
|
|
1741
2217
|
const router = createRouter({
|
|
1742
2218
|
WORKFLOW_DB: fleet.namespace("runs"),
|
|
1743
2219
|
WORKFLOW_STREAMS: fleet.namespace("streams"),
|
|
@@ -1771,7 +2247,10 @@ async function startHarness(options = {}) {
|
|
|
1771
2247
|
return {
|
|
1772
2248
|
url: `http://127.0.0.1:${port}`,
|
|
1773
2249
|
fleet,
|
|
1774
|
-
close: () => new Promise((resolve, reject) =>
|
|
2250
|
+
close: () => new Promise((resolve, reject) => {
|
|
2251
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
2252
|
+
server.closeAllConnections();
|
|
2253
|
+
})
|
|
1775
2254
|
};
|
|
1776
2255
|
}
|
|
1777
2256
|
//#endregion
|