@lostgradient/weft 0.24.1 → 0.24.2

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.
Files changed (97) hide show
  1. package/README.md +1 -1
  2. package/dist/core/engine/bulk-operations-purge.d.ts +1 -1
  3. package/dist/core/engine/bulk-operations-purge.js +3 -2
  4. package/dist/core/engine/bulk-operations-retry.d.ts +16 -1
  5. package/dist/core/engine/bulk-operations-retry.js +19 -6
  6. package/dist/core/engine/bulk-operations.d.ts +3 -2
  7. package/dist/core/engine/bulk-operations.js +2 -2
  8. package/dist/core/engine/callback-creators-bundles.js +1 -1
  9. package/dist/core/engine/callback-creators-schedule.js +2 -2
  10. package/dist/core/engine/child-workflow.d.ts +13 -1
  11. package/dist/core/engine/child-workflow.js +22 -4
  12. package/dist/core/engine/generation-codec.d.ts +22 -0
  13. package/dist/core/engine/generation-codec.js +2 -0
  14. package/dist/core/engine/index.js +1 -1
  15. package/dist/core/engine/lifecycle/recovery-isolation.d.ts +20 -0
  16. package/dist/core/engine/lifecycle/recovery-isolation.js +26 -0
  17. package/dist/core/engine/lifecycle/resume-body.d.ts +44 -0
  18. package/dist/core/engine/lifecycle/resume-body.js +211 -0
  19. package/dist/core/engine/lifecycle/resume.d.ts +46 -3
  20. package/dist/core/engine/lifecycle/resume.js +21 -210
  21. package/dist/core/engine/lifecycle/standalone-claim-acquire.d.ts +68 -2
  22. package/dist/core/engine/lifecycle/standalone-claim-acquire.js +14 -4
  23. package/dist/core/engine/lifecycle/start-commit.d.ts +2 -0
  24. package/dist/core/engine/lifecycle/start-commit.js +16 -8
  25. package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +8 -2
  26. package/dist/core/engine/lifecycle/start-or-signal-resolution.js +11 -3
  27. package/dist/core/engine/lifecycle/start-precondition-attribution.d.ts +10 -4
  28. package/dist/core/engine/lifecycle/start-schedule-timing.d.ts +11 -0
  29. package/dist/core/engine/lifecycle/start-schedule-timing.js +21 -0
  30. package/dist/core/engine/lifecycle/start-terminal-conflict-purge.d.ts +90 -11
  31. package/dist/core/engine/lifecycle/start-terminal-conflict-purge.js +24 -5
  32. package/dist/core/engine/lifecycle/start.d.ts +40 -3
  33. package/dist/core/engine/lifecycle/start.js +19 -26
  34. package/dist/core/engine/lifecycle/transition.d.ts +9 -0
  35. package/dist/core/engine/lifecycle/transition.js +20 -25
  36. package/dist/core/engine/lifecycle.d.ts +2 -1
  37. package/dist/core/engine/lifecycle.js +2 -1
  38. package/dist/core/engine/schedule-overlap.js +2 -1
  39. package/dist/core/engine/schedule-run.d.ts +10 -0
  40. package/dist/core/engine/schedule-run.js +1 -1
  41. package/dist/core/engine/schedules.d.ts +11 -2
  42. package/dist/core/engine/termination/complete.js +1 -1
  43. package/dist/core/engine/termination/suspend.js +3 -0
  44. package/dist/core/engine/validation/schedule.d.ts +25 -1
  45. package/dist/core/engine/validation/schedule.js +4 -10
  46. package/dist/core/engine/validation.js +10 -17
  47. package/dist/core/engine/workflow-claim-reclaim-target.js +6 -6
  48. package/dist/core/engine/workflow-claim-registry.d.ts +79 -75
  49. package/dist/core/engine/workflow-claim-registry.js +15 -2
  50. package/dist/core/engine/workflow-generation-fence.d.ts +74 -0
  51. package/dist/core/engine/workflow-generation-fence.js +19 -0
  52. package/dist/core/start-workflow-validation.d.ts +15 -0
  53. package/dist/core/start-workflow-validation.js +11 -1
  54. package/dist/core/workflow-identifiers.d.ts +37 -0
  55. package/dist/core/workflow-identifiers.js +19 -1
  56. package/dist/http.js +2 -2
  57. package/dist/indexeddb.js +1 -1
  58. package/dist/server/fleet-event-feed.js +6 -6
  59. package/dist/server/rest-binding.d.ts +11 -0
  60. package/dist/server/runtime/task-dispatch-envelope.d.ts +32 -5
  61. package/dist/server/runtime/task-dispatch-envelope.js +23 -1
  62. package/dist/server/runtime/task-dispatch.d.ts +3 -1
  63. package/dist/server/runtime/task-dispatch.js +9 -8
  64. package/dist/server/task-ledger-codec.d.ts +19 -0
  65. package/dist/server/task-ledger-codec.js +3 -0
  66. package/dist/server/task-ledger.d.ts +1 -1
  67. package/dist/server/task-ledger.js +1 -0
  68. package/dist/storage/bun-sql.js +132 -99
  69. package/dist/storage/compressed-storage.js +1 -1
  70. package/dist/storage/generation-keys.d.ts +52 -0
  71. package/dist/storage/generation-keys.js +4 -0
  72. package/dist/storage/index.d.ts +76 -65
  73. package/dist/storage/interface.d.ts +24 -232
  74. package/dist/storage/interface.js +1 -1
  75. package/dist/storage/key-prefixes.d.ts +1 -1
  76. package/dist/storage/key-prefixes.js +1 -0
  77. package/dist/storage/lease-keys.d.ts +48 -0
  78. package/dist/storage/lease-keys.js +8 -0
  79. package/dist/storage/lmdb.js +1 -1
  80. package/dist/storage/memory.js +1 -1
  81. package/dist/storage/neon.js +3 -3
  82. package/dist/storage/node-sqlite.js +132 -99
  83. package/dist/storage/postgres.js +3 -3
  84. package/dist/storage/resolve.js +1 -1
  85. package/dist/storage/scoped-storage.js +1 -1
  86. package/dist/storage/signal-keys.d.ts +23 -0
  87. package/dist/storage/signal-keys.js +9 -0
  88. package/dist/storage/testing.js +1 -1
  89. package/dist/storage/turso.js +2 -2
  90. package/dist/storage/workflow-lifecycle-keys.d.ts +128 -0
  91. package/dist/storage/workflow-lifecycle-keys.js +46 -0
  92. package/dist/storage/workflow-record-keys.d.ts +98 -0
  93. package/dist/storage/workflow-record-keys.js +47 -0
  94. package/dist/version.d.ts +1 -1
  95. package/dist/version.js +1 -1
  96. package/dist/web-extension.js +1 -1
  97. package/package.json +1 -1
@@ -1,4 +1,47 @@
1
- import { type WorkflowHandle } from '../handles.ts';
1
+ /**
2
+ * `engine.resume(id)` / `recoverAll()`'s standalone-replay-from-storage entry
3
+ * point. The actual work — load tracking/checkpoint/registration state,
4
+ * prepare the resume, and relaunch inline or in a worker — lives in
5
+ * `resume-body.ts`'s `performResumeAfterClaimAcquired` (split out to stay
6
+ * under this repository's 500-line implementation-file ceiling once the
7
+ * WFT-134 claim-release wrapping below was added). This file's own job is
8
+ * just: load and validate state, acquire a claim if this run doesn't already
9
+ * hold one, and release a claim it freshly acquired if the resume turns out
10
+ * not to succeed after all — UNLESS `options.deferClaimReleaseOnRejection`
11
+ * asks this call to leave that claim installed for its caller instead (see
12
+ * {@link ResumeFromStorageOptions}).
13
+ *
14
+ * @module core/engine/lifecycle/resume
15
+ */
16
+ import type { WorkflowHandle } from '../handles.ts';
2
17
  import type { EngineInternals } from '../internals.ts';
3
- import { type LifecycleCallbacks, type RecoverAllOptions } from './shared.ts';
4
- export declare function resumeWorkflowFromStorage(internals: EngineInternals, workflowId: string, dispatchResumedEvent: boolean, callbacks: LifecycleCallbacks, onRecoveredWorkflow?: RecoverAllOptions['onRecoveredWorkflow']): Promise<WorkflowHandle>;
18
+ import type { LifecycleCallbacks, RecoverAllOptions } from './shared.ts';
19
+ /**
20
+ * An out-parameter this call populates with the epoch of a claim it freshly
21
+ * acquired, so a caller using `deferClaimReleaseOnRejection` can release that
22
+ * EXACT generation itself once it is done using it (WFT-134 review round 2,
23
+ * issue D). Left at `{ epoch: null }` when no fresh acquisition happened —
24
+ * folding disabled, no registry, the cached claim matched, or acquisition
25
+ * itself threw `WorkflowClaimUnavailableError` before returning.
26
+ */
27
+ export type FreshResumeClaimTracker = {
28
+ epoch: number | null;
29
+ };
30
+ /** Options for {@link resumeWorkflowFromStorage}. */
31
+ export type ResumeFromStorageOptions = {
32
+ /**
33
+ * `true` for `recoverAll()`'s per-entry recovery loop (WFT-134 issue D):
34
+ * its own `recoverEntryOrIsolateFailure` isolates several errors into a
35
+ * fenced `failWorkflowForXxx()` commit that needs a freshly-acquired claim
36
+ * to still be installed, so this call must NOT release on rejection —
37
+ * instead it records the acquired epoch in `freshClaimTracker` and lets the
38
+ * caller release once its own isolated-failure handling is done. Default
39
+ * (`false`/omitted) is correct for an explicit `engine.resume()` caller,
40
+ * which has nothing further to do with the claim and must release
41
+ * immediately on any rejection.
42
+ */
43
+ deferClaimReleaseOnRejection?: boolean;
44
+ /** Populated with the freshly-acquired epoch, if any — see {@link FreshResumeClaimTracker}. */
45
+ freshClaimTracker?: FreshResumeClaimTracker;
46
+ };
47
+ export declare function resumeWorkflowFromStorage(internals: EngineInternals, workflowId: string, dispatchResumedEvent: boolean, callbacks: LifecycleCallbacks, onRecoveredWorkflow?: RecoverAllOptions['onRecoveredWorkflow'], options?: ResumeFromStorageOptions): Promise<WorkflowHandle>;
@@ -1,220 +1,31 @@
1
1
  import { KEYS } from "../../../storage/interface.js";
2
- import { deserializeCheckpoint, serializeCheckpoint } from "../../checkpoint.js";
3
- import { encode } from "../../codec.js";
4
- import { Context, setContextWorkflowInterceptor } from "../../context.js";
5
- import { EventLog } from "../../event-log.js";
6
- import { WorkflowResumedEvent } from "../../events.js";
7
- import { buildTimerBatchOperations } from "../../scheduler.js";
8
- import { createCancelHandlerRegistration, resetCancelHandlers } from "../cancel-handlers.js";
9
- import { rememberCommittedCheckpointBytes } from "../checkpoint-commit-snapshots.js";
10
- import { rehydrateChildCancellationHandlers } from "../child-workflow-cancellation.js";
11
- import { resolveExecutableRegistrationOrRenamedNotFound } from "../dynamic-source-execution.js";
12
- import { commitFencedEngineWrite } from "../fenced-write.js";
13
- import { getWorkflowExecutionStartedAt } from "../handles.js";
14
- import { loadWorkflowState } from "../storage-io.js";
15
- import { getComposedWorkflowInterceptor } from "../strategy-helpers.js";
16
2
  import { decodeWorkflowState } from "../validation.js";
17
- import { buildWorkflowVisibilityIndexTransition } from "../workflow-indexes.js";
18
- import { prepareResumeState } from "./persist.js";
3
+ import { performResumeAfterClaimAcquired } from "./resume-body.js";
19
4
  import {
20
- reprovideRecoveredServices,
21
- workflowServicesResolverInfoFromState
22
- } from "./recovered-services.js";
23
- import { assertSameGeneration, deriveResumeGeneration } from "./resume-generation-guard.js";
24
- import {
25
- enforceHistoryPolicyBeforeReplay,
26
- loadTerminalCleanupTrackedState,
27
- loadWorkflowStartHeaders,
28
- setWorkflowStartHeaders
29
- } from "./shared.js";
30
- import { acquireStandaloneClaimBeforeResume } from "./standalone-claim-acquire.js";
31
- async function prepareRecoveredServicesOrFail(internals, state, callbacks, resolverInfo) {
32
- return reprovideRecoveredServices(internals, state, callbacks.failWorkflowForUnavailableServices, callbacks.handleCleanupError, callbacks.dispatchEvent, resolverInfo);
33
- }
34
- async function runRecoveredWorkflowHookOrFail(internals, state, handle, resolverInfo, onRecoveredWorkflow, callbacks) {
35
- const info = {
36
- ...resolverInfo,
37
- handle,
38
- launchOptions: resolverInfo.launchOptions ?? { id: state.id },
39
- services: internals.workflowServices.get(state.id)
40
- };
41
- try {
42
- await onRecoveredWorkflow(info);
43
- return !1;
44
- } catch (error) {
45
- const reason = error instanceof Error ? error.message : String(error), hookError = Error(`Recovery hook failed for workflow "${state.id}": ${reason}`);
46
- try {
47
- await callbacks.failWorkflowForRecoveryHook(state.id, hookError);
48
- } catch (commitError) {
49
- callbacks.handleCleanupError("onRecoveredWorkflow", commitError, state.id);
50
- }
51
- return !0;
52
- }
53
- }
54
- async function prepareRecoveredWorkflowOrFail(internals, state, callbacks, onRecoveredWorkflow) {
55
- const resolverInfo = onRecoveredWorkflow === void 0 ? void 0 : await workflowServicesResolverInfoFromState(internals, state), handle = callbacks.getHandle(state.id);
56
- if (await prepareRecoveredServicesOrFail(internals, state, callbacks, resolverInfo))
57
- return { handle, shouldStop: !0 };
58
- if (onRecoveredWorkflow === void 0 || resolverInfo === void 0)
59
- return { handle, shouldStop: !1 };
60
- const shouldStop = await runRecoveredWorkflowHookOrFail(internals, state, handle, resolverInfo, onRecoveredWorkflow, callbacks);
61
- return { handle, shouldStop };
62
- }
63
- function assertResumeNotTerminating(internals, workflowId) {
64
- if (internals.terminalizingWorkflows.has(workflowId))
65
- throw Error(`Cannot resume workflow "${workflowId}": termination is in progress`);
66
- }
67
- function commitSerializedResumeState(internals, args) {
68
- const {
69
- workflowId,
70
- resumeCheckpoint,
71
- serializedCheckpoint,
72
- registeredVersionTuple,
73
- restoredHead,
74
- callbacks
75
- } = args;
76
- internals.checkpoints.set(workflowId, resumeCheckpoint);
77
- rememberCommittedCheckpointBytes(internals, workflowId, serializedCheckpoint);
78
- internals.workflowVersionTuples.set(workflowId, registeredVersionTuple);
79
- internals.eventLogHeads.set(workflowId, restoredHead);
80
- setWorkflowStartHeaders(internals, workflowId, args.workflowStartHeaders, callbacks);
81
- internals.parkedInlineWorkflows.delete(workflowId);
82
- }
83
- async function relaunchInlineWorkflowAfterResume(internals, latestState, args) {
84
- const { workflowId, resumeCheckpoint, registration, resolvedRevision, callbacks } = args, accumulatedResults = new Map(resumeCheckpoint.accumulatedResults), workflowAbort = new AbortController;
85
- internals.workflowTypeByWorkflowId.set(workflowId, {
86
- type: latestState.type,
87
- revision: resolvedRevision
88
- });
89
- resetCancelHandlers(internals, workflowId);
90
- await rehydrateChildCancellationHandlers(internals, workflowId, callbacks);
91
- const context = new Context({
92
- workflowId,
93
- ...latestState.workflowExecutionToken !== void 0 && {
94
- workflowExecutionToken: latestState.workflowExecutionToken
95
- },
96
- workflowType: latestState.type,
97
- startedAt: getWorkflowExecutionStartedAt(latestState),
98
- abortController: workflowAbort,
99
- getNow: internals.options.getNow,
100
- resolveWorkflowType: callbacks.resolveWorkflowTypeTarget,
101
- executionStateOwnerId: latestState.executionStateOwnerId ?? workflowId,
102
- accumulatedResults,
103
- locals: resumeCheckpoint.locals,
104
- searchAttributes: resumeCheckpoint.searchAttributes,
105
- registerCancelHandler: createCancelHandlerRegistration(internals, workflowId),
106
- ...registration.searchAttributes && {
107
- searchAttributeSchema: registration.searchAttributes
108
- },
109
- sleepReferenceTime: resumeCheckpoint.createdAt,
110
- ...latestState.executionDeadline !== void 0 && {
111
- deadline: latestState.executionDeadline
112
- },
113
- services: internals.workflowServices.get(workflowId),
114
- ...internals.options.onLog != null && { logSink: internals.options.onLog }
115
- });
116
- setContextWorkflowInterceptor(context, getComposedWorkflowInterceptor(internals));
117
- if (internals.options.development)
118
- context.explain(!0);
119
- const generator = registration.handler(context, latestState.input), inlineStrategy = internals.inlineStrategy;
120
- inlineStrategy.adoptWorkflow(workflowId, generator, context, workflowAbort);
121
- inlineStrategy.continueWorkflow(workflowId, void 0);
122
- }
123
- async function relaunchWorkerWorkflowAfterResume(internals, latestState, args) {
124
- const { workflowId, resumeCheckpoint, workflowStartHeaders, resolvedRevision, callbacks } = args;
125
- internals.workflowTypeByWorkflowId.set(workflowId, {
126
- type: latestState.type,
127
- revision: resolvedRevision
128
- });
129
- resetCancelHandlers(internals, workflowId);
130
- await rehydrateChildCancellationHandlers(internals, workflowId, callbacks);
131
- const serialized = serializeCheckpoint(resumeCheckpoint);
132
- internals.strategy.startWorkflow({
133
- workflowId,
134
- ...latestState.workflowExecutionToken !== void 0 && {
135
- workflowExecutionToken: latestState.workflowExecutionToken
136
- },
137
- workflowType: latestState.type,
138
- input: latestState.input,
139
- checkpoint: serialized,
140
- nestingDepth: internals.workflowNestingDepths.get(workflowId) ?? 0,
141
- executionStateOwnerId: latestState.executionStateOwnerId ?? workflowId,
142
- ...latestState.executionDeadline !== void 0 && {
143
- deadline: latestState.executionDeadline
144
- },
145
- ...workflowStartHeaders !== void 0 && workflowStartHeaders.size > 0 && {
146
- headers: [...workflowStartHeaders]
147
- }
148
- });
149
- }
150
- async function performSerializedResume(internals, args) {
151
- const { workflowId } = args;
152
- assertResumeNotTerminating(internals, workflowId);
153
- const latestState = await loadWorkflowState(internals, workflowId);
154
- assertResumeNotTerminating(internals, workflowId);
155
- if (!latestState)
156
- throw Error(`Workflow "${workflowId}" not found in storage`);
157
- if (latestState.status !== "running" && latestState.status !== "suspended")
158
- throw Error(`Cannot resume workflow "${workflowId}": status is "${latestState.status}", expected "running" or "suspended"`);
159
- assertSameGeneration(workflowId, latestState, args.expectedGeneration);
160
- await reactivateSuspendedWorkflowState(internals, latestState);
161
- commitSerializedResumeState(internals, args);
162
- if (internals.inlineStrategy) {
163
- await relaunchInlineWorkflowAfterResume(internals, latestState, args);
164
- return;
165
- }
166
- await relaunchWorkerWorkflowAfterResume(internals, latestState, args);
167
- }
168
- async function reactivateSuspendedWorkflowState(internals, state) {
169
- if (state.status !== "suspended")
170
- return;
171
- const previousState = { ...state };
172
- state.status = "running";
173
- state.updatedAt = internals.options.getNow();
174
- await commitFencedEngineWrite(internals, state.id, [
175
- { type: "put", key: KEYS.workflow(state.id), value: encode(state) },
176
- ...buildWorkflowVisibilityIndexTransition(state.id, previousState, state).batchOps,
177
- ...state.executionDeadline !== void 0 ? buildTimerBatchOperations({
178
- id: `deadline:${state.id}`,
179
- workflowId: state.id,
180
- fireAt: state.executionDeadline,
181
- kind: "execution-deadline"
182
- }) : []
183
- ], [], () => Error(`Resume of workflow "${state.id}" lost its CAS race.`));
184
- }
185
- export async function resumeWorkflowFromStorage(internals, workflowId, dispatchResumedEvent, callbacks, onRecoveredWorkflow) {
5
+ acquireStandaloneClaimBeforeResume,
6
+ releaseFreshlyAcquiredResumeClaim
7
+ } from "./standalone-claim-acquire.js";
8
+ function recordFreshClaimEpoch(acquireResult, tracker) {
9
+ if (acquireResult.freshlyAcquired && tracker !== void 0)
10
+ tracker.epoch = acquireResult.epoch;
11
+ }
12
+ async function releaseFreshClaimOnRejectionUnlessDeferred(internals, workflowId, acquireResult, deferRelease) {
13
+ if (acquireResult.freshlyAcquired && deferRelease !== !0)
14
+ await releaseFreshlyAcquiredResumeClaim(internals, workflowId, acquireResult.epoch);
15
+ }
16
+ export async function resumeWorkflowFromStorage(internals, workflowId, dispatchResumedEvent, callbacks, onRecoveredWorkflow, options) {
186
17
  const stateBytes = await internals.storage.get(KEYS.workflow(workflowId));
187
18
  if (!stateBytes)
188
19
  throw Error(`Workflow "${workflowId}" not found in storage`);
189
20
  const state = decodeWorkflowState(stateBytes);
190
21
  if (state.status !== "running" && state.status !== "suspended")
191
22
  throw Error(`Cannot resume workflow "${workflowId}": status is "${state.status}", expected "running" or "suspended"`);
192
- await acquireStandaloneClaimBeforeResume(internals, workflowId);
193
- await loadTerminalCleanupTrackedState(internals, workflowId, callbacks);
194
- const checkpointBytes = await internals.storage.get(KEYS.checkpoint(workflowId));
195
- if (!checkpointBytes)
196
- throw Error(`Checkpoint not found for workflow "${workflowId}"`);
197
- const checkpoint = deserializeCheckpoint(checkpointBytes), { entry: registration, revision: resolvedRevision } = await resolveExecutableRegistrationOrRenamedNotFound((type) => callbacks.resolveExecutableRegistrationForRevision(type, state.revision), state.type, () => Error(`No workflow registered with name "${state.type}" (needed to resume "${workflowId}")`)), preparedResumeState = await prepareResumeState(internals, workflowId, state, checkpoint, checkpointBytes, registration, callbacks), resumeCheckpoint = preparedResumeState.checkpoint, registeredVersionTuple = preparedResumeState.versionTuple, restoredHead = await new EventLog(internals.storage, workflowId).loadHead();
198
- if (await enforceHistoryPolicyBeforeReplay(internals, workflowId, restoredHead, callbacks))
199
- return callbacks.getHandle(workflowId);
200
- const workflowStartHeaders = await loadWorkflowStartHeaders(internals, workflowId, callbacks), { handle, shouldStop } = await prepareRecoveredWorkflowOrFail(internals, state, callbacks, onRecoveredWorkflow);
201
- if (shouldStop)
202
- return handle;
203
- await callbacks.runSerializedWorkflowStateWrite(workflowId, () => performSerializedResume(internals, {
204
- workflowId,
205
- resumeCheckpoint,
206
- serializedCheckpoint: preparedResumeState.serializedCheckpoint,
207
- registeredVersionTuple,
208
- restoredHead,
209
- workflowStartHeaders,
210
- registration,
211
- resolvedRevision,
212
- expectedGeneration: deriveResumeGeneration(state),
213
- callbacks
214
- }));
215
- if (dispatchResumedEvent)
216
- callbacks.dispatchEvent(new WorkflowResumedEvent(workflowId, resumeCheckpoint.step));
217
- if (internals.inlineStrategy)
218
- callbacks.swallowPromiseRejection(callbacks.processPendingUpdatesAfterInlineAdvance(workflowId));
219
- return handle;
23
+ const acquireResult = await acquireStandaloneClaimBeforeResume(internals, workflowId);
24
+ recordFreshClaimEpoch(acquireResult, options?.freshClaimTracker);
25
+ try {
26
+ return await performResumeAfterClaimAcquired(internals, workflowId, state, dispatchResumedEvent, callbacks, onRecoveredWorkflow);
27
+ } catch (error) {
28
+ await releaseFreshClaimOnRejectionUnlessDeferred(internals, workflowId, acquireResult, options?.deferClaimReleaseOnRejection);
29
+ throw error;
30
+ }
220
31
  }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Deliberately covers `running` and `suspended` the same way. The ADR
12
12
  * describes `suspended` as folding `acquire` into the status flip itself
13
- * (`reactivateSuspendedWorkflowState`'s commit in `resume.ts`), but the
13
+ * (`reactivateSuspendedWorkflowState`'s commit in `resume-body.ts`), but the
14
14
  * EARLIER fenced writes above need a held claim regardless of final status,
15
15
  * so one early standalone acquire is the pragmatic shape for both — a
16
16
  * documented deviation from that row, not an oversight.
@@ -27,10 +27,76 @@
27
27
  * re-check (it can win the CAS against a still-valid claim), so only the
28
28
  * durable holder is re-read (no write) and compared.
29
29
  *
30
+ * **`'holder-absent'` falls through to a fresh `acquire()` instead of hard-
31
+ * failing (WFT-134).** `suspendWorkflow`'s external terminal rotation
32
+ * durably deletes `wf-owner-holder:<id>` as part of its suspend commit, which
33
+ * can outpace this engine's own local cache correction
34
+ * (`WorkflowClaimRegistry.forgetLocalClaim`). A same-engine `resume()`
35
+ * landing in that gap must not be treated as a real conflict — it is
36
+ * exactly the "no other holder to lose to" case a fresh `acquire()` handles
37
+ * safely. `'holder-undecodable'` and `'generation-mismatch'` remain hard
38
+ * failures: both mean a holder record IS present and either corrupt or
39
+ * naming a different generation, a real conflict this function must not
40
+ * paper over.
41
+ *
30
42
  * @module core/engine/lifecycle/standalone-claim-acquire
31
43
  */
32
44
  import type { EngineInternals } from '../internals.ts';
33
- export declare function acquireStandaloneClaimBeforeResume(internals: EngineInternals, workflowId: string): Promise<void>;
45
+ /**
46
+ * Result of {@link acquireStandaloneClaimBeforeResume}. `freshlyAcquired:
47
+ * true` means this call itself performed a fresh `registry.acquire()`
48
+ * (whether because no claim was cached yet, or because the `'holder-absent'`
49
+ * fallback below ran), and carries the exact `epoch` that acquire minted —
50
+ * `freshlyAcquired: false` means no acquisition happened at all (folding
51
+ * disabled, no registry, or the cached claim matched). Callers use this to
52
+ * know whether, and under which exact generation, THEY are now responsible
53
+ * for releasing a claim this call just installed if the resume they were
54
+ * preparing turns out not to be resumable after all — see `resume.ts`'s
55
+ * `resumeWorkflowFromStorage` (WFT-134).
56
+ */
57
+ export type StandaloneClaimAcquireResult = {
58
+ freshlyAcquired: true;
59
+ epoch: number;
60
+ } | {
61
+ freshlyAcquired: false;
62
+ };
63
+ export declare function acquireStandaloneClaimBeforeResume(internals: EngineInternals, workflowId: string): Promise<StandaloneClaimAcquireResult>;
64
+ /**
65
+ * Best-effort durable release of a claim {@link acquireStandaloneClaimBeforeResume}
66
+ * freshly acquired for THIS resume attempt, called when a later, status-aware
67
+ * check (`performSerializedResume`'s status/generation re-validation in
68
+ * `resume-body.ts`) rejects the resume after all (WFT-134). Without this, a claim
69
+ * acquired for a workflow that turned out to be non-resumable — already
70
+ * terminal, or replaced by an `onTerminalConflict: 'start-new'` run — stays
71
+ * installed: the renewal task keeps refreshing it indefinitely, and a
72
+ * legitimate `start-new` (or the replacement run's own fold-acquire) loses
73
+ * its CAS against a claim nothing is actually driving.
74
+ *
75
+ * `acquiredEpoch` MUST be the exact epoch {@link acquireStandaloneClaimBeforeResume}
76
+ * returned to this same caller — passed straight through to
77
+ * `WorkflowClaimRegistry.release()`'s own `expectedEpoch` guard (WFT-134
78
+ * review round 2). Between this resume's acquire and its rejection, a
79
+ * DIFFERENT `start-new` or resume can legitimately replace the registry's
80
+ * entry for `workflowId`; releasing "whatever is current" instead of this
81
+ * exact generation would drop that replacement's live claim and strand it
82
+ * without local epoch bytes. `release()` no-ops (`'not-held'`) when the
83
+ * registry has moved past `acquiredEpoch`, so this call is always safe to
84
+ * make regardless of what has happened to the entry since.
85
+ *
86
+ * A full durable `release()`, not a local-only `forgetLocalClaim()`: the
87
+ * acquire this undoes durably wrote BOTH `wf-owner-epoch:<id>` and
88
+ * `wf-owner-holder:<id>`, and it is specifically the durable holder record
89
+ * that would otherwise block a legitimate replacement's own acquire — merely
90
+ * clearing this engine's local cache would leave that durable block in place.
91
+ * Swallows a lost-race/thrown release the same way every other best-effort
92
+ * claim release in this codebase does (see `complete.ts`'s
93
+ * `releaseWorkflowClaimAfterTerminalSettlement`): a failed release here just
94
+ * leaves the claim for TTL/grace expiry, never worse than not attempting it.
95
+ * `release()` itself already forgets its LOCAL entry on a thrown durable
96
+ * error (identity/epoch-guarded), so this call needs no matching cleanup of
97
+ * its own to stop the renewal task from renewing a claim nothing drives.
98
+ */
99
+ export declare function releaseFreshlyAcquiredResumeClaim(internals: EngineInternals, workflowId: string, acquiredEpoch: number): Promise<void>;
34
100
  /**
35
101
  * Standalone-acquire `workflowId`'s claim (see {@link acquireStandaloneClaimBeforeResume},
36
102
  * a no-op once already held) and hydrate its terminal-cleanup tracking from
@@ -3,10 +3,10 @@ import { WorkflowClaimUnavailableError } from "../lease-errors.js";
3
3
  import { wakeOwnershipCheck } from "../wake-ownership-check.js";
4
4
  export async function acquireStandaloneClaimBeforeResume(internals, workflowId) {
5
5
  if (internals.options.ownershipMode !== "workflow-lease")
6
- return;
6
+ return { freshlyAcquired: !1 };
7
7
  const registry = internals.workflowClaimRegistry;
8
8
  if (registry === null)
9
- return;
9
+ return { freshlyAcquired: !1 };
10
10
  const cachedEpoch = registry.currentEpoch(workflowId);
11
11
  if (cachedEpoch !== null) {
12
12
  const check = await wakeOwnershipCheck({
@@ -16,13 +16,23 @@ export async function acquireStandaloneClaimBeforeResume(internals, workflowId)
16
16
  expectedEngineId: registry.engineId,
17
17
  expectedEpoch: cachedEpoch
18
18
  });
19
- if (check.status === "discarded")
19
+ if (check.status === "match")
20
+ return { freshlyAcquired: !1 };
21
+ if (check.reason !== "holder-absent")
20
22
  throw new WorkflowClaimUnavailableError(workflowId, check.observedEngineId);
21
- return;
22
23
  }
23
24
  const result = await registry.acquire(workflowId);
24
25
  if (result.status === "lost-race")
25
26
  throw new WorkflowClaimUnavailableError(workflowId, result.heldBy);
27
+ return { freshlyAcquired: !0, epoch: result.epoch };
28
+ }
29
+ export async function releaseFreshlyAcquiredResumeClaim(internals, workflowId, acquiredEpoch) {
30
+ const registry = internals.workflowClaimRegistry;
31
+ if (registry === null)
32
+ return;
33
+ try {
34
+ await registry.release(workflowId, acquiredEpoch);
35
+ } catch {}
26
36
  }
27
37
  export async function ensureDelayedStartClaimAndCleanupBeforeFailure(internals, workflowId) {
28
38
  await acquireStandaloneClaimBeforeResume(internals, workflowId);
@@ -59,6 +59,8 @@ export type StartBatchContext = {
59
59
  * path.
60
60
  */
61
61
  duplicateIdCondition: ConditionalBatchCondition | undefined;
62
+ /** ADDITIONAL WFT-153 precondition on observed `wf-gen:<id>` bytes; see `StartDuplicateIdDecision.duplicateIdGenerationCondition` in `start-terminal-conflict-purge.ts`. Undefined exactly when `duplicateIdCondition` is. */
63
+ duplicateIdGenerationCondition: ConditionalBatchCondition | undefined;
62
64
  };
63
65
  /**
64
66
  * Assemble the start batch — folding in the id-dependent idempotency mapping and
@@ -49,9 +49,9 @@ async function persistStartBatch(internals, workflowId, startOperations, conditi
49
49
  requireStorageCapability(internals.storage, "conditionalBatch", "start preconditions");
50
50
  return await commitFencedEngineWriteAllowingPreconditionFailure(internals, fenceWorkflowId, startOperations, conditions.map((entry) => entry.condition)) ? "committed" : "precondition-lost";
51
51
  }
52
- async function hasStartConditionConflict(internals, conditions, source) {
52
+ async function hasStartConditionConflict(internals, conditions, sources) {
53
53
  for (const entry of conditions) {
54
- if (entry.source !== source)
54
+ if (!sources.includes(entry.source))
55
55
  continue;
56
56
  const currentValue = await internals.storage.get(entry.condition.key);
57
57
  if (!storageValuesEqual(currentValue, entry.condition.expectedValue))
@@ -75,8 +75,13 @@ function tagStartPreconditions(conditions) {
75
75
  condition
76
76
  }));
77
77
  }
78
- function tagDuplicateIdCondition(condition) {
79
- return condition === void 0 ? [] : [{ source: "duplicate-id", condition }];
78
+ function tagDuplicateIdConditions(condition, generationCondition) {
79
+ const tagged = [];
80
+ if (condition !== void 0)
81
+ tagged.push({ source: "duplicate-id", condition });
82
+ if (generationCondition !== void 0)
83
+ tagged.push({ source: "duplicate-id-generation", condition: generationCondition });
84
+ return tagged;
80
85
  }
81
86
  function tagWorkflowConcurrencyConditions(conditions) {
82
87
  return conditions.map((condition) => ({
@@ -97,21 +102,24 @@ export async function buildAndCommitStartBatch(context, buildIdempotentStartOper
97
102
  lastWorkflowConcurrencyStateKey = workflowConcurrency?.stateKey ?? lastWorkflowConcurrencyStateKey;
98
103
  const catalogEntryPrecondition = needsCatalogEntryStartPrecondition(internals, state) ? await buildCatalogEntryStartPrecondition(internals, state) : void 0, startOperations = buildStartBatchOperations(internals, workflowId, state, checkpoint, registration, options, state.executionDeadline, context.delayedStartTimer, context.persistedWorkflowStartHeaders, mergeAdditionalStartOperations(context.additionalStartOperations, mergeAdditionalStartOperations(idempotent?.operations, workflowConcurrency?.operations)), context.callbacks, context.purgeDeleteOperations), conditions = [
99
104
  ...tagStartPreconditions(idempotent?.conditions),
100
- ...tagDuplicateIdCondition(context.duplicateIdCondition),
105
+ ...tagDuplicateIdConditions(context.duplicateIdCondition, context.duplicateIdGenerationCondition),
101
106
  ...tagWorkflowConcurrencyConditions(workflowConcurrency?.conditions ?? []),
102
107
  ...catalogEntryPrecondition === void 0 ? [] : [catalogEntryPrecondition]
103
108
  ], isDelayedStart = context.delayedStartTimer !== void 0, claimFold = isDelayedStart ? void 0 : await prepareWorkflowClaimFold(internals, workflowId), outcome = await persistStartBatch(internals, workflowId, startOperations, conditions, claimFold, isDelayedStart);
104
109
  if (outcome === "committed")
105
110
  return;
106
- if (outcome !== "claim-lost" && await hasStartConditionConflict(internals, conditions, "duplicate-id"))
111
+ if (outcome !== "claim-lost" && await hasStartConditionConflict(internals, conditions, [
112
+ "duplicate-id",
113
+ "duplicate-id-generation"
114
+ ]))
107
115
  throw new WorkflowAlreadyExistsError(workflowId);
108
- if (await hasStartConditionConflict(internals, conditions, "start-precondition"))
116
+ if (await hasStartConditionConflict(internals, conditions, ["start-precondition"]))
109
117
  throw new StartIdempotencyRaceLostError;
110
118
  if (await hasCatalogEntryConflict(internals, conditions))
111
119
  throw new WorkflowRevisionUnavailableError(state.type, state.revision, "not-installed");
112
120
  if (outcome === "claim-lost")
113
121
  return throwWorkflowClaimUnavailable(internals, workflowId);
114
- await attributeLostStartPreconditionOrRetry(workflowId, context.duplicateIdCondition !== void 0, workflowConcurrency !== void 0, () => hasStartConditionConflict(internals, conditions, "workflow-concurrency"));
122
+ await attributeLostStartPreconditionOrRetry(workflowId, context.duplicateIdCondition !== void 0, workflowConcurrency !== void 0, () => hasStartConditionConflict(internals, conditions, ["workflow-concurrency"]));
115
123
  }
116
124
  throw new AtomicStateConflictError(lastWorkflowConcurrencyStateKey ?? "workflow concurrency admission", WORKFLOW_CONCURRENCY_ADMISSION_MAX_ATTEMPTS);
117
125
  }
@@ -41,11 +41,17 @@ export declare function resolveExistingRunOrThrowPurged(internals: EngineInterna
41
41
  * resolves it instead of racing it to a terminal-conflict). Only when the record is
42
42
  * absent do we wait for the reservation to clear and read once more to discriminate:
43
43
  *
44
- * - **record present** — the winner committed: signal it (or conflict if terminal)
45
- * and return the handle.
44
+ * - **record present** — the winner committed: signal it (or conflict if terminal).
46
45
  * - **record absent after the reservation clears** — the winner aborted before
47
46
  * committing (storage failure, oversized payload, throwing start interceptor): no
48
47
  * run exists, so return `undefined` and let the caller retry its own create.
48
+ *
49
+ * Either terminal-conflict branch is checked against {@link wasSignalAcceptedByWinner}
50
+ * before throwing: a fast workflow can reach a terminal status between this
51
+ * loser's reads, entirely independent of how much slower or faster the winner's
52
+ * OWN commit path happens to be — so a same-`signalId` convergent caller (the
53
+ * documented "concurrent absent-target callers" contract) must not depend on
54
+ * catching the winner mid-flight to avoid a spurious conflict.
49
55
  */
50
56
  export declare function resolveCallerIdWinnerOrRetry(internals: EngineInternals, winnerId: string, signalSpec: StartOrSignalSignal, signalId: string, callbacks: StartOrSignalCallbacks, allowTerminalRestart?: boolean): Promise<WorkflowHandle | undefined>;
51
57
  /**
@@ -1,5 +1,5 @@
1
1
  import { sleep } from "../../../runtime/portable.js";
2
- import { KEYS } from "../../../storage/interface.js";
2
+ import { KEYS, storageHas } from "../../../storage/interface.js";
3
3
  import { decode } from "../../codec.js";
4
4
  import { IdempotencyKeyPurgedError, StartOrSignalConflictError } from "../errors.js";
5
5
  import { loadWorkflowState } from "../storage-io.js";
@@ -23,6 +23,14 @@ async function awaitReservationCleared(internals, workflowId) {
23
23
  await sleep(RESERVATION_CLEAR_RETRY_DELAY_MS);
24
24
  }
25
25
  }
26
+ async function wasSignalAcceptedByWinner(internals, workflowId, signalName, signalId) {
27
+ return storageHas(internals.storage, KEYS.signalAcceptedResponse(workflowId, signalName, signalId));
28
+ }
29
+ async function resolveTerminalConflictOrConvergence(internals, winnerId, status, signalSpec, signalId, callbacks) {
30
+ if (await wasSignalAcceptedByWinner(internals, winnerId, signalSpec.name, signalId))
31
+ return callbacks.getHandle(winnerId);
32
+ throw new StartOrSignalConflictError(winnerId, status);
33
+ }
26
34
  export async function resolveCallerIdWinnerOrRetry(internals, winnerId, signalSpec, signalId, callbacks, allowTerminalRestart = !1) {
27
35
  const state = await loadWorkflowState(internals, winnerId);
28
36
  if (state !== null) {
@@ -31,7 +39,7 @@ export async function resolveCallerIdWinnerOrRetry(internals, winnerId, signalSp
31
39
  return callbacks.getHandle(winnerId);
32
40
  }
33
41
  if (!allowTerminalRestart)
34
- throw new StartOrSignalConflictError(winnerId, state.status);
42
+ return resolveTerminalConflictOrConvergence(internals, winnerId, state.status, signalSpec, signalId, callbacks);
35
43
  }
36
44
  await awaitReservationCleared(internals, winnerId);
37
45
  const stateAfterReservation = await loadWorkflowState(internals, winnerId);
@@ -40,7 +48,7 @@ export async function resolveCallerIdWinnerOrRetry(internals, winnerId, signalSp
40
48
  if (isTerminalWorkflowStatus(stateAfterReservation.status)) {
41
49
  if (state !== null && isSameTerminalRun(state, stateAfterReservation))
42
50
  return;
43
- throw new StartOrSignalConflictError(winnerId, stateAfterReservation.status);
51
+ return resolveTerminalConflictOrConvergence(internals, winnerId, stateAfterReservation.status, signalSpec, signalId, callbacks);
44
52
  }
45
53
  await callbacks.signalExistingWorkflow(winnerId, signalSpec.name, signalSpec.payload, signalId);
46
54
  return callbacks.getHandle(winnerId);
@@ -37,10 +37,16 @@
37
37
  * expectation — the id is free right now, and a retry re-conditions on that same
38
38
  * still-matching value.
39
39
  *
40
- * The residual is the pre-compare-and-swap purge ABA (WFT-153): a winner purged
41
- * between the read and the commit makes an absent record look never-used, which no
42
- * value-comparing condition can detect. That is tracked separately and is the reason
43
- * this function does not claim exclusive attribution.
40
+ * The pre-compare-and-swap purge ABA a winner purged between the read and the
41
+ * commit, making an absent record look never-used to a value-comparing condition
42
+ * alone no longer reaches this function's elimination logic (WFT-153, closing
43
+ * the residual this comment used to describe as untracked). The caller now checks
44
+ * a SECOND, positive-evidence condition first (`'duplicate-id-generation'` in
45
+ * `buildAndCommitStartBatch`) on a durable per-id generation counter that a purge
46
+ * bumps but never resets, so that case throws `WorkflowAlreadyExistsError` before
47
+ * reaching here. This function still does not claim exclusive attribution for
48
+ * every conceivable cause — only that the two positively-detectable duplicate-id
49
+ * causes are ruled out ahead of it.
44
50
  *
45
51
  * With no concurrency evidence at all, fail closed: something missed, nothing
46
52
  * retryable explains it, and a spurious `WorkflowAlreadyExistsError` is public,
@@ -0,0 +1,11 @@
1
+ import type { StartOptions } from '../../types.ts';
2
+ import type { EngineInternals } from '../internals.ts';
3
+ import { type LifecycleCallbacks } from './shared.ts';
4
+ /**
5
+ * Resolve a start's effective delayed-start timestamp from
6
+ * `options.startAt`/`options.startAfter`, or `undefined` for an immediate
7
+ * start. Split out of `start.ts` to keep that file under the repository's
8
+ * line-count ceiling; this is a pure resolution step with no dependency on
9
+ * `startWorkflow`'s own reservation/commit state.
10
+ */
11
+ export declare function resolveScheduledStartAt(internals: EngineInternals, options: StartOptions | undefined, submissionTime: number, callbacks: LifecycleCallbacks): number | undefined;
@@ -0,0 +1,21 @@
1
+ import { normalizeStorageTimestamp } from "../../scheduler.js";
2
+ import {
3
+ assertExclusiveStartWorkflowOptions,
4
+ coerceStartWorkflowTimestamp,
5
+ StartWorkflowValidationError
6
+ } from "../../start-workflow-validation.js";
7
+ import { parseStartOptionDuration } from "./start-state.js";
8
+ export function resolveScheduledStartAt(internals, options, submissionTime, callbacks) {
9
+ assertExclusiveStartWorkflowOptions(options?.startAt, options?.startAfter);
10
+ if (options?.startAt !== void 0)
11
+ return coerceStartWorkflowTimestamp(options.startAt, "options.startAt");
12
+ if (options?.startAfter !== void 0) {
13
+ const startAfterMilliseconds = parseStartOptionDuration(internals, options.startAfter, "options.startAfter", callbacks);
14
+ try {
15
+ return normalizeStorageTimestamp(submissionTime + startAfterMilliseconds, "options.startAfter");
16
+ } catch {
17
+ throw new StartWorkflowValidationError("options.startAfter must resolve to a finite, non-negative start time");
18
+ }
19
+ }
20
+ return;
21
+ }