← back to Nationalrealestate
src/jobs/self_correct.ts
159 lines
/**
* Self-correcting engine — turns the hourly loop from a dumb timer into a
* health-aware controller. Each tick, for every job, we read its recent
* ingest_runs history and derive a HEALTH STATE, then tell the loop how to act:
*
* healthy → run on normal staleness.
* degraded → last run was 'ok' but rows_upserted collapsed vs baseline
* (a SILENT break — URL drift, selector rot). Keep running but
* alert; the source is "up" yet lying.
* retrying → last run FAILED. Not eligible until an exponential backoff
* window elapses (1h·2^(n-1), capped 12h) — don't hammer a
* flapping source, but keep trying.
* quarantined → N consecutive failures. Blocked for a cooldown (24h), then
* released as a single PROBE to test recovery. Auto-recovers
* (back to healthy) the moment a probe succeeds.
*
* State is persisted in source_health (memory between ticks) and every
* transition is logged to source_events + best-effort surfaced to CNCP. Alerts
* fire ONLY on a worsening/recovery TRANSITION, never every tick.
*/
import { query } from '../../db/pool.ts';
const QUARANTINE_AFTER = Number(process.env.USRE_QUARANTINE_AFTER || 3); // consecutive failures
const COOLDOWN_H = Number(process.env.USRE_QUARANTINE_COOLDOWN_H || 24);
const BACKOFF_BASE_H = Number(process.env.USRE_BACKOFF_BASE_H || 1);
const BACKOFF_CAP_H = Number(process.env.USRE_BACKOFF_CAP_H || 12);
const DEGRADE_FRACTION = Number(process.env.USRE_DEGRADE_FRACTION || 0.3); // ok run < 30% baseline = silent break
const DEGRADE_MIN_BASE = Number(process.env.USRE_DEGRADE_MIN_BASE || 10); // ignore tiny-count sources
export type Health = {
job: string;
state: 'healthy' | 'degraded' | 'retrying' | 'quarantined' | 'unknown';
blockedUntilMs: number | null; // engine says: don't run before this (backoff / cooldown)
isProbe: boolean; // quarantined but cooldown elapsed → run once to test recovery
priorityBoost: number; // added to overdueBy so breakage gets fixed before routine refresh
reason: string;
};
function median(xs: number[]): number {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
async function logEvent(job: string, event: string, detail: string): Promise<void> {
await query(`INSERT INTO source_events (job, event, detail) VALUES ($1,$2,$3)`, [job, event, detail]);
}
/** Best-effort CNCP parking-lot card (Mac2 only; silently no-ops on Kamatera). */
async function alertCNCP(job: string, state: string, detail: string): Promise<void> {
try {
await fetch('http://127.0.0.1:3333/api/parking-lot', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: 'usrealestate', title: `ingest source ${state}: ${job}`, note: detail }),
signal: AbortSignal.timeout(2000),
});
} catch { /* CNCP unreachable (prod) — source_events is the durable record */ }
}
/** Recent run history for a job's sources, newest first (carries source so the
* degrade baseline can be computed per-source — a rotating job like brokers
* processes different-sized states each run, so a job-wide baseline lies). */
async function history(runSources: string[]): Promise<Array<{ source: string; status: string; upserted: number; at: number }>> {
const like = runSources.map((_, i) => `source LIKE $${i + 1}`).join(' OR ');
const r = await query<{ source: string; status: string; rows_upserted: number | null; started_at: string }>(
`SELECT source, status, rows_upserted, started_at
FROM ingest_runs WHERE (${like}) AND status IN ('ok','failed')
ORDER BY started_at DESC LIMIT 24`, runSources);
return r.rows.map(x => ({ source: x.source, status: x.status, upserted: Number(x.rows_upserted ?? 0), at: Date.parse(x.started_at) }));
}
export async function assess(job: string, runSources: string[], nowMs: number, stableVolume = false): Promise<Health> {
const h = await history(runSources);
if (!h.length) {
await query(
`INSERT INTO source_health (job, state) VALUES ($1,'unknown')
ON CONFLICT (job) DO UPDATE SET state='unknown', updated_at=NOW()`, [job]);
return { job, state: 'unknown', blockedUntilMs: null, isProbe: false, priorityBoost: 0, reason: 'no runs yet' };
}
let consecutiveFailures = 0;
for (const run of h) { if (run.status === 'failed') consecutiveFailures++; else break; }
const lastOk = h.find(r => r.status === 'ok');
const lastFail = h.find(r => r.status === 'failed');
// Degrade baseline is PER-SOURCE: compare the newest ok run against prior ok
// runs of the SAME source, so rotating jobs (brokers cycling states of very
// different sizes) don't false-trip. Homogeneous jobs have one source anyway.
const sameSourceOk = lastOk ? h.filter(r => r.status === 'ok' && r.source === lastOk.source) : [];
const baseline = median(sameSourceOk.slice(1, 8).map(r => r.upserted)); // excludes the newest ok run
const lastUpserted = lastOk?.upserted ?? 0;
// prior persisted state (for transition detection)
const prev = await query<{ state: string; alerted_state: string | null; quarantined_at: string | null }>(
`SELECT state, alerted_state, quarantined_at::text FROM source_health WHERE job=$1`, [job]);
const prevAlerted = prev.rows[0]?.alerted_state ?? null;
const prevQuarantinedAt = prev.rows[0]?.quarantined_at ? Date.parse(prev.rows[0].quarantined_at) : null;
// classify
let state: Health['state'];
let blockedUntilMs: number | null = null;
let isProbe = false;
let priorityBoost = 0;
let reason = '';
let quarantinedAtMs: number | null = prevQuarantinedAt;
if (consecutiveFailures >= QUARANTINE_AFTER) {
state = 'quarantined';
quarantinedAtMs = prevQuarantinedAt ?? (lastFail?.at ?? nowMs); // pin the start of quarantine
const releaseAt = quarantinedAtMs + COOLDOWN_H * 3_600_000;
if (nowMs >= releaseAt) { isProbe = true; priorityBoost = 5000; reason = `probe after ${COOLDOWN_H}h cooldown (${consecutiveFailures} fails)`; }
else { blockedUntilMs = releaseAt; reason = `quarantined ${consecutiveFailures} fails, cooldown until ${new Date(releaseAt).toISOString()}`; }
} else if (consecutiveFailures > 0) {
state = 'retrying';
const backoffH = Math.min(BACKOFF_CAP_H, BACKOFF_BASE_H * 2 ** (consecutiveFailures - 1));
const readyAt = (lastFail?.at ?? nowMs) + backoffH * 3_600_000;
priorityBoost = 3000;
if (nowMs < readyAt) { blockedUntilMs = readyAt; reason = `retry backoff ${backoffH}h (fail #${consecutiveFailures})`; }
else reason = `retry ready (fail #${consecutiveFailures}, waited ${backoffH}h)`;
} else if (stableVolume && baseline >= DEGRADE_MIN_BASE && lastUpserted < baseline * DEGRADE_FRACTION) {
// Only meaningful for FIXED-workload jobs. Paging/rotating jobs (parcels-sd,
// brokers, places-seed) have legitimately variable per-run counts, so the
// degrade check is off for them — a real break there shows as a failed run.
state = 'degraded';
priorityBoost = 1000;
reason = `SILENT break: last ok upserted ${lastUpserted} vs baseline ${baseline} (<${DEGRADE_FRACTION * 100}%)`;
} else {
state = 'healthy';
quarantinedAtMs = null;
reason = `ok (last ${lastUpserted}, baseline ${baseline || 'n/a'})`;
}
await query(
`INSERT INTO source_health (job, state, consecutive_failures, last_ok_at, last_failure_at,
quarantined_at, baseline_upserted, last_upserted, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (job) DO UPDATE SET state=$2, consecutive_failures=$3, last_ok_at=$4,
last_failure_at=$5, quarantined_at=$6, baseline_upserted=$7, last_upserted=$8, updated_at=NOW()`,
[job, state, consecutiveFailures,
lastOk ? new Date(lastOk.at) : null, lastFail ? new Date(lastFail.at) : null,
quarantinedAtMs ? new Date(quarantinedAtMs) : null, baseline || null, lastUpserted]);
// alert only on a real transition (worsening or recovery)
const bad = (s: string) => s === 'degraded' || s === 'quarantined' || s === 'retrying';
if (state !== prevAlerted) {
if (bad(state) && state !== 'retrying') { // retrying is noisy/expected; alert on degraded+quarantined
await logEvent(job, state, reason); await alertCNCP(job, state, reason);
await query(`UPDATE source_health SET alerted_state=$2 WHERE job=$1`, [job, state]);
} else if (state === 'healthy' && prevAlerted && bad(prevAlerted)) {
await logEvent(job, 'recovered', `back to healthy from ${prevAlerted}`); await alertCNCP(job, 'recovered', reason);
await query(`UPDATE source_health SET alerted_state='healthy' WHERE job=$1`, [job]);
} else {
await query(`UPDATE source_health SET alerted_state=$2 WHERE job=$1`, [job, state]);
}
}
return { job, state, blockedUntilMs, isProbe, priorityBoost, reason };
}