import type { ClusterHoldingsLedger } from './holdings.js'; import type { ClusterTiming } from './timing.js'; import type { ClusterConsumerStartContext, ClusterClock, ClusterLogger, ClusterMessage, ClusterMessageType, ClusterRole, ClusterSurfaceStatus } from './types.js'; /** * What a surface election needs from the node it lives in. * * Everything shared across surfaces is here rather than duplicated per * surface: one socket, one peer table, one holdings ledger, one node identity. */ export interface SurfaceElectionHost { readonly nodeId: string; readonly version: string; readonly logger: ClusterLogger; readonly clock: ClusterClock; readonly timing: ClusterTiming; readonly ledger: ClusterHoldingsLedger; /** Broadcast a datagram stamped with this surface's digest. */ send(type: ClusterMessageType, surfaceId: string): Promise; /** * Can this node serve the surface RIGHT NOW, gate registered, credential * present, surface enabled locally? Re-asked at every promotion rather than * captured once, because a credential can be removed while the node runs. */ canServe(surfaceId: string): boolean; /** Start this surface's consumer. Must not resolve until it is running. */ startConsumer(surfaceId: string, context: ClusterConsumerStartContext): Promise; /** Stop it. Must not resolve until consumption has genuinely ceased. */ stopConsumer(surfaceId: string, reason: string): Promise; /** * Claim this NODE's single rebalancing slot, or false if one was used too * recently. * * Node-level, not per surface, and that is the second half of the * anti-oscillation argument. Every surface a node holds runs its own yield * check, and they all read the same holdings number: an overloaded node * holding three surfaces would have all three conclude "I am two ahead, I * should yield" in the same instant, hand over all three, and leave the * other node overloaded by exactly as much. One slot per node per cooldown * means one surface moves, everyone re-observes, and the next check sees the * corrected numbers. * * Synchronous by contract: it is called with no await between the decision * and the reservation, so two surfaces can never both win the slot. */ tryReserveYield(mono: number): boolean; } export interface SurfaceElectionOptions { readonly surfaceId: string; /** Local, digest-derived label for logs. Never the topic or bot name. */ readonly label: string; readonly kind: string; readonly host: SurfaceElectionHost; /** Test seam for the CLAIM jitter draw. Defaults to Math.random. */ readonly random?: (() => number) | undefined; } export declare class SurfaceElection { private readonly options; private readonly surfaceId; private readonly host; private readonly random; private role; private consumerRunning; private holderNodeId; private lastHolderHeartbeatAt; private lastHolderHeartbeatMono; /** * Consecutive refusals by the provider for THIS surface, and what was waited * for the last one. A refusal means another process holds the credential, * which retrying cannot fix, so the interval grows rather than staying flat *, see consumer-conflict-backoff.ts. */ private consumerConflictBackoff; /** When the consumer last actually started, for judging "did it serve?". */ private consumerStartedMono; private cancelProbe; private cancelHeartbeat; private cancelElection; private cancelSettle; private cancelHandoff; private cancelCandidacy; private cancelYieldCheck; private claimSentThisElection; private preemptTarget; /** Serializes every role transition for THIS surface; see the file header. */ private queue; constructor(options: SurfaceElectionOptions); /** Join this surface's election. */ start(): Promise; /** * Leave this surface's election cleanly. * * A holder stops its consumer and broadcasts RESIGN on the way out, which is * what turns a restart from a 90-second outage into a sub-second one. The * watchdog exists for the case this path never runs, a crash, a kill -9, a * lost power cable, not for the ordinary case. */ stop(reason?: string): Promise; /** Resolves once every queued transition has finished. */ settled(): Promise; get currentRole(): ClusterRole; get isMaster(): boolean; get id(): string; status(): ClusterSurfaceStatus; /** * A provider told us somebody else is already consuming THIS surface, * Telegram answers a concurrent getUpdates with 409. * * Never fight over it. The other consumer is real whether or not it speaks * this protocol, so stop, back off, and re-probe. Fighting produces two * processes that each keep terminating the other's long poll and a user * whose messages arrive nowhere. Only this surface stands down; the node's * other surfaces are unaffected, because the conflict is about one bot token * or one topic and says nothing about the rest. */ reportConsumerConflict(detail: string): void; /** See consumer-conflict-backoff.ts for why this grows rather than repeats. */ private nextConsumerConflictDelay; private beginProbe; private beginElection; private sendClaim; /** * Take the surface and start consuming it. * * `handoff` decides where consumption resumes from, and the distinction is * not cosmetic, it is the difference between losing messages and answering * them twice: * * 'ordered' , the predecessor stopped consuming and THEN said so, so it * read right up to its last moment. There is no gap. Resuming * from its last heartbeat would replay everything it already * handled between that heartbeat and its stop, and the user * would get a second answer to a message that was answered. * * 'gap' , the predecessor vanished (crash, kill -9, a handoff it never * completed). The last moment it is KNOWN to have been alive * is its last heartbeat for THIS surface, so consumption * resumes there. A provider without a per-subscriber cursor * may redeliver a message or two from that window; a duplicate * is a nuisance and a lost message is not recoverable, so the * window is deliberately replayed rather than skipped. */ private becomeMaster; /** * The ordered half of every handoff: the consumer stops FIRST, and only once * it has genuinely stopped does RESIGN go out. The successor keys off * RESIGN, so this ordering is the entire reason a handoff cannot * double-consume. Reversing these two lines would reintroduce the bug this * module exists to fix. */ private stopConsumerThenResign; private becomeStandby; /** * Take the surface from a sitting holder because this build is strictly * newer. * * We do NOT start consuming here. The old holder owns the stop; we wait for * its RESIGN, with a grace timer for the case it died mid-handoff. */ private beginPreemption; /** * Handle a datagram already decoded, already authenticated, already * deduplicated by sequence, and already confirmed to carry THIS surface's * digest. Everything before that is the node manager's job. */ deliver(message: ClusterMessage): void; private dispatch; /** A node is probing this surface. Only its holder answers, immediately. */ private onProbe; private onHeartbeat; /** * Two nodes can hold one surface after a partition heals. Both sides run the * same ordering over the same two candidates, so they agree on the winner * without negotiating; the loser performs the ordered stop-then-RESIGN and * the winner, which never stopped, simply carries on. * * Decided on the STABLE order, which excludes holdings. Holdings are * observed from traffic, and two nodes that were partitioned have by * definition been observing different traffic, ranking a reconciliation on * a number they disagree about could leave both sides believing they won. * Version and the per-surface hash come out of the datagram itself and can * never disagree. */ private reconcileWithPeerHolder; private onClaim; private onResign; private noteHolderAlive; private armHeartbeat; /** * A standby says, periodically, that it can serve this surface. * * Without it a standby is invisible: it sends nothing after losing, so the * holder's ledger forgets it, and an overloaded holder concludes there is * nobody to rebalance to. A PROBE is the right shape for the beat, the * holder answers it with a HEARTBEAT, so the beat also re-confirms the * holder is alive. */ private armCandidacy; private armYieldCheck; /** * Rebalancing, as a voluntary yield rather than an outside preemption. * * The holder decides, and it only decides yes when it is at least * SURFACE_YIELD_GAP surfaces ahead of a node that can serve this one. See * shouldYieldSurface for why the threshold is two and not one. The release * itself is the ordinary ordered stop-then-RESIGN, so consumption of the * surface stops before anything anywhere starts it again, a rebalance never * opens a window where two nodes read the same topic. */ private considerYield; /** * Driven by the node manager's single watchdog: notice a holder that stopped * breathing. Suspend detection is node-level and arrives through * `onWakeFromSuspend` instead. */ onWatchdogTick(mono: number): void; /** * A woken node knows nothing about who holds this surface now, and its own * consumer was frozen mid-flight. Stop first, THEN re-probe, resuming a long * poll that a successor has already taken over is precisely the double * consumption this module prevents. */ onWakeFromSuspend(gapMs: number): void; private clearElectionTimers; private clearAllTimers; private selfRank; private peerRank; private send; private enqueue; } //# sourceMappingURL=election.d.ts.map