import type { createPinboard } from "./server.js"; import { runScript } from "./placement.js"; import { epochOf, leaseReleaseScript, leaseScript } from "./scripts/lease.js"; import { logger, serializeError } from "./logger.js"; type RedisKV = createPinboard.RedisKV; export namespace createLease { export type Options = { key: string; value: string; ttlMs: number; operation: string; /** Compare-and-deletes the key on close, freeing it without waiting out the TTL. */ releaseOnClose?: boolean | undefined; now?: (() => number) | undefined; /** Fires once per transition into mismatch, never on repeats. */ onMismatch?: (() => void) | undefined; /** Fires once per transition into mismatch against a higher-epoch holder. */ onDisplaced?: (() => void) | undefined; }; export type Instance = { held(): Promise; refresh(): Promise; start(): void; close(): Promise; }; } /** * Single-holder lease on `key`: claimed with this holder's `value` when * absent, compare-and-refreshed atomically every tick (a matching value * extends the TTL, a mismatched one is never overwritten — a stalled old * holder delays takeover instead of split-braining). The verdict is cached * for one tick; a stale verdict re-checks on the next request, so a holder * that lost the lease regains it once the key expires or matches again. * With `releaseOnClose`, a clean close compare-and-deletes the key, freeing * it for the next holder without waiting out the TTL; a displaced holder's * close leaves the new holder's key intact. */ export const createLease = ( redis: RedisKV, options: createLease.Options, ): createLease.Instance => { if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) { throw new Error("ttlMs must be a positive finite number"); } const now = options.now ?? (() => Date.now()); const intervalMs = Math.floor(options.ttlMs / 3); let verdict: { held: boolean; expires: number } | null = null; let inflight: Promise | null = null; const refresh = (): Promise => (inflight ??= (async () => { const status = await runScript( redis, leaseScript, [options.key], [options.value, String(options.ttlMs)], ); if ( status !== "claimed" && status !== "serving" && status !== "mismatch" ) { throw new Error(`unknown lease status: ${JSON.stringify(status)}`); } const held = status !== "mismatch"; if (!held && verdict?.held !== false) { options.onMismatch?.(); if (options.onDisplaced !== undefined) { const ours = epochOf(options.value); const stored = epochOf(await redis.get(options.key)); if (ours !== null && stored !== null && stored > ours) { options.onDisplaced(); } } } verdict = { held, expires: now() + intervalMs }; return held; })().finally(() => { inflight = null; })); let timer: NodeJS.Timeout | null = null; let closed = false; const schedule = (): void => { if (closed) return; timer = setTimeout(() => { refresh() .catch((err) => { logger.error( { operation: options.operation, err: serializeError(err) }, "lease refresh failed", ); }) .finally(schedule); }, intervalMs); timer.unref?.(); }; return { held: () => verdict !== null && verdict.expires > now() ? Promise.resolve(verdict.held) : refresh(), refresh, start: schedule, close: async () => { closed = true; if (timer !== null) clearTimeout(timer); await inflight?.catch(() => {}); if (options.releaseOnClose === true) { await runScript( redis, leaseReleaseScript, [options.key], [options.value], ).catch(() => {}); } }, }; };