@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/testing.js CHANGED
@@ -1744,8 +1744,11 @@ var RunCatalogDO = class extends DurableObject {
1744
1744
  prefix: options.prefix ?? "",
1745
1745
  ...options.reverse ? {
1746
1746
  reverse: true,
1747
- end: options.cursor
1748
- } : { startAfter: options.cursor },
1747
+ end: options.cursor ?? options.end
1748
+ } : {
1749
+ startAfter: options.cursor,
1750
+ end: options.end
1751
+ },
1749
1752
  limit: limit + 1
1750
1753
  });
1751
1754
  const page = Array.from(entries).slice(0, limit);
@@ -1759,6 +1762,15 @@ var RunCatalogDO = class extends DurableObject {
1759
1762
  cursor: listComplete ? void 0 : page.at(-1)?.[0]
1760
1763
  };
1761
1764
  }
1765
+ /** Compare-and-delete one rejected retention discovery candidate. */
1766
+ async deleteStaleGlobalRun(runId, key, expectedValue) {
1767
+ if (!key.startsWith("runall:") || !key.endsWith(`:${runId}`)) throw new Error("Stale run catalog deletion requires the exact global run key");
1768
+ if (JSON.parse(expectedValue).runId !== runId) throw new Error("Stale run catalog deletion requires matching run metadata");
1769
+ return await this.ctx.storage.transaction(async (txn) => {
1770
+ if (await txn.get(key) !== expectedValue) return { deleted: false };
1771
+ return { deleted: await txn.delete(key) };
1772
+ });
1773
+ }
1762
1774
  async expireRun(runId, keys, expiredAt) {
1763
1775
  if (keys.length !== 2) throw new Error("Run catalog expiry requires exactly two keys");
1764
1776
  return await this.ctx.storage.transaction(async (txn) => {
@@ -3043,6 +3055,7 @@ function cleanupTombstone(record, now) {
3043
3055
  return {
3044
3056
  version: 1,
3045
3057
  runId: record.runId,
3058
+ status: record.status ?? record.terminalStatus,
3046
3059
  terminalStatus: record.terminalStatus,
3047
3060
  completedAt: record.completedAt,
3048
3061
  expiredAt: record.dueAt,
@@ -3074,6 +3087,28 @@ const STREAM_BYTES_PER_CLEANUP_PAGE = 16777216;
3074
3087
  const QUEUE_REFERENCES_PER_CLEANUP_PAGE = 64;
3075
3088
  const CLEANUP_RETRY_MAX_MS = 36e5;
3076
3089
  const CLEANUP_PROGRESS_KEY = "retention:progress";
3090
+ const RUN_QUEUE_SHARDS_KEY = "retention:queue-shards";
3091
+ function cleanupRecord(run, dueAt, queueShards, reason) {
3092
+ const terminal = isTerminalWorkflowRunStatus(run.status) ? run : void 0;
3093
+ return {
3094
+ version: 1,
3095
+ runId: run.runId,
3096
+ workflowName: run.workflowName,
3097
+ createdAt: run.createdAt,
3098
+ status: run.status,
3099
+ completedAt: terminal?.completedAt,
3100
+ terminalStatus: terminal?.status,
3101
+ reason,
3102
+ dueAt,
3103
+ queueShards,
3104
+ phase: "retained",
3105
+ generation: 0,
3106
+ attempts: 0,
3107
+ deletedPayloadKeys: 0,
3108
+ deletedStreams: 0,
3109
+ deletedQueueMessages: 0
3110
+ };
3111
+ }
3077
3112
  /**
3078
3113
  * Adapt DO storage (or a transaction) to the {@link EventStore} interface
3079
3114
  * shared with the test mocks. No casts: the methods line up structurally,
@@ -3169,6 +3204,7 @@ var WorkflowRunDO = class extends DurableObject {
3169
3204
  now
3170
3205
  });
3171
3206
  if (!outcome.ok) return outcome;
3207
+ if (outcome.runCreated && request.cleanup) await txn.put(RUN_QUEUE_SHARDS_KEY, request.cleanup.queueShards);
3172
3208
  await txn.put("event_sequence", eventSequence);
3173
3209
  await putStorageEntries(txn, (outcome.hookToIndex ? [{
3174
3210
  hookId: outcome.hookToIndex.hookId,
@@ -3182,22 +3218,7 @@ var WorkflowRunDO = class extends DurableObject {
3182
3218
  ...terminalRun,
3183
3219
  expiredAt: dueAt
3184
3220
  });
3185
- const cleanup = {
3186
- version: 1,
3187
- runId: run.runId,
3188
- workflowName: run.workflowName,
3189
- createdAt: run.createdAt,
3190
- completedAt: run.completedAt,
3191
- terminalStatus: run.status,
3192
- dueAt,
3193
- queueShards: request.cleanup?.queueShards ?? 1,
3194
- phase: "retained",
3195
- generation: 0,
3196
- attempts: 0,
3197
- deletedPayloadKeys: 0,
3198
- deletedStreams: 0,
3199
- deletedQueueMessages: 0
3200
- };
3221
+ const cleanup = cleanupRecord(run, dueAt, request.cleanup?.queueShards ?? 1, "terminal-retention");
3201
3222
  await txn.put("run", run);
3202
3223
  await txn.put(CLEANUP_RECORD_KEY, cleanup);
3203
3224
  await txn.setAlarm(dueAt);
@@ -3307,28 +3328,95 @@ var WorkflowRunDO = class extends DurableObject {
3307
3328
  ...terminalRun,
3308
3329
  expiredAt: dueAt
3309
3330
  });
3310
- const cleanup = {
3311
- version: 1,
3312
- runId: run.runId,
3313
- workflowName: run.workflowName,
3314
- createdAt: run.createdAt,
3315
- completedAt: run.completedAt,
3316
- terminalStatus: run.status,
3317
- dueAt,
3318
- queueShards: request.queueShards,
3319
- phase: "retained",
3320
- generation: 0,
3321
- attempts: 0,
3322
- deletedPayloadKeys: 0,
3323
- deletedStreams: 0,
3324
- deletedQueueMessages: 0
3325
- };
3331
+ const cleanup = cleanupRecord(run, dueAt, request.queueShards, "manual");
3326
3332
  await txn.put("run", run);
3327
3333
  await txn.put(CLEANUP_RECORD_KEY, cleanup);
3328
3334
  await txn.setAlarm(values.get("terminal:cleanup") || dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
3329
3335
  return cleanup;
3330
3336
  });
3331
3337
  }
3338
+ /**
3339
+ * Apply the fleet-wide maximum-age policy to one authoritative run.
3340
+ *
3341
+ * The catalog is only a discovery index: eligibility is rechecked from the
3342
+ * RunDO's own createdAt. Once eligible, the cleanup deadline immediately
3343
+ * becomes an authoritative write/read fence, including for active runs.
3344
+ */
3345
+ async enforceRetention(request) {
3346
+ if (!Number.isSafeInteger(request.retentionMs) || request.retentionMs <= 0) throw new Error("world-celld retentionMs must be a positive safe integer");
3347
+ if (!Number.isSafeInteger(request.queueShards) || request.queueShards <= 0) throw new Error("world-celld retention queueShards must be a positive safe integer");
3348
+ if (!Number.isSafeInteger(request.scheduledTime) || request.scheduledTime < 0) throw new Error("world-celld retention scheduledTime must be a non-negative safe integer");
3349
+ const result = await this.ctx.storage.transaction(async (txn) => {
3350
+ const values = await txn.get([
3351
+ CLEANUP_RECORD_KEY,
3352
+ TOMBSTONE_KEY,
3353
+ RUN_QUEUE_SHARDS_KEY,
3354
+ "run"
3355
+ ]);
3356
+ const tombstone = values.get(TOMBSTONE_KEY);
3357
+ if (tombstone) return {
3358
+ state: "expired",
3359
+ cleanup: cleanupFromTombstone(tombstone)
3360
+ };
3361
+ const run = values.get("run");
3362
+ if (!run) return {
3363
+ state: "missing",
3364
+ cleanup: null
3365
+ };
3366
+ const dueAtMs = run.createdAt.getTime() + request.retentionMs;
3367
+ if (!Number.isSafeInteger(dueAtMs) || dueAtMs > 864e13) throw new Error(`world-celld retention deadline is out of range for ${run.runId}`);
3368
+ if (dueAtMs > request.scheduledTime) return {
3369
+ state: "not-due",
3370
+ cleanup: null
3371
+ };
3372
+ const dueAt = new Date(dueAtMs);
3373
+ const existing = values.get(CLEANUP_RECORD_KEY);
3374
+ const storedQueueShards = values.get(RUN_QUEUE_SHARDS_KEY);
3375
+ const queueShards = Number.isSafeInteger(storedQueueShards) && storedQueueShards > 0 ? storedQueueShards : request.queueShards;
3376
+ let cleanup = existing;
3377
+ if (!cleanup) {
3378
+ cleanup = cleanupRecord(run, dueAt, queueShards, "maximum-age");
3379
+ await txn.put("run", WorkflowRunSchema.parse({
3380
+ ...run,
3381
+ expiredAt: dueAt
3382
+ }));
3383
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
3384
+ } else if (cleanup.phase === "retained" && dueAtMs < cleanup.dueAt.getTime()) {
3385
+ const terminal = isTerminalWorkflowRunStatus(run.status) ? run : void 0;
3386
+ cleanup = {
3387
+ ...cleanup,
3388
+ status: run.status,
3389
+ completedAt: terminal?.completedAt,
3390
+ terminalStatus: terminal?.status,
3391
+ reason: "maximum-age",
3392
+ dueAt,
3393
+ generation: cleanup.generation + 1
3394
+ };
3395
+ await txn.put("run", WorkflowRunSchema.parse({
3396
+ ...run,
3397
+ expiredAt: dueAt
3398
+ }));
3399
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
3400
+ }
3401
+ await txn.setAlarm(this.now() + 1);
3402
+ return {
3403
+ state: cleanup.dueAt.getTime() <= this.now() ? "expired" : "scheduled",
3404
+ cleanup
3405
+ };
3406
+ });
3407
+ if (result.state === "scheduled" || result.state === "expired") {
3408
+ for (let page = 0; page < 2; page++) {
3409
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
3410
+ if (cleanup?.phase !== "retained" && cleanup?.phase !== "index") break;
3411
+ await this.executeScheduledCleanupPage();
3412
+ }
3413
+ return {
3414
+ ...result,
3415
+ cleanup: await this.getCleanupStatus() ?? result.cleanup
3416
+ };
3417
+ }
3418
+ return result;
3419
+ }
3332
3420
  async cleanupNow(request) {
3333
3421
  if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
3334
3422
  ...request,
@@ -3392,7 +3480,17 @@ var WorkflowRunDO = class extends DurableObject {
3392
3480
  });
3393
3481
  }
3394
3482
  async executeScheduledCleanupPage() {
3395
- const terminalCleanup = await this.ctx.storage.get(TERMINAL_CLEANUP_KEY);
3483
+ const values = await this.ctx.storage.get([CLEANUP_RECORD_KEY, TERMINAL_CLEANUP_KEY]);
3484
+ const cleanup = values.get(CLEANUP_RECORD_KEY);
3485
+ const terminalCleanup = values.get(TERMINAL_CLEANUP_KEY);
3486
+ if (cleanup && cleanup.phase !== "tombstoned" && cleanup.dueAt.getTime() <= this.now()) {
3487
+ try {
3488
+ await this.executeCleanup(cleanup);
3489
+ } catch (error) {
3490
+ await this.recordCleanupFailure(error, cleanup);
3491
+ }
3492
+ return;
3493
+ }
3396
3494
  if (terminalCleanup) {
3397
3495
  try {
3398
3496
  await this.executeTerminalCleanup(terminalCleanup);
@@ -3401,7 +3499,6 @@ var WorkflowRunDO = class extends DurableObject {
3401
3499
  }
3402
3500
  return;
3403
3501
  }
3404
- const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
3405
3502
  if (!cleanup || cleanup.phase === "tombstoned") {
3406
3503
  await this.ctx.storage.deleteAlarm();
3407
3504
  return;