← back to Nationalrealestate
src/jobs/hourly_loop.ts
121 lines
/**
* Hourly self-balancing ingest loop (launchd com.steve.usre-hourly, StartInterval 3600).
*
* Generalizes broker_rotate's "stalest source wins" idea across every fast-moving
* stream. Each JOB declares a minIntervalHours; each tick we read the last OK run
* per job (from ingest_runs) and run the most-overdue eligible job(s) — so no
* source is ever hammered, coverage self-balances, and a new adapter joins the
* rotation the moment it registers a run. Jobs spawn as child processes (each
* script owns its own pool.end() lifecycle), mirroring refresh_all/broker_rotate.
*
* Politeness > freshness: MAX_JOBS_PER_TICK (default 2) caps outbound bursts.
* The slow macro-data (zillow/redfin/acs/fhfa) stays on the MONTHLY refresh_all
* orchestrator — it does not belong here (those feeds only change monthly).
*
* npm run loop # one tick
* npm run loop -- --dry # show the plan, spawn nothing
*/
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { query, pool } from '../../db/pool.ts';
import { assess } from './self_correct.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const DRY = process.argv.includes('--dry');
const MAX_JOBS_PER_TICK = Number(process.env.USRE_MAX_JOBS_PER_TICK || 2);
type Job = {
name: string;
script: string;
args: string[];
minIntervalHours: number;
// ingest_runs.source value(s) that mark this job as having run; freshest wins.
runSources: string[];
// true only for FIXED-workload jobs — enables the silent-break (degrade) check.
// Paging/rotating jobs have variable per-run counts, so leave false for them.
stableVolume?: boolean;
};
// Order = tie-break priority when equally overdue (listings freshest first).
const JOBS: Job[] = [
{ name: 'listings', script: 'src/ingest/listings/engine.ts', args: ['all'], minIntervalHours: 2, runSources: ['coldwellbanker', 'realtytexas'], stableVolume: true },
{ name: 'places-seed',script: 'src/ingest/places/seed.ts', args: [], minIntervalHours: 2, runSources: ['google_places'] },
{ name: 'firm-crawl', script: 'src/crawl/firm_front_page.ts', args: [], minIntervalHours: 4, runSources: ['firm_crawl'] },
{ name: 'discover', script: 'src/enrich/firm_website_discovery.ts', args: [], minIntervalHours: 4, runSources: ['firm_discovery'] },
{ name: 'brokers', script: 'src/jobs/broker_rotate.ts', args: [], minIntervalHours: 6, runSources: ['%_dre', '%_trec', '%_dos', '%_dbpr', '%_idfpr', '%_dcp', '%_dpr'] },
{ name: 'commercial', script: 'src/ingest/commercial/engine.ts', args: ['la'], minIntervalHours: 24, runSources: ['la_assessor'], stableVolume: true },
{ name: 'parcels-sd', script: 'src/ingest/parcels/engine.ts', args: ['sandiego-county'], minIntervalHours: 1, runSources: ['sandiego_ca'] },
// Free priced-DEED feeds (west-coast) — each paginates its county set over time.
{ name: 'deeds-or', script: 'src/ingest/parcels/engine.ts', args: ['oregon-rlis'], minIntervalHours: 2, runSources: ['oregon_rlis'] },
{ name: 'deeds-spok', script: 'src/ingest/parcels/engine.ts', args: ['spokane'], minIntervalHours: 3, runSources: ['spokane'] },
{ name: 'deeds-alam', script: 'src/ingest/parcels/engine.ts', args: ['alameda'], minIntervalHours: 2, runSources: ['alameda'] },
{ name: 'deeds-desc', script: 'src/ingest/parcels/engine.ts', args: ['deschutes'], minIntervalHours: 3, runSources: ['deschutes'] },
// King County WA — 2.4M full-history priced deeds (grantor+grantee). Heavy 150MB
// bulk CSV, so weekly cadence (not hourly).
{ name: 'deeds-king', script: 'src/ingest/parcels/engine.ts', args: ['king'], minIntervalHours: 168, runSources: ['parcel_king_wa'] },
// Scout round 2 — more free priced-deed feeds (OR/CA/CO/AZ/WA).
{ name: 'deeds-crook',script: 'src/ingest/parcels/engine.ts', args: ['crook'], minIntervalHours: 4, runSources: ['crook'] },
{ name: 'deeds-sonoma',script:'src/ingest/parcels/engine.ts', args: ['sonoma'], minIntervalHours: 3, runSources: ['sonoma'] },
{ name: 'deeds-co', script: 'src/ingest/parcels/engine.ts', args: ['colorado'], minIntervalHours: 2, runSources: ['colorado'] },
{ name: 'deeds-maricopa',script:'src/ingest/parcels/engine.ts', args: ['maricopa'], minIntervalHours: 2, runSources: ['maricopa'] },
{ name: 'deeds-thur', script: 'src/ingest/parcels/engine.ts', args: ['thurston'], minIntervalHours: 4, runSources: ['thurston'] },
{ name: 'deeds-pb', script: 'src/ingest/parcels/engine.ts', args: ['palm-beach'], minIntervalHours: 4, runSources: ['palm_beach'] },
{ name: 'deeds-klam', script: 'src/ingest/parcels/engine.ts', args: ['klamath'], minIntervalHours: 4, runSources: ['klamath'] },
{ name: 'deeds-marion',script:'src/ingest/parcels/engine.ts', args: ['marion'], minIntervalHours: 3, runSources: ['marion'] },
{ name: 'deeds-yakima',script:'src/ingest/parcels/engine.ts', args: ['yakima'], minIntervalHours: 3, runSources: ['yakima'] },
];
/** hours since this job's freshest OK run across its runSources (∞ if never). */
async function ageHours(job: Job): Promise<number> {
const likeClauses = job.runSources.map((_, i) => `source LIKE $${i + 1}`).join(' OR ');
const r = await query<{ age: number | null }>(
`SELECT EXTRACT(EPOCH FROM (NOW() - MAX(started_at)))/3600 AS age
FROM ingest_runs WHERE status='ok' AND (${likeClauses})`, job.runSources);
return r.rows[0]?.age == null ? Infinity : Number(r.rows[0].age);
}
async function main() {
const nowMs = Date.now();
// Assess health + staleness together. The engine can BLOCK a job (backoff /
// quarantine cooldown) regardless of how stale it is, and BOOST a broken job
// so it gets corrected before routine refresh.
const scored = await Promise.all(JOBS.map(async j => {
const age = await ageHours(j);
const health = await assess(j.name, j.runSources, nowMs, j.stableVolume);
const overdueBy = age - j.minIntervalHours;
const blocked = health.blockedUntilMs != null && nowMs < health.blockedUntilMs;
const eligible = !blocked && (overdueBy >= 0 || health.isProbe);
return { job: j, age, overdueBy, health, blocked, eligible, sortKey: health.priorityBoost + overdueBy };
}));
const plan = [...scored].sort((a, b) => b.sortKey - a.sortKey);
const nEligible = plan.filter(s => s.eligible).length;
console.log(`[loop] ${new Date().toISOString()} eligible=${nEligible}/${JOBS.length} cap=${MAX_JOBS_PER_TICK}`);
for (const s of plan) {
const a = s.age === Infinity ? 'never' : `${s.age.toFixed(1)}h`;
const mark = s.eligible ? (s.health.isProbe ? '◆' : '▶') : (s.blocked ? '⏸' : '·');
console.log(` ${mark} ${s.job.name.padEnd(12)} [${s.health.state.padEnd(11)}] age=${a.padStart(6)} ${s.health.reason}`);
}
const pick = plan.filter(s => s.eligible).slice(0, MAX_JOBS_PER_TICK);
if (!pick.length) { console.log('[loop] nothing eligible — idle tick (all fresh, blocked, or quarantined)'); await pool.end(); return; }
await pool.end(); // children own their own pools
for (const { job } of pick) {
console.log(`\n[loop] ── ${job.name} ──`);
if (DRY) { console.log(` (dry) would run: tsx ${job.script} ${job.args.join(' ')}`); continue; }
try {
execFileSync('npx', ['tsx', job.script, ...job.args], { cwd: ROOT, stdio: 'inherit', timeout: 50 * 60_000 });
} catch (e: any) {
// one dead source must not starve the rest of the tick
console.error(`[loop] ${job.name} FAILED: ${e.message}`);
}
}
console.log(`\n[loop] tick complete — ran ${pick.length} job(s)`);
}
main().catch(e => { console.error('[loop] FATAL:', e); process.exit(1); });