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%
4.5 KB · 119 lines typescript
Raw Blame History
1import type { Observation } from "@market-atlas/market-model";2import { config } from "../config.js";3import { pool } from "../db/pool.js";4import { ensureObservationPartition } from "../db/migrate.js";5import { logger } from "../logger.js";6import { telemetry } from "./telemetry.js";78/**9 * Batched, idempotent observation writer. Bounded memory: when the queue exceeds the hard cap,10 * P2 tick observations are dropped (counted), never events or non-REALTIME values.11 */12export class ObservationWriter {13  private queue: Observation[] = [];14  private timer: NodeJS.Timeout | null = null;15  private flushing = false;16  private partitionsReady = new Set<string>();17  private seen = new Map<string, number>(); // fingerprint -> ts (dedupe replays for 10 min)18  readonly hardCap = 200_000;1920  enqueue(o: Observation): void {21    const fp = o.observationId;22    const now = Date.now();23    const prev = this.seen.get(fp);24    if (prev && now - prev < 600_000) {25      telemetry.inc("observations_duplicate_total");26      return;27    }28    this.seen.set(fp, now);29    if (this.seen.size > 500_000) this.pruneSeen(now);30    if (this.queue.length >= this.hardCap) {31      if (o.realtimeStatus === "REALTIME") {32        telemetry.inc("observations_dropped_total", 1, { reason: "backpressure" });33        return;34      }35    }36    this.queue.push(o);37    if (this.queue.length >= config.writerMaxBatch) void this.flush();38    else if (!this.timer) this.timer = setTimeout(() => void this.flush(), config.writerFlushMs);39  }4041  private pruneSeen(now: number) {42    for (const [k, t] of this.seen) if (now - t > 600_000) this.seen.delete(k);43  }4445  depth(): number {46    return this.queue.length;47  }4849  async flush(): Promise<void> {50    if (this.timer) {51      clearTimeout(this.timer);52      this.timer = null;53    }54    if (this.flushing || !this.queue.length) return;55    this.flushing = true;56    const batch = this.queue.splice(0, config.writerMaxBatch);57    const started = Date.now();58    try {59      const days = new Set(batch.map((o) => new Date(o.receivedAt).toISOString().slice(0, 10)));60      for (const d of days) {61        if (!this.partitionsReady.has(d)) {62          await ensureObservationPartition(Date.parse(d));63          this.partitionsReady.add(d);64        }65      }66      const cols = 18;67      const values: unknown[] = [];68      const tuples: string[] = [];69      batch.forEach((o, i) => {70        const b = i * cols;71        tuples.push(`(${Array.from({ length: cols }, (_, j) => `$${b + j + 1}`).join(",")})`);72        values.push(73          new Date(o.receivedAt).toISOString(),74          o.sourceTimestamp == null ? null : new Date(o.sourceTimestamp).toISOString(),75          o.instrumentId,76          o.field,77          o.value,78          o.currency ?? null,79          o.sourceId,80          o.connectorId,81          o.rightsStatus,82          o.realtimeStatus,83          o.timestampTrust,84          o.confidence ?? null,85          o.latencyMs,86          o.sequence == null ? null : String(o.sequence),87          o.observationId,88          o.rawRef,89          o.normalizerVersion,90          o.observationType ?? null,91        );92      });93      await pool.query(94        `insert into observations (received_at, source_ts, instrument_id, field, value, currency, source_id, connector_id, rights_status, realtime_status, timestamp_trust, confidence, latency_ms, sequence, fingerprint, raw_ref, normalizer_version, observation_type)95         values ${tuples.join(",")}`,96        values,97      );98      await pool.query(99        `insert into daily_stats (day, observations) values (current_date, $1)100         on conflict (day) do update set observations = daily_stats.observations + excluded.observations, updated_at = now()`,101        [batch.length],102      );103      telemetry.inc("observations_written_total", batch.length);104      telemetry.observe("db_insert_latency_ms", Date.now() - started);105    } catch (err) {106      telemetry.inc("observations_write_errors_total");107      logger.error({ err: err instanceof Error ? err.message : String(err), size: batch.length }, "observation batch insert failed");108      // Re-queue once at the front (bounded), so a transient DB hiccup does not lose canonical history.109      if (this.queue.length + batch.length <= this.hardCap) this.queue.unshift(...batch);110    } finally {111      this.flushing = false;112      telemetry.gauge("observation_queue_depth", this.queue.length);113      if (this.queue.length) this.timer = setTimeout(() => void this.flush(), config.writerFlushMs);114    }115  }116}117118export const observationWriter = new ObservationWriter();119