import { getSettings, clampHeartbeatSeconds, } from "yt-dlp-transcript-common/lib/settings"; import { runSchedulerTick } from "./runTick"; // In-process scheduler heartbeat. // // Historically the scheduler tick was driven only by an external cron heartbeat // (`pnpm sync:tick` -> POST /api/scheduler/tick). This module lets the editor // server drive the same tick itself: the instrumentation hook // (editor/instrumentation.ts) calls startSyncHeartbeat() once at startup, which // arms a self-rescheduling timer that calls runSchedulerTick() directly — no // HTTP, no cron, no token. // // Modeled on common/jobs/snapshotScheduler.ts: a single global timer, unref'd so // it never keeps the process (or a test runner) alive, with all state on // globalThis so HMR reloads / repeated register() calls can't spawn duplicates. type HeartbeatState = { timer: ReturnType | null; // Generation counter: bumped by stop() so a fire() scheduled before the stop // can detect it's stale and not reschedule. generation: number; }; declare global { // eslint-disable-next-line no-var var __yttSyncHeartbeat__: HeartbeatState | undefined; } function getState(): HeartbeatState { if (!globalThis.__yttSyncHeartbeat__) { globalThis.__yttSyncHeartbeat__ = { timer: null, generation: 0 }; } return globalThis.__yttSyncHeartbeat__; } // Effective cadence in seconds. The env var SYNC_HEARTBEAT_SECONDS overrides the // stored setting (ops knob, mirrors SYNC_TICK_URL/SYNC_TICK_TOKEN); 0 = off. // Settings are read from disk, so a live change to the cadence is picked up on // the next fire. Falls back to the setting if the env var is unset/invalid. export function resolveHeartbeatSeconds(): number { const raw = process.env.SYNC_HEARTBEAT_SECONDS; if (raw != null && raw.trim() !== "") { const n = Number.parseInt(raw, 10); if (Number.isFinite(n)) return clampHeartbeatSeconds(n); } try { return clampHeartbeatSeconds(getSettings().syncScheduler.heartbeatSeconds); } catch { // getSettings reads the filesystem; if unavailable, stay off. return 0; } } function schedule(state: HeartbeatState, generation: number, seconds: number) { state.timer = setTimeout(() => void fire(generation), seconds * 1000); // Never let the heartbeat hold the event loop open. state.timer.unref?.(); } async function fire(generation: number): Promise { const state = getState(); // A stop() (or restart) happened while this fire was pending — abandon it. if (generation !== state.generation) return; state.timer = null; try { await runSchedulerTick(); } catch { // A thrown tick must never become an unhandledRejection. runSchedulerTick // already records its own outcome; the heartbeat just keeps beating. } // Re-read the cadence each fire so a live settings change is honored without a // restart. If it has been turned off (0), the loop simply stops here. if (generation !== state.generation) return; const seconds = resolveHeartbeatSeconds(); if (seconds > 0) schedule(state, generation, seconds); } // Arm the heartbeat. Idempotent: a timer already running is left untouched, so // HMR reloads and repeated register() calls can't spawn duplicate loops. Does // nothing when the cadence resolves to 0 ("only run when configured"); to start // after enabling it from 0 the server must be restarted. export function startSyncHeartbeat(): void { const state = getState(); if (state.timer) return; const seconds = resolveHeartbeatSeconds(); if (seconds <= 0) return; schedule(state, state.generation, seconds); } // Cancel the heartbeat and invalidate any pending fire. For test hygiene (the // unref'd timer would otherwise leak across specs) and clean teardown. export function stopSyncHeartbeat(): void { const state = globalThis.__yttSyncHeartbeat__; if (!state) return; if (state.timer) clearTimeout(state.timer); state.timer = null; state.generation += 1; }