/** * Virtual endpoints (SPEC §13.6): a registered endpoint (`spec.activation = on-demand`) with no * live instance. Submissions buffer on the ordinary journal plane; the CANONICALIZER (running * wherever the endpoint's activator/owning authority runs) checks pool admission BEFORE deciding, * and an ACTIVATOR watches the pool and starts an instance. This module owns the transport-thin * core of that loop; everything durable composes the existing work-pool, service-record, and * timer-plane primitives (never reinvented): * * - ADMISSION ({@link admitVirtualWork}): occupancy is the pool consumer's * `num_pending + num_ack_pending`, read FRESH from the exact per-pool Consumer INFO * ({@link readPoolOccupancy}) — and only after RECONCILING the canonicalizer's own * outstanding acceptances against the §13.6 predicate (reconcile-orphans-before-admit: an * accepted-but-lost item is repaired INTO the pool first, so the count it competes under is * honest). The read FAILS CLOSED against EVERY editable consumer knob: a missing/unreadable * consumer is `unavailable` (never a fabricated zero); a reported `max_deliver` other than * unlimited is `failed-precondition` (a finite ceiling strands exhausted items outside BOTH * counters); a `filter_subject` that is not EXACTLY the pool's derived filter (or any * multi-filter shape) is `failed-precondition` (a narrowed/foreign filter undercounts while * stored work remains). Admission itself RE-PROVES the serial pin live: the canonicalizer * durable's `max_ack_pending` must read 1 at every admit (MaxAckPending is editable, so * creation intent proves nothing later). Capacity comes from the endpoint's REGISTERED * {@link VirtualActivationPolicy} (closed schema, capacity required), never a free-floating * knob. An over-capacity verdict is the caller's durable `resource-exhausted` decision fact, * never an accepted-and-stranded submission. * - SERIAL admission ({@link virtualAdmissionConsumerConfig}): the virtual endpoint's * canonicalizer durable pins `max_ack_pending: 1` BY CONSTRUCTION, so count → decide → * enqueue cannot interleave across submissions. The pin is on the ADMISSION durable only: * pool-worker execution concurrency is an independent knob, and occupancy already counts * every worker's `num_ack_pending`. * - ACTIVATION ({@link startVirtualActivator} over an {@link activatorContext}): exact * Consumer INFO is a request/reply SNAPSHOT — there is no broker wakeup — so watching is * BOUNDED POLLING with backoff to a FINITE maximum interval, and an INFO failure is LOUD * (the required `onError` seam) while polling continues. The activator runs over its own * NARROW branded context (a JetStream manager bound with `checkAPI: false`; no KV, no * publisher — an exact-INFO-only credential can construct it), its grant profile is exactly * {@link activatorGrants} (the one `$JS.API.CONSUMER.INFO.EPW_.pool__` row), * and the start is a TARGET-BOUND mediated seam (`startInstance()`, fully bound at * construction). `stop()` is re-checked after EVERY await: a not-yet-started activation * never begins after stop (an already-running start completes on its own). * - RESTART-INTENSITY supervision ({@link noteInstanceRestart}): the restart history is * DURABLE and SUPERVISOR-OWNED — it rides the instance's `svc….status` record, and * `writeServiceStatus` carries it forward through every ordinary (unpinned) instance write, * so a successor's `ready` convergence can neither reset nor forge it. Every note is a * revision-pinned CAS (two concurrent notes can never merge-lose a restart), each history * entry is BOUND TO THE DYING PROCESS EPOCH (a real restart advances the epoch, so a * retried/duplicated note for the same restart is an idempotent no-op, never a double * count), and a CLOCK REGRESSION (`now` before the newest recorded restart) REFUSES rather * than silently amnestying history. More than `maxRestarts` (default * {@link RESTART_MAX_DEFAULT}) within `restartWindowMs` (default * {@link RESTART_WINDOW_MS_DEFAULT}) escalates: the status records * {@link SERVICE_ESCALATED} — IRREVERSIBLE at the status writer, excluded from scatter's * live expected set — and the caller's D13 retire seam (`retireLifecycle`, the §13.1 * terminal barrier, MUST be idempotent) runs after. A retire failure surfaces `unavailable` * with the escalation standing, and {@link reconcileEscalation} is the RETRY: it acts on * already-escalated rows, re-invokes the idempotent retire seam until it succeeds, and * marks completion durably (the pinned retirement mark is the ONE touch an escalated row * admits, written directly by the reconciler). * * Passivation is composition, not new machinery: drain, `writeServiceStatus` to * `SERVICE_EXITED`, exit; durable reminders ride the timer plane (`emitScheduleRequest`). */ import type { KV } from "@nats-io/kv"; import type { NatsConnection } from "@nats-io/transport-node"; import { type ConsumerConfig, type JetStreamManager } from "@nats-io/jetstream"; import { type WorkPoolContext, type WorkItemRef } from "./endpoint-work.js"; /** The activator's NARROW broker context: a JetStream manager and the space, nothing else — no * KV, no publisher, so a credential holding only the exact Consumer INFO row can construct it * (`checkAPI: false`: binding does not require `$JS.API.INFO`). Branded like WorkPoolContext: * a hand-assembled look-alike is rejected at every consuming seam. */ export interface ActivatorContext { jsm: JetStreamManager; space: string; } export declare function activatorContext(nc: NatsConnection, space: string): Promise; /** The activator principal's COMPLETE broker authority (§13.6/§13.9): the exact per-pool * Consumer INFO PUBLISH row plus — since Consumer INFO is a request/reply call — the * CONNECTION-SCOPED reply inbox SUBSCRIBE row `_INBOX_.>` (never account-wide * `_INBOX.>`, which is cross-connection reply-read authority). The start is a mediated, * target-bound seam (off-broker or its own ep rail), so it contributes no row here; NOTHING * else — no MSG.NEXT/ACK, no EPW STREAM.MSG.GET, no consumer create/update/delete, no EPW * publish. The credential's OWN lifetime/renewal/lifecycle-UID binding rides the production * activator provisioning (the named manager-wiring slice); this is the confined subject set. * `connId` is the connection's validated inbox nonce (the same `_INBOX_` prefix the * connection sets); omit it to get the publish half alone. */ export declare function activatorGrants(space: string, endpoint: string, pool: string, connId?: string): { publish: string[]; subscribe: string[]; }; /** The pool's admission occupancy (§13.6): stored-and-uncounted states do not exist while the * reader's invariants hold (WorkQueue retention + unlimited delivery + the exact pool filter + * explicit ack). */ export interface PoolOccupancy { /** Not yet delivered to the pool consumer. */ pending: number; /** Delivered, awaiting ack (a worker is on it, or redelivery is due). */ ackPending: number; /** `pending + ackPending` — the §13.6 admission count. */ occupancy: number; } /** * Read the pool's occupancy FRESH from the exact per-pool Consumer INFO (request/reply * snapshot; leader-routed like every JS API call). FAIL CLOSED, never fabricated — and every * EDITABLE knob the count depends on is re-proved at EVERY read, because a post-create * consumer edit must not silently falsify the fence: * - a missing or unreadable consumer is `unavailable` (an admission fence cannot treat * ignorance as an empty pool); * - a reported `max_deliver` other than -1 (unlimited) is `failed-precondition` * ({@link poolConsumerConfig} pins -1 at create; MaxDeliver is editable after); * - a `filter_subject` that is not EXACTLY the pool's derived filter — or any multi-filter * (`filter_subjects`) shape — is `failed-precondition`: FilterSubject is editable, and a * narrowed/foreign filter reads 0 while stored work remains; * - a non-explicit ack policy is `failed-precondition` (without an ack barrier, * `num_ack_pending` does not mean "work still owned"). * Accepts the branded {@link WorkPoolContext} (admission path) or the narrow branded * {@link ActivatorContext} (the watch loop); a hand-assembled context is refused. */ export declare function readPoolOccupancy(ctx: WorkPoolContext | ActivatorContext, endpoint: string, pool: string): Promise; /** * The VIRTUAL endpoint's canonicalizer durable: {@link canonConsumerConfig} with * `max_ack_pending` PINNED to 1 (not an option — the pin is the point). One submission is in * the admission path at a time, so the count → decide → enqueue sequence is SERIAL and two * concurrent submissions cannot both observe the same free slot. Pool-worker execution * concurrency is untouched: occupancy already counts every worker's `num_ack_pending`, and the * worker durable keeps its own knobs. Because MaxAckPending is EDITABLE post-create, * {@link admitVirtualWork} re-proves the live pin at every admission — creation intent alone * is not the invariant. */ export declare function virtualAdmissionConsumerConfig(space: string, endpoint: string, opts?: { ackWaitMs?: number; }): Partial; /** One outstanding acceptance the canonicalizer must reconcile before it counts (§13.6: the * acceptance→enqueue bridge is repaired by the decidable predicate, never guessed). */ export interface OutstandingAcceptance { ref: WorkItemRef; itemBytes: Uint8Array; workExpiry: number; } export interface VirtualAdmissionVerdict { /** True iff the pool has a free slot under the policy's capacity AFTER repair. A false * verdict is the caller's durable `resource-exhausted` decision fact (§13.6), not an error. */ admitted: boolean; occupancy: PoolOccupancy; capacity: number; /** How many outstanding acceptances the pre-admission reconciliation re-enqueued. */ repaired: number; /** The RE-PROVEN registration revision the decision bound to (§13.6): the caller carries it * into the acceptance commit so the accept CAS fences on the same policy coordinate. */ policyRevision: number; } /** * The §13.6 admission fence, in the canonicalizer's decide path (admission BEFORE decision): * 1. RE-PROVE the serial pin LIVE: the canonicalizer durable must report * `max_ack_pending === 1` and its exact EPJ filter (both editable post-create); a drifted * admission durable means the serial invariant is GONE — refuse, never decide; * 2. RECONCILE the caller's outstanding acceptances (`reconcileWorkItem` per item, the §13.6 * predicate): an accepted item with no terminal, no settled lease, and no live pool entry * is re-enqueued NOW, so repaired work is inside the count new work competes under; * 3. read occupancy FRESH ({@link readPoolOccupancy}, fail-closed against every editable knob); * 4. verdict: `occupancy < policy.capacity` (the REGISTERED activation policy, closed schema). * Infrastructure failures THROW (`unavailable`/`failed-precondition`); only a genuine * over-capacity state returns `admitted: false`. */ export declare function admitVirtualWork(ctx: WorkPoolContext, args: { endpoint: string; pool: string; /** The registered instance whose `svc` spec carries the activation policy (§13.6): the * capacity is READ from that registration, LEADER-SERVED, never taken from the caller. */ instanceId: string; /** The canonicalizer's own accepted-but-unsettled items (its journal redeliveries). */ outstanding?: OutstandingAcceptance[]; now: number; }): Promise; export interface VirtualActivatorOpts { /** The NARROW branded activator context ({@link activatorContext}) — the watch loop's whole * broker surface is the exact Consumer INFO its {@link activatorGrants} row permits. */ ctx: ActivatorContext; endpoint: string; pool: string; /** The TARGET-BOUND mediated start seam: fully bound at construction (no arguments, so a * compromised activator cannot redirect it), resolved by the supervisor/manager that holds * the actual start authority. Idempotence against a concurrent start is the registration * barrier's job, not this seam's. It MUST refuse an escalated identity — production wires * it through the supervisor, whose start path consults the status record * ({@link SERVICE_ESCALATED} is irreversible and excluded from liveness). */ startInstance(): Promise; /** Mediated liveness: is a live instance already serving? Answered by the caller's authority * (a `svc` record read, or the supervisor), so the activator itself stays INFO-only. */ isLive(): Promise | boolean; /** REQUIRED loud-failure seam: an unobserved activator failure is not allowed (`kind` is * `info` for an occupancy-read failure, `start` for a start/liveness failure). Polling * continues with backoff after either. */ onError(kind: "info" | "start", err: unknown): void; /** Base poll interval (ms), default 500. Exact Consumer INFO is a snapshot; polling IS the * watch — there is no broker wakeup to wait for. */ pollMs?: number; /** FINITE backoff ceiling (ms), default 5000: quiet polls back off toward it, work resets to * `pollMs`. Refused unless a safe integer >= pollMs (an unbounded interval is a liveness * hole dressed as economy). */ maxPollMs?: number; /** Injectable timer/clock (testability); defaults to Node setTimeout/clearTimeout. */ setTimeoutFn?: (fn: () => void, ms: number) => { unref?: () => void; }; clearTimeoutFn?: (h: unknown) => void; } export interface VirtualActivator { /** Stop polling. Idempotent. Re-checked after EVERY await: a start not yet begun never * begins after stop; a start already running completes on its own. */ stop(): void; /** Loop observability (smoke assertions + operator introspection). */ stats(): { polls: number; starts: number; infoErrors: number; startErrors: number; lastOccupancy: number; }; } /** * Watch one pool by BOUNDED POLLING of its exact Consumer INFO and start the instance through * the bound seam when there is work and no live instance. See the module header for the * authority profile this loop is confined to. One start is in flight at a time (the guard is * local dedupe, not the correctness fence — registration owns that); polls stay serial with the * tick chain, so a slow INFO read never stacks requests. */ export declare function startVirtualActivator(opts: VirtualActivatorOpts): VirtualActivator; /** §13.6 defaults: more than 3 restarts within 60s escalates. */ export declare const RESTART_MAX_DEFAULT = 3; export declare const RESTART_WINDOW_MS_DEFAULT = 60000; /** The SUPERVISOR-OWNED status field carrying the durable restart history: entries are * `{ t, epoch }`, one per DYING PROCESS EPOCH (a real restart advances the epoch, so a * replayed note is an idempotent no-op). The field is defined by the service module because * {@link writeServiceStatus} carries it forward through instance-side writes. */ export declare const RESTART_HISTORY_FIELD = "restarts"; /** The reconciler's durable retirement-complete mark (see {@link reconcileEscalation}). */ export declare const RETIRED_MARK_FIELD = "retiredAt"; /** One durable restart-history entry: when, and WHICH process epoch died. */ export interface RestartHistoryEntry { t: number; epoch: number; } /** * Note one restart of a virtual instance, DURABLY and CAS-fenced (§13.6 supervision): * - the restart history rides the instance's `svc….status` record and is SUPERVISOR-OWNED * ({@link writeServiceStatus} carries it forward through every unpinned instance write, so * a successor's ordinary `ready` convergence cannot reset or forge it); * - the write is pinned to the revision THIS call read (`expectedStatusRevision`), so two * concurrent notes can never merge-lose a restart (one loses `conflict` and retries); * - each entry is BOUND to the dying process epoch: a note whose epoch is already recorded is * an idempotent NO-OP (`duplicate: true`) — one physical restart counts once, however many * times its notification is retried or duplicated; * - a CLOCK REGRESSION (`now` before the newest recorded restart) REFUSES * (`failed-precondition`): a rolled-back supervisor clock must not amnesty durable history; * - a stored {@link SERVICE_ESCALATED} state REFUSES (`failed-precondition`): the instance * has stopped restarting, terminally — {@link reconcileEscalation} is the retirement retry; * - more than `maxRestarts` entries within `restartWindowMs` (the pruned history plus this * note) ESCALATES: the status commits {@link SERVICE_ESCALATED} first (irreversible at the * writer), then the caller's D13 retire seam runs (`retireLifecycle`, the §13.1 terminal * barrier, MUST be idempotent). A retire failure is `unavailable` with the escalated status * already durable — honest halves; {@link reconcileEscalation} retries until it completes. * A non-escalating note records {@link SERVICE_EXITED} (the instance just died; its successor * writes `ready` itself when it registers) with the pruned history plus this note. */ export declare function noteInstanceRestart(kv: KV, args: { endpoint: string; instanceId: string; /** The DYING instance's epoch (still the mapping's current one until a successor activates). */ epoch: number; now: number; /** The trusted lifecycle-mapping reader (same seam as {@link writeServiceStatus}). */ readProcessEpoch: () => Promise | number; /** LEADER-SERVED reader of the endpoint's `svc..` spec (value + * revision). The restart-intensity thresholds are FENCING by use (they gate escalation and * retirement), so the policy MUST NOT be read through a follower-capable KV get — a stale * wider window would suppress an escalation. Production wires the records-KV leader-served * `STREAM.MSG.GET` reader here (the same one the admission fence uses). */ readSpecLeader: () => Promise<{ value: unknown; revision: number; } | undefined>; /** The D13/§13.1 terminal retire seam, invoked ONLY on escalation. MUST be idempotent * ({@link reconcileEscalation} re-invokes it on retry). */ retireLifecycle: () => Promise; }): Promise<{ escalated: boolean; restartsInWindow: number; duplicate: boolean; }>; /** * The escalation RETIREMENT reconciler (§13.6: "the lifecycle retires terminally" must survive * a crash or a failed retire between the escalation CAS and the barrier): acts on an * already-escalated status row, re-invokes the caller's IDEMPOTENT retire seam until it * succeeds, and then durably marks completion (a revision-pinned direct update — the one touch * an escalated row admits; {@link writeServiceStatus} refuses everything else). Run it on * supervisor startup and whenever {@link noteInstanceRestart} refuses on an escalated row. * Returns what it found and whether THIS pass did the retirement. */ export declare function reconcileEscalation(kv: KV, args: { endpoint: string; instanceId: string; now: number; retireLifecycle: () => Promise; }): Promise<{ escalated: boolean; retired: boolean; acted: boolean; }>; //# sourceMappingURL=endpoint-virtual.d.ts.map