@ewhauser/world-celld 0.3.2 → 0.4.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/dist/worker.js CHANGED
@@ -736,6 +736,120 @@ function createRouter(env) {
736
736
  };
737
737
  }
738
738
  //#endregion
739
+ //#region src/retention.ts
740
+ const CLEANUP_RECORD_KEY = "retention:cleanup";
741
+ const TOMBSTONE_KEY = "retention:tombstone";
742
+ const HOOK_MARKER_PREFIX = "retention:hook:";
743
+ const TERMINAL_CLEANUP_KEY = "terminal:cleanup";
744
+ function sortableTimestamp(date) {
745
+ return date.getTime().toString().padStart(13, "0");
746
+ }
747
+ function workflowRunIndexKey(run) {
748
+ return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
749
+ }
750
+ function globalRunIndexKey(run) {
751
+ return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
752
+ }
753
+ function hookMarkerKey(reference) {
754
+ return `${HOOK_MARKER_PREFIX}${encodeURIComponent(reference.hookId)}:${encodeURIComponent(reference.token)}`;
755
+ }
756
+ function cleanupTombstone(record, now) {
757
+ const cleanup = {
758
+ ...record,
759
+ phase: "tombstoned",
760
+ tombstonedAt: now
761
+ };
762
+ return {
763
+ version: 1,
764
+ runId: record.runId,
765
+ status: record.status ?? record.terminalStatus,
766
+ terminalStatus: record.terminalStatus,
767
+ completedAt: record.completedAt,
768
+ expiredAt: record.dueAt,
769
+ tombstonedAt: now,
770
+ cleanup
771
+ };
772
+ }
773
+ function cleanupFromTombstone(tombstone) {
774
+ return tombstone.cleanup;
775
+ }
776
+ function expiredRead(tombstone) {
777
+ return {
778
+ ok: false,
779
+ code: "RUN_EXPIRED",
780
+ message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`,
781
+ tombstone
782
+ };
783
+ }
784
+ const MAX_RETENTION_SWEEP_BATCH_SIZE = 1e3;
785
+ const RETENTION_SWEEP_CONCURRENCY = 8;
786
+ function integerSetting(name, raw, fallback, minimum, maximum = Number.MAX_SAFE_INTEGER) {
787
+ const value = raw === void 0 || raw === "" ? fallback : Number(raw);
788
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`world-celld: ${name} must be an integer between ${minimum} and ${maximum}`);
789
+ return value;
790
+ }
791
+ function runIdFromCatalogValue(value) {
792
+ const parsed = JSON.parse(value);
793
+ if (typeof parsed.runId !== "string" || parsed.runId.length === 0) throw new Error("world-celld: run catalog entry has no runId");
794
+ return parsed.runId;
795
+ }
796
+ /**
797
+ * Discover and fence one bounded page of workflows older than the configured
798
+ * maximum age. RunDO alarms finish the persisted, idempotent deletion phases.
799
+ */
800
+ async function runRetentionSweep(scheduledTime, env) {
801
+ if (!Number.isSafeInteger(scheduledTime) || scheduledTime < 0) throw new Error("world-celld: scheduledTime must be a non-negative safe integer");
802
+ const retentionMs = integerSetting("WORKFLOW_RETENTION_MS", env.WORKFLOW_RETENTION_MS, 0, 0);
803
+ if (retentionMs === 0 || scheduledTime < retentionMs) return {
804
+ disabled: retentionMs === 0,
805
+ cutoff: retentionMs === 0 ? null : scheduledTime - retentionMs,
806
+ scanned: 0,
807
+ scheduled: 0,
808
+ expired: 0,
809
+ missing: 0,
810
+ notDue: 0
811
+ };
812
+ const batchSize = integerSetting("WORKFLOW_RETENTION_BATCH_SIZE", env.WORKFLOW_RETENTION_BATCH_SIZE, 128, 1, MAX_RETENTION_SWEEP_BATCH_SIZE);
813
+ const queueShards = integerSetting("WORKFLOW_RETENTION_QUEUE_SHARDS", env.WORKFLOW_RETENTION_QUEUE_SHARDS, 1, 1);
814
+ const cutoff = scheduledTime - retentionMs;
815
+ const page = await createWorkflowIndex({
816
+ runCatalog: env.WORKFLOW_RUN_CATALOG,
817
+ hookTokens: env.WORKFLOW_HOOK_TOKENS,
818
+ hookIds: env.WORKFLOW_HOOK_IDS
819
+ }).listRuns({
820
+ prefix: "runall:",
821
+ end: `runall:${sortableTimestamp(new Date(cutoff + 1))}:`,
822
+ limit: batchSize
823
+ });
824
+ const result = {
825
+ disabled: false,
826
+ cutoff,
827
+ scanned: page.keys.length,
828
+ scheduled: 0,
829
+ expired: 0,
830
+ missing: 0,
831
+ notDue: 0
832
+ };
833
+ const failures = [];
834
+ for (let offset = 0; offset < page.keys.length; offset += RETENTION_SWEEP_CONCURRENCY) {
835
+ const candidates = page.keys.slice(offset, offset + RETENTION_SWEEP_CONCURRENCY);
836
+ const settled = await Promise.allSettled(candidates.map(async (entry) => {
837
+ const runId = runIdFromCatalogValue(entry.value);
838
+ const outcome = await env.WORKFLOW_DB.get(env.WORKFLOW_DB.idFromName(runId)).enforceRetention({
839
+ retentionMs,
840
+ queueShards,
841
+ scheduledTime
842
+ });
843
+ if (outcome.state === "missing" || outcome.state === "not-due") await env.WORKFLOW_RUN_CATALOG.get(env.WORKFLOW_RUN_CATALOG.idFromName(runCatalogShardName(runId))).deleteStaleGlobalRun(runId, entry.name, entry.value);
844
+ return outcome;
845
+ }));
846
+ for (const outcome of settled) if (outcome.status === "rejected") failures.push(outcome.reason);
847
+ else result[outcome.value.state === "not-due" ? "notDue" : outcome.value.state]++;
848
+ }
849
+ if (failures.length > 0) throw new AggregateError(failures, `world-celld retention sweep failed for ${failures.length} run(s)`);
850
+ return result;
851
+ }
852
+ //#endregion
739
853
  //#region src/vendor/shared/util.ts
740
854
  function compact(obj) {
741
855
  const value = {};
@@ -1781,8 +1895,11 @@ var RunCatalogDO = class extends DurableObject {
1781
1895
  prefix: options.prefix ?? "",
1782
1896
  ...options.reverse ? {
1783
1897
  reverse: true,
1784
- end: options.cursor
1785
- } : { startAfter: options.cursor },
1898
+ end: options.cursor ?? options.end
1899
+ } : {
1900
+ startAfter: options.cursor,
1901
+ end: options.end
1902
+ },
1786
1903
  limit: limit + 1
1787
1904
  });
1788
1905
  const page = Array.from(entries).slice(0, limit);
@@ -1796,6 +1913,15 @@ var RunCatalogDO = class extends DurableObject {
1796
1913
  cursor: listComplete ? void 0 : page.at(-1)?.[0]
1797
1914
  };
1798
1915
  }
1916
+ /** Compare-and-delete one rejected retention discovery candidate. */
1917
+ async deleteStaleGlobalRun(runId, key, expectedValue) {
1918
+ if (!key.startsWith("runall:") || !key.endsWith(`:${runId}`)) throw new Error("Stale run catalog deletion requires the exact global run key");
1919
+ if (JSON.parse(expectedValue).runId !== runId) throw new Error("Stale run catalog deletion requires matching run metadata");
1920
+ return await this.ctx.storage.transaction(async (txn) => {
1921
+ if (await txn.get(key) !== expectedValue) return { deleted: false };
1922
+ return { deleted: await txn.delete(key) };
1923
+ });
1924
+ }
1799
1925
  async expireRun(runId, keys, expiredAt) {
1800
1926
  if (keys.length !== 2) throw new Error("Run catalog expiry requires exactly two keys");
1801
1927
  return await this.ctx.storage.transaction(async (txn) => {
@@ -3054,51 +3180,6 @@ async function finalizeEventPage(store, outcome, params) {
3054
3180
  };
3055
3181
  }
3056
3182
  //#endregion
3057
- //#region src/retention.ts
3058
- const CLEANUP_RECORD_KEY = "retention:cleanup";
3059
- const TOMBSTONE_KEY = "retention:tombstone";
3060
- const HOOK_MARKER_PREFIX = "retention:hook:";
3061
- const TERMINAL_CLEANUP_KEY = "terminal:cleanup";
3062
- function sortableTimestamp(date) {
3063
- return date.getTime().toString().padStart(13, "0");
3064
- }
3065
- function workflowRunIndexKey(run) {
3066
- return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
3067
- }
3068
- function globalRunIndexKey(run) {
3069
- return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
3070
- }
3071
- function hookMarkerKey(reference) {
3072
- return `${HOOK_MARKER_PREFIX}${encodeURIComponent(reference.hookId)}:${encodeURIComponent(reference.token)}`;
3073
- }
3074
- function cleanupTombstone(record, now) {
3075
- const cleanup = {
3076
- ...record,
3077
- phase: "tombstoned",
3078
- tombstonedAt: now
3079
- };
3080
- return {
3081
- version: 1,
3082
- runId: record.runId,
3083
- terminalStatus: record.terminalStatus,
3084
- completedAt: record.completedAt,
3085
- expiredAt: record.dueAt,
3086
- tombstonedAt: now,
3087
- cleanup
3088
- };
3089
- }
3090
- function cleanupFromTombstone(tombstone) {
3091
- return tombstone.cleanup;
3092
- }
3093
- function expiredRead(tombstone) {
3094
- return {
3095
- ok: false,
3096
- code: "RUN_EXPIRED",
3097
- message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`,
3098
- tombstone
3099
- };
3100
- }
3101
- //#endregion
3102
3183
  //#region src/worker/durable-objects/WorkflowRunDO.ts
3103
3184
  const STORAGE_BATCH_SIZE = 128;
3104
3185
  const PAYLOAD_DELETE_BATCH = STORAGE_BATCH_SIZE;
@@ -3111,6 +3192,28 @@ const STREAM_BYTES_PER_CLEANUP_PAGE = 16777216;
3111
3192
  const QUEUE_REFERENCES_PER_CLEANUP_PAGE = 64;
3112
3193
  const CLEANUP_RETRY_MAX_MS = 36e5;
3113
3194
  const CLEANUP_PROGRESS_KEY = "retention:progress";
3195
+ const RUN_QUEUE_SHARDS_KEY = "retention:queue-shards";
3196
+ function cleanupRecord(run, dueAt, queueShards, reason) {
3197
+ const terminal = isTerminalWorkflowRunStatus(run.status) ? run : void 0;
3198
+ return {
3199
+ version: 1,
3200
+ runId: run.runId,
3201
+ workflowName: run.workflowName,
3202
+ createdAt: run.createdAt,
3203
+ status: run.status,
3204
+ completedAt: terminal?.completedAt,
3205
+ terminalStatus: terminal?.status,
3206
+ reason,
3207
+ dueAt,
3208
+ queueShards,
3209
+ phase: "retained",
3210
+ generation: 0,
3211
+ attempts: 0,
3212
+ deletedPayloadKeys: 0,
3213
+ deletedStreams: 0,
3214
+ deletedQueueMessages: 0
3215
+ };
3216
+ }
3114
3217
  /**
3115
3218
  * Adapt DO storage (or a transaction) to the {@link EventStore} interface
3116
3219
  * shared with the test mocks. No casts: the methods line up structurally,
@@ -3206,6 +3309,7 @@ var WorkflowRunDO = class extends DurableObject {
3206
3309
  now
3207
3310
  });
3208
3311
  if (!outcome.ok) return outcome;
3312
+ if (outcome.runCreated && request.cleanup) await txn.put(RUN_QUEUE_SHARDS_KEY, request.cleanup.queueShards);
3209
3313
  await txn.put("event_sequence", eventSequence);
3210
3314
  await putStorageEntries(txn, (outcome.hookToIndex ? [{
3211
3315
  hookId: outcome.hookToIndex.hookId,
@@ -3219,22 +3323,7 @@ var WorkflowRunDO = class extends DurableObject {
3219
3323
  ...terminalRun,
3220
3324
  expiredAt: dueAt
3221
3325
  });
3222
- const cleanup = {
3223
- version: 1,
3224
- runId: run.runId,
3225
- workflowName: run.workflowName,
3226
- createdAt: run.createdAt,
3227
- completedAt: run.completedAt,
3228
- terminalStatus: run.status,
3229
- dueAt,
3230
- queueShards: request.cleanup?.queueShards ?? 1,
3231
- phase: "retained",
3232
- generation: 0,
3233
- attempts: 0,
3234
- deletedPayloadKeys: 0,
3235
- deletedStreams: 0,
3236
- deletedQueueMessages: 0
3237
- };
3326
+ const cleanup = cleanupRecord(run, dueAt, request.cleanup?.queueShards ?? 1, "terminal-retention");
3238
3327
  await txn.put("run", run);
3239
3328
  await txn.put(CLEANUP_RECORD_KEY, cleanup);
3240
3329
  await txn.setAlarm(dueAt);
@@ -3344,28 +3433,95 @@ var WorkflowRunDO = class extends DurableObject {
3344
3433
  ...terminalRun,
3345
3434
  expiredAt: dueAt
3346
3435
  });
3347
- const cleanup = {
3348
- version: 1,
3349
- runId: run.runId,
3350
- workflowName: run.workflowName,
3351
- createdAt: run.createdAt,
3352
- completedAt: run.completedAt,
3353
- terminalStatus: run.status,
3354
- dueAt,
3355
- queueShards: request.queueShards,
3356
- phase: "retained",
3357
- generation: 0,
3358
- attempts: 0,
3359
- deletedPayloadKeys: 0,
3360
- deletedStreams: 0,
3361
- deletedQueueMessages: 0
3362
- };
3436
+ const cleanup = cleanupRecord(run, dueAt, request.queueShards, "manual");
3363
3437
  await txn.put("run", run);
3364
3438
  await txn.put(CLEANUP_RECORD_KEY, cleanup);
3365
3439
  await txn.setAlarm(values.get("terminal:cleanup") || dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
3366
3440
  return cleanup;
3367
3441
  });
3368
3442
  }
3443
+ /**
3444
+ * Apply the fleet-wide maximum-age policy to one authoritative run.
3445
+ *
3446
+ * The catalog is only a discovery index: eligibility is rechecked from the
3447
+ * RunDO's own createdAt. Once eligible, the cleanup deadline immediately
3448
+ * becomes an authoritative write/read fence, including for active runs.
3449
+ */
3450
+ async enforceRetention(request) {
3451
+ if (!Number.isSafeInteger(request.retentionMs) || request.retentionMs <= 0) throw new Error("world-celld retentionMs must be a positive safe integer");
3452
+ if (!Number.isSafeInteger(request.queueShards) || request.queueShards <= 0) throw new Error("world-celld retention queueShards must be a positive safe integer");
3453
+ if (!Number.isSafeInteger(request.scheduledTime) || request.scheduledTime < 0) throw new Error("world-celld retention scheduledTime must be a non-negative safe integer");
3454
+ const result = await this.ctx.storage.transaction(async (txn) => {
3455
+ const values = await txn.get([
3456
+ CLEANUP_RECORD_KEY,
3457
+ TOMBSTONE_KEY,
3458
+ RUN_QUEUE_SHARDS_KEY,
3459
+ "run"
3460
+ ]);
3461
+ const tombstone = values.get(TOMBSTONE_KEY);
3462
+ if (tombstone) return {
3463
+ state: "expired",
3464
+ cleanup: cleanupFromTombstone(tombstone)
3465
+ };
3466
+ const run = values.get("run");
3467
+ if (!run) return {
3468
+ state: "missing",
3469
+ cleanup: null
3470
+ };
3471
+ const dueAtMs = run.createdAt.getTime() + request.retentionMs;
3472
+ if (!Number.isSafeInteger(dueAtMs) || dueAtMs > 864e13) throw new Error(`world-celld retention deadline is out of range for ${run.runId}`);
3473
+ if (dueAtMs > request.scheduledTime) return {
3474
+ state: "not-due",
3475
+ cleanup: null
3476
+ };
3477
+ const dueAt = new Date(dueAtMs);
3478
+ const existing = values.get(CLEANUP_RECORD_KEY);
3479
+ const storedQueueShards = values.get(RUN_QUEUE_SHARDS_KEY);
3480
+ const queueShards = Number.isSafeInteger(storedQueueShards) && storedQueueShards > 0 ? storedQueueShards : request.queueShards;
3481
+ let cleanup = existing;
3482
+ if (!cleanup) {
3483
+ cleanup = cleanupRecord(run, dueAt, queueShards, "maximum-age");
3484
+ await txn.put("run", WorkflowRunSchema.parse({
3485
+ ...run,
3486
+ expiredAt: dueAt
3487
+ }));
3488
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
3489
+ } else if (cleanup.phase === "retained" && dueAtMs < cleanup.dueAt.getTime()) {
3490
+ const terminal = isTerminalWorkflowRunStatus(run.status) ? run : void 0;
3491
+ cleanup = {
3492
+ ...cleanup,
3493
+ status: run.status,
3494
+ completedAt: terminal?.completedAt,
3495
+ terminalStatus: terminal?.status,
3496
+ reason: "maximum-age",
3497
+ dueAt,
3498
+ generation: cleanup.generation + 1
3499
+ };
3500
+ await txn.put("run", WorkflowRunSchema.parse({
3501
+ ...run,
3502
+ expiredAt: dueAt
3503
+ }));
3504
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
3505
+ }
3506
+ await txn.setAlarm(this.now() + 1);
3507
+ return {
3508
+ state: cleanup.dueAt.getTime() <= this.now() ? "expired" : "scheduled",
3509
+ cleanup
3510
+ };
3511
+ });
3512
+ if (result.state === "scheduled" || result.state === "expired") {
3513
+ for (let page = 0; page < 2; page++) {
3514
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
3515
+ if (cleanup?.phase !== "retained" && cleanup?.phase !== "index") break;
3516
+ await this.executeScheduledCleanupPage();
3517
+ }
3518
+ return {
3519
+ ...result,
3520
+ cleanup: await this.getCleanupStatus() ?? result.cleanup
3521
+ };
3522
+ }
3523
+ return result;
3524
+ }
3369
3525
  async cleanupNow(request) {
3370
3526
  if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
3371
3527
  ...request,
@@ -3429,7 +3585,17 @@ var WorkflowRunDO = class extends DurableObject {
3429
3585
  });
3430
3586
  }
3431
3587
  async executeScheduledCleanupPage() {
3432
- const terminalCleanup = await this.ctx.storage.get(TERMINAL_CLEANUP_KEY);
3588
+ const values = await this.ctx.storage.get([CLEANUP_RECORD_KEY, TERMINAL_CLEANUP_KEY]);
3589
+ const cleanup = values.get(CLEANUP_RECORD_KEY);
3590
+ const terminalCleanup = values.get(TERMINAL_CLEANUP_KEY);
3591
+ if (cleanup && cleanup.phase !== "tombstoned" && cleanup.dueAt.getTime() <= this.now()) {
3592
+ try {
3593
+ await this.executeCleanup(cleanup);
3594
+ } catch (error) {
3595
+ await this.recordCleanupFailure(error, cleanup);
3596
+ }
3597
+ return;
3598
+ }
3433
3599
  if (terminalCleanup) {
3434
3600
  try {
3435
3601
  await this.executeTerminalCleanup(terminalCleanup);
@@ -3438,7 +3604,6 @@ var WorkflowRunDO = class extends DurableObject {
3438
3604
  }
3439
3605
  return;
3440
3606
  }
3441
- const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
3442
3607
  if (!cleanup || cleanup.phase === "tombstoned") {
3443
3608
  await this.ctx.storage.deleteAlarm();
3444
3609
  return;
@@ -3794,9 +3959,19 @@ var WorkflowRunDO = class extends DurableObject {
3794
3959
  * default fetch router. This module (and everything it imports) must stay
3795
3960
  * free of Node built-ins — it runs inside celld's workerd runtime.
3796
3961
  */
3797
- var worker_default = { async fetch(request, env) {
3798
- return createRouter(env)(request);
3799
- } };
3962
+ async function scheduled(controller, env) {
3963
+ const result = await runRetentionSweep(controller.scheduledTime, env);
3964
+ if (result.scanned > 0) console.info("world-celld retention sweep", {
3965
+ cron: controller.cron,
3966
+ ...result
3967
+ });
3968
+ }
3969
+ var worker_default = {
3970
+ async fetch(request, env) {
3971
+ return createRouter(env)(request);
3972
+ },
3973
+ scheduled
3974
+ };
3800
3975
  //#endregion
3801
3976
  export { HookIdDO, HookTokenDO, QueueDO, RunCatalogDO, StreamDO, WorkflowRunDO, createRouter, worker_default as default };
3802
3977