@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.
Files changed (138) hide show
  1. package/README.md +2 -2
  2. package/dist/core/engine/activity-reconciliation.d.ts +2 -2
  3. package/dist/core/engine/activity-reconciliation.js +12 -8
  4. package/dist/core/engine/async-activity-completion.js +12 -4
  5. package/dist/core/engine/async-activity-records.d.ts +11 -1
  6. package/dist/core/engine/async-activity-records.js +4 -3
  7. package/dist/core/engine/attributes-tags.d.ts +11 -1
  8. package/dist/core/engine/attributes-tags.js +4 -3
  9. package/dist/core/engine/bulk-operations-purge.js +5 -2
  10. package/dist/core/engine/bulk-operations.js +17 -5
  11. package/dist/core/engine/callback-creators-bundles.js +1 -1
  12. package/dist/core/engine/callback-creators-core.js +4 -2
  13. package/dist/core/engine/checkpoint-io.js +1 -1
  14. package/dist/core/engine/child-workflow.js +2 -1
  15. package/dist/core/engine/completed-review-storage.js +1 -1
  16. package/dist/core/engine/condition-waiters.d.ts +45 -3
  17. package/dist/core/engine/condition-waiters.js +12 -3
  18. package/dist/core/engine/disposal.js +6 -0
  19. package/dist/core/engine/engine-internal-types.d.ts +12 -4
  20. package/dist/core/engine/fenced-write.d.ts +46 -15
  21. package/dist/core/engine/fenced-write.js +39 -6
  22. package/dist/core/engine/finalizer-state.d.ts +3 -1
  23. package/dist/core/engine/handle-result.d.ts +49 -1
  24. package/dist/core/engine/handle-result.js +95 -20
  25. package/dist/core/engine/index.d.ts +13 -5
  26. package/dist/core/engine/index.js +155 -5
  27. package/dist/core/engine/inline-launch-queue.js +9 -0
  28. package/dist/core/engine/internals.d.ts +73 -0
  29. package/dist/core/engine/invoke-update-handler.d.ts +19 -0
  30. package/dist/core/engine/invoke-update-handler.js +7 -0
  31. package/dist/core/engine/lease-deposition.d.ts +132 -0
  32. package/dist/core/engine/lease-deposition.js +31 -0
  33. package/dist/core/engine/lease-errors.d.ts +80 -9
  34. package/dist/core/engine/lease-errors.js +28 -2
  35. package/dist/core/engine/lifecycle/resume.js +3 -1
  36. package/dist/core/engine/lifecycle/standalone-claim-acquire.d.ts +33 -0
  37. package/dist/core/engine/lifecycle/standalone-claim-acquire.js +25 -0
  38. package/dist/core/engine/lifecycle/start-commit.js +20 -6
  39. package/dist/core/engine/lifecycle/transition.d.ts +15 -1
  40. package/dist/core/engine/lifecycle/transition.js +6 -3
  41. package/dist/core/engine/operations-activity.js +1 -1
  42. package/dist/core/engine/operations-time.js +21 -7
  43. package/dist/core/engine/owner-side-signal-poll.d.ts +132 -0
  44. package/dist/core/engine/owner-side-signal-poll.js +22 -0
  45. package/dist/core/engine/owner-side-update-poll.d.ts +116 -0
  46. package/dist/core/engine/owner-side-update-poll.js +22 -0
  47. package/dist/core/engine/ownership-bootstrap.d.ts +280 -0
  48. package/dist/core/engine/ownership-bootstrap.js +89 -0
  49. package/dist/core/engine/ownership-mode-marker.d.ts +82 -0
  50. package/dist/core/engine/ownership-mode-marker.js +52 -0
  51. package/dist/core/engine/ownership-options.d.ts +22 -6
  52. package/dist/core/engine/ownership-options.js +42 -12
  53. package/dist/core/engine/pending-updates.js +35 -24
  54. package/dist/core/engine/queries.d.ts +70 -0
  55. package/dist/core/engine/queries.js +58 -4
  56. package/dist/core/engine/schedules.js +1 -1
  57. package/dist/core/engine/signals.d.ts +11 -1
  58. package/dist/core/engine/signals.js +11 -7
  59. package/dist/core/engine/sleep-timer-acknowledgements.d.ts +68 -0
  60. package/dist/core/engine/sleep-timer-acknowledgements.js +19 -0
  61. package/dist/core/engine/storage-io.d.ts +58 -10
  62. package/dist/core/engine/storage-io.js +27 -9
  63. package/dist/core/engine/termination/cleanup.d.ts +5 -4
  64. package/dist/core/engine/termination/complete.js +23 -4
  65. package/dist/core/engine/termination/finalizer-claim.js +4 -4
  66. package/dist/core/engine/termination/state-commit-callbacks.d.ts +15 -0
  67. package/dist/core/engine/termination/state-commit-callbacks.js +0 -0
  68. package/dist/core/engine/termination/suspend.js +1 -1
  69. package/dist/core/engine/update-validation.d.ts +28 -0
  70. package/dist/core/engine/update-validation.js +37 -0
  71. package/dist/core/engine/updates.d.ts +17 -16
  72. package/dist/core/engine/updates.js +24 -47
  73. package/dist/core/engine/wake-ownership-check.d.ts +86 -0
  74. package/dist/core/engine/wake-ownership-check.js +19 -0
  75. package/dist/core/engine/wake-ownership-guard.d.ts +46 -0
  76. package/dist/core/engine/wake-ownership-guard.js +23 -0
  77. package/dist/core/engine/workflow-claim-codec.d.ts +64 -0
  78. package/dist/core/engine/workflow-claim-codec.js +44 -0
  79. package/dist/core/engine/workflow-claim-cooldown.d.ts +60 -0
  80. package/dist/core/engine/workflow-claim-cooldown.js +17 -0
  81. package/dist/core/engine/workflow-claim-fold.d.ts +82 -0
  82. package/dist/core/engine/workflow-claim-fold.js +53 -0
  83. package/dist/core/engine/workflow-claim-metrics.d.ts +131 -0
  84. package/dist/core/engine/workflow-claim-metrics.js +62 -0
  85. package/dist/core/engine/workflow-claim-reclaim-scan.d.ts +96 -0
  86. package/dist/core/engine/workflow-claim-reclaim-scan.js +66 -0
  87. package/dist/core/engine/workflow-claim-reclaim-target.d.ts +107 -0
  88. package/dist/core/engine/workflow-claim-reclaim-target.js +155 -0
  89. package/dist/core/engine/workflow-claim-registry.d.ts +227 -0
  90. package/dist/core/engine/workflow-claim-registry.js +198 -0
  91. package/dist/core/engine/workflow-claim-renewal-interval.d.ts +50 -0
  92. package/dist/core/engine/workflow-claim-renewal-interval.js +132 -0
  93. package/dist/core/engine/workflow-claim-renewal-subpasses.d.ts +265 -0
  94. package/dist/core/engine/workflow-claim-renewal-subpasses.js +104 -0
  95. package/dist/core/engine/workflow-claim-renewal-task.d.ts +159 -0
  96. package/dist/core/engine/workflow-claim-renewal-task.js +65 -0
  97. package/dist/core/engine/workflow-claim-transitions.d.ts +186 -0
  98. package/dist/core/engine/workflow-claim-transitions.js +120 -0
  99. package/dist/core/inline-execution-strategy.d.ts +20 -0
  100. package/dist/core/inline-execution-strategy.js +15 -4
  101. package/dist/core/scheduler/scheduler-class.js +3 -3
  102. package/dist/core/scheduler/timer-sources.d.ts +13 -0
  103. package/dist/core/scheduler/timer-sources.js +1 -1
  104. package/dist/core/types/options.d.ts +17 -1
  105. package/dist/core/weft-error.d.ts +1 -1
  106. package/dist/core/weft-error.js +2 -1
  107. package/dist/http.js +2 -2
  108. package/dist/index.d.ts +2 -1
  109. package/dist/index.js +8 -0
  110. package/dist/indexeddb.js +1 -1
  111. package/dist/json-schema.js +3 -3
  112. package/dist/observability/index.js +2 -2
  113. package/dist/storage/auto.js +1 -1
  114. package/dist/storage/bun-sql.js +113 -0
  115. package/dist/storage/compressed-storage.js +1 -1
  116. package/dist/storage/index.d.ts +38 -35
  117. package/dist/storage/interface.d.ts +87 -125
  118. package/dist/storage/interface.js +1 -1
  119. package/dist/storage/key-encoding.d.ts +51 -0
  120. package/dist/storage/key-encoding.js +13 -0
  121. package/dist/storage/key-prefixes.d.ts +1 -1
  122. package/dist/storage/key-prefixes.js +3 -0
  123. package/dist/storage/lmdb.js +1 -1
  124. package/dist/storage/memory.js +1 -1
  125. package/dist/storage/neon.js +2 -2
  126. package/dist/storage/node-sqlite.js +113 -0
  127. package/dist/storage/ownership-keys.d.ts +55 -0
  128. package/dist/storage/ownership-keys.js +6 -0
  129. package/dist/storage/postgres.js +2 -2
  130. package/dist/storage/resolve.js +1 -1
  131. package/dist/storage/scoped-storage.js +1 -1
  132. package/dist/storage/testing.js +1 -1
  133. package/dist/storage/turso.js +2 -2
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/web-extension.js +1 -1
  137. package/dist/worker/protocol.js +1 -1
  138. package/package.json +1 -1
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The three individual sub-pass implementations `workflow-claim-renewal-task.ts`
3
+ * composes into its combined `runOnce()` pass and, in interval mode, its two
4
+ * independently single-flight-guarded tick passes (see that module's
5
+ * "Renewal cadence is independent of the reclaim scan and signal poll in
6
+ * interval mode" doc section, WFT-79 Finding 2). Split into its own module so
7
+ * `workflow-claim-renewal-task.ts` stays under the repository's
8
+ * implementation-file-size ceiling; these are pure, target-driven functions
9
+ * with no dependency on the renewal task's own scheduling/single-flight
10
+ * state.
11
+ *
12
+ * Also home to every structural target/result type these sub-passes and
13
+ * `workflow-claim-renewal-task.ts` share, for the same reason —
14
+ * `ownership-bootstrap.ts` imports several of these types directly from
15
+ * `workflow-claim-renewal-task.ts` (not from here), so that module re-exports
16
+ * them; this module is the one place they are actually defined.
17
+ *
18
+ * @module core/engine/workflow-claim-renewal-subpasses
19
+ */
20
+ import { type OwnerSideSignalPollResult, type OwnerSideSignalPollTarget } from './owner-side-signal-poll.ts';
21
+ import { type OwnerSideUpdatePollResult, type OwnerSideUpdatePollTarget } from './owner-side-update-poll.ts';
22
+ /**
23
+ * The minimal structural shape the renewal sub-pass needs from a per-workflow
24
+ * claim holder. A `WorkflowClaimRegistry` (built separately) is expected to
25
+ * satisfy this interface; it is defined locally, rather than imported, so
26
+ * this module has no dependency on that registry's concrete shape or module
27
+ * path.
28
+ */
29
+ export type WorkflowClaimRenewalTarget = {
30
+ /**
31
+ * Every workflow id this engine currently holds a live claim for, active or
32
+ * parked. Read fresh at the start of every pass — implementations may
33
+ * return a live or a defensive-copy array; the caller never mutates it and
34
+ * takes its own snapshot before iterating.
35
+ */
36
+ listHeldWorkflowIds(): readonly string[];
37
+ /**
38
+ * Renew this engine's claim for one workflow. Resolves when the renewal
39
+ * committed; rejects (with any error shape) when it did not — a lost-race
40
+ * CAS failure, a storage error, or anything else. The implementation is
41
+ * responsible for its own per-workflow in-flight-renewal guard against a
42
+ * concurrent `release`, and for reacting to a lost claim (aborting
43
+ * in-flight work, emitting `WeftWorkflowClaimLostWarning`). The caller only
44
+ * calls it, catches whatever it throws, and continues to the next
45
+ * workflow.
46
+ */
47
+ renewWorkflowClaim(workflowId: string): Promise<void>;
48
+ };
49
+ /** One workflow's outcome within a single renewal pass. */
50
+ export type WorkflowClaimRenewalOutcome = {
51
+ workflowId: string;
52
+ status: 'renewed';
53
+ } | {
54
+ workflowId: string;
55
+ status: 'failed';
56
+ error: unknown;
57
+ };
58
+ /**
59
+ * The minimal structural shape the reclaim-scan sub-pass needs. Defined
60
+ * locally for the same decoupling reason as {@link WorkflowClaimRenewalTarget}
61
+ * — expected to be satisfied by an adapter over
62
+ * `listWorkflowClaimReclaimCandidates` (`workflow-claim-reclaim-scan.ts`) and
63
+ * `WorkflowClaimRegistry.takeover`, built by `ownership-bootstrap.ts`.
64
+ */
65
+ export type WorkflowClaimReclaimTarget = {
66
+ /**
67
+ * Every workflow id with a currently-persisted holder record this engine
68
+ * does not itself already hold. Read fresh at the start of every pass.
69
+ */
70
+ listReclaimCandidateWorkflowIds(): Promise<readonly string[]>;
71
+ /**
72
+ * Attempt to reclaim one candidate. Retrying a lost-race CAS (bounded, per
73
+ * the ADR, at 5 attempts within this call) and gating on the per-workflow-id
74
+ * anti-thrash cooldown are the implementation's responsibility — the caller
75
+ * calls it exactly once per candidate, catches whatever it throws, and
76
+ * continues to the next one.
77
+ */
78
+ attemptWorkflowClaimTakeover(workflowId: string): Promise<WorkflowClaimReclaimAttemptResult>;
79
+ };
80
+ /** One candidate's non-throwing outcome from {@link WorkflowClaimReclaimTarget.attemptWorkflowClaimTakeover}. */
81
+ export type WorkflowClaimReclaimAttemptResult = {
82
+ status: 'reclaimed';
83
+ } | {
84
+ status: 'not-eligible';
85
+ } | {
86
+ status: 'backoff-skipped';
87
+ } | {
88
+ status: 'lost-race';
89
+ };
90
+ /** One workflow's outcome within a single reclaim-scan pass. */
91
+ export type WorkflowClaimReclaimOutcome = ({
92
+ workflowId: string;
93
+ } & WorkflowClaimReclaimAttemptResult) | {
94
+ workflowId: string;
95
+ status: 'error';
96
+ error: unknown;
97
+ };
98
+ /**
99
+ * The reclaim-scan sub-pass's result. `'discovery-failed'` covers
100
+ * `listReclaimCandidateWorkflowIds()` itself throwing — without a candidate
101
+ * list there is no per-workflow loop to run, but that must not fail the rest
102
+ * of an enclosing combined pass (renewals already committed by then, and a
103
+ * `backgroundTasks: 'manual'` host awaiting `runMaintenance()` must not see a
104
+ * rejected promise for a problem isolated to this one sub-step).
105
+ */
106
+ export type WorkflowClaimReclaimPassResult = {
107
+ status: 'completed';
108
+ outcomes: WorkflowClaimReclaimOutcome[];
109
+ reclaimedCount: number;
110
+ } | {
111
+ status: 'discovery-failed';
112
+ error: unknown;
113
+ };
114
+ /**
115
+ * The owner-side signal-poll sub-pass's result. `'failed'` covers
116
+ * {@link runOwnerSideSignalPoll} itself rejecting (e.g. its target's
117
+ * `hasBufferedSignal` throwing) — same non-fatal-to-the-enclosing-pass
118
+ * treatment as {@link WorkflowClaimReclaimPassResult}'s `'discovery-failed'`.
119
+ */
120
+ export type WorkflowClaimSignalPollOutcome = {
121
+ status: 'completed';
122
+ result: OwnerSideSignalPollResult;
123
+ } | {
124
+ status: 'failed';
125
+ error: unknown;
126
+ };
127
+ /**
128
+ * The owner-side update-poll sub-pass's result (WFT-79). `'failed'` covers
129
+ * {@link runOwnerSideUpdatePoll} itself rejecting (e.g. its target's
130
+ * `hasPendingUpdates` throwing) — same non-fatal-to-the-enclosing-pass
131
+ * treatment as {@link WorkflowClaimSignalPollOutcome}'s `'failed'`.
132
+ */
133
+ export type WorkflowClaimUpdatePollOutcome = {
134
+ status: 'completed';
135
+ result: OwnerSideUpdatePollResult;
136
+ } | {
137
+ status: 'failed';
138
+ error: unknown;
139
+ };
140
+ /**
141
+ * The result of one full claim-renewal pass
142
+ * (`workflow-claim-renewal-task.ts`'s `WorkflowClaimRenewalTask.runOnce`, or
143
+ * one of `workflow-claim-renewal-interval.ts`'s interval-mode sub-passes).
144
+ * `reclaim`/`signalPoll`/`updatePoll` are `undefined` exactly when the
145
+ * matching target option was omitted — that omission is how a caller (or a
146
+ * test exercising renewal alone) opts out of running that sub-step at all.
147
+ * Defined here (rather than in `workflow-claim-renewal-task.ts`) so both that
148
+ * module and `workflow-claim-renewal-interval.ts` can depend on it without a
149
+ * cycle between them; `workflow-claim-renewal-task.ts` re-exports it for
150
+ * backward-compatible import paths.
151
+ */
152
+ export type WorkflowClaimRenewalPassResult = {
153
+ /** `getNow()` read at the start of the pass, before any renewal call. */
154
+ startedAt: number;
155
+ /** `getNow()` read after renewal, reclaim, and signal-poll have all settled. */
156
+ finishedAt: number;
157
+ /** One entry per workflow id the pass attempted, in iteration order. */
158
+ outcomes: WorkflowClaimRenewalOutcome[];
159
+ /** `outcomes.filter(o => o.status === 'renewed').length`, precomputed for observability consumers. */
160
+ renewedCount: number;
161
+ /** `outcomes.filter(o => o.status === 'failed').length`, precomputed for observability consumers. */
162
+ failedCount: number;
163
+ /** Present only when this task was constructed with a `reclaimTarget`. */
164
+ reclaim?: WorkflowClaimReclaimPassResult;
165
+ /** Present only when this task was constructed with a `signalPollTarget`. */
166
+ signalPoll?: WorkflowClaimSignalPollOutcome;
167
+ /** Present only when this task was constructed with an `updatePollTarget`. */
168
+ updatePoll?: WorkflowClaimUpdatePollOutcome;
169
+ };
170
+ /**
171
+ * The interval-scheduling seam `workflow-claim-renewal-interval.ts` drives
172
+ * its interval-mode cadence through. The handle type is deliberately
173
+ * `unknown` on this public interface — nothing inspects a handle, only
174
+ * round-trips whatever `setInterval` returned back into `clearInterval` — so
175
+ * a test double can use a plain number, object, or anything else as its
176
+ * handle without either side needing to know the real timer type. Defined
177
+ * here for the same cross-module-without-a-cycle reason as
178
+ * {@link WorkflowClaimRenewalPassResult}; `workflow-claim-renewal-task.ts`
179
+ * re-exports it.
180
+ */
181
+ export type WorkflowClaimRenewalIntervalScheduler = {
182
+ setInterval(callback: () => void, intervalMs: number): unknown;
183
+ clearInterval(handle: unknown): void;
184
+ };
185
+ /**
186
+ * How many reclaim attempts may be in flight at once within a single pass.
187
+ *
188
+ * A serial loop lets one stuck candidate block every later one indefinitely —
189
+ * `attemptWorkflowClaimTakeover` can await an `onReclaimed` drive that never
190
+ * settles (e.g. a stalled storage read during replay), and a serial `for`
191
+ * loop never reaches the next candidate until that await resolves. Each
192
+ * candidate's takeover/acquire CAS and drive are independent per-workflow
193
+ * operations, so running them through the same bounded pool
194
+ * `runRenewalSubPass` uses for renewals is safe here too — see that
195
+ * function's doc for why a fixed-width pool is the right middle ground
196
+ * between full serialization and an unbounded stampede.
197
+ */
198
+ export declare const WORKFLOW_CLAIM_RECLAIM_CONCURRENCY = 16;
199
+ /**
200
+ * Run one reclaim-scan sub-pass: list candidates, attempt each through a
201
+ * bounded pool (see {@link WORKFLOW_CLAIM_RECLAIM_CONCURRENCY}), and catch
202
+ * both a per-candidate throw and the listing call itself throwing. See
203
+ * {@link WorkflowClaimReclaimPassResult}'s doc for why discovery failure is a
204
+ * result, not a rejection. `outcomes` stays in `candidates` order regardless
205
+ * of the order attempts actually settle, matching `runRenewalSubPass`'s own
206
+ * positional-result discipline.
207
+ */
208
+ export declare function runReclaimPass(target: WorkflowClaimReclaimTarget): Promise<WorkflowClaimReclaimPassResult>;
209
+ /**
210
+ * How many claim renewals may be in flight at once within a single pass.
211
+ *
212
+ * A serial loop costs one storage round trip per held claim before returning to
213
+ * the first one, so with many claims on a high-latency shared store the pass
214
+ * itself can outlast `workflowClaimTtl`: later claims expire before their first
215
+ * renewal, and earlier ones expire before the next pass. Separating the reclaim
216
+ * scan out of the renewal single-flight does not help — this loop is unbounded
217
+ * in the number of claims, independently of what else shares the tick.
218
+ *
219
+ * The opposite extreme is just as wrong: renewing every claim at once turns
220
+ * starvation into a storage stampede that the store may then rate-limit or
221
+ * queue, reproducing the latency it was meant to avoid. So renewals run through
222
+ * a fixed-width pool.
223
+ *
224
+ * Sixteen is chosen to be wide enough that per-request latency dominates rather
225
+ * than accumulates — it cuts a 1000-claim pass from 1000 sequential round trips
226
+ * to 63 — while staying within the connection budget a modest remote store
227
+ * offers. It is deliberately a constant rather than an option: it trades two
228
+ * failure modes against each other and neither is something a caller is well
229
+ * placed to tune. Revisit it with measurements, not intuition.
230
+ */
231
+ export declare const WORKFLOW_CLAIM_RENEWAL_CONCURRENCY = 16;
232
+ /**
233
+ * Renew every id in `workflowIds`, continuing past a per-workflow failure.
234
+ *
235
+ * Renewals run through a bounded pool (see
236
+ * {@link WORKFLOW_CLAIM_RENEWAL_CONCURRENCY}) rather than one at a time, so a
237
+ * large claim set cannot push the pass past the claim validity window. Losing
238
+ * one claim still stops only that workflow: each renewal keeps its own
239
+ * `try`/`catch`, and `outcomes` stays in `workflowIds` order regardless of the
240
+ * order results actually arrive, so callers and tests see a stable, positional
241
+ * result.
242
+ *
243
+ * Pure — no clock reads, no `onPassComplete` — so it is shared verbatim by
244
+ * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval
245
+ * mode's standalone renewal sub-pass.
246
+ */
247
+ export declare function runRenewalSubPass(target: WorkflowClaimRenewalTarget, workflowIds: readonly string[]): Promise<{
248
+ outcomes: WorkflowClaimRenewalOutcome[];
249
+ renewedCount: number;
250
+ failedCount: number;
251
+ }>;
252
+ /**
253
+ * Run the owner-side signal-poll sub-step, translating a throw into the
254
+ * `'failed'` result shape rather than letting it reject. Shared by
255
+ * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval
256
+ * mode's standalone reclaim-plus-poll sub-pass.
257
+ */
258
+ export declare function runSignalPollSubPass(signalPollTarget: OwnerSideSignalPollTarget, getNow: () => number): Promise<WorkflowClaimSignalPollOutcome>;
259
+ /**
260
+ * Run the owner-side update-poll sub-step (WFT-79), translating a throw into
261
+ * the `'failed'` result shape rather than letting it reject. Shared by
262
+ * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval
263
+ * mode's standalone update-poll sub-pass.
264
+ */
265
+ export declare function runUpdatePollSubPass(updatePollTarget: OwnerSideUpdatePollTarget, getNow: () => number): Promise<WorkflowClaimUpdatePollOutcome>;
@@ -0,0 +1,104 @@
1
+ import {
2
+ runOwnerSideSignalPoll
3
+ } from "./owner-side-signal-poll.js";
4
+ import {
5
+ runOwnerSideUpdatePoll
6
+ } from "./owner-side-update-poll.js";
7
+ export const WORKFLOW_CLAIM_RECLAIM_CONCURRENCY = 16;
8
+ export async function runReclaimPass(target) {
9
+ let candidates;
10
+ try {
11
+ candidates = await target.listReclaimCandidateWorkflowIds();
12
+ } catch (error) {
13
+ return { status: "discovery-failed", error };
14
+ }
15
+ const outcomes = Array.from({ length: candidates.length });
16
+ async function attemptOne(index) {
17
+ const workflowId = candidates[index];
18
+ try {
19
+ const attempt = await target.attemptWorkflowClaimTakeover(workflowId);
20
+ outcomes[index] = { workflowId, ...attempt };
21
+ } catch (error) {
22
+ outcomes[index] = { workflowId, status: "error", error };
23
+ }
24
+ }
25
+ if (candidates.length <= 1)
26
+ for (const [index] of candidates.entries())
27
+ await attemptOne(index);
28
+ else {
29
+ let nextIndex = 0;
30
+ async function attemptFromQueue() {
31
+ for (;; ) {
32
+ const index = nextIndex;
33
+ nextIndex += 1;
34
+ if (index >= candidates.length)
35
+ return;
36
+ await attemptOne(index);
37
+ }
38
+ }
39
+ const workerCount = Math.min(WORKFLOW_CLAIM_RECLAIM_CONCURRENCY, candidates.length);
40
+ await Promise.all(Array.from({ length: workerCount }, attemptFromQueue));
41
+ }
42
+ return {
43
+ status: "completed",
44
+ outcomes,
45
+ reclaimedCount: outcomes.filter((outcome) => outcome.status === "reclaimed").length
46
+ };
47
+ }
48
+ export const WORKFLOW_CLAIM_RENEWAL_CONCURRENCY = 16;
49
+ export async function runRenewalSubPass(target, workflowIds) {
50
+ const outcomes = Array.from({ length: workflowIds.length });
51
+ let nextIndex = 0;
52
+ async function renewFromQueue() {
53
+ for (;; ) {
54
+ const index = nextIndex;
55
+ nextIndex += 1;
56
+ if (index >= workflowIds.length)
57
+ return;
58
+ const workflowId = workflowIds[index];
59
+ try {
60
+ await target.renewWorkflowClaim(workflowId);
61
+ outcomes[index] = { workflowId, status: "renewed" };
62
+ } catch (error) {
63
+ outcomes[index] = { workflowId, status: "failed", error };
64
+ }
65
+ }
66
+ }
67
+ if (workflowIds.length <= 1)
68
+ for (const [index, workflowId] of workflowIds.entries())
69
+ try {
70
+ await target.renewWorkflowClaim(workflowId);
71
+ outcomes[index] = { workflowId, status: "renewed" };
72
+ } catch (error) {
73
+ outcomes[index] = { workflowId, status: "failed", error };
74
+ }
75
+ else {
76
+ const workerCount = Math.min(WORKFLOW_CLAIM_RENEWAL_CONCURRENCY, workflowIds.length);
77
+ await Promise.all(Array.from({ length: workerCount }, renewFromQueue));
78
+ }
79
+ return {
80
+ outcomes,
81
+ renewedCount: outcomes.filter((outcome) => outcome.status === "renewed").length,
82
+ failedCount: outcomes.filter((outcome) => outcome.status === "failed").length
83
+ };
84
+ }
85
+ export async function runSignalPollSubPass(signalPollTarget, getNow) {
86
+ try {
87
+ return {
88
+ status: "completed",
89
+ result: await runOwnerSideSignalPoll({ target: signalPollTarget, getNow })
90
+ };
91
+ } catch (error) {
92
+ return { status: "failed", error };
93
+ }
94
+ }
95
+ export async function runUpdatePollSubPass(updatePollTarget, getNow) {
96
+ try {
97
+ return {
98
+ status: "completed",
99
+ result: await runOwnerSideUpdatePoll({ target: updatePollTarget, getNow })
100
+ };
101
+ } catch (error) {
102
+ return { status: "failed", error };
103
+ }
104
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Recurring driver for the per-workflow claim-renewal lifecycle task described
3
+ * in [ADR 0002 § Reclaiming stranded claims](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#reclaiming-stranded-claims)
4
+ * and its `renew` transition row.
5
+ *
6
+ * **Why this must be its own driver, independent of the durable-timer
7
+ * scheduler.** Renewal keeps a *parked* workflow's claim alive — sleep,
8
+ * wait-signal, wait-update, wait-condition, wait-async-activity, and
9
+ * await-child-completion all keep the claim held rather than releasing it (see
10
+ * the ADR's `wf-owner-holder` row). A parked workflow has no pending durable
11
+ * timer of its own to piggyback on, and `startScheduler: false` is an
12
+ * explicitly supported, orthogonal configuration — tying renewal to
13
+ * `Scheduler#tick` would silently expire every parked claim the moment the
14
+ * scheduler is disabled, even though the owning engine is perfectly healthy.
15
+ * This task runs on its own cadence, or on an explicitly awaited host tick
16
+ * under `backgroundTasks: 'manual'` (see `Engine#runMaintenance()`), and never
17
+ * depends on the scheduler being started.
18
+ *
19
+ * **What this module owns.** `runOnce()` — the manual/`backgroundTasks:
20
+ * 'manual'` combined pass — plus the public options/result/task types. The
21
+ * actual claim storage transitions (`buildWorkflowClaimRenewTransition`), the
22
+ * in-flight-renewal guard *per workflow* that serializes a renewal against a
23
+ * concurrent `release` (ADR `renew` row), and the deposition side effects of
24
+ * a failed renewal — aborting the workflow's in-flight work and emitting
25
+ * {@link WeftWorkflowClaimLostWarning} — all belong to the claim holder this
26
+ * task is handed ({@link WorkflowClaimRenewalTarget}), typically a
27
+ * `WorkflowClaimRegistry`. This module never imports that registry directly
28
+ * and never imports `lease-deposition.ts`: coupling to the registry's exact
29
+ * shape here would tie two patches built in parallel together, and duplicating
30
+ * its warning-emission would double-emit once the two are wired together.
31
+ * Interval-mode driving (four independent single-flight sub-pass slots) lives
32
+ * in `workflow-claim-renewal-interval.ts`, split out to keep this file under
33
+ * the repository's implementation-file-size ceiling — see that module's doc
34
+ * for the full "each sub-pass gets its own slot" rationale (WFT-79 Finding 2
35
+ * and its extensions).
36
+ *
37
+ * **`runOnce()` still runs every configured sub-step sequentially in one
38
+ * awaited call**, exactly as ADR 0002 describes — a host calling
39
+ * `runMaintenance()` gets one coherent pass (serialized against any other
40
+ * concurrent `runOnce()` call — see `inFlightManualPass`). Interval mode's
41
+ * `start()`/`stop()` (delegated to `workflow-claim-renewal-interval.ts`)
42
+ * gives renewal, reclaim, signal poll, and update poll each their own
43
+ * cadence instead.
44
+ *
45
+ * **Three more ADR 0002 responsibilities besides renewal itself**, each
46
+ * OPTIONAL: a reclaim scan that attempts `takeover` for workflows whose
47
+ * holders have passed the grace-adjusted `expire` judgment (ADR § Reclaiming
48
+ * stranded claims), driven through the {@link WorkflowClaimReclaimTarget}
49
+ * structural seam — expected to be satisfied by
50
+ * `listWorkflowClaimReclaimCandidates` (`workflow-claim-reclaim-scan.ts`)
51
+ * plus `WorkflowClaimRegistry.takeover`, adapted by `ownership-bootstrap.ts`
52
+ * — owner-side polling of the durable signal buffer for parked
53
+ * `waitForSignal` workflows (ADR § signal delivery, "owner-side polling"),
54
+ * and owner-side polling of pending coordinated updates (WFT-79). All three
55
+ * are `undefined` in {@link WorkflowClaimRenewalPassResult} when their target
56
+ * option is omitted, which is how a caller with only the renewal target (or
57
+ * a test) opts out of running them at all.
58
+ *
59
+ * @module core/engine/workflow-claim-renewal-task
60
+ */
61
+ import type { OwnerSideSignalPollTarget } from './owner-side-signal-poll.ts';
62
+ import type { OwnerSideUpdatePollTarget } from './owner-side-update-poll.ts';
63
+ import { type WorkflowClaimReclaimTarget, type WorkflowClaimRenewalIntervalScheduler, type WorkflowClaimRenewalPassResult, type WorkflowClaimRenewalTarget } from './workflow-claim-renewal-subpasses.ts';
64
+ export type { WorkflowClaimRenewalIntervalScheduler, WorkflowClaimRenewalPassResult, } from './workflow-claim-renewal-subpasses.ts';
65
+ /** Options for {@link createWorkflowClaimRenewalTask}. */
66
+ export type WorkflowClaimRenewalTaskOptions = {
67
+ /** The claim holder this task renews on behalf of. */
68
+ target: WorkflowClaimRenewalTarget;
69
+ /**
70
+ * Wall-clock source (ms), injected so tests never depend on real time. There
71
+ * is no default — every call site (the engine) already threads an injected
72
+ * clock through the rest of the ownership machinery (see `lease-manager.ts`),
73
+ * and an implicit `Date.now()` default would be a code path tests could
74
+ * never exercise deterministically.
75
+ */
76
+ getNow: () => number;
77
+ /** Interval-mode cadence (ms). Should be the configured `workflowClaimRenewInterval`. */
78
+ intervalMs: number;
79
+ /**
80
+ * Interval-scheduling seam. Defaults to the real `setInterval`/`clearInterval`,
81
+ * with the created interval `unref()`'d (when supported) so it never keeps an
82
+ * otherwise-idle process alive — mirroring `lease-manager.ts`'s `startRenewal`.
83
+ */
84
+ scheduler?: WorkflowClaimRenewalIntervalScheduler;
85
+ /**
86
+ * Optional reclaim-scan target. Omitted (the default) runs renewal alone —
87
+ * `result.reclaim` stays `undefined`. See {@link WorkflowClaimReclaimTarget}.
88
+ */
89
+ reclaimTarget?: WorkflowClaimReclaimTarget;
90
+ /**
91
+ * Optional owner-side signal-poll target. Omitted (the default) leaves
92
+ * `result.signalPoll` `undefined`. See `owner-side-signal-poll.ts`'s
93
+ * `OwnerSideSignalPollTarget`.
94
+ */
95
+ signalPollTarget?: OwnerSideSignalPollTarget;
96
+ /**
97
+ * Optional owner-side update-poll target (WFT-79). Omitted (the default)
98
+ * leaves `result.updatePoll` `undefined`. See `owner-side-update-poll.ts`'s
99
+ * `OwnerSideUpdatePollTarget`.
100
+ */
101
+ updatePollTarget?: OwnerSideUpdatePollTarget;
102
+ /**
103
+ * Called after every completed pass, interval-driven or explicit. This is
104
+ * the reporting seam a later observability stage hangs a metrics counter
105
+ * off of (e.g. `weft_workflow_claim_renewal_failures_total`); this task does
106
+ * not touch any metrics registry itself.
107
+ */
108
+ onPassComplete?: (result: WorkflowClaimRenewalPassResult) => void;
109
+ };
110
+ /** A running claim-renewal task. */
111
+ export type WorkflowClaimRenewalTask = {
112
+ /**
113
+ * Run exactly one pass: snapshot the currently-held workflow ids, renew each
114
+ * in turn, and continue past a failure on any one of them so a single lost
115
+ * claim never stops the rest from renewing. A per-workflow renewal failure is
116
+ * captured as a `'failed'` outcome rather than a rejection; the pass itself
117
+ * rejects only if `listHeldWorkflowIds()` throws, since without a snapshot
118
+ * there is no pass to run. A throwing `onPassComplete` sink never fails the
119
+ * pass — the renewal work already committed by then.
120
+ * This is the entry point `backgroundTasks: 'manual'` hosts drive from an
121
+ * awaited host tick (`Engine#runMaintenance()`), and the one tests use to
122
+ * step the task deterministically. Bypasses the interval-mode single-flight
123
+ * slots by design — same as `renewOnce()` does relative to
124
+ * `startRenewal()`'s timer in `lease-manager.ts` — so an explicit caller
125
+ * is never silently skipped because an interval-driven pass happens to be
126
+ * in flight. It IS, however, deduped against another concurrent `runOnce()`
127
+ * call: `Engine.create()` documents concurrent `runMaintenance()` calls as
128
+ * safe, and two independent combined passes sharing this task's targets
129
+ * could otherwise both act on the same stale reclaim candidate. An
130
+ * overlapping caller receives the already-running pass's result rather
131
+ * than starting a second one.
132
+ */
133
+ runOnce(): Promise<WorkflowClaimRenewalPassResult>;
134
+ /**
135
+ * Start interval-driven passes. Idempotent — a second call while already
136
+ * started does not create a second interval. Each interval tick is guarded
137
+ * by a single-flight lock: if the previous interval-driven pass is still
138
+ * running when the next tick fires, that tick is skipped rather than
139
+ * starting an overlapping pass.
140
+ */
141
+ start(): void;
142
+ /**
143
+ * Stop interval-driven passes. Idempotent — a second call, or a call before
144
+ * `start()`, does not throw. Only clears the interval; a pass already in
145
+ * flight (started by the last tick before `stop()`) still runs to
146
+ * completion, but no further tick fires afterward.
147
+ */
148
+ stop(): void;
149
+ };
150
+ /**
151
+ * Create a claim-renewal task. Does not start any timer and does not run any
152
+ * pass itself — the caller drives it, either via `start()` for interval mode
153
+ * (delegated to `workflow-claim-renewal-interval.ts`) or by awaiting
154
+ * `runOnce()` directly under `backgroundTasks: 'manual'`. The sub-pass
155
+ * implementations both this module and the interval driver compose
156
+ * (`runReclaimPass`, `runRenewalSubPass`, `runSignalPollSubPass`,
157
+ * `runUpdatePollSubPass`) live in `workflow-claim-renewal-subpasses.ts`.
158
+ */
159
+ export declare function createWorkflowClaimRenewalTask(options: WorkflowClaimRenewalTaskOptions): WorkflowClaimRenewalTask;
@@ -0,0 +1,65 @@
1
+ import {
2
+ createWorkflowClaimRenewalIntervalDriver,
3
+ defaultWorkflowClaimRenewalScheduler
4
+ } from "./workflow-claim-renewal-interval.js";
5
+ import {
6
+ runReclaimPass,
7
+ runRenewalSubPass,
8
+ runSignalPollSubPass,
9
+ runUpdatePollSubPass
10
+ } from "./workflow-claim-renewal-subpasses.js";
11
+ export function createWorkflowClaimRenewalTask(options) {
12
+ const {
13
+ target,
14
+ getNow,
15
+ intervalMs,
16
+ reclaimTarget,
17
+ signalPollTarget,
18
+ updatePollTarget,
19
+ onPassComplete
20
+ } = options, scheduler = options.scheduler ?? defaultWorkflowClaimRenewalScheduler();
21
+ let inFlightManualPass = null;
22
+ function emitPassComplete(result) {
23
+ try {
24
+ onPassComplete?.(result);
25
+ } catch (error) {
26
+ console.error("weft: workflow claim renewal onPassComplete sink threw", error);
27
+ }
28
+ }
29
+ async function runCombinedPass() {
30
+ const startedAt = getNow(), workflowIds = [...target.listHeldWorkflowIds()], renewal = await runRenewalSubPass(target, workflowIds), reclaim = reclaimTarget === void 0 ? void 0 : await runReclaimPass(reclaimTarget), signalPoll = signalPollTarget === void 0 ? void 0 : await runSignalPollSubPass(signalPollTarget, getNow), updatePoll = updatePollTarget === void 0 ? void 0 : await runUpdatePollSubPass(updatePollTarget, getNow), finishedAt = getNow(), result = {
31
+ startedAt,
32
+ finishedAt,
33
+ ...renewal,
34
+ ...reclaim === void 0 ? {} : { reclaim },
35
+ ...signalPoll === void 0 ? {} : { signalPoll },
36
+ ...updatePoll === void 0 ? {} : { updatePoll }
37
+ };
38
+ emitPassComplete(result);
39
+ return result;
40
+ }
41
+ async function runOnce() {
42
+ if (inFlightManualPass !== null)
43
+ return inFlightManualPass;
44
+ const pass = runCombinedPass().finally(() => {
45
+ inFlightManualPass = null;
46
+ });
47
+ inFlightManualPass = pass;
48
+ return pass;
49
+ }
50
+ const intervalDriver = createWorkflowClaimRenewalIntervalDriver({
51
+ target,
52
+ getNow,
53
+ intervalMs,
54
+ scheduler,
55
+ ...reclaimTarget === void 0 ? {} : { reclaimTarget },
56
+ ...signalPollTarget === void 0 ? {} : { signalPollTarget },
57
+ ...updatePollTarget === void 0 ? {} : { updatePollTarget },
58
+ ...onPassComplete === void 0 ? {} : { onPassComplete }
59
+ });
60
+ return {
61
+ runOnce,
62
+ start: () => intervalDriver.start(),
63
+ stop: () => intervalDriver.stop()
64
+ };
65
+ }