fix(aviation): preserve last-good intl snapshot on unhealthy/skipped fetch + restore NOTAM quota-exhaust handling

Review feedback on PR #3238:

(1) Intl unhealthy → was silently overwriting aviation:delays:intl:v3 with
    an empty or partial snapshot because fetchAll() always returned
    { alerts } and zeroIsValid:true let runSeed publish. Now:
      • seedIntlDelays() returns { alerts, healthy, skipped } unchanged
      • fetchAll() refuses to publish when !healthy || skipped:
          - extendExistingTtl([INTL_KEY, INTL_META_KEY], INTL_TTL)
          - throws so runSeed enters its graceful catch path (which also
            extends these TTLs — idempotent)
      • Per-run cache (cachedRun) short-circuits subsequent withRetry(3)
        invocations so the retries don't burn 3x NOTAM quota + 3x FAA/RSS
        fetches when intl is sick.

(2) NOTAM quota exhausted — PR claimed "preserved" but only logged; the
    NOTAM data key was drifting toward TTL expiry and seed-meta was going
    stale, which would flip api/health.js maxStaleMin=240 red after 4h
    despite the intended 24h backoff window. Now matches the pre-strip
    ais-relay behavior byte-for-byte:
      • extendExistingTtl([NOTAM_KEY], NOTAM_TTL)
      • upstashSet(NOTAM_META_KEY, {fetchedAt: now, recordCount: 0,
        quotaExhausted: true}, 604800)
    Consumers keep serving the last known closure list; health stays green.

Also added extendExistingTtl fallbacks on FAA/NOTAM network-rejection paths
so transient network failures also don't drift to TTL expiry.
This commit is contained in:
Elie Habib
2026-04-20 19:20:09 +04:00
parent 0070951598
commit ba7ed014e2

View File

@@ -23,6 +23,7 @@ import {
CHROME_UA,
runSeed,
writeExtraKeyWithMeta,
extendExistingTtl,
getRedisCredentials,
} from './_seed-utils.mjs';
@@ -741,7 +742,14 @@ async function dispatchNotamNotifications(closedIcaos, reasons) {
// News are written as "extra keys" after the primary publish. Each has its own
// seed-meta override that matches api/health.js expectations.
async function fetchAll() {
// Per-run cache. runSeed wraps fetchFn in withRetry(3) — if our first attempt
// throws (e.g. intl unhealthy → throw to preserve prior snapshot), the retry
// re-invokes fetchAll. Without this cache, each retry would re-fetch FAA /
// NOTAM / RSS (burning ICAO quota 3x). Memoize the single real fetch so retries
// short-circuit to the same result.
let cachedRun = null;
async function doFetchAll() {
const [intlRes, faaRes, notamRes, newsRes] = await Promise.allSettled([
seedIntlDelays(),
seedFaaDelays(),
@@ -749,32 +757,45 @@ async function fetchAll() {
seedAviationNews(),
]);
// Secondary key: FAA
// Secondary key: FAA (free API — safe to overwrite when the fetch succeeded)
if (faaRes.status === 'fulfilled' && Array.isArray(faaRes.value?.alerts)) {
try {
await writeExtraKeyWithMeta(FAA_KEY, faaRes.value, FAA_TTL, faaRes.value.alerts.length, FAA_META_KEY);
} catch (e) { console.warn(` ${FAA_KEY} write error: ${e?.message || e}`); }
} else if (faaRes.status === 'rejected') {
console.warn(` FAA fetch failed: ${faaRes.reason?.message || faaRes.reason}`);
console.warn(` FAA fetch failed: ${faaRes.reason?.message || faaRes.reason} — extending TTL`);
try { await extendExistingTtl([FAA_KEY, FAA_META_KEY], FAA_TTL); } catch {}
}
// Secondary key: NOTAM. On quota exhaustion we don't overwrite — the key
// and meta are left at whatever they were; runSeed's extendExistingTtl path
// doesn't apply to secondaries, so we just skip.
// Secondary key: NOTAM.
// • quota exhausted ("Reach call limit"): refresh data-key TTL AND write a
// fresh seed-meta{fetchedAt: now, recordCount: 0, quotaExhausted: true}
// so consumers keep serving the last known closure list and api/health.js
// stays green during the 24h ICAO backoff (matches prior ais-relay
// behavior at scripts/ais-relay.cjs:2805-2808 pre-strip).
// • fetch rejected / network error: just extend the existing TTL.
// • success: normal write.
if (notamRes.status === 'fulfilled' && !notamRes.value.skipped) {
const n = notamRes.value;
if (n.quotaExhausted) {
console.log(` ${NOTAM_KEY}: quota exhausted — leaving prior value + meta untouched`);
try { await extendExistingTtl([NOTAM_KEY], NOTAM_TTL); } catch {}
// Fresh seed-meta with quotaExhausted flag keeps health maxStaleMin=240
// satisfied even though the data key wasn't rewritten.
try {
await upstashSet(NOTAM_META_KEY, { fetchedAt: Date.now(), recordCount: 0, quotaExhausted: true }, 604_800);
} catch (e) { console.warn(` ${NOTAM_META_KEY} write error: ${e?.message || e}`); }
console.log(` ${NOTAM_KEY}: ICAO quota exhausted — extended data TTL + wrote fresh meta (quotaExhausted=true)`);
} else {
try {
await writeExtraKeyWithMeta(NOTAM_KEY, { closedIcaos: n.closedIcaos, reasons: n.reasons }, NOTAM_TTL, n.closedIcaos.length, NOTAM_META_KEY);
} catch (e) { console.warn(` ${NOTAM_KEY} write error: ${e?.message || e}`); }
}
} else if (notamRes.status === 'rejected') {
console.warn(` NOTAM fetch failed: ${notamRes.reason?.message || notamRes.reason}`);
console.warn(` NOTAM fetch failed: ${notamRes.reason?.message || notamRes.reason} — extending TTL`);
try { await extendExistingTtl([NOTAM_KEY, NOTAM_META_KEY], NOTAM_TTL); } catch {}
}
// Secondary key: News (RSS prewarm)
// Secondary key: News (RSS prewarm, free)
if (newsRes.status === 'fulfilled' && newsRes.value?.items?.length > 0) {
try {
await writeExtraKeyWithMeta(NEWS_KEY, newsRes.value, NEWS_TTL, newsRes.value.items.length);
@@ -783,8 +804,8 @@ async function fetchAll() {
console.warn(` News fetch failed: ${newsRes.reason?.message || newsRes.reason}`);
}
// Dispatch notifications based on fresh intl delays + notam closures
if (intlRes.status === 'fulfilled' && intlRes.value?.healthy) {
// Dispatch notifications only when source was healthy AND produced real data.
if (intlRes.status === 'fulfilled' && intlRes.value?.healthy && !intlRes.value.skipped) {
try { await dispatchAviationNotifications(intlRes.value.alerts); }
catch (e) { console.warn(` Aviation notify error: ${e?.message || e}`); }
}
@@ -793,9 +814,37 @@ async function fetchAll() {
catch (e) { console.warn(` NOTAM notify error: ${e?.message || e}`); }
}
// Primary return — INTL alerts for runSeed canonical key
if (intlRes.status === 'fulfilled') return { alerts: intlRes.value.alerts };
throw new Error(`intl delays failed: ${intlRes.reason?.message || intlRes.reason}`);
// Intl primary key decision: publish only when the fetch succeeded AND was healthy
// AND wasn't skipped (missing API key). An unhealthy or skipped intl fetch MUST
// NOT overwrite the last-good snapshot — consumers keep serving it while we
// extend the TTL + meta TTL so health.js stays green.
const intlFulfilled = intlRes.status === 'fulfilled';
const intlPublishable = intlFulfilled && intlRes.value.healthy && !intlRes.value.skipped;
if (!intlPublishable) {
// Refresh intl key TTL + seed-meta TTL so consumers keep the prior payload
// until the next healthy tick. runSeed's catch-path also calls
// extendExistingTtl on these keys — double-extend is idempotent.
try { await extendExistingTtl([INTL_KEY, INTL_META_KEY], INTL_TTL); } catch {}
const why = !intlFulfilled
? (intlRes.reason?.message || intlRes.reason)
: intlRes.value.skipped ? 'no AVIATIONSTACK_API key'
: 'systemic fetch failure (failures > successes)';
return { _publishable: false, _reason: why };
}
return { _publishable: true, alerts: intlRes.value.alerts };
}
async function fetchAll() {
if (!cachedRun) cachedRun = await doFetchAll();
if (!cachedRun._publishable) {
// Throw so runSeed enters its graceful failure path and ALSO extends the
// existing TTL on INTL_KEY + seed-meta:aviation:intl. withRetry will
// invoke us up to 3 more times; the cachedRun gate makes each retry a
// no-op that just rethrows — no duplicate fetches or quota burn.
throw new Error(`intl unpublishable: ${cachedRun._reason}`);
}
return { alerts: cachedRun.alerts };
}
function validate(data) {