/** * The three individual sub-pass implementations `workflow-claim-renewal-task.ts` * composes into its combined `runOnce()` pass and, in interval mode, its two * independently single-flight-guarded tick passes (see that module's * "Renewal cadence is independent of the reclaim scan and signal poll in * interval mode" doc section, WFT-79 Finding 2). Split into its own module so * `workflow-claim-renewal-task.ts` stays under the repository's * implementation-file-size ceiling; these are pure, target-driven functions * with no dependency on the renewal task's own scheduling/single-flight * state. * * Also home to every structural target/result type these sub-passes and * `workflow-claim-renewal-task.ts` share, for the same reason — * `ownership-bootstrap.ts` imports several of these types directly from * `workflow-claim-renewal-task.ts` (not from here), so that module re-exports * them; this module is the one place they are actually defined. * * @module core/engine/workflow-claim-renewal-subpasses */ import { type OwnerSideSignalPollResult, type OwnerSideSignalPollTarget } from './owner-side-signal-poll.ts'; import { type OwnerSideUpdatePollResult, type OwnerSideUpdatePollTarget } from './owner-side-update-poll.ts'; /** * The minimal structural shape the renewal sub-pass needs from a per-workflow * claim holder. A `WorkflowClaimRegistry` (built separately) is expected to * satisfy this interface; it is defined locally, rather than imported, so * this module has no dependency on that registry's concrete shape or module * path. */ export type WorkflowClaimRenewalTarget = { /** * Every workflow id this engine currently holds a live claim for, active or * parked. Read fresh at the start of every pass — implementations may * return a live or a defensive-copy array; the caller never mutates it and * takes its own snapshot before iterating. */ listHeldWorkflowIds(): readonly string[]; /** * Renew this engine's claim for one workflow. Resolves when the renewal * committed; rejects (with any error shape) when it did not — a lost-race * CAS failure, a storage error, or anything else. The implementation is * responsible for its own per-workflow in-flight-renewal guard against a * concurrent `release`, and for reacting to a lost claim (aborting * in-flight work, emitting `WeftWorkflowClaimLostWarning`). The caller only * calls it, catches whatever it throws, and continues to the next * workflow. */ renewWorkflowClaim(workflowId: string): Promise; }; /** One workflow's outcome within a single renewal pass. */ export type WorkflowClaimRenewalOutcome = { workflowId: string; status: 'renewed'; } | { workflowId: string; status: 'failed'; error: unknown; }; /** * The minimal structural shape the reclaim-scan sub-pass needs. Defined * locally for the same decoupling reason as {@link WorkflowClaimRenewalTarget} * — expected to be satisfied by an adapter over * `listWorkflowClaimReclaimCandidates` (`workflow-claim-reclaim-scan.ts`) and * `WorkflowClaimRegistry.takeover`, built by `ownership-bootstrap.ts`. */ export type WorkflowClaimReclaimTarget = { /** * Every workflow id with a currently-persisted holder record this engine * does not itself already hold. Read fresh at the start of every pass. */ listReclaimCandidateWorkflowIds(): Promise; /** * Attempt to reclaim one candidate. Retrying a lost-race CAS (bounded, per * the ADR, at 5 attempts within this call) and gating on the per-workflow-id * anti-thrash cooldown are the implementation's responsibility — the caller * calls it exactly once per candidate, catches whatever it throws, and * continues to the next one. */ attemptWorkflowClaimTakeover(workflowId: string): Promise; }; /** One candidate's non-throwing outcome from {@link WorkflowClaimReclaimTarget.attemptWorkflowClaimTakeover}. */ export type WorkflowClaimReclaimAttemptResult = { status: 'reclaimed'; } | { status: 'not-eligible'; } | { status: 'backoff-skipped'; } | { status: 'lost-race'; }; /** One workflow's outcome within a single reclaim-scan pass. */ export type WorkflowClaimReclaimOutcome = ({ workflowId: string; } & WorkflowClaimReclaimAttemptResult) | { workflowId: string; status: 'error'; error: unknown; }; /** * The reclaim-scan sub-pass's result. `'discovery-failed'` covers * `listReclaimCandidateWorkflowIds()` itself throwing — without a candidate * list there is no per-workflow loop to run, but that must not fail the rest * of an enclosing combined pass (renewals already committed by then, and a * `backgroundTasks: 'manual'` host awaiting `runMaintenance()` must not see a * rejected promise for a problem isolated to this one sub-step). */ export type WorkflowClaimReclaimPassResult = { status: 'completed'; outcomes: WorkflowClaimReclaimOutcome[]; reclaimedCount: number; } | { status: 'discovery-failed'; error: unknown; }; /** * The owner-side signal-poll sub-pass's result. `'failed'` covers * {@link runOwnerSideSignalPoll} itself rejecting (e.g. its target's * `hasBufferedSignal` throwing) — same non-fatal-to-the-enclosing-pass * treatment as {@link WorkflowClaimReclaimPassResult}'s `'discovery-failed'`. */ export type WorkflowClaimSignalPollOutcome = { status: 'completed'; result: OwnerSideSignalPollResult; } | { status: 'failed'; error: unknown; }; /** * The owner-side update-poll sub-pass's result (WFT-79). `'failed'` covers * {@link runOwnerSideUpdatePoll} itself rejecting (e.g. its target's * `hasPendingUpdates` throwing) — same non-fatal-to-the-enclosing-pass * treatment as {@link WorkflowClaimSignalPollOutcome}'s `'failed'`. */ export type WorkflowClaimUpdatePollOutcome = { status: 'completed'; result: OwnerSideUpdatePollResult; } | { status: 'failed'; error: unknown; }; /** * The result of one full claim-renewal pass * (`workflow-claim-renewal-task.ts`'s `WorkflowClaimRenewalTask.runOnce`, or * one of `workflow-claim-renewal-interval.ts`'s interval-mode sub-passes). * `reclaim`/`signalPoll`/`updatePoll` are `undefined` exactly when the * matching target option was omitted — that omission is how a caller (or a * test exercising renewal alone) opts out of running that sub-step at all. * Defined here (rather than in `workflow-claim-renewal-task.ts`) so both that * module and `workflow-claim-renewal-interval.ts` can depend on it without a * cycle between them; `workflow-claim-renewal-task.ts` re-exports it for * backward-compatible import paths. */ export type WorkflowClaimRenewalPassResult = { /** `getNow()` read at the start of the pass, before any renewal call. */ startedAt: number; /** `getNow()` read after renewal, reclaim, and signal-poll have all settled. */ finishedAt: number; /** One entry per workflow id the pass attempted, in iteration order. */ outcomes: WorkflowClaimRenewalOutcome[]; /** `outcomes.filter(o => o.status === 'renewed').length`, precomputed for observability consumers. */ renewedCount: number; /** `outcomes.filter(o => o.status === 'failed').length`, precomputed for observability consumers. */ failedCount: number; /** Present only when this task was constructed with a `reclaimTarget`. */ reclaim?: WorkflowClaimReclaimPassResult; /** Present only when this task was constructed with a `signalPollTarget`. */ signalPoll?: WorkflowClaimSignalPollOutcome; /** Present only when this task was constructed with an `updatePollTarget`. */ updatePoll?: WorkflowClaimUpdatePollOutcome; }; /** * The interval-scheduling seam `workflow-claim-renewal-interval.ts` drives * its interval-mode cadence through. The handle type is deliberately * `unknown` on this public interface — nothing inspects a handle, only * round-trips whatever `setInterval` returned back into `clearInterval` — so * a test double can use a plain number, object, or anything else as its * handle without either side needing to know the real timer type. Defined * here for the same cross-module-without-a-cycle reason as * {@link WorkflowClaimRenewalPassResult}; `workflow-claim-renewal-task.ts` * re-exports it. */ export type WorkflowClaimRenewalIntervalScheduler = { setInterval(callback: () => void, intervalMs: number): unknown; clearInterval(handle: unknown): void; }; /** * How many reclaim attempts may be in flight at once within a single pass. * * A serial loop lets one stuck candidate block every later one indefinitely — * `attemptWorkflowClaimTakeover` can await an `onReclaimed` drive that never * settles (e.g. a stalled storage read during replay), and a serial `for` * loop never reaches the next candidate until that await resolves. Each * candidate's takeover/acquire CAS and drive are independent per-workflow * operations, so running them through the same bounded pool * `runRenewalSubPass` uses for renewals is safe here too — see that * function's doc for why a fixed-width pool is the right middle ground * between full serialization and an unbounded stampede. */ export declare const WORKFLOW_CLAIM_RECLAIM_CONCURRENCY = 16; /** * Run one reclaim-scan sub-pass: list candidates, attempt each through a * bounded pool (see {@link WORKFLOW_CLAIM_RECLAIM_CONCURRENCY}), and catch * both a per-candidate throw and the listing call itself throwing. See * {@link WorkflowClaimReclaimPassResult}'s doc for why discovery failure is a * result, not a rejection. `outcomes` stays in `candidates` order regardless * of the order attempts actually settle, matching `runRenewalSubPass`'s own * positional-result discipline. */ export declare function runReclaimPass(target: WorkflowClaimReclaimTarget): Promise; /** * How many claim renewals may be in flight at once within a single pass. * * A serial loop costs one storage round trip per held claim before returning to * the first one, so with many claims on a high-latency shared store the pass * itself can outlast `workflowClaimTtl`: later claims expire before their first * renewal, and earlier ones expire before the next pass. Separating the reclaim * scan out of the renewal single-flight does not help — this loop is unbounded * in the number of claims, independently of what else shares the tick. * * The opposite extreme is just as wrong: renewing every claim at once turns * starvation into a storage stampede that the store may then rate-limit or * queue, reproducing the latency it was meant to avoid. So renewals run through * a fixed-width pool. * * Sixteen is chosen to be wide enough that per-request latency dominates rather * than accumulates — it cuts a 1000-claim pass from 1000 sequential round trips * to 63 — while staying within the connection budget a modest remote store * offers. It is deliberately a constant rather than an option: it trades two * failure modes against each other and neither is something a caller is well * placed to tune. Revisit it with measurements, not intuition. */ export declare const WORKFLOW_CLAIM_RENEWAL_CONCURRENCY = 16; /** * Renew every id in `workflowIds`, continuing past a per-workflow failure. * * Renewals run through a bounded pool (see * {@link WORKFLOW_CLAIM_RENEWAL_CONCURRENCY}) rather than one at a time, so a * large claim set cannot push the pass past the claim validity window. Losing * one claim still stops only that workflow: each renewal keeps its own * `try`/`catch`, and `outcomes` stays in `workflowIds` order regardless of the * order results actually arrive, so callers and tests see a stable, positional * result. * * Pure — no clock reads, no `onPassComplete` — so it is shared verbatim by * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval * mode's standalone renewal sub-pass. */ export declare function runRenewalSubPass(target: WorkflowClaimRenewalTarget, workflowIds: readonly string[]): Promise<{ outcomes: WorkflowClaimRenewalOutcome[]; renewedCount: number; failedCount: number; }>; /** * Run the owner-side signal-poll sub-step, translating a throw into the * `'failed'` result shape rather than letting it reject. Shared by * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval * mode's standalone reclaim-plus-poll sub-pass. */ export declare function runSignalPollSubPass(signalPollTarget: OwnerSideSignalPollTarget, getNow: () => number): Promise; /** * Run the owner-side update-poll sub-step (WFT-79), translating a throw into * the `'failed'` result shape rather than letting it reject. Shared by * `workflow-claim-renewal-task.ts`'s combined `runOnce()` pass and interval * mode's standalone update-poll sub-pass. */ export declare function runUpdatePollSubPass(updatePollTarget: OwnerSideUpdatePollTarget, getNow: () => number): Promise;