/** * Lease-fenced single-writer ownership over a shared durable store. This is the * opt-in `Engine.create({ ownership: 'lease' })` mechanism — a CORRECTNESS-PATH * coordinator, NOT a best-effort smoke alarm like the second-instance detector. * Lease outcomes are acted on, never silently dropped, but the channel differs by * phase: a failed acquire THROWS and blocks recovery (corruption fails closed, a * timed-out handoff raises a typed error); a renewal reports loss through * `onLeaseLost` rather than throwing — a CAS-failed renewal means a successor took * the lease ('deposed'), and a transient renewal storage error is tolerated until * the lease is too close to lapsing to prove ownership ('renewal-unconfirmable'). * In Step 1 that loss is observability only (the engine warns); Step 2 will make it * enforceable by fencing every durable write on the lease epoch. * * **Why a lease.** Weft's supported model is one engine process per durable * store. Without a lease, a rolling deploy briefly runs two engines (old draining * + new booting), both recovering and both issuing at-least-once activities. The * lease turns that into a clean handoff: the booting instance ACQUIRES the lease * before recovering, the draining instance RELEASES it on dispose, and the new * instance only recovers once it owns the lease. * * **Two-key schema (this is load-bearing, not an optimization).** Ownership is * tracked by two keys, never one: * * - `lease:epoch` — an 8-byte big-endian uint64 fencing token. It changes ONLY on * ownership transfer (cold acquire, or a steal/re-acquire after the prior * holder's lease lapsed), NEVER on a renewal. It is a monotonic high-water mark * that SURVIVES release. * - `lease:holder` — a JSON `{ holderId, expiresAt, epoch }` record renewed on * every heartbeat (its `expiresAt` advances), so its bytes churn constantly. * * They are split because `conditionalBatch` compares the WHOLE stored value as * bytes. If the epoch lived inside the churning holder record, the fencing token * (Step 2) would change on every renewal, spuriously failing the fence and * self-terminating healthy holders. Keeping the epoch in its own stable key lets * a holder cache its epoch once ({@link LeaseManager.currentEpochBytes}) and use * it unchanged as a fencing condition across many renewals. * * **Epoch monotonicity is the anti-split-brain invariant.** Every transfer * conditions on BOTH the holder AND the current epoch and bumps the epoch by one; * `release()` deletes ONLY the holder and never the epoch. This guarantees a * deposed zombie from generation N can never see its epoch re-minted under it — * so once Step 2 fences durable writes on the epoch, the zombie always loses. * * @module core/engine/lease-manager */ import { type Storage } from '../../storage/interface.ts'; import type { LeaseLostReason, LeaseManagerHealth } from './lease-health.ts'; export type { LeaseLostReason } from './lease-health.ts'; /** Options for {@link createLeaseManager}. */ export type LeaseManagerOptions = { storage: Storage; /** This engine's unique instance id (the lease holder id). */ holderId: string; /** Wall-clock source (ms), injected so tests can advance time deterministically. */ getNow: () => number; /** Lease time-to-live (ms). A stolen lease becomes available `ttlMs` after its last renewal. */ ttlMs: number; /** Renewal interval (ms). The holder re-asserts the lease this often; must be `< ttlMs`. */ renewIntervalMs: number; /** Boot-time wait window (ms) before {@link LeaseManager.acquire} throws. */ waitTimeoutMs: number; /** Poll interval (ms) for the acquire wait loop. */ acquirePollIntervalMs?: number; /** * Delay primitive for the acquire poll loop. Defaults to a real `setTimeout`. * Injected so tests drive the loop deterministically: a test `delay` advances * the same injected clock by the poll interval and resolves immediately, so the * wait deadline (read from `getNow`) trips without real waiting. Mirrors the * `getNow` injection — not test-only scaffolding. */ delay?: (ms: number) => Promise; /** * Called once when this holder loses the lease while running — either CAS-false * on renewal (`'deposed'`: a successor stole it) or storage failures that make * the holder unable to prove it still holds before the lease lapses * (`'renewal-unconfirmable'`). In Step 1 the engine reacts by warning; Step 2 * uses this to halt fenced writes. The lease manager never throws into the * renewal timer — it reports through this seam instead. */ onLeaseLost?: (reason: LeaseLostReason) => void; }; /** * A running lease manager. * * - `acquire()` blocks until this instance owns the lease, then resolves; throws * {@link EngineLeaseAcquisitionTimeoutError} on timeout. Call before recovery. * One exception to "resolved ⇒ owned": if the manager was `stop()`ped (disposal) * while acquire was waiting, it resolves WITHOUT owning — the engine gates * recovery on `disposed` so a stopped-then-resolved acquire never proceeds. * - `startRenewal()` begins the heartbeat that keeps the lease held (drives * {@link LeaseManager.renewOnce} on an interval). * - `renewOnce()` runs a single renewal round; exposed for tests so renewal can be * driven deterministically with an injected clock, mirroring the detector's * `tick()`. * - `currentEpochBytes()` returns the held epoch as bytes for Step-2 fencing * conditions; `null` before a successful acquire. Synchronous and stable across * renewals. * - `release()` best-effort relinquishes the lease (holder key only) on dispose * and resolves to whether the holder delete committed. */ export type LeaseManager = { acquire(): Promise; startRenewal(): void; renewOnce(): Promise; currentEpochBytes(): Uint8Array | null; /** Return a defensive, synchronous view of this process's last-known lease state. */ health(): LeaseManagerHealth; release(): Promise; stop(): void; }; /** * Create a lease manager. Does not start any timer or acquire anything itself — * the engine drives `acquire()` at the boot gate (before recovery) and * `startRenewal()` afterward, and clears the renewal timer through its own * disposal path (mirroring how it owns the second-instance detector's interval). */ export declare function createLeaseManager(options: LeaseManagerOptions): LeaseManager;