| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { spawn } from 'node:child_process'; |
| import { dirname, join } from 'node:path'; |
| import { fileURLToPath } from 'node:url'; |
| import { GRACEFUL_FETCH_FAILURE_EXIT_CODE, loadEnvFile } from './_seed-utils.mjs'; |
| import { unwrapEnvelope } from './_seed-envelope-source.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 readRedisKey(key) { |
| if (!REDIS_URL || !REDIS_TOKEN) return null; |
| try { |
| const resp = await fetch(`${REDIS_URL}/get/${encodeURIComponent(key)}`, { |
| headers: { Authorization: `Bearer ${REDIS_TOKEN}` }, |
| signal: AbortSignal.timeout(5_000), |
| }); |
| if (!resp.ok) return null; |
| const body = await resp.json(); |
| return body.result ? JSON.parse(body.result) : null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function readSectionFreshness(section, readKey = readRedisKey) { |
| if (section.freshnessMetaKey) { |
| if (section.requireCanonical && section.canonicalKey) { |
| const canonical = await readKey(section.canonicalKey); |
| if (!unwrapEnvelope(canonical)._seed?.fetchedAt) return null; |
| } |
| const raw = await readKey(section.freshnessMetaKey); |
| const meta = unwrapEnvelope(raw).data; |
| if (!Number.isFinite(meta?.fetchedAt)) return null; |
| if (!section.completionMetaKey) return { fetchedAt: meta.fetchedAt }; |
| const completionRaw = await readKey(section.completionMetaKey); |
| const completion = unwrapEnvelope(completionRaw).data; |
| if (!Number.isFinite(completion?.fetchedAt)) return null; |
| if (completion.fetchedAt < meta.fetchedAt) return null; |
| return { fetchedAt: meta.fetchedAt }; |
| } |
| |
| |
| |
| |
| if (section.canonicalKey) { |
| const raw = await readKey(section.canonicalKey); |
| const { _seed } = unwrapEnvelope(raw); |
| if (_seed?.fetchedAt) return { fetchedAt: _seed.fetchedAt }; |
| |
| |
| |
| if (section.requireCanonical) return null; |
| } |
| if (section.seedMetaKey) { |
| const raw = await readKey(`seed-meta:${section.seedMetaKey}`); |
| |
| |
| const meta = unwrapEnvelope(raw).data; |
| if (meta?.fetchedAt) return { fetchedAt: meta.fetchedAt }; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| const KILL_GRACE_MS = 10_000; |
|
|
| function streamLines(stream, onLine) { |
| let buf = ''; |
| stream.setEncoding('utf8'); |
| stream.on('data', (chunk) => { |
| buf += chunk; |
| let idx; |
| while ((idx = buf.indexOf('\n')) !== -1) { |
| const line = buf.slice(0, idx); |
| buf = buf.slice(idx + 1); |
| if (line) onLine(line); |
| } |
| }); |
| stream.on('end', () => { if (buf) onLine(buf); }); |
| |
| |
| stream.on('error', (err) => onLine(`<stdio error: ${err.message}>`)); |
| } |
|
|
| function spawnSeed(scriptPath, { timeoutMs, label, bundleStartedAtMs }) { |
| return new Promise((resolve) => { |
| const t0 = Date.now(); |
| |
| |
| |
| |
| |
| |
| |
| let lastSeedComplete = null; |
| |
| |
| |
| |
| const child = spawn(process.execPath, [scriptPath], { |
| env: { |
| ...process.env, |
| BUNDLE_RUN_STARTED_AT_MS: String(bundleStartedAtMs ?? Date.now()), |
| }, |
| stdio: ['ignore', 'pipe', 'pipe'], |
| }); |
|
|
| streamLines(child.stdout, (line) => { |
| console.log(` [${label}] ${line}`); |
| const idx = line.indexOf('{"event":"seed_complete"'); |
| if (idx >= 0) { |
| try { |
| lastSeedComplete = JSON.parse(line.slice(idx)); |
| } catch { } |
| } |
| }); |
| streamLines(child.stderr, (line) => console.warn(` [${label}] ${line}`)); |
|
|
| let settled = false; |
| let timedOut = false; |
| let killTimer = null; |
| |
| |
| |
| |
| |
| const softKill = setTimeout(() => { |
| timedOut = true; |
| const elapsedAtTimeout = ((Date.now() - t0) / 1000).toFixed(1); |
| console.error(` [${label}] Failed after ${elapsedAtTimeout}s: timeout after ${Math.round(timeoutMs / 1000)}s — sending SIGTERM`); |
| child.kill('SIGTERM'); |
| killTimer = setTimeout(() => { |
| console.warn(` [${label}] Did not exit on SIGTERM within ${KILL_GRACE_MS / 1000}s — sending SIGKILL`); |
| child.kill('SIGKILL'); |
| }, KILL_GRACE_MS); |
| }, timeoutMs); |
| const settle = (value) => { |
| if (settled) return; |
| settled = true; |
| clearTimeout(softKill); |
| if (killTimer) clearTimeout(killTimer); |
| resolve(value); |
| }; |
|
|
| child.on('error', (err) => { |
| const elapsed = ((Date.now() - t0) / 1000).toFixed(1); |
| console.error(` [${label}] Failed after ${elapsed}s: spawn error: ${err.message}`); |
| settle({ elapsed, ok: false, reason: `spawn error: ${err.message}`, alreadyLogged: true }); |
| }); |
|
|
| child.on('close', (code, signal) => { |
| const elapsed = ((Date.now() - t0) / 1000).toFixed(1); |
| if (timedOut) { |
| |
| settle({ elapsed, ok: false, reason: `timeout after ${Math.round(timeoutMs / 1000)}s (signal ${signal || 'SIGTERM'})`, alreadyLogged: true }); |
| } else if (code === 0) { |
| settle({ elapsed, ok: true, seedComplete: lastSeedComplete }); |
| } else if (code === GRACEFUL_FETCH_FAILURE_EXIT_CODE) { |
| settle({ |
| elapsed, |
| ok: false, |
| status: 'GRACEFUL_FAIL', |
| reason: `graceful fetch failure (exit ${GRACEFUL_FETCH_FAILURE_EXIT_CODE})`, |
| }); |
| } else { |
| settle({ elapsed, ok: false, reason: `exit ${code ?? 'null'}${signal ? ` (signal ${signal})` : ''}` }); |
| } |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function runBundle(label, sections, opts = {}) { |
| const missingEnvBySection = new Map(); |
| for (const section of sections) { |
| if (section.requiredEnv == null) continue; |
| if (!Array.isArray(section.requiredEnv)) { |
| throw new Error(`[Bundle:${label}] section '${section.label}' requiredEnv must be an array`); |
| } |
| const missing = []; |
| for (const requirement of section.requiredEnv) { |
| |
| |
| |
| |
| |
| const alternatives = Array.isArray(requirement) ? requirement : [requirement]; |
| if (alternatives.length === 0) { |
| throw new Error(`[Bundle:${label}] section '${section.label}' has an empty requiredEnv group`); |
| } |
| for (const name of alternatives) { |
| if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name)) { |
| throw new Error(`[Bundle:${label}] section '${section.label}' has invalid requiredEnv name '${name}'`); |
| } |
| } |
| const satisfied = alternatives.some( |
| (name) => String(process.env[name] ?? '').trim(), |
| ); |
| if (!satisfied) missing.push(alternatives.join(' or ')); |
| } |
| if (missing.length > 0) missingEnvBySection.set(section.label, missing); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| const labelIndex = new Map(sections.map((s, i) => [s.label, i])); |
| for (let i = 0; i < sections.length; i++) { |
| const deps = sections[i].dependsOn; |
| if (!Array.isArray(deps)) continue; |
| for (const depLabel of deps) { |
| const depIdx = labelIndex.get(depLabel); |
| if (depIdx == null) { |
| throw new Error(`[Bundle:${label}] section '${sections[i].label}' dependsOn unknown label '${depLabel}'`); |
| } |
| if (depIdx >= i) { |
| throw new Error(`[Bundle:${label}] section '${sections[i].label}' dependsOn '${depLabel}' but '${depLabel}' is at index ${depIdx} (must be < ${i})`); |
| } |
| } |
| } |
|
|
| const t0 = Date.now(); |
| const maxBundleMs = opts.maxBundleMs ?? Infinity; |
| const budgetLabel = Number.isFinite(maxBundleMs) ? `, budget ${Math.round(maxBundleMs / 1000)}s` : ''; |
| console.log(`[Bundle:${label}] Starting (${sections.length} sections${budgetLabel})`); |
|
|
| let ran = 0, skipped = 0, deferred = 0, failed = 0, gracefulFailed = 0; |
|
|
| for (const section of sections) { |
| const missingEnv = missingEnvBySection.get(section.label); |
| if (missingEnv) { |
| const reason = `missing required environment configuration: ${missingEnv.join(', ')}`; |
| console.error(` [${section.label}] Failed configuration: ${reason}`); |
| console.error(`[Bundle:${label}] section=${section.label} status=CONFIG_ERROR reason=${reason}`); |
| failed++; |
| continue; |
| } |
|
|
| const scriptPath = join(__dirname, section.script); |
| const timeout = section.timeoutMs || 300_000; |
|
|
| const freshness = await readSectionFreshness(section); |
| if (freshness?.fetchedAt) { |
| const elapsed = Date.now() - freshness.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; |
| } |
| } |
|
|
| const elapsedBundle = Date.now() - t0; |
| |
| |
| const worstCase = timeout + KILL_GRACE_MS; |
| if (elapsedBundle + worstCase > maxBundleMs) { |
| const remainingSec = Math.max(0, Math.round((maxBundleMs - elapsedBundle) / 1000)); |
| const needSec = Math.round(worstCase / 1000); |
| console.log(` [${section.label}] Deferred, needs ${needSec}s (timeout+grace) but only ${remainingSec}s left in bundle budget`); |
| deferred++; |
| continue; |
| } |
|
|
| const result = await spawnSeed(scriptPath, { timeoutMs: timeout, label: section.label, bundleStartedAtMs: t0 }); |
| if (result.ok) { |
| console.log(` [${section.label}] Done (${result.elapsed}s)`); |
| |
| |
| |
| |
| const sc = result.seedComplete; |
| if (sc && typeof sc === 'object') { |
| console.log(`[Bundle:${label}] section=${section.label} status=OK durationMs=${sc.durationMs ?? ''} records=${sc.recordCount ?? ''} state=${sc.state || 'OK'}`); |
| } else { |
| |
| |
| console.log(`[Bundle:${label}] section=${section.label} status=OK elapsed=${result.elapsed}s`); |
| } |
| ran++; |
| } else { |
| if (!result.alreadyLogged) { |
| console.error(` [${section.label}] Failed after ${result.elapsed}s: ${result.reason}`); |
| } |
| |
| |
| |
| |
| |
| |
| const status = result.status || 'FAILED'; |
| console.error(`[Bundle:${label}] section=${section.label} status=${status} elapsed=${result.elapsed}s reason=${(result.reason || 'unknown').replace(/\s+/g, ' ')}`); |
| |
| |
| |
| |
| |
| if (status === 'GRACEFUL_FAIL') gracefulFailed++; |
| else failed++; |
| } |
| } |
|
|
| const totalSec = ((Date.now() - t0) / 1000).toFixed(1); |
| console.log(`[Bundle:${label}] Finished in ${totalSec}s, ran:${ran} skipped:${skipped} deferred:${deferred} failed:${failed} graceful:${gracefulFailed}`); |
| |
| |
| |
| if (failed === 0 && gracefulFailed > 0) { |
| console.log(`[Bundle:${label}] ${gracefulFailed} graceful fetch skip(s), no hard failures — no data lost, exiting 0 (not a crash)`); |
| } |
| process.exit(failed > 0 ? 1 : 0); |
| } |
|
|