@lostgradient/weft 0.20.0 → 0.21.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/README.md +2 -2
- package/dist/core/engine/activity-reconciliation.d.ts +2 -2
- package/dist/core/engine/activity-reconciliation.js +12 -8
- package/dist/core/engine/async-activity-completion.js +12 -4
- package/dist/core/engine/async-activity-records.d.ts +11 -1
- package/dist/core/engine/async-activity-records.js +4 -3
- package/dist/core/engine/attributes-tags.d.ts +11 -1
- package/dist/core/engine/attributes-tags.js +4 -3
- package/dist/core/engine/bulk-operations-purge.js +5 -2
- package/dist/core/engine/bulk-operations.js +17 -5
- package/dist/core/engine/callback-creators-bundles.js +1 -1
- package/dist/core/engine/callback-creators-core.js +4 -2
- package/dist/core/engine/checkpoint-io.js +1 -1
- package/dist/core/engine/child-workflow.js +2 -1
- package/dist/core/engine/completed-review-storage.js +1 -1
- package/dist/core/engine/condition-waiters.d.ts +45 -3
- package/dist/core/engine/condition-waiters.js +12 -3
- package/dist/core/engine/disposal.js +6 -0
- package/dist/core/engine/engine-internal-types.d.ts +12 -4
- package/dist/core/engine/fenced-write.d.ts +46 -15
- package/dist/core/engine/fenced-write.js +39 -6
- package/dist/core/engine/finalizer-state.d.ts +3 -1
- package/dist/core/engine/handle-result.d.ts +49 -1
- package/dist/core/engine/handle-result.js +95 -20
- package/dist/core/engine/index.d.ts +13 -5
- package/dist/core/engine/index.js +155 -5
- package/dist/core/engine/inline-launch-queue.js +9 -0
- package/dist/core/engine/internals.d.ts +73 -0
- package/dist/core/engine/invoke-update-handler.d.ts +19 -0
- package/dist/core/engine/invoke-update-handler.js +7 -0
- package/dist/core/engine/lease-deposition.d.ts +132 -0
- package/dist/core/engine/lease-deposition.js +31 -0
- package/dist/core/engine/lease-errors.d.ts +80 -9
- package/dist/core/engine/lease-errors.js +28 -2
- package/dist/core/engine/lifecycle/resume.js +3 -1
- package/dist/core/engine/lifecycle/standalone-claim-acquire.d.ts +33 -0
- package/dist/core/engine/lifecycle/standalone-claim-acquire.js +25 -0
- package/dist/core/engine/lifecycle/start-commit.js +20 -6
- package/dist/core/engine/lifecycle/transition.d.ts +15 -1
- package/dist/core/engine/lifecycle/transition.js +6 -3
- package/dist/core/engine/operations-activity.js +1 -1
- package/dist/core/engine/operations-time.js +21 -7
- package/dist/core/engine/owner-side-signal-poll.d.ts +132 -0
- package/dist/core/engine/owner-side-signal-poll.js +22 -0
- package/dist/core/engine/owner-side-update-poll.d.ts +116 -0
- package/dist/core/engine/owner-side-update-poll.js +22 -0
- package/dist/core/engine/ownership-bootstrap.d.ts +280 -0
- package/dist/core/engine/ownership-bootstrap.js +89 -0
- package/dist/core/engine/ownership-mode-marker.d.ts +82 -0
- package/dist/core/engine/ownership-mode-marker.js +52 -0
- package/dist/core/engine/ownership-options.d.ts +22 -6
- package/dist/core/engine/ownership-options.js +42 -12
- package/dist/core/engine/pending-updates.js +35 -24
- package/dist/core/engine/queries.d.ts +70 -0
- package/dist/core/engine/queries.js +58 -4
- package/dist/core/engine/schedules.js +1 -1
- package/dist/core/engine/signals.d.ts +11 -1
- package/dist/core/engine/signals.js +11 -7
- package/dist/core/engine/sleep-timer-acknowledgements.d.ts +68 -0
- package/dist/core/engine/sleep-timer-acknowledgements.js +19 -0
- package/dist/core/engine/storage-io.d.ts +58 -10
- package/dist/core/engine/storage-io.js +27 -9
- package/dist/core/engine/termination/cleanup.d.ts +5 -4
- package/dist/core/engine/termination/complete.js +23 -4
- package/dist/core/engine/termination/finalizer-claim.js +4 -4
- package/dist/core/engine/termination/state-commit-callbacks.d.ts +15 -0
- package/dist/core/engine/termination/state-commit-callbacks.js +0 -0
- package/dist/core/engine/termination/suspend.js +1 -1
- package/dist/core/engine/update-validation.d.ts +28 -0
- package/dist/core/engine/update-validation.js +37 -0
- package/dist/core/engine/updates.d.ts +17 -16
- package/dist/core/engine/updates.js +24 -47
- package/dist/core/engine/wake-ownership-check.d.ts +86 -0
- package/dist/core/engine/wake-ownership-check.js +19 -0
- package/dist/core/engine/wake-ownership-guard.d.ts +46 -0
- package/dist/core/engine/wake-ownership-guard.js +23 -0
- package/dist/core/engine/workflow-claim-codec.d.ts +64 -0
- package/dist/core/engine/workflow-claim-codec.js +44 -0
- package/dist/core/engine/workflow-claim-cooldown.d.ts +60 -0
- package/dist/core/engine/workflow-claim-cooldown.js +17 -0
- package/dist/core/engine/workflow-claim-fold.d.ts +82 -0
- package/dist/core/engine/workflow-claim-fold.js +53 -0
- package/dist/core/engine/workflow-claim-metrics.d.ts +131 -0
- package/dist/core/engine/workflow-claim-metrics.js +62 -0
- package/dist/core/engine/workflow-claim-reclaim-scan.d.ts +96 -0
- package/dist/core/engine/workflow-claim-reclaim-scan.js +66 -0
- package/dist/core/engine/workflow-claim-reclaim-target.d.ts +107 -0
- package/dist/core/engine/workflow-claim-reclaim-target.js +155 -0
- package/dist/core/engine/workflow-claim-registry.d.ts +227 -0
- package/dist/core/engine/workflow-claim-registry.js +198 -0
- package/dist/core/engine/workflow-claim-renewal-interval.d.ts +50 -0
- package/dist/core/engine/workflow-claim-renewal-interval.js +132 -0
- package/dist/core/engine/workflow-claim-renewal-subpasses.d.ts +265 -0
- package/dist/core/engine/workflow-claim-renewal-subpasses.js +104 -0
- package/dist/core/engine/workflow-claim-renewal-task.d.ts +159 -0
- package/dist/core/engine/workflow-claim-renewal-task.js +65 -0
- package/dist/core/engine/workflow-claim-transitions.d.ts +186 -0
- package/dist/core/engine/workflow-claim-transitions.js +120 -0
- package/dist/core/inline-execution-strategy.d.ts +20 -0
- package/dist/core/inline-execution-strategy.js +15 -4
- package/dist/core/scheduler/scheduler-class.js +3 -3
- package/dist/core/scheduler/timer-sources.d.ts +13 -0
- package/dist/core/scheduler/timer-sources.js +1 -1
- package/dist/core/types/options.d.ts +17 -1
- package/dist/core/weft-error.d.ts +1 -1
- package/dist/core/weft-error.js +2 -1
- package/dist/http.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -0
- package/dist/indexeddb.js +1 -1
- package/dist/json-schema.js +3 -3
- package/dist/observability/index.js +2 -2
- package/dist/storage/auto.js +1 -1
- package/dist/storage/bun-sql.js +113 -0
- package/dist/storage/compressed-storage.js +1 -1
- package/dist/storage/index.d.ts +38 -35
- package/dist/storage/interface.d.ts +87 -125
- package/dist/storage/interface.js +1 -1
- package/dist/storage/key-encoding.d.ts +51 -0
- package/dist/storage/key-encoding.js +13 -0
- package/dist/storage/key-prefixes.d.ts +1 -1
- package/dist/storage/key-prefixes.js +3 -0
- package/dist/storage/lmdb.js +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/neon.js +2 -2
- package/dist/storage/node-sqlite.js +113 -0
- package/dist/storage/ownership-keys.d.ts +55 -0
- package/dist/storage/ownership-keys.js +6 -0
- package/dist/storage/postgres.js +2 -2
- package/dist/storage/resolve.js +1 -1
- package/dist/storage/scoped-storage.js +1 -1
- package/dist/storage/testing.js +1 -1
- package/dist/storage/turso.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/web-extension.js +1 -1
- package/dist/worker/protocol.js +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { UpdateCompletedEvent, UpdateReceivedEvent } from "../events.js";
|
|
2
|
-
import { isGeneratorResult } from "../step-context.js";
|
|
3
|
-
import { UpdateValidationError } from "../updates.js";
|
|
4
2
|
import { notifyConditionWaiters } from "./condition-waiters.js";
|
|
3
|
+
import { invokeUpdateHandler } from "./invoke-update-handler.js";
|
|
4
|
+
import { isLiveContextStale, isWorkflowClaimedByAnotherEngine } from "./queries.js";
|
|
5
5
|
import { trackWaiterKey, untrackWaiterKey } from "./signals.js";
|
|
6
|
+
import { runUpdateValidator } from "./update-validation.js";
|
|
6
7
|
import { waitForUpdateResponse } from "./waiting-update-response.js";
|
|
8
|
+
import { confirmWakeOwnership } from "./wake-ownership-guard.js";
|
|
7
9
|
export async function update(internals, workflowId, name, payload, options, callbacks) {
|
|
8
10
|
const timeout = options?.timeout ?? 30000;
|
|
9
11
|
await runUpdateValidator(internals, workflowId, name, payload);
|
|
@@ -11,19 +13,32 @@ export async function update(internals, workflowId, name, payload, options, call
|
|
|
11
13
|
const inlineResult = await tryInlineUpdateHandler(internals, workflowId, name, payload, callbacks);
|
|
12
14
|
if (inlineResult.handled)
|
|
13
15
|
return inlineResult.value;
|
|
16
|
+
if (inlineResult.reason === "not-owned-locally")
|
|
17
|
+
return await runCoordinatedUpdate(internals, workflowId, name, payload, timeout, callbacks);
|
|
14
18
|
const waitingResult = await tryWaitingUpdateHandler(internals, workflowId, name, payload, timeout, callbacks);
|
|
15
19
|
if (waitingResult.handled)
|
|
16
20
|
return waitingResult.value;
|
|
17
21
|
return await runCoordinatedUpdate(internals, workflowId, name, payload, timeout, callbacks);
|
|
18
22
|
}
|
|
19
|
-
|
|
23
|
+
function refreshUpdateHandlerAfterAwait(internals, workflowId, name, capturedHandler, stale) {
|
|
24
|
+
if (stale === !1)
|
|
25
|
+
return capturedHandler;
|
|
26
|
+
return internals.inlineStrategy?.getContext(workflowId)?.updateHandlers.get(name);
|
|
27
|
+
}
|
|
28
|
+
export async function tryInlineUpdateHandler(internals, workflowId, name, payload, callbacks) {
|
|
20
29
|
const handler = internals.inlineStrategy?.getContext(workflowId)?.updateHandlers.get(name);
|
|
21
30
|
if (!handler)
|
|
22
|
-
return { handled: !1 };
|
|
31
|
+
return { handled: !1, reason: await isWorkflowClaimedByAnotherEngine(internals, workflowId) ? "not-owned-locally" : "no-handler" };
|
|
32
|
+
const stale = isLiveContextStale(internals, workflowId);
|
|
33
|
+
if (stale !== !1 && await stale)
|
|
34
|
+
return { handled: !1, reason: "not-owned-locally" };
|
|
35
|
+
const liveHandler = refreshUpdateHandlerAfterAwait(internals, workflowId, name, handler, stale);
|
|
36
|
+
if (!liveHandler)
|
|
37
|
+
return { handled: !1, reason: "no-handler" };
|
|
23
38
|
const updateId = crypto.randomUUID();
|
|
24
39
|
callbacks.dispatchEvent(new UpdateReceivedEvent(updateId, workflowId, name, payload));
|
|
25
40
|
try {
|
|
26
|
-
const result = await invokeUpdateHandler(internals, name,
|
|
41
|
+
const result = await invokeUpdateHandler(internals, name, liveHandler, payload);
|
|
27
42
|
callbacks.dispatchEvent(new UpdateCompletedEvent(updateId, workflowId, name, result));
|
|
28
43
|
callbacks.broadcast({ type: "update:completed", workflowId, updateId });
|
|
29
44
|
notifyConditionWaiters(internals, workflowId);
|
|
@@ -43,6 +58,8 @@ async function tryWaitingUpdateHandler(internals, workflowId, name, payload, tim
|
|
|
43
58
|
const existingPendingUpdate = await callbacks.findPendingUpdateByName(workflowId, name);
|
|
44
59
|
if (internals.updateWaiters.get(waiterKey) !== updateWaiter || existingPendingUpdate)
|
|
45
60
|
return { handled: !1 };
|
|
61
|
+
if (await confirmWakeOwnership(internals, workflowId, "update") === "discard")
|
|
62
|
+
return { handled: !1 };
|
|
46
63
|
internals.updateWaiters.delete(waiterKey);
|
|
47
64
|
untrackWaiterKey(internals.updateWaitersByWorkflow, workflowId, waiterKey);
|
|
48
65
|
const updateId = crypto.randomUUID();
|
|
@@ -152,6 +169,8 @@ export async function deliverCoordinatedUpdateToWaiterIfAvailable(internals, wor
|
|
|
152
169
|
const oldestPendingUpdate = await callbacks.findPendingUpdateByName(workflowId, updateRequest.name);
|
|
153
170
|
if (!oldestPendingUpdate || oldestPendingUpdate.updateId !== updateRequest.updateId)
|
|
154
171
|
return !1;
|
|
172
|
+
if (await confirmWakeOwnership(internals, workflowId, "update") === "discard")
|
|
173
|
+
return !1;
|
|
155
174
|
await internals.updateCoordinator.deleteRequest(workflowId, updateRequest.updateId);
|
|
156
175
|
internals.updateWaiters.delete(waiterKey);
|
|
157
176
|
untrackWaiterKey(internals.updateWaitersByWorkflow, workflowId, waiterKey);
|
|
@@ -166,45 +185,3 @@ export async function deliverCoordinatedUpdateToWaiterIfAvailable(internals, wor
|
|
|
166
185
|
export async function findPendingUpdateByName(internals, workflowId, name) {
|
|
167
186
|
return (await internals.updateCoordinator.getPendingUpdates(workflowId)).find((updateRequest) => updateRequest.name === name);
|
|
168
187
|
}
|
|
169
|
-
export async function invokeUpdateHandler(_internals, name, handler, payload) {
|
|
170
|
-
const result = handler(payload);
|
|
171
|
-
if (isGeneratorResult(result))
|
|
172
|
-
throw TypeError(`Update handler "${name}" returned a generator. Update handlers must return a plain value or a Promise, not a generator.`);
|
|
173
|
-
return await result;
|
|
174
|
-
}
|
|
175
|
-
async function runUpdateValidator(internals, workflowId, name, payload) {
|
|
176
|
-
const validator = internals.inlineStrategy?.getContext(workflowId)?.updateValidators.get(name);
|
|
177
|
-
if (validator === void 0)
|
|
178
|
-
return;
|
|
179
|
-
let result;
|
|
180
|
-
try {
|
|
181
|
-
result = await validator(payload);
|
|
182
|
-
} catch (error) {
|
|
183
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
-
throw new UpdateValidationError(name, [{ message }]);
|
|
185
|
-
}
|
|
186
|
-
const issues = extractStandardSchemaIssues(result);
|
|
187
|
-
if (issues !== null && issues.length > 0)
|
|
188
|
-
throw new UpdateValidationError(name, issues);
|
|
189
|
-
}
|
|
190
|
-
export function extractStandardSchemaIssues(result) {
|
|
191
|
-
if (result === null || typeof result !== "object" || !("issues" in result))
|
|
192
|
-
return null;
|
|
193
|
-
const { issues } = result;
|
|
194
|
-
if (!Array.isArray(issues))
|
|
195
|
-
return null;
|
|
196
|
-
return issues.flatMap((issue) => {
|
|
197
|
-
if (issue === null || typeof issue !== "object")
|
|
198
|
-
return [];
|
|
199
|
-
const obj = issue;
|
|
200
|
-
if (typeof obj.message !== "string")
|
|
201
|
-
return [];
|
|
202
|
-
const entry = { message: obj.message };
|
|
203
|
-
if (Array.isArray(obj.path) && obj.path.length > 0)
|
|
204
|
-
entry.path = obj.path.reduce((p, seg) => {
|
|
205
|
-
const k = seg !== null && typeof seg === "object" && "key" in seg ? String(seg.key) : String(seg);
|
|
206
|
-
return p + "/" + k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
207
|
-
}, "");
|
|
208
|
-
return [entry];
|
|
209
|
-
});
|
|
210
|
-
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wakeOwnershipCheck` — the read-only re-read of `wf-owner-holder:<id>`
|
|
3
|
+
* specified in
|
|
4
|
+
* [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions)
|
|
5
|
+
* (the paragraph immediately after the transitions table).
|
|
6
|
+
*
|
|
7
|
+
* **Why this exists.** Under `ownership: 'workflow-lease'`, every code path
|
|
8
|
+
* that is about to resolve an in-memory waiter or drive a generator for a
|
|
9
|
+
* parked workflow — a fired sleep timer, a re-evaluated `ctx.waitUntil()`
|
|
10
|
+
* condition, a delivered signal, an async-activity completion/failure, a
|
|
11
|
+
* child workflow's termination, or a deferred inline macrotask drive — must
|
|
12
|
+
* first confirm THIS engine still holds the claim generation it parked the
|
|
13
|
+
* workflow under. Skipping that check would let a stale in-memory resolver
|
|
14
|
+
* from a prior generation drive a workflow this engine no longer owns.
|
|
15
|
+
*
|
|
16
|
+
* **Why the FULL generation, not just the engine id.** When an engine
|
|
17
|
+
* releases a workflow and later reacquires the same id, its `engineId` is
|
|
18
|
+
* unchanged while `epoch` names a new generation. An engine-id-only check
|
|
19
|
+
* would let a delayed wake or a stale in-memory resolver from the PRIOR
|
|
20
|
+
* generation drive the NEW one — the exact ABA hazard the epoch exists to
|
|
21
|
+
* close. This check therefore compares `engineId` AND `epoch` together,
|
|
22
|
+
* never `engineId` alone.
|
|
23
|
+
*
|
|
24
|
+
* **What a match does and does not prove.** A match is a cheap pre-check,
|
|
25
|
+
* not the safety mechanism itself — the durable write that follows still
|
|
26
|
+
* carries the epoch as a `conditionalBatch` precondition, and that CAS is
|
|
27
|
+
* the actual backstop. A mismatch means this engine no longer holds the
|
|
28
|
+
* generation it parked under: the caller must discard its in-memory
|
|
29
|
+
* resolver without driving the generator. This module emits
|
|
30
|
+
* `WeftWorkflowWakeDiscardedWarning` on every mismatch; it does not decide
|
|
31
|
+
* what the caller does next (that decision, and the actual discard, belong
|
|
32
|
+
* to the wake call sites this check is wired into — a later stage).
|
|
33
|
+
*
|
|
34
|
+
* **Scope.** This module is the check alone: read the holder record, decide
|
|
35
|
+
* match or discard, warn on discard. It is NOT wired into any of the wake
|
|
36
|
+
* call sites named above — that wiring is a later stage's responsibility.
|
|
37
|
+
*
|
|
38
|
+
* @module core/engine/wake-ownership-check
|
|
39
|
+
*/
|
|
40
|
+
import { type Storage } from '../../storage/interface.ts';
|
|
41
|
+
import { type EmitWorkflowLeaseWarning, type WorkflowWakeKind } from './lease-deposition.ts';
|
|
42
|
+
/** Parameters for {@link wakeOwnershipCheck}. */
|
|
43
|
+
export type WakeOwnershipCheckParams = {
|
|
44
|
+
/** Durable storage to re-read `wf-owner-holder:<workflowId>` from. */
|
|
45
|
+
storage: Storage;
|
|
46
|
+
/** The workflow whose parked wake is about to be resolved or driven. */
|
|
47
|
+
workflowId: string;
|
|
48
|
+
/** Which wake path is asking — folded into the discard warning for operator diagnosis. */
|
|
49
|
+
wakeKind: WorkflowWakeKind;
|
|
50
|
+
/** The `engineId` this engine parked the workflow under. */
|
|
51
|
+
expectedEngineId: string;
|
|
52
|
+
/** The `epoch` this engine parked the workflow under. */
|
|
53
|
+
expectedEpoch: number;
|
|
54
|
+
/** Operator-warning seam; defaults to `process.emitWarning` via `lease-deposition.ts`. */
|
|
55
|
+
warn?: EmitWorkflowLeaseWarning;
|
|
56
|
+
};
|
|
57
|
+
/** Why a re-read holder record failed to match the expected generation. */
|
|
58
|
+
export type WakeOwnershipDiscardReason = 'holder-absent' | 'holder-undecodable' | 'generation-mismatch';
|
|
59
|
+
/** Result of {@link wakeOwnershipCheck}. */
|
|
60
|
+
export type WakeOwnershipCheckResult = {
|
|
61
|
+
status: 'match';
|
|
62
|
+
} | {
|
|
63
|
+
status: 'discarded';
|
|
64
|
+
reason: WakeOwnershipDiscardReason;
|
|
65
|
+
/** The `engineId` actually found on re-read, or `null` when the holder was absent/undecodable. */
|
|
66
|
+
observedEngineId: string | null;
|
|
67
|
+
/** The `epoch` actually found on re-read, or `null` when the holder was absent/undecodable. */
|
|
68
|
+
observedEpoch: number | null;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Re-read `wf-owner-holder:<workflowId>` and compare it against the full
|
|
72
|
+
* generation (`engineId` + `epoch`) this engine parked the workflow under.
|
|
73
|
+
*
|
|
74
|
+
* Returns `{ status: 'match' }` when both fields agree — the caller may
|
|
75
|
+
* proceed to resolve its in-memory waiter or drive the generator (the
|
|
76
|
+
* following durable write still carries the epoch as the real backstop).
|
|
77
|
+
* Returns `{ status: 'discarded', reason }` and emits
|
|
78
|
+
* `WeftWorkflowWakeDiscardedWarning` in every other case: the holder key is
|
|
79
|
+
* absent (`'holder-absent'`), the stored bytes do not decode to a valid
|
|
80
|
+
* holder record (`'holder-undecodable'`), or a holder record was found but
|
|
81
|
+
* its `engineId`/`epoch` do not both match what was expected
|
|
82
|
+
* (`'generation-mismatch'`) — including the stale-generation case where
|
|
83
|
+
* `engineId` matches but `epoch` does not, the exact hazard this check
|
|
84
|
+
* exists to close.
|
|
85
|
+
*/
|
|
86
|
+
export declare function wakeOwnershipCheck(params: WakeOwnershipCheckParams): Promise<WakeOwnershipCheckResult>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { KEYS } from "../../storage/interface.js";
|
|
2
|
+
import {
|
|
3
|
+
emitWorkflowWakeDiscardedWarning
|
|
4
|
+
} from "./lease-deposition.js";
|
|
5
|
+
import { decodeWorkflowClaimHolder } from "./workflow-claim-codec.js";
|
|
6
|
+
export async function wakeOwnershipCheck(params) {
|
|
7
|
+
const { storage, workflowId, wakeKind, expectedEngineId, expectedEpoch, warn } = params, discard = (reason, observedEngineId, observedEpoch) => {
|
|
8
|
+
emitWorkflowWakeDiscardedWarning(workflowId, wakeKind, warn);
|
|
9
|
+
return { status: "discarded", reason, observedEngineId, observedEpoch };
|
|
10
|
+
}, raw = await storage.get(KEYS.workflowOwnerHolder(workflowId));
|
|
11
|
+
if (raw === null)
|
|
12
|
+
return discard("holder-absent", null, null);
|
|
13
|
+
const holder = decodeWorkflowClaimHolder(raw);
|
|
14
|
+
if (holder === null)
|
|
15
|
+
return discard("holder-undecodable", null, null);
|
|
16
|
+
if (holder.engineId !== expectedEngineId || holder.epoch !== expectedEpoch)
|
|
17
|
+
return discard("generation-mismatch", holder.engineId, holder.epoch);
|
|
18
|
+
return { status: "match" };
|
|
19
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `confirmWakeOwnership` — the single call-site helper every claim-requiring
|
|
3
|
+
* wake path (sleep, wait-condition, async-activity, inline-macrotask-drive)
|
|
4
|
+
* uses to run `wakeOwnershipCheck` before resolving an in-memory waiter or
|
|
5
|
+
* driving a generator for a parked workflow, per
|
|
6
|
+
* [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions).
|
|
7
|
+
*
|
|
8
|
+
* **Why a wrapper instead of calling `wakeOwnershipCheck` directly at each
|
|
9
|
+
* site.** `wakeOwnershipCheck` requires a non-null `expectedEngineId` /
|
|
10
|
+
* `expectedEpoch` pair — it has no opinion on how a caller obtains those. Every
|
|
11
|
+
* wake site needs the exact same three-way decision tree first:
|
|
12
|
+
*
|
|
13
|
+
* - `internals.workflowClaimRegistry` is `null` (ownership is `'none'` or
|
|
14
|
+
* `'lease'`): this check is a no-op. Proceed — byte-identical to every
|
|
15
|
+
* pre-ADR-0002 deployment. This is the property every wake site must
|
|
16
|
+
* preserve above all else.
|
|
17
|
+
* - The registry is present but tracks no epoch for this workflow id
|
|
18
|
+
* (`currentEpoch(workflowId)` is `null` — e.g. a `renew()` loss already
|
|
19
|
+
* dropped this engine's local claim entry): there is nothing to compare
|
|
20
|
+
* against, so this is itself a discard. `wakeOwnershipCheck` cannot be
|
|
21
|
+
* called with a null epoch, so this module emits
|
|
22
|
+
* `WeftWorkflowWakeDiscardedWarning` itself rather than skipping the
|
|
23
|
+
* operator diagnostic.
|
|
24
|
+
* - The registry tracks an epoch: re-read the durable holder record and
|
|
25
|
+
* compare the full generation via `wakeOwnershipCheck`.
|
|
26
|
+
*
|
|
27
|
+
* **A thrown storage read is `'proceed'`, not `'discard'`.** This helper is a
|
|
28
|
+
* cheap pre-check; the epoch-conditioned durable write that follows every
|
|
29
|
+
* proceed path is the real backstop (`EngineDeposedError` if it has actually
|
|
30
|
+
* lost the claim). A storage blip during the pre-check must not permanently
|
|
31
|
+
* strand a wake — a fired sleep timer or a signalled `waitUntil` deadline is
|
|
32
|
+
* not retried — so a thrown read is treated the same as a match and the
|
|
33
|
+
* caller proceeds to its normal (already-fenced) write path.
|
|
34
|
+
*
|
|
35
|
+
* @module core/engine/wake-ownership-guard
|
|
36
|
+
*/
|
|
37
|
+
import type { EngineInternals } from './internals.ts';
|
|
38
|
+
import { type WorkflowWakeKind } from './lease-deposition.ts';
|
|
39
|
+
/** Outcome of {@link confirmWakeOwnership}: whether the caller may proceed with this wake. */
|
|
40
|
+
export type WakeOwnershipDecision = 'proceed' | 'discard';
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether a claim-requiring wake path may resolve its in-memory
|
|
43
|
+
* waiter / drive its generator for `workflowId`. See the module doc for the
|
|
44
|
+
* three-way decision tree and the thrown-read policy.
|
|
45
|
+
*/
|
|
46
|
+
export declare function confirmWakeOwnership(internals: EngineInternals, workflowId: string, wakeKind: WorkflowWakeKind): Promise<WakeOwnershipDecision>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { emitWorkflowWakeDiscardedWarning } from "./lease-deposition.js";
|
|
2
|
+
import { wakeOwnershipCheck } from "./wake-ownership-check.js";
|
|
3
|
+
export async function confirmWakeOwnership(internals, workflowId, wakeKind) {
|
|
4
|
+
const registry = internals.workflowClaimRegistry;
|
|
5
|
+
if (registry === null)
|
|
6
|
+
return "proceed";
|
|
7
|
+
const expectedEpoch = registry.currentEpoch(workflowId);
|
|
8
|
+
if (expectedEpoch === null) {
|
|
9
|
+
emitWorkflowWakeDiscardedWarning(workflowId, wakeKind);
|
|
10
|
+
return "discard";
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return (await wakeOwnershipCheck({
|
|
14
|
+
storage: internals.storage,
|
|
15
|
+
workflowId,
|
|
16
|
+
wakeKind,
|
|
17
|
+
expectedEngineId: registry.engineId,
|
|
18
|
+
expectedEpoch
|
|
19
|
+
})).status === "match" ? "proceed" : "discard";
|
|
20
|
+
} catch {
|
|
21
|
+
return "proceed";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure codecs for the per-workflow ownership claim keys (`wf-owner-epoch:<id>`
|
|
3
|
+
* and `wf-owner-holder:<id>`) and for the store-wide `ownership-mode-marker`.
|
|
4
|
+
*
|
|
5
|
+
* These back the `ownership: 'workflow-lease'` mode described in
|
|
6
|
+
* [ADR 0002](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md).
|
|
7
|
+
* The epoch codec itself is shared with the global lease — `encodeEpoch` and
|
|
8
|
+
* `decodeEpoch` are imported from `lease-codec.ts` rather than reimplemented, so
|
|
9
|
+
* both fencing tokens stay byte-identical in representation.
|
|
10
|
+
*
|
|
11
|
+
* Only the holder record differs: the global lease holds three fields for one
|
|
12
|
+
* store-wide lease, while a per-workflow claim holds four, adding `claimedAt`
|
|
13
|
+
* for operator visibility and carrying `engineId` rather than `holderId`.
|
|
14
|
+
*
|
|
15
|
+
* Every decoder is fail-closed: any structurally invalid, foreign, or corrupt
|
|
16
|
+
* value decodes to `null` rather than throwing, matching `lease-codec.ts`.
|
|
17
|
+
*
|
|
18
|
+
* @module core/engine/workflow-claim-codec
|
|
19
|
+
*/
|
|
20
|
+
import { decodeEpoch, encodeEpoch } from './lease-codec.ts';
|
|
21
|
+
export { decodeEpoch, encodeEpoch };
|
|
22
|
+
/** The ownership modes that fence engine work and therefore stamp the store-wide marker. */
|
|
23
|
+
export type FencingOwnershipMode = 'lease' | 'workflow-lease';
|
|
24
|
+
/** The decoded `wf-owner-holder:<workflowId>` record. */
|
|
25
|
+
export type WorkflowClaimHolderRecord = {
|
|
26
|
+
/** Identity of the owning engine process, minted once per engine. */
|
|
27
|
+
engineId: string;
|
|
28
|
+
/** The claim generation this holder owns. Mirrors `wf-owner-epoch:<workflowId>`. */
|
|
29
|
+
epoch: number;
|
|
30
|
+
/** Engine-clock ms after which the claim becomes eligible for takeover. */
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
/** Engine-clock ms when this epoch was first claimed. Unchanged across renewals. */
|
|
33
|
+
claimedAt: number;
|
|
34
|
+
};
|
|
35
|
+
/** The decoded store-wide `ownership-mode-marker` record. */
|
|
36
|
+
export type OwnershipModeMarkerRecord = {
|
|
37
|
+
/** The fencing mode the first fencing-mode engine stamped on this store. */
|
|
38
|
+
mode: FencingOwnershipMode;
|
|
39
|
+
/** Engine-clock ms when the marker was established. Diagnostics only. */
|
|
40
|
+
establishedAt: number;
|
|
41
|
+
};
|
|
42
|
+
/** Encode a per-workflow claim holder record to its stored JSON bytes. */
|
|
43
|
+
export declare function encodeWorkflowClaimHolder(record: WorkflowClaimHolderRecord): Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* Decode a stored per-workflow claim holder record, tolerating any
|
|
46
|
+
* malformed or foreign value as `null`.
|
|
47
|
+
*
|
|
48
|
+
* An empty `engineId` is rejected: it can never match a real engine's identity,
|
|
49
|
+
* so admitting it would produce a holder that no engine can renew or release.
|
|
50
|
+
*/
|
|
51
|
+
export declare function decodeWorkflowClaimHolder(raw: Uint8Array): WorkflowClaimHolderRecord | null;
|
|
52
|
+
/** Encode the store-wide ownership-mode marker to its stored JSON bytes. */
|
|
53
|
+
export declare function encodeOwnershipModeMarker(record: OwnershipModeMarkerRecord): Uint8Array;
|
|
54
|
+
/**
|
|
55
|
+
* Decode the store-wide ownership-mode marker, tolerating any malformed or
|
|
56
|
+
* foreign value as `null`.
|
|
57
|
+
*
|
|
58
|
+
* An unrecognized `mode` decodes to `null` rather than being preserved. The
|
|
59
|
+
* marker exists to make a mode mismatch detectable, and a mode this build does
|
|
60
|
+
* not understand cannot be compared meaningfully; treating it as absent lets the
|
|
61
|
+
* reader fail closed on its own terms instead of comparing against a string it
|
|
62
|
+
* cannot interpret.
|
|
63
|
+
*/
|
|
64
|
+
export declare function decodeOwnershipModeMarker(raw: Uint8Array): OwnershipModeMarkerRecord | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { decodeEpoch, encodeEpoch } from "./lease-codec.js";
|
|
2
|
+
|
|
3
|
+
export { decodeEpoch, encodeEpoch };
|
|
4
|
+
const textEncoder = new TextEncoder, textDecoder = new TextDecoder;
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null;
|
|
7
|
+
}
|
|
8
|
+
function isUsableTimestamp(value) {
|
|
9
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
10
|
+
}
|
|
11
|
+
function isUsableEpoch(value) {
|
|
12
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value < Number.MAX_SAFE_INTEGER;
|
|
13
|
+
}
|
|
14
|
+
function parseJsonBytes(raw) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(textDecoder.decode(raw));
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function encodeWorkflowClaimHolder(record) {
|
|
22
|
+
return textEncoder.encode(JSON.stringify(record));
|
|
23
|
+
}
|
|
24
|
+
export function decodeWorkflowClaimHolder(raw) {
|
|
25
|
+
const parsed = parseJsonBytes(raw);
|
|
26
|
+
if (!isRecord(parsed))
|
|
27
|
+
return null;
|
|
28
|
+
const { engineId, epoch, expiresAt, claimedAt } = parsed;
|
|
29
|
+
if (typeof engineId !== "string" || engineId.length === 0 || !isUsableEpoch(epoch) || !isUsableTimestamp(expiresAt) || !isUsableTimestamp(claimedAt))
|
|
30
|
+
return null;
|
|
31
|
+
return { engineId, epoch, expiresAt, claimedAt };
|
|
32
|
+
}
|
|
33
|
+
export function encodeOwnershipModeMarker(record) {
|
|
34
|
+
return textEncoder.encode(JSON.stringify(record));
|
|
35
|
+
}
|
|
36
|
+
export function decodeOwnershipModeMarker(raw) {
|
|
37
|
+
const parsed = parseJsonBytes(raw);
|
|
38
|
+
if (!isRecord(parsed))
|
|
39
|
+
return null;
|
|
40
|
+
const { mode, establishedAt } = parsed;
|
|
41
|
+
if (mode !== "lease" && mode !== "workflow-lease" || !isUsableTimestamp(establishedAt))
|
|
42
|
+
return null;
|
|
43
|
+
return { mode, establishedAt };
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workflow-id anti-thrash takeover cooldown, specified in
|
|
3
|
+
* [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions)
|
|
4
|
+
* (the `takeover` row's "A per-workflow-id anti-thrash cooldown" sentence).
|
|
5
|
+
*
|
|
6
|
+
* **The problem this dampens.** Without it, a reclaim scan and the engine
|
|
7
|
+
* that just lost a claim can thrash: this engine loses `workflowId` via a
|
|
8
|
+
* failed `renew`, its own recurring reclaim scan later judges the same
|
|
9
|
+
* holder stale (a genuine race — the deposing engine can itself stall or
|
|
10
|
+
* crash right after depositing this one), and this engine immediately steals
|
|
11
|
+
* it back, only to lose it again the same way. The cooldown makes that loop
|
|
12
|
+
* back off instead of spinning.
|
|
13
|
+
*
|
|
14
|
+
* **Shape.** In-memory, per-engine-process, keyed by workflow id — not
|
|
15
|
+
* durable and not shared across engines; a restart forgets every cooldown,
|
|
16
|
+
* which is safe, since a fresh process has not just been deposed from
|
|
17
|
+
* anything yet. `WorkflowClaimRegistry` owns one instance and drives it from
|
|
18
|
+
* exactly three points: a failed `renew` calls {@link recordDeposition}, a
|
|
19
|
+
* successful `acquire`/`takeover`/folded-acquire calls {@link clear}, and
|
|
20
|
+
* `takeover` consults {@link isActive} before attempting its CAS.
|
|
21
|
+
*
|
|
22
|
+
* **Curve.** A fixed window of `workflowClaimRenewInterval`, not exponential.
|
|
23
|
+
* Through `WorkflowClaimRegistry`'s real call graph, a second
|
|
24
|
+
* `recordDeposition` for the same id is only reachable after an intervening
|
|
25
|
+
* successful (re)acquire has already {@link clear}ed the tracked entry — this
|
|
26
|
+
* class never observes consecutive depositions for the same id without a
|
|
27
|
+
* clear between them, so a growing window would never actually grow. An
|
|
28
|
+
* exponential curve was tried here and found unreachable in review; keep this
|
|
29
|
+
* simple until a call-graph change (preserving loss history across successful
|
|
30
|
+
* reacquisition) makes a growing window meaningful.
|
|
31
|
+
*
|
|
32
|
+
* @module core/engine/workflow-claim-cooldown
|
|
33
|
+
*/
|
|
34
|
+
/** Options for {@link WorkflowClaimTakeoverCooldown}. Mirrors `WorkflowClaimRegistryOptions`'s same-named field, so the caller need not compute the window itself. */
|
|
35
|
+
export type WorkflowClaimTakeoverCooldownOptions = {
|
|
36
|
+
/** Cooldown window (ms) — `workflowClaimRenewInterval`. */
|
|
37
|
+
claimRenewIntervalMs: number;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Tracks one engine's per-workflow-id takeover cooldown. See the module doc
|
|
41
|
+
* for the mechanism and curve.
|
|
42
|
+
*/
|
|
43
|
+
export declare class WorkflowClaimTakeoverCooldown {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(options: WorkflowClaimTakeoverCooldownOptions);
|
|
46
|
+
/**
|
|
47
|
+
* Whether `workflowId` is currently within its cooldown window at `now`.
|
|
48
|
+
* The boundary itself (`now === untilMs`) is NOT active — matches
|
|
49
|
+
* `isWorkflowClaimExpired`'s "strictly earlier" convention for the sibling
|
|
50
|
+
* expiry judgment.
|
|
51
|
+
*/
|
|
52
|
+
isActive(workflowId: string, now: number): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Record that this engine just lost ownership of `workflowId` via a failed
|
|
55
|
+
* `renew`. Starts (or restarts) a fixed-length cooldown window.
|
|
56
|
+
*/
|
|
57
|
+
recordDeposition(workflowId: string, now: number): void;
|
|
58
|
+
/** Clear any tracked cooldown for `workflowId` — call on a successful (re)acquire or takeover. */
|
|
59
|
+
clear(workflowId: string): void;
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class WorkflowClaimTakeoverCooldown {
|
|
2
|
+
#windowMs;
|
|
3
|
+
#untilMsByWorkflowId = new Map;
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.#windowMs = options.claimRenewIntervalMs;
|
|
6
|
+
}
|
|
7
|
+
isActive(workflowId, now) {
|
|
8
|
+
const untilMs = this.#untilMsByWorkflowId.get(workflowId);
|
|
9
|
+
return untilMs !== void 0 && now < untilMs;
|
|
10
|
+
}
|
|
11
|
+
recordDeposition(workflowId, now) {
|
|
12
|
+
this.#untilMsByWorkflowId.set(workflowId, now + this.#windowMs);
|
|
13
|
+
}
|
|
14
|
+
clear(workflowId) {
|
|
15
|
+
this.#untilMsByWorkflowId.delete(workflowId);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "fold `acquire` into an enabling write" helper for ADR 0002's
|
|
3
|
+
* CLAIM-ACQUIRING entry points this stage owns: workflow start
|
|
4
|
+
* (`lifecycle/start-commit.ts` — the create batch), delayed-start timer fire
|
|
5
|
+
* (`operations-time.ts`), and bulk retry reactivation (`bulk-operations.ts`).
|
|
6
|
+
* Scheduled runs and child-workflow launches funnel through `startWorkflow`
|
|
7
|
+
* (`lifecycle/start.ts`) and inherit the fold from there — see
|
|
8
|
+
* [ADR 0002 § Entry point classification](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#entry-point-classification),
|
|
9
|
+
* the `startWorkflow`/`buildAndCommitStartBatch` row, which explicitly groups
|
|
10
|
+
* `ctx.startChild` and `startScheduledRun` with it.
|
|
11
|
+
*
|
|
12
|
+
* A claim-acquiring write establishes a claim THIS commit has never held
|
|
13
|
+
* before, so it can never be fenced through `commitFencedEngineWrite` (which
|
|
14
|
+
* requires an ALREADY-tracked epoch in `EngineInternals.workflowClaimRegistry`
|
|
15
|
+
* — see `fenced-write.ts`'s `fencedCommitForWorkflow`). Instead, the claim's
|
|
16
|
+
* OWN `acquire` conditions (holder absent, epoch matches what was just read)
|
|
17
|
+
* ARE the fence for this first write: {@link prepareWorkflowClaimFold} reads
|
|
18
|
+
* fresh `wf-owner-epoch:<id>` bytes and builds the pure acquire fragment via
|
|
19
|
+
* `WorkflowClaimRegistry.prepareAcquireFragment`, the caller merges the
|
|
20
|
+
* fragment's conditions/operations into its own enabling write, and
|
|
21
|
+
* {@link commitWithWorkflowClaimFold} commits ONE atomic
|
|
22
|
+
* `storageConditionalBatch`. Only after that commit succeeds does it call
|
|
23
|
+
* `WorkflowClaimRegistry.recordFoldedAcquire`, installing the tracking entry
|
|
24
|
+
* every later `commitFencedEngineWrite` for this workflow needs.
|
|
25
|
+
*
|
|
26
|
+
* Under `ownership: 'none'`/`'lease'`, or `'workflow-lease'` with no registry
|
|
27
|
+
* constructed yet (Gate 1/Gate 2 wiring is a parallel/later stage) —
|
|
28
|
+
* {@link prepareWorkflowClaimFold} returns `undefined` and every caller's
|
|
29
|
+
* pre-existing code path runs byte-for-byte unchanged. It also returns
|
|
30
|
+
* `undefined` when this engine already tracks a claim for `workflowId`: bulk
|
|
31
|
+
* retry folds `acquire` into its own reactivation write, then calls
|
|
32
|
+
* `engine.resume()`, which must not attempt a second acquire for the same
|
|
33
|
+
* workflow (see `lifecycle/resume.ts`).
|
|
34
|
+
*
|
|
35
|
+
* @module core/engine/workflow-claim-fold
|
|
36
|
+
*/
|
|
37
|
+
import { type BatchOperation, type ConditionalBatchCondition } from '../../storage/interface.ts';
|
|
38
|
+
import type { EngineInternals } from './internals.ts';
|
|
39
|
+
import type { WorkflowClaimAcquirePreparation, WorkflowClaimRegistry } from './workflow-claim-registry.ts';
|
|
40
|
+
/** A prepared, not-yet-committed claim-acquire fold for one workflow id. */
|
|
41
|
+
export type WorkflowClaimFold = {
|
|
42
|
+
registry: WorkflowClaimRegistry;
|
|
43
|
+
workflowId: string;
|
|
44
|
+
preparation: WorkflowClaimAcquirePreparation;
|
|
45
|
+
conditions: ConditionalBatchCondition[];
|
|
46
|
+
operations: BatchOperation[];
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Prepare a claim-acquire fold for `workflowId`, or `undefined` when folding
|
|
50
|
+
* does not apply — see the module doc for the three cases that return
|
|
51
|
+
* `undefined`. Call again on every retry attempt of the caller's own
|
|
52
|
+
* enabling-write loop; never reuse a preparation across attempts, since a
|
|
53
|
+
* stale epoch read dooms the retry's CAS.
|
|
54
|
+
*/
|
|
55
|
+
export declare function prepareWorkflowClaimFold(internals: EngineInternals, workflowId: string): Promise<WorkflowClaimFold | undefined>;
|
|
56
|
+
/** Outcome of {@link commitWithWorkflowClaimFold}. */
|
|
57
|
+
export type WorkflowClaimFoldCommitResult = {
|
|
58
|
+
status: 'committed';
|
|
59
|
+
} | {
|
|
60
|
+
status: 'lost-race';
|
|
61
|
+
/** Whether the fold's OWN conditions (not the caller's) were the ones that lost the CAS. */
|
|
62
|
+
claimConflict: boolean;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Commit `operations`/`conditions` merged with `fold`'s own acquire
|
|
66
|
+
* fragment, as ONE atomic `storageConditionalBatch`. On success, installs
|
|
67
|
+
* the fold's tracking entry via `WorkflowClaimRegistry.recordFoldedAcquire`.
|
|
68
|
+
* On a lost CAS, re-reads the fold's own conditions to report whether THEY
|
|
69
|
+
* were the ones that failed — distinct from the caller's own conditions
|
|
70
|
+
* (idempotency mapping, workflow-concurrency admission) — so the caller can
|
|
71
|
+
* raise `WorkflowClaimUnavailableError` instead of misreporting a claim loss
|
|
72
|
+
* as an unrelated precondition conflict, or vice versa.
|
|
73
|
+
*/
|
|
74
|
+
export declare function commitWithWorkflowClaimFold(internals: EngineInternals, fold: WorkflowClaimFold, operations: BatchOperation[], conditions: ConditionalBatchCondition[], featureName: string): Promise<WorkflowClaimFoldCommitResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Raise `WorkflowClaimUnavailableError` for `workflowId`, re-reading its
|
|
77
|
+
* current holder for the `heldBy` field — the shared shape every explicit,
|
|
78
|
+
* single-workflow claim-acquiring call site (start, bulk retry reactivation)
|
|
79
|
+
* uses on a confirmed claim conflict, instead of each repeating the
|
|
80
|
+
* re-read-then-throw.
|
|
81
|
+
*/
|
|
82
|
+
export declare function throwWorkflowClaimUnavailable(internals: EngineInternals, workflowId: string): Promise<never>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KEYS,
|
|
3
|
+
requireStorageCapability,
|
|
4
|
+
storageConditionalBatch,
|
|
5
|
+
storageValuesEqual
|
|
6
|
+
} from "../../storage/interface.js";
|
|
7
|
+
import { WorkflowClaimUnavailableError } from "./lease-errors.js";
|
|
8
|
+
import { decodeWorkflowClaimHolder } from "./workflow-claim-codec.js";
|
|
9
|
+
export async function prepareWorkflowClaimFold(internals, workflowId) {
|
|
10
|
+
if (internals.options.ownershipMode !== "workflow-lease")
|
|
11
|
+
return;
|
|
12
|
+
const registry = internals.workflowClaimRegistry;
|
|
13
|
+
if (registry === null)
|
|
14
|
+
return;
|
|
15
|
+
if (registry.currentEpoch(workflowId) !== null)
|
|
16
|
+
return;
|
|
17
|
+
const preparation = await registry.prepareAcquireFragment(workflowId);
|
|
18
|
+
return {
|
|
19
|
+
registry,
|
|
20
|
+
workflowId,
|
|
21
|
+
preparation,
|
|
22
|
+
conditions: preparation.fragment.conditions,
|
|
23
|
+
operations: preparation.fragment.operations
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function commitWithWorkflowClaimFold(internals, fold, operations, conditions, featureName) {
|
|
27
|
+
requireStorageCapability(internals.storage, "conditionalBatch", featureName);
|
|
28
|
+
if (await storageConditionalBatch(internals.storage, [...conditions, ...fold.conditions], [...operations, ...fold.operations])) {
|
|
29
|
+
fold.registry.recordFoldedAcquire(fold.workflowId, fold.preparation);
|
|
30
|
+
return { status: "committed" };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
status: "lost-race",
|
|
34
|
+
claimConflict: await hasWorkflowClaimFoldConflict(internals, fold)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function hasWorkflowClaimFoldConflict(internals, fold) {
|
|
38
|
+
for (const condition of fold.conditions) {
|
|
39
|
+
const currentValue = await internals.storage.get(condition.key);
|
|
40
|
+
if (!storageValuesEqual(currentValue, condition.expectedValue))
|
|
41
|
+
return !0;
|
|
42
|
+
}
|
|
43
|
+
return !1;
|
|
44
|
+
}
|
|
45
|
+
async function resolveWorkflowClaimHolder(internals, workflowId) {
|
|
46
|
+
const raw = await internals.storage.get(KEYS.workflowOwnerHolder(workflowId));
|
|
47
|
+
if (raw === null)
|
|
48
|
+
return null;
|
|
49
|
+
return decodeWorkflowClaimHolder(raw)?.engineId ?? null;
|
|
50
|
+
}
|
|
51
|
+
export async function throwWorkflowClaimUnavailable(internals, workflowId) {
|
|
52
|
+
throw new WorkflowClaimUnavailableError(workflowId, await resolveWorkflowClaimHolder(internals, workflowId));
|
|
53
|
+
}
|