← back to La Socrata Ingester

scripts/canary-freshness.js

123 lines

// LA-feed freshness + row-count canary (READ-ONLY, $0 local).
// Baseline-aware, per Steve's canary doctrine: flag a REGRESSION (row count dropped
// below the stored baseline, or a feed's newest record aged past its max) — not
// absolute thresholds. First run establishes the baseline and reports OK.
// Writes tmp/canary/latest.json (heartbeat). Report-only: it alerts nobody and
// writes no source data. Alerting (CNCP/George) is a separate, gated follow-up.
import { q, pool } from '../src/db.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = path.resolve(__dirname, '../data/canary-baseline.json');
const OUT = path.resolve(__dirname, '../tmp/canary');

// table -> { where?, freshCol?, maxAgeDays? }  (freshCol omitted = no freshness check)
const MANIFEST = {
  la_building_permits_raw:      { freshCol: 'issue_date', where: "dataset_id='pi9x-tg5x'", maxAgeDays: 10 },
  la_assessor_parcels_raw:      {},
  la_parcel_geom:               {},
  la_gis_features:              {},
  la_code_enforcement_raw:      { freshCol: 'add_dttm', maxAgeDays: 10 },
  la_business_registrations_raw:{}, // location_start_date is future-dated; skip freshness
  cslb_raw:                     {},
};
const DROP_TOLERANCE = 0.97; // count below baseline*0.97 = regression

async function main() {
  const baseline = fs.existsSync(BASE) ? JSON.parse(fs.readFileSync(BASE, 'utf8')) : {};
  const firstRun = Object.keys(baseline).length === 0;
  const results = [];

  // Rolling history for SLOW-BLEED detection (Cody c1 #2): the ratchet floor catches a
  // big drop but hides a slow net decline. Compare each table's count to its EARLIEST
  // observation (widest window); flag BLEED on a sustained shrink the floor would miss.
  const HIST = path.resolve(__dirname, '../data/canary-history.jsonl');
  const history = fs.existsSync(HIST) ? fs.readFileSync(HIST, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
  const nowTs = (await q(`SELECT now() n`)).rows[0].n;
  // TIME-anchored bleed reference (Cody c3): the newest history entry OLDER than 30d
  // (else the earliest available). Run-count anchoring let a slow decline walk the
  // floor up; a time anchor holds regardless of run cadence / the 120-run trim.
  const cutoff = new Date(new Date(nowTs).getTime() - 30 * 86400 * 1000).toISOString();
  const older = history.filter(h => String(h.ts) <= cutoff);
  const anchor = ((older.length ? older[older.length - 1] : history[0]) || {}).counts || {};
  const curCounts = {};

  for (const [table, cfg] of Object.entries(MANIFEST)) {
    const whereSql = cfg.where ? `WHERE ${cfg.where}` : '';
    const count = Number((await q(`SELECT count(*) c FROM ${table} ${whereSql}`)).rows[0].c);

    let ageDays = null, freshest = null;
    if (cfg.freshCol) {
      const r = (await q(`SELECT max(${cfg.freshCol}) m, EXTRACT(EPOCH FROM (now() - max(${cfg.freshCol})))/86400 age FROM ${table} ${whereSql}`)).rows[0];
      freshest = r.m; ageDays = r.age == null ? null : Math.round(Number(r.age) * 10) / 10;
    }

    const base = baseline[table] ?? count;
    const floor = Math.floor(base * DROP_TOLERANCE);
    curCounts[table] = count;
    const ref = anchor[table]; // time-anchored (~30d) reference count
    let status = 'OK', reason = '';
    if (count < floor) { status = 'LOW'; reason = `count ${count.toLocaleString()} < floor ${floor.toLocaleString()} (baseline ${base.toLocaleString()})`; }
    else if (ref != null && history.length >= 1 && count < Math.floor(ref * 0.99)) { status = 'BLEED'; reason = `count ${count.toLocaleString()} < earliest observed ${Number(ref).toLocaleString()} (slow decline the floor misses)`; }
    else if (ageDays != null && ageDays > cfg.maxAgeDays) { status = 'STALE'; reason = `newest record ${ageDays}d old (> ${cfg.maxAgeDays}d)`; }

    results.push({ table, count, baseline: base, ageDays, freshest, status, reason });
    // baseline ratchets UP (never down) so a bad reload can't quietly reset the floor
    baseline[table] = Math.max(base, count);
  }

  // SOURCE staleness from la_ingest_state — the real "when did we last ingest / did it
  // error" signal. Covers the count-only tables the row check alone can't see stale, and
  // surfaces any source whose last run errored (Cody c1: count-only = false comfort).
  const SRC_MAX_AGE = 14; // days; generous until a scheduled refresh job exists
  // Known-broken upstreams: demoted to WARNING (BROKEN) so one unfixable source can't
  // hold the whole canary red forever (Cody c3: a permanently-FAIL canary is ignored).
  // Document the reason + date; remove the entry when the source is fixed.
  const KNOWN_BROKEN = {
    gis_zoning: 'NavigateLA ArcGIS gateway 502s past offset 44k; ~75% partial load; full LA zoning is fragmented across per-community Zone_Builder services (noted 2026-08-11)',
  };
  const srcRows = (await q(`SELECT source, last_status, EXTRACT(EPOCH FROM (now()-last_run))/86400 age FROM la_ingest_state`)).rows;
  const sources = srcRows.map(r => {
    const age = r.age == null ? null : Math.round(Number(r.age) * 10) / 10;
    const errored = String(r.last_status || '').startsWith('error');
    const status = errored ? (KNOWN_BROKEN[r.source] ? 'BROKEN' : 'ERRORED')
      : (age != null && age > SRC_MAX_AGE ? 'STALE' : 'OK');
    return { source: r.source, ageDays: age, last_status: r.last_status, status, note: KNOWN_BROKEN[r.source] };
  }).sort((a, b) => (b.ageDays || 0) - (a.ageDays || 0));

  fs.mkdirSync(path.dirname(BASE), { recursive: true });
  fs.writeFileSync(BASE, JSON.stringify(baseline, null, 2));
  // append this run to rolling history (keep last 120 runs)
  fs.appendFileSync(HIST, JSON.stringify({ ts: nowTs, counts: curCounts }) + '\n');
  const trimmed = fs.readFileSync(HIST, 'utf8').trim().split('\n').filter(Boolean);
  if (trimmed.length > 120) fs.writeFileSync(HIST, trimmed.slice(-120).join('\n') + '\n');
  fs.mkdirSync(OUT, { recursive: true });
  const anyErr = sources.some(s => s.status === 'ERRORED'); // excludes known-broken
  const anyWarnSrc = sources.some(s => s.status === 'STALE' || s.status === 'BROKEN');
  const anyBleed = results.some(r => r.status === 'BLEED');
  const worst = (results.some(r => r.status === 'LOW') || anyErr) ? 'FAIL'
    : (results.some(r => r.status === 'STALE') || anyWarnSrc || anyBleed) ? 'WARN' : 'OK';
  fs.writeFileSync(path.join(OUT, 'latest.json'), JSON.stringify({ verdict: worst, firstRun, results, sources }, null, 2));

  // report
  console.log(`\nLA data canary — ${firstRun ? 'BASELINE established' : 'verdict ' + worst}\n`);
  console.log('  ROW COUNTS + freshness:');
  for (const r of results) {
    const mark = r.status === 'OK' ? '✔' : r.status === 'STALE' ? '⏳' : r.status === 'BLEED' ? '↓' : '✖';
    const fresh = r.ageDays != null ? `  fresh ${r.ageDays}d` : '';
    console.log(`  ${mark} ${r.table.padEnd(30)} ${r.count.toLocaleString().padStart(12)}${fresh}${r.reason ? '  — ' + r.reason : ''}`);
  }
  console.log('\n  SOURCE last-ingest (la_ingest_state):');
  for (const s of sources) {
    const mark = s.status === 'OK' ? '✔' : s.status === 'STALE' ? '⏳' : s.status === 'BROKEN' ? '⚠' : '✖';
    const age = s.ageDays != null ? `${s.ageDays}d ago` : 'never';
    const detail = s.status === 'OK' ? '' : '  — ' + (s.status === 'BROKEN' ? `known-broken: ${s.note}` : s.status === 'ERRORED' ? s.last_status : 'stale');
    console.log(`  ${mark} ${s.source.padEnd(30)} ${age.padStart(10)}${detail}`);
  }
  console.log('');
}

main().catch(e => { console.error('canary error:', e.message); process.exitCode = 1; }).finally(() => pool.end());