Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Root cause of warm-establish failures was resume-into-dirty-session +
broken window-close + no backoff, not OCR/detection. Four composing fixes:
A. Clean teardown (cleanTeardown): before the browser close(), close each
open catalog window via the corrected tab-✕ then click "Çıkış yap" logout
to END the RDS session, so the next warm-up starts from a fresh login/grid
instead of resuming into the last-open 3-window desktop. Wired into
teardownWarm() and both failed-warmUp exits. Bounded + never-throw.
B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45}
(validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the
PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access
language dropdown) is no longer used there. Escape pressed after each close
to dismiss an accidental dropdown before re-OCR. After N failed closes,
ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead
of limping into the ePER-open loop.
C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s).
D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s,
exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand
honour; a successful warm resets it — so a failing seat is no longer
hammered every ~60s leaving fresh dirty windows.
Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS,
sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard,
Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be
exercised in dev (single seat on prod) — needs prod validation on a rested seat.
Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin
escalation, warm-up backoff respected by reconcile + warm-on-demand + reset).
81 vinpin tests green; tsc + biome clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
8.4 KiB
TypeScript
196 lines
8.4 KiB
TypeScript
/**
|
||
* 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<typeof setInterval> | null = null;
|
||
private keepaliveTimer: ReturnType<typeof setInterval> | 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<void> {
|
||
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<void> {
|
||
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<VinpinDecodeResult | null> {
|
||
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;
|
||
}
|