SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
7.1 KB · 158 lines typescript
Raw Blame History
1import { spawn } from "node:child_process";2import { createWriteStream, mkdirSync } from "node:fs";3import { stat } from "node:fs/promises";4import { join } from "node:path";5import { createGzip } from "node:zlib";6import { pipeline as streamPipeline } from "node:stream/promises";7import { Readable } from "node:stream";8import { config } from "../config.js";9import { pool } from "../db/pool.js";10import { logger } from "../logger.js";11import { rollupBars } from "./bars.js";12import { bus } from "./bus.js";13import { calendar } from "./calendar.js";14import { eventEngine } from "./events.js";15import { health } from "./health.js";16import { instruments } from "./instruments.js";17import { pipeline } from "./pipeline.js";18import { quoteStore } from "./quotes.js";19import { telemetry } from "./telemetry.js";20import { connectorManager } from "./connector-manager.js";21import { lineage } from "./lineage.js";2223/** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */24export class Scheduler {25  private timers: NodeJS.Timeout[] = [];26  private lastState = new Map<string, string>();27  private lastDaily: string | null = null;2829  start(): void {30    this.every(60_000, () => health.tick((id) => staleAfter(id)));31    this.every(5_000, () => this.marketStates());32    this.every(10_000, () => pipeline.sweep());33    this.every(5 * 60_000, () => rollupBars(3));34    this.every(5 * 60_000, () => lineage.recompute());35    this.every(60_000, () => this.gauges());36    this.every(15 * 60_000, () => this.daily());37    this.marketStates();38    this.gauges();39  }4041  stop(): void {42    for (const t of this.timers) clearInterval(t);43    this.timers = [];44  }4546  private every(ms: number, fn: () => unknown) {47    const t = setInterval(() => {48      Promise.resolve()49        .then(fn)50        .catch((err) => logger.error({ err }, "scheduled job failed"));51    }, ms);52    this.timers.push(t);53  }5455  private gauges() {56    telemetry.gauge("quotes_cached", quoteStore.size());57    telemetry.gauge("instruments_total", instruments.count());58    telemetry.gauge("connectors_healthy", connectorManager.list().filter((r) => health.state(r.def.metadata.id) === "HEALTHY").length);59    telemetry.gauge("connectors_total", connectorManager.list().length);60  }6162  /** Emit MARKET_OPEN / MARKET_CLOSE events and reset session stats at session start. */63  private marketStates() {64    const now = Date.now();65    for (const ex of calendar.list()) {66      if (ex.sessions.continuous) continue;67      const st = calendar.status(ex.id, now);68      const prev = this.lastState.get(ex.id);69      this.lastState.set(ex.id, st.state);70      if (prev === undefined || prev === st.state) continue;71      bus.publish("market.state", { exchangeId: ex.id, state: st.state, at: now });72      if (st.state === "OPEN") {73        eventEngine.system("MARKET_OPEN", `${ex.id}:${new Date(now).toISOString().slice(0, 10)}`, now, "NOTICE", `${ex.name} market open`, { exchangeId: ex.id, localTime: st.localTime }, ["market-atlas-calendar"]);74        for (const inst of instruments.all()) {75          if (inst.exchangeId === ex.id) {76            pipeline.consensus.resetSession(inst.id, now);77            eventEngine.resetSession(inst.id);78          }79        }80      } else if (st.state === "CLOSED" && (prev === "OPEN" || prev === "POST")) {81        eventEngine.system("MARKET_CLOSE", `${ex.id}:${new Date(now).toISOString().slice(0, 10)}`, now, "NOTICE", `${ex.name} market close`, { exchangeId: ex.id, localTime: st.localTime }, ["market-atlas-calendar"]);82      }83    }84  }8586  /** Once per day (UTC 04:40-ish): archive old observation partitions, backup metadata. */87  private async daily() {88    const now = new Date();89    const key = now.toISOString().slice(0, 10);90    if (this.lastDaily === key) return;91    if (now.getUTCHours() < 8) return; // ~04:00 America/Toronto92    this.lastDaily = key;93    await archiveOldPartitions().catch((err) => logger.error({ err }, "archive failed"));94    await backupMetadata().catch((err) => logger.error({ err }, "backup failed"));95    await pool.query("delete from connector_health where ts < now() - interval '90 days'");96  }97}9899function staleAfter(connectorId: string): number {100  const rt = connectorManager.get(connectorId);101  const m = rt?.def.metadata;102  if (!m) return 10 * 60_000;103  if (m.sourceType === "WEBSOCKET") return 5 * 60_000;104  const s = rt?.def.schedule;105  if (s && "intervalMs" in s) return s.intervalMs * 3 + 60_000;106  if (s) return s.weekendMs * 2 + 60_000;107  return 24 * 3_600_000;108}109110/** Export observation partitions older than the retention window to gzip NDJSON, then drop them. */111export async function archiveOldPartitions(): Promise<string[]> {112  const cutoff = new Date(Date.now() - config.observationRetentionDays * 86_400_000).toISOString().slice(0, 10).replace(/-/g, "");113  const parts = await pool.query<{ relname: string }>(114    `select c.relname from pg_inherits i join pg_class c on c.oid = i.inhrelid join pg_class p on p.oid = i.inhparent where p.relname = 'observations' order by 1`,115  );116  const archived: string[] = [];117  for (const { relname } of parts.rows) {118    const day = relname.replace("observations_", "");119    if (day >= cutoff) continue;120    const dir = join(config.dataDir, "archive", day.slice(0, 4));121    mkdirSync(dir, { recursive: true });122    const path = join(dir, `${relname}.ndjson.gz`);123    const rows = await pool.query(`select * from ${relname}`);124    await streamPipeline(Readable.from(rows.rows.map((r) => JSON.stringify(r) + "\n")), createGzip({ level: 9 }), createWriteStream(path));125    const { size } = await stat(path);126    await pool.query(`insert into observation_archives (partition_name, day, rows_archived, path, bytes) values ($1,$2,$3,$4,$5) on conflict (partition_name) do update set rows_archived = excluded.rows_archived, bytes = excluded.bytes, archived_at = now()`, [127      relname,128      `${day.slice(0, 4)}-${day.slice(4, 6)}-${day.slice(6, 8)}`,129      rows.rowCount ?? 0,130      path,131      size,132    ]);133    await pool.query(`drop table ${relname}`);134    archived.push(relname);135    logger.info({ partition: relname, rows: rows.rowCount, bytes: size }, "partition archived");136  }137  return archived;138}139140/** pg_dump of metadata tables (schema + data, excluding observations/bars) into data/backups. */141export function backupMetadata(): Promise<string> {142  return new Promise((resolve, reject) => {143    const dir = join(config.dataDir, "backups");144    mkdirSync(dir, { recursive: true });145    const file = join(dir, `market-atlas-meta-${new Date().toISOString().slice(0, 10)}.sql.gz`);146    const pgDump = process.env.PG_DUMP ?? "pg_dump";147    const child = spawn(pgDump, ["--no-owner", "--exclude-table-data=observations*", "--exclude-table-data=bars", "--exclude-table-data=connector_health", config.databaseUrl], { stdio: ["ignore", "pipe", "pipe"] });148    const out = createWriteStream(file);149    child.stdout.pipe(createGzip()).pipe(out);150    let stderr = "";151    child.stderr.on("data", (d) => (stderr += d));152    child.on("error", reject);153    child.on("close", (code) => (code === 0 ? resolve(file) : reject(new Error(`pg_dump exited ${code}: ${stderr.slice(0, 300)}`))));154  });155}156157export const scheduler = new Scheduler();158