mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* refactor(seeds): add bundle orchestrator to consolidate Railway cron services Railway is at the 100-service limit. This adds a shared _bundle-runner.mjs orchestrator and 11 bundle scripts that group related seed cron services, reducing the count from 100 to ~65 when deployed. Each bundle spawns sub-seeds via child_process.execFile (proven pattern from ais-relay.cjs), with freshness-gated skipping so monthly seeds in a daily bundle only run when due. Original scripts are unchanged and independently runnable. Bundles: ecb-eu (4→1), portwatch (4→1), climate (5→1), energy-sources (6→1), macro (6→1), health (4→1), static-ref (3→1), resilience (2→1), derived-signals (2→1), market-backup (5→1), relay-backup (4→1). * refactor(seeds): deduplicate time constants across bundle scripts Export MIN/HOUR/DAY/WEEK from _bundle-runner.mjs so all 11 bundle scripts import shared constants instead of re-declaring them locally. Eliminates inconsistent computation styles (24*60*60*1000 vs 24*HOUR). * fix(seeds): correct wb-indicators seedMetaKey in relay-backup bundle The seed writes to seed-meta:economic:worldbank-techreadiness:v1 but the bundle config was missing the :v1 suffix, causing the freshness gate to always return null and the seed to run every cycle instead of daily. Found by architecture-strategist review agent. * fix(seeds): address review findings in bundle runner - Remove em dashes from comment and log line (project convention) - Read Redis creds directly instead of via getRedisCredentials() which calls process.exit(1) on missing env vars, bypassing try/catch and silently killing the entire bundle before any seed runs - Missing creds now gracefully skip freshness check (seeds still run) * fix(seeds): correct intervalMs values and exit code in bundle runner P1 fixes from external review: 1. process.exit(0) on failure now exits non-zero (exit 1 when failed > 0) so Railway cron monitoring detects degraded runs. 2. Corrected intervalMs to match actual cron cadences (was using TTL values): - crypto-quotes: 15min -> 5min (actual cron is 5min) - stablecoin-markets: 15min -> 10min (actual cron is 10min) - gulf-quotes: 15min -> 10min (actual cron is 10min) - health-air-quality: 3h -> 1h (actual cron is 1h) - bls-series: 3d -> 1d (actual cron is daily) - eurostat: 3d -> 1d (actual cron is daily) - fao-ffpi: 30d -> 1d (runs daily to catch monthly release window) - imf-macro: 35d -> 30d (monthly data) - national-debt: 35d -> 30d (monthly data) * docs: add Railway seed consolidation runbook Complete migration checklist with: - 46 services to delete (with Railway UUIDs) - 11 bundle services to create (with cron, start cmd, watch paths) - 43 standalone services that stay (with reasons) - Execution order, verification checklist, env var guidance - Watch paths: scripts/** + shared/** (covers loadSharedConfig resolution) - Inventory checksum: 4+4+3+46+43 = 100
120 lines
3.7 KiB
JavaScript
120 lines
3.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Bundle orchestrator: spawns multiple seed scripts sequentially
|
|
* via child_process.execFile, with freshness-gated skipping.
|
|
*
|
|
* Pattern matches ais-relay.cjs:5645-5695 (ClimateNews/ChokepointFlows spawns).
|
|
*
|
|
* Usage from a bundle script:
|
|
* import { runBundle } from './_bundle-runner.mjs';
|
|
* await runBundle('ecb-eu', [ { label, script, seedMetaKey, intervalMs, timeoutMs } ]);
|
|
*/
|
|
|
|
import { execFile } from 'node:child_process';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { loadEnvFile } from './_seed-utils.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export const MIN = 60_000;
|
|
export const HOUR = 3_600_000;
|
|
export const DAY = 86_400_000;
|
|
export const WEEK = 604_800_000;
|
|
|
|
loadEnvFile(import.meta.url);
|
|
|
|
const REDIS_URL = process.env.UPSTASH_REDIS_REST_URL;
|
|
const REDIS_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
|
|
async function readSeedMeta(seedMetaKey) {
|
|
if (!REDIS_URL || !REDIS_TOKEN) return null;
|
|
try {
|
|
const resp = await fetch(`${REDIS_URL}/get/${encodeURIComponent(`seed-meta:${seedMetaKey}`)}`, {
|
|
headers: { Authorization: `Bearer ${REDIS_TOKEN}` },
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (!resp.ok) return null;
|
|
const data = await resp.json();
|
|
return data.result ? JSON.parse(data.result) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function spawnSeed(scriptPath, { timeoutMs, label }) {
|
|
return new Promise((resolve, reject) => {
|
|
const t0 = Date.now();
|
|
execFile(process.execPath, [scriptPath], {
|
|
env: process.env,
|
|
timeout: timeoutMs,
|
|
maxBuffer: 2 * 1024 * 1024,
|
|
}, (err, stdout, stderr) => {
|
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
if (stdout) {
|
|
for (const line of String(stdout).trim().split('\n')) {
|
|
if (line) console.log(` [${label}] ${line}`);
|
|
}
|
|
}
|
|
if (stderr) {
|
|
for (const line of String(stderr).trim().split('\n')) {
|
|
if (line) console.warn(` [${label}] ${line}`);
|
|
}
|
|
}
|
|
if (err) {
|
|
const reason = err.killed ? 'timeout' : (err.code || err.message);
|
|
reject(new Error(`${label} failed after ${elapsed}s: ${reason}`));
|
|
} else {
|
|
resolve({ elapsed });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} label - Bundle name for logging
|
|
* @param {Array<{
|
|
* label: string,
|
|
* script: string,
|
|
* seedMetaKey: string,
|
|
* intervalMs: number,
|
|
* timeoutMs?: number,
|
|
* }>} sections
|
|
*/
|
|
export async function runBundle(label, sections) {
|
|
const t0 = Date.now();
|
|
console.log(`[Bundle:${label}] Starting (${sections.length} sections)`);
|
|
|
|
let ran = 0, skipped = 0, failed = 0;
|
|
|
|
for (const section of sections) {
|
|
const scriptPath = join(__dirname, section.script);
|
|
const timeout = section.timeoutMs || 300_000;
|
|
|
|
const meta = await readSeedMeta(section.seedMetaKey);
|
|
if (meta?.fetchedAt) {
|
|
const elapsed = Date.now() - meta.fetchedAt;
|
|
if (elapsed < section.intervalMs * 0.8) {
|
|
const agoMin = Math.round(elapsed / 60_000);
|
|
const intervalMin = Math.round(section.intervalMs / 60_000);
|
|
console.log(` [${section.label}] Skipped, last seeded ${agoMin}min ago (interval: ${intervalMin}min)`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await spawnSeed(scriptPath, { timeoutMs: timeout, label: section.label });
|
|
console.log(` [${section.label}] Done (${result.elapsed}s)`);
|
|
ran++;
|
|
} catch (err) {
|
|
console.error(` [${section.label}] ${err.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
const totalSec = ((Date.now() - t0) / 1000).toFixed(1);
|
|
console.log(`[Bundle:${label}] Finished in ${totalSec}s, ran:${ran} skipped:${skipped} failed:${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|