/** * Vinpin warm-session daemon (worker-process singleton). * * Owns the LIFECYCLE of the persistent warm Vinpin seat, on top of the browser * mechanics in VinpinDriverService: * - a scheduler that warms the seat at 08:00 and tears it down at 21:00 * Europe/Istanbul (and warms on worker start if already inside the window), * - an idle keepalive that nudges the RDS session every ~75s so it never drops, * - `decode(vin)` — the entry the vinpin-decode processor calls. Inside business * hours it makes sure the seat is warm (warming on-demand once), then delegates * to the driver, which self-routes to the HOT warm path when warm and the * proven COLD per-decode path otherwise. Outside hours it never holds the seat * — the driver's cold path handles the decode and is torn down after. * * ADDITIVE + fail-safe: every warm operation degrades to the cold path, and * `decode()` never throws (the driver returns null on any failure). Gated by * VINPIN_ENABLED (feature flag) and VINPIN_WARM_DAEMON (kill-switch: set to * "false" to force the legacy per-decode cold path with the daemon inert). */ import { Logger } from "@nestjs/common"; import { getVinpinDriver } from "./vinpin-driver.service"; import type { VinpinDecodeResult, VinpinDriverService } from "./vinpin-driver.service"; import { VINPIN_WARM, isVinpinBusinessHours } from "./vinpin.constants"; /** Whether the warm daemon is allowed to run (feature flag + kill-switch). */ export function isVinpinWarmDaemonEnabled(): boolean { return process.env.VINPIN_ENABLED === "true" && process.env.VINPIN_WARM_DAEMON !== "false"; } export interface VinpinDaemonDeps { /** Driver singleton (injectable for tests). Defaults to the process-wide one. */ driver?: VinpinDriverService; /** Business-hours predicate (injectable so tests can drive the clock). */ isBusinessHours?: () => boolean; /** Warm-daemon enabled predicate (injectable for tests). */ isEnabled?: () => boolean; /** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */ now?: () => number; } export class VinpinDaemonService { private readonly logger = new Logger(VinpinDaemonService.name); private readonly driver: VinpinDriverService; private readonly isBusinessHours: () => boolean; private readonly isEnabled: () => boolean; private readonly now: () => number; private schedulerTimer: ReturnType | null = null; private keepaliveTimer: ReturnType | null = null; private reconciling = false; private started = false; // ─── Warm-up backoff ───────────────────────────────────── /** Wall-clock time until which a re-warm is suppressed after a failed warmUp. * Both reconcile() and decode()'s warm-on-demand honour this so a failing seat * isn't hammered every ~60s (each failed attempt would leave a fresh dirty * window). A successful warm resets it. */ private warmCooldownUntil = 0; /** Current backoff span (ms): 0 when healthy, else base…max, doubling per * consecutive failure. */ private warmBackoffMs = 0; constructor(deps: VinpinDaemonDeps = {}) { this.driver = deps.driver ?? getVinpinDriver(); this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours()); this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled; this.now = deps.now ?? (() => Date.now()); } /** True while a failed warmUp's cooldown is still in effect. */ private inWarmCooldown(): boolean { return this.now() < this.warmCooldownUntil; } /** * Record a warm-up outcome and update the backoff. Success clears the cooldown; * failure sets/extends it (base, then exponential up to max). Returns `ok` so * callers can chain. */ private noteWarmResult(ok: boolean): boolean { if (ok) { this.warmBackoffMs = 0; this.warmCooldownUntil = 0; } else { this.warmBackoffMs = this.warmBackoffMs === 0 ? VINPIN_WARM.warmBackoffBaseMs : Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs); this.warmCooldownUntil = this.now() + this.warmBackoffMs; this.logger.warn( `warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`, ); } return ok; } /** Start the scheduler + keepalive loops (idempotent). */ start(): void { if (this.started) return; this.started = true; // Reconcile once now (warm immediately if the worker booted inside hours). void this.reconcile(); this.schedulerTimer = setInterval(() => { void this.reconcile(); }, VINPIN_WARM.schedulerIntervalMs); this.keepaliveTimer = setInterval(() => { void this.driver.keepalivePing(); }, VINPIN_WARM.keepaliveIntervalMs); // Don't keep the event loop alive just for these timers. this.schedulerTimer.unref?.(); this.keepaliveTimer.unref?.(); this.logger.log( `Vinpin warm daemon started (enabled=${this.isEnabled()}, hours ${VINPIN_WARM.businessStartHour}:00–${VINPIN_WARM.businessEndHour}:00 Europe/Istanbul)`, ); } /** Stop the loops and tear the warm seat down (worker shutdown). */ async stop(): Promise { if (this.schedulerTimer) clearInterval(this.schedulerTimer); if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); this.schedulerTimer = null; this.keepaliveTimer = null; this.started = false; await this.driver.teardownWarm().catch(() => undefined); } /** * Reconcile the warm seat against the schedule: warm up when enabled + inside * hours + not already warm; tear down when warm but disabled or outside hours. * Guards against overlapping runs (a slow warmUp must not stack). Never throws. */ async reconcile(): Promise { if (this.reconciling) return; this.reconciling = true; try { const enabled = this.isEnabled(); const inHours = this.isBusinessHours(); if (enabled && inHours && !this.driver.isWarm()) { if (this.inWarmCooldown()) { this.logger.debug("scheduler: in warm-up backoff — skipping this cycle"); } else { this.logger.log("scheduler: inside business hours — warming the seat"); this.noteWarmResult(await this.driver.warmUp()); } } else if (this.driver.isWarm() && (!enabled || !inHours)) { this.logger.log("scheduler: outside business hours / disabled — tearing the seat down"); await this.driver.teardownWarm(); // Intentional teardown → clear any stale warm-up backoff so the next window // (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown. this.warmBackoffMs = 0; this.warmCooldownUntil = 0; } } catch (err) { this.logger.warn(`reconcile failed: ${(err as Error).message}`); } finally { this.reconciling = false; } } /** * Decode entry the processor calls. Inside hours, ensure the seat is warm (warm * on-demand once), then delegate to the driver — which uses the hot warm path * when warm, else the cold per-decode path. Off-hours, delegate straight to the * driver's cold path (no seat held). Never throws — returns null on any failure. */ async decode(vin: string): Promise { try { if ( this.isEnabled() && this.isBusinessHours() && !this.driver.isWarm() && !this.inWarmCooldown() ) { // Warm-on-demand: a decode arrived inside hours before the scheduler warmed // (e.g. right after 08:00, or after a drop). Best-effort — if it fails the // driver silently runs the cold path for this decode, and the backoff spaces // out the next warm attempt (respected by both this check and reconcile()). this.noteWarmResult(await this.driver.warmUp().catch(() => false)); } return await this.driver.decode(vin); } catch (err) { // Defensive: driver.decode never throws, but guarantee the processor a null. this.logger.warn(`decode(${vin}) unexpected error: ${(err as Error).message}`); return null; } } } // ─── Worker singleton ──────────────────────────────────────── let daemonSingleton: VinpinDaemonService | null = null; export function getVinpinDaemon(): VinpinDaemonService { if (!daemonSingleton) daemonSingleton = new VinpinDaemonService(); return daemonSingleton; }