/** * Storage-scan candidate discovery for the recurring reclaim pass described * in [ADR 0002 § Reclaiming stranded claims](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#reclaiming-stranded-claims). * * A single boot-time `recoverAll()` sweep only catches a stranded claim that * happens to already be past its grace-adjusted expiry at boot. An engine * that crashes (or whose graceful-shutdown release fails) later leaves its * `wf-owner-holder:` record behind for every OTHER engine to discover; * nothing rescans for that unless something recurring does. This module is * the discovery half of that recurring scan: enumerate every workflow id * with a currently-persisted holder record, store-wide, so a caller can * attempt `WorkflowClaimRegistry.takeover` against each one. * * **Deliberately not a liveness filter.** This module does not decode holder * bytes or judge staleness — `WorkflowClaimRegistry.takeover` already does * that with a fresh read at the moment of the attempt (see its own * grace-adjusted `isWorkflowClaimExpired` check), and re-deciding it here * from a possibly-stale scan read would be redundant at best and a source of * a second, drifting judgment at worst. This is discovery only. * * **Ownerless-but-running workflows.** The holder-keyed scan above misses a * real rolling-deploy shape: an incoming engine boots and runs its * `recoverAll()` sweep WHILE an outgoing engine is still the live holder of * some workflow — recovery correctly skips it, since the holder is not yet * expired. If the outgoing engine then disposes gracefully, its * `releaseAll()` DELETES that workflow's `wf-owner-holder:` record * (per the ADR's `release` row — the epoch is retained, only the holder * goes). The workflow is now `running` in `WorkflowState` with no holder at * all, and the holder-keyed scan above will never find it again — there is * no `wf-owner-holder:` key left to enumerate. The already-running * incoming engine has nothing further to trigger a re-scan, so the workflow * is stranded until an operator explicitly calls `recoverAll()`/`resume()`. * This module closes that gap with a second scan over the `running`-status * workflow-visibility index (`wf-idx-status:running:`), checking each * candidate's holder key fresh and including only the genuinely holderless * ones — bounded to the same cost class as the holder-keyed scan (one extra * index prefix scan per pass, plus one point read per running-and-holderless * candidate). * * **Workflows with no visibility-index entry at all (WFT-79 Finding 2).** * The index-based scan above is itself incomplete on a Bun SQLite deployment * that predates the workflow visibility indexes and has not yet run the * one-time backfill (see * [Workflow Visibility Backfill](../../../documentation/guides/workflow-visibility-backfill.md)): * such a workflow has no `wf-idx-status:running:` row to enumerate. If * that workflow is the outgoing engine's live holder at the moment an * incoming engine's `recoverAll()` sweep runs, recovery correctly skips it * (the holder is not yet expired); if the outgoing engine then disposes * gracefully, `releaseAll()` deletes the holder record and the workflow is * left `running` with no holder AND no visibility-index entry — invisible to * both scans above. Left there, it is stranded indefinitely: nothing else * re-scans for it. * * This module closes that second gap with a bounded, cursor-rotated fallback * scan directly over the authoritative `wf:` workflow records (the same * source `recoverAll()`'s own preflight already scans at boot — see * `lifecycle/transition.ts`'s `preflightRecoverAll`), decoding each record * and including only genuinely holderless `running` ones not already found * by either scan above. This IS a store-wide operation and therefore more * expensive than the index-based scans, so it is bounded per pass to * {@link WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT} records rather than * scanning the entire keyspace on every call. A per-storage cursor (advanced * past the last key read, and wrapped back to the start once a pass reaches * the end of the keyspace) rotates the scanned window across passes, so a * store with more un-backfilled workflows than the per-pass limit still gets * full coverage over several reclaim-scan passes rather than only ever * re-scanning the same lexicographically-first window. This fallback is * expected to do genuine work only on deployments that have not yet run the * visibility backfill; run that backfill to eliminate this scan's ongoing * cost entirely. * * @module core/engine/workflow-claim-reclaim-scan */ import { type Storage } from '../../storage/interface.ts'; /** * Per-pass bound on the authoritative-record fallback scan * ({@link listOwnerlessRunningCandidatesFromAuthoritativeRecords}). This * fallback is a store-wide scan, unlike the two index-based scans above, so * it is capped rather than run to exhaustion on every pass — see the module * doc's "Workflows with no visibility-index entry at all" section for why * the cap is safe (a per-storage cursor rotates the scanned window across * passes, so a store with more un-backfilled workflows than this limit still * gets full coverage over several passes). */ export declare const WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT = 500; /** * Full candidate discovery for one reclaim-scan pass: every workflow id with * a currently-persisted holder record, every `running`-status workflow id * with NO holder record at all found via the visibility index (see the * module doc's "Ownerless-but-running workflows" section), and every * `running`-status, holderless workflow id found by the bounded * authoritative-record fallback for workflows with no visibility-index entry * at all (see the module doc's "Workflows with no visibility-index entry at * all" section) — excluding `excludeWorkflowIds` from all three. */ export declare function listWorkflowClaimReclaimCandidates(storage: Storage, excludeWorkflowIds: ReadonlySet): Promise;