/** * Best-effort, warn-only detection of a SECOND engine instance writing to the * same durable store — a smoke alarm for singleton-deployment misconfiguration * (an autoscaler accidentally set above one replica, or overlapping rolling * deploys), NOT a correctness mechanism. * * **This is liveness, not fencing.** Weft's supported model is one engine process * per durable store (see the recovery-and-deploys guide); fenced ownership is a * future `MultiEngine` capability that does not exist yet. This detector never * blocks boot, gates recovery, refuses a write, or claims ownership. It only * observes whether another instance's heartbeat is *advancing while this instance * is also running* and emits a warning if so. It does not prevent duplicate * execution — infrastructure-level enforcement (`replicas: 1` + a `Recreate` * deploy strategy, or a single systemd unit) is the real control. * * **Why liveness, not a boot check.** A boot-time check cannot distinguish a * rolling-deploy handoff from a genuine second instance: both leave a recent * heartbeat record in the store. Only observing a *foreign* heartbeat advance * across several of our own ticks separates a live peer (autoscaling=2 → both * heartbeats advance forever → both warn) from a dead-but-recent predecessor (a * clean `Recreate` deploy → old heartbeat never advances after handoff → quiet). * * **Advance is measured by sequence, not wall clock.** Each heartbeat carries a * per-instance monotonic `sequence`; a peer counts as advancing only when its * `sequence` grows between two of *our* ticks. A peer's sequence cannot increase * unless it is alive and ticking in our own time frame, so detection never * compares clocks across hosts — it stays correct even if a peer's clock is * frozen, skewed, or stepped backward. `heartbeatAt` exists only for the boot * staleness sweep that garbage-collects long-dead instances' keys. * * Each engine writes its own heartbeat under `liveness:` and scans * the `liveness:` prefix to observe peers. Per-instance keys (not one shared, * clobbered key) keep every heartbeat independently observable and the sequence * monotonic per writer. * * @module core/engine/second-instance-detector */ import { type Storage } from '../../storage/interface.ts'; import type { EngineCleanupIntervalDisposalTracker } from './engine-leak-warnings.ts'; /** Options for {@link createSecondInstanceDetector}. */ export type SecondInstanceDetectorOptions = { storage: Storage; /** This engine's unique instance id. */ instanceId: string; /** Wall-clock source (ms), injected so tests can advance time deterministically. */ getNow: () => number; /** * Heartbeat interval in ms. The staleness window is derived from this, so the * interval also sets how long a deploy overlap must last before it warns. */ intervalMs: number; /** * Emit a warning. Defaults to `process.emitWarning(message, WARNING_NAME)`, * so the emitted `Warning.name` is `WeftSecondInstanceWarning` and consumers * can filter on `warning.name` rather than scraping the message. Injected for * tests; the seam is message-only because the name is a fixed constant. */ warn?: (message: string) => void; }; /** * The `name` of the emitted warning. A stable, filterable identifier on the * `Warning` object — consumers subscribe to the process `warning` event and * match `warning.name === WARNING_NAME` (see the singleton-deployment guide). */ export declare const SECOND_INSTANCE_WARNING_NAME = "WeftSecondInstanceWarning"; /** * A running detector. `tick()` runs one heartbeat round (exposed for tests and * driven by an interval in production); `stop()` clears the interval and * best-effort removes this instance's heartbeat so the next boot starts quiet. */ export type SecondInstanceDetector = { /** Run one heartbeat round: observe peers, then write our own heartbeat. */ tick(): Promise; /** Stop the interval and best-effort delete this instance's heartbeat key. */ stop(): Promise; }; /** * Create a best-effort second-instance detector. Does not start an interval * itself — the engine owns timer lifecycle so disposal can clear it through the * same path as its other intervals. Call {@link SecondInstanceDetector.tick} on * an interval and {@link SecondInstanceDetector.stop} on dispose. */ export declare function createSecondInstanceDetector(options: SecondInstanceDetectorOptions): SecondInstanceDetector; /** * Build the `setInterval` callback that drives a detector tick. `resolveDetector` * returns the live detector, or `null` when the engine has been garbage-collected * or disposed. When it returns `null` the tick SELF-CLEARS its own interval (via * `tracker.secondInstanceDetectionInterval`) and returns — mirroring * {@link createCleanupIntervalTick}. This is the prompt cleanup path: a leaked * engine's first post-GC tick clears the timer immediately, rather than relying * on the `FinalizationRegistry` backstop, whose callbacks are not guaranteed to * run promptly (or at all). Extracted so the skip/clear guard is directly * testable without a timer. A tick failure is swallowed: the detector is a smoke * alarm, never a correctness path, so it must not surface as an unhandled rejection. */ export declare function createSecondInstanceDetectionTick(resolveDetector: () => SecondInstanceDetector | null, tracker: EngineCleanupIntervalDisposalTracker): () => void;