refactor(aviation): consolidate intl+FAA+NOTAM+news seeds into seed-aviation.mjs (#3238)

* refactor(aviation): consolidate intl+FAA+NOTAM+news seeds into seed-aviation.mjs

seed-aviation.mjs was misnamed: it wrote to a dead Redis key while the
51-airport AviationStack loop + ICAO NOTAM loop lived hidden inside
ais-relay.cjs, duplicating the NOTAM write already done by
seed-airport-delays.mjs.

Make seed-aviation.mjs the single home for every aviation Redis key:
  aviation:delays:intl:v3     (AviationStack 51 intl — primary)
  aviation:delays:faa:v1      (FAA ASWS 30 US)
  aviation:notam:closures:v2  (ICAO NOTAM 60 global)
  aviation:news::24:v1        (9 RSS feeds prewarmer)

One unified AIRPORTS registry (~85 entries) replaces the three separate lists.
Notifications preserved via wm:events:queue LPUSH + SETNX dedup; prev-state
migrated from in-process Sets to Redis so short-lived cron runs don't spam
on every tick. ICAO quota-exhaustion backoff retained.

Contracts preserved byte-identically for consumers (AirportDelayAlert shape,
seed-meta:aviation:{intl,faa,notam} meta keys, runSeed envelope writes).

Impact: kills ~8,640/mo wasted AviationStack calls (dead-key writes), strips
~490 lines of hidden seed logic from ais-relay, eliminates duplicate NOTAM
writer. Net -243 lines across three files.

Railway steps after merge:
  1. Ensure seed-aviation service env has AVIATIONSTACK_API + ICAO_API_KEY.
  2. Delete/disable the seed-airport-delays Railway service.
  3. ais-relay redeploys automatically; /aviationstack + /notam live proxies
     for user-triggered flight lookups preserved.

* 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.

* refactor(aviation): move secondary writes + notifications into afterPublish

Review feedback on PR #3238: fetchAll() was impure — it wrote FAA / NOTAM /
news and dispatched notifications during runSeed's fetch phase, before the
canonical aviation:delays:intl:v3 publish ran. If that later publish failed,
consumers could see fresh FAA/NOTAM/news alongside a stale intl key, and
notifications could fire for a run whose primary key never published,
breaking the "single home / one cron tick" atomic contract.

Restructure:
  • fetchAll() now pure — returns { intl, faa, notam, news + rejection refs }.
    No Redis writes, no notifications.
  • Intl gate stays: unhealthy / skipped → throw. runSeed's catch extends
    TTL on INTL_KEY + seed-meta:aviation:intl and exits 0. afterPublish
    never runs, so no side effects escape.
  • publishTransform extracts { alerts } from the bundle for the canonical
    envelope; declareRecords sees the transformed shape.
  • afterPublish handles ALL secondary writes (FAA, NOTAM, news) and
    notification dispatch. Runs only after a successful canonical publish.
  • Per-run memo (cachedBundle) still short-circuits withRetry(3) retries
    so NOTAM quota isn't burned 3x when intl is sick.

NOTAM quota-exhaustion + rejection TTL-extend branches preserved inside
afterPublish — same behavior, different location.

* refactor(aviation): decouple FAA/NOTAM/news side-cars from intl's runSeed gate

Review feedback on PR #3238: the previous refactor coupled all secondary
outputs to the AviationStack primary key. If AVIATIONSTACK_API was missing
or intl was systemically unhealthy, fetchAll() threw → runSeed skipped
afterPublish → FAA/NOTAM/news all went stale despite their own upstream
sources being fine. Before consolidation, FAA and NOTAM each ran their own
cron and could freshen independently. This restores that independence.

Structure:
  • Three side-car runners: runFaaSideCar, runNotamSideCar, runNewsSideCar.
    Each acquires its own Redis lock (aviation:faa / aviation:notam /
    aviation:news — distinct from aviation:intl), fetches its source,
    writes data-key + seed-meta on success, extends TTL on failure,
    releases the lock. Completely independent of the AviationStack path.
  • NOTAM side-car keeps the quota-exhausted + rejection handling and
    dispatches notam_closure notifications inline.
  • main() runs the three side-cars sequentially, then hands off to runSeed
    for intl. runSeed still process.exit()s at the end so it remains the
    last call.
  • Intl's afterPublish now only dispatches aviation_closure notifications
    (its single responsibility).

Removed: the per-run memo for fetchAll (no longer needed — withRetry now
only re-runs the intl fetch, not FAA/NOTAM/RSS).

Net behavior:
  • AviationStack 500s / missing key → FAA, NOTAM, news still refresh
    normally; only aviation:delays:intl:v3 extends TTL + preserves prior
    snapshot.
  • ICAO quota exhausted → NOTAM extends TTL + writes fresh meta (as before);
    FAA/intl/news unaffected.
  • FAA upstream failure → only FAA extends TTL; other sources unaffected.

* fix(aviation): correct Gaborone ICAO + populate FAA alert meta from registry

Greptile review on PR #3238:

P1: GABS is not the ICAO for Gaborone — the value was faithfully copied
    from the pre-strip ais-relay NOTAM list which was wrong. Botswana's
    ICAO prefix is FB; the correct code is FBSK. NOTAM queries for GABS
    would silently exclude Gaborone from closure detection. (Pre-existing
    bug in the repo; fixing while in this neighborhood.)

P2 (FAA alerts): Now that the unified AIRPORTS registry carries
    icao/name/city/country for every FAA airport, use it. Previous code
    returned icao:'', name:iata, city:'' — consumers saw bare IATA codes
    for US-only alerts. Registry lookup via a new FAA_META map; lat/lon
    stays 0,0 by design (FAA rows aren't rendered on the globe, so lat/lon
    is intentionally absent from those registry rows).

P2 (NOTAM TTL on quota exhaustion): already fixed in commit ba7ed014e
    (pre-decouple) — confirmed line 803 calls extendExistingTtl([NOTAM_KEY])
    and line 805 writes fresh meta with quotaExhausted=true.
This commit is contained in:
Elie Habib
2026-04-20 22:37:49 +04:00
committed by GitHub
parent ecd56d4212
commit d880f6a0e7
3 changed files with 838 additions and 986 deletions

View File

@@ -2407,490 +2407,18 @@ async function startMarketDataSeedLoop() {
// Aviation Seed — Railway fetches AviationStack → writes to Redis // Aviation Seed — Railway fetches AviationStack → writes to Redis
// so Vercel handler serves from cache (avoids 114 API calls per miss) // so Vercel handler serves from cache (avoids 114 API calls per miss)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// AviationStack API key — used only by the /aviationstack live proxy below.
// The aviation + NOTAM background seeds that used to live here were
// consolidated into scripts/seed-aviation.mjs (standalone Railway cron).
const AVIATIONSTACK_API_KEY = process.env.AVIATIONSTACK_API || ''; const AVIATIONSTACK_API_KEY = process.env.AVIATIONSTACK_API || '';
const AVIATION_SEED_INTERVAL_MS = 30 * 60 * 1000; // 30min
const AVIATION_SEED_TTL = 10800; // 3h — 6x interval; survives ~5 consecutive missed pings
const AVIATION_RETRY_MS = 20 * 60 * 1000;
const AVIATION_REDIS_KEY = 'aviation:delays:intl:v3';
const AVIATION_BATCH_CONCURRENCY = 10;
const AVIATION_MIN_FLIGHTS_FOR_CLOSURE = 10;
const RESOLVED_STATUSES = new Set(['cancelled', 'landed', 'active', 'arrived', 'diverted']);
// Must match src/config/airports.ts AVIATIONSTACK_AIRPORTS — update both when changing // In-process dedup sets for non-aviation seed notifications (cyber + UCDP).
const AVIATIONSTACK_AIRPORTS = [ // These track IDs already queued within a seed process's lifetime so the
'YYZ', 'YVR', 'MEX', 'GRU', 'EZE', 'BOG', 'SCL', // loops below don't re-notify on every poll. Cleared at 500 entries to
'LHR', 'CDG', 'FRA', 'AMS', 'MAD', 'FCO', 'MUC', 'BCN', 'ZRH', 'IST', 'VIE', 'CPH', // bound memory. Aviation + NOTAM moved their dedup state to Redis
'DUB', 'LIS', 'ATH', 'WAW', // (notifications:dedup:aviation:prev-alerted:v1 / notam:prev-closed-state:v1).
'HND', 'NRT', 'PEK', 'PVG', 'HKG', 'SIN', 'ICN', 'BKK', 'SYD', 'DEL', 'BOM', 'KUL', const cyberPrevAlertedIds = new Set();
'CAN', 'TPE', 'MNL', const ucdpPrevAlertedIds = new Set();
'DXB', 'DOH', 'AUH', 'RUH', 'CAI', 'TLV', 'AMM', 'KWI', 'CMN',
'JNB', 'NBO', 'LOS', 'ADD', 'CPT',
];
// Airport metadata needed for alert construction (inlined from airports.ts)
const AIRPORT_META = {
YYZ: { icao: 'CYYZ', name: 'Toronto Pearson', city: 'Toronto', country: 'Canada', lat: 43.6777, lon: -79.6248, region: 'americas' },
MEX: { icao: 'MMMX', name: 'Mexico City International', city: 'Mexico City', country: 'Mexico', lat: 19.4363, lon: -99.0721, region: 'americas' },
GRU: { icao: 'SBGR', name: 'São PauloGuarulhos', city: 'São Paulo', country: 'Brazil', lat: -23.4356, lon: -46.4731, region: 'americas' },
EZE: { icao: 'SAEZ', name: 'Ministro Pistarini', city: 'Buenos Aires', country: 'Argentina', lat: -34.8222, lon: -58.5358, region: 'americas' },
BOG: { icao: 'SKBO', name: 'El Dorado International', city: 'Bogotá', country: 'Colombia', lat: 4.7016, lon: -74.1469, region: 'americas' },
LHR: { icao: 'EGLL', name: 'London Heathrow', city: 'London', country: 'UK', lat: 51.4700, lon: -0.4543, region: 'europe' },
CDG: { icao: 'LFPG', name: 'Paris Charles de Gaulle', city: 'Paris', country: 'France', lat: 49.0097, lon: 2.5479, region: 'europe' },
FRA: { icao: 'EDDF', name: 'Frankfurt Airport', city: 'Frankfurt', country: 'Germany', lat: 50.0379, lon: 8.5622, region: 'europe' },
AMS: { icao: 'EHAM', name: 'Amsterdam Schiphol', city: 'Amsterdam', country: 'Netherlands', lat: 52.3105, lon: 4.7683, region: 'europe' },
MAD: { icao: 'LEMD', name: 'Adolfo Suárez MadridBarajas', city: 'Madrid', country: 'Spain', lat: 40.4983, lon: -3.5676, region: 'europe' },
FCO: { icao: 'LIRF', name: 'Leonardo da VinciFiumicino', city: 'Rome', country: 'Italy', lat: 41.8003, lon: 12.2389, region: 'europe' },
MUC: { icao: 'EDDM', name: 'Munich Airport', city: 'Munich', country: 'Germany', lat: 48.3537, lon: 11.7750, region: 'europe' },
BCN: { icao: 'LEBL', name: 'BarcelonaEl Prat', city: 'Barcelona', country: 'Spain', lat: 41.2974, lon: 2.0833, region: 'europe' },
ZRH: { icao: 'LSZH', name: 'Zurich Airport', city: 'Zurich', country: 'Switzerland', lat: 47.4647, lon: 8.5492, region: 'europe' },
IST: { icao: 'LTFM', name: 'Istanbul Airport', city: 'Istanbul', country: 'Turkey', lat: 41.2753, lon: 28.7519, region: 'europe' },
VIE: { icao: 'LOWW', name: 'Vienna International', city: 'Vienna', country: 'Austria', lat: 48.1103, lon: 16.5697, region: 'europe' },
CPH: { icao: 'EKCH', name: 'Copenhagen Airport', city: 'Copenhagen', country: 'Denmark', lat: 55.6180, lon: 12.6508, region: 'europe' },
HND: { icao: 'RJTT', name: 'Tokyo Haneda', city: 'Tokyo', country: 'Japan', lat: 35.5494, lon: 139.7798, region: 'apac' },
NRT: { icao: 'RJAA', name: 'Narita International', city: 'Tokyo', country: 'Japan', lat: 35.7720, lon: 140.3929, region: 'apac' },
PEK: { icao: 'ZBAA', name: 'Beijing Capital', city: 'Beijing', country: 'China', lat: 40.0799, lon: 116.6031, region: 'apac' },
PVG: { icao: 'ZSPD', name: 'Shanghai Pudong', city: 'Shanghai', country: 'China', lat: 31.1443, lon: 121.8083, region: 'apac' },
HKG: { icao: 'VHHH', name: 'Hong Kong International', city: 'Hong Kong', country: 'China', lat: 22.3080, lon: 113.9185, region: 'apac' },
SIN: { icao: 'WSSS', name: 'Singapore Changi', city: 'Singapore', country: 'Singapore', lat: 1.3644, lon: 103.9915, region: 'apac' },
ICN: { icao: 'RKSI', name: 'Incheon International', city: 'Seoul', country: 'South Korea', lat: 37.4602, lon: 126.4407, region: 'apac' },
BKK: { icao: 'VTBS', name: 'Suvarnabhumi Airport', city: 'Bangkok', country: 'Thailand', lat: 13.6900, lon: 100.7501, region: 'apac' },
SYD: { icao: 'YSSY', name: 'Sydney Kingsford Smith', city: 'Sydney', country: 'Australia', lat: -33.9461, lon: 151.1772, region: 'apac' },
DEL: { icao: 'VIDP', name: 'Indira Gandhi International', city: 'Delhi', country: 'India', lat: 28.5562, lon: 77.1000, region: 'apac' },
BOM: { icao: 'VABB', name: 'Chhatrapati Shivaji Maharaj', city: 'Mumbai', country: 'India', lat: 19.0896, lon: 72.8656, region: 'apac' },
KUL: { icao: 'WMKK', name: 'Kuala Lumpur International', city: 'Kuala Lumpur', country: 'Malaysia', lat: 2.7456, lon: 101.7099, region: 'apac' },
DXB: { icao: 'OMDB', name: 'Dubai International', city: 'Dubai', country: 'UAE', lat: 25.2532, lon: 55.3657, region: 'mena' },
DOH: { icao: 'OTHH', name: 'Hamad International', city: 'Doha', country: 'Qatar', lat: 25.2731, lon: 51.6081, region: 'mena' },
AUH: { icao: 'OMAA', name: 'Abu Dhabi International', city: 'Abu Dhabi', country: 'UAE', lat: 24.4330, lon: 54.6511, region: 'mena' },
RUH: { icao: 'OERK', name: 'King Khalid International', city: 'Riyadh', country: 'Saudi Arabia', lat: 24.9576, lon: 46.6988, region: 'mena' },
CAI: { icao: 'HECA', name: 'Cairo International', city: 'Cairo', country: 'Egypt', lat: 30.1219, lon: 31.4056, region: 'mena' },
TLV: { icao: 'LLBG', name: 'Ben Gurion Airport', city: 'Tel Aviv', country: 'Israel', lat: 32.0055, lon: 34.8854, region: 'mena' },
JNB: { icao: 'FAOR', name: 'O.R. Tambo International', city: 'Johannesburg', country: 'South Africa', lat: -26.1392, lon: 28.2460, region: 'africa' },
NBO: { icao: 'HKJK', name: 'Jomo Kenyatta International', city: 'Nairobi', country: 'Kenya', lat: -1.3192, lon: 36.9278, region: 'africa' },
LOS: { icao: 'DNMM', name: 'Murtala Muhammed International', city: 'Lagos', country: 'Nigeria', lat: 6.5774, lon: 3.3212, region: 'africa' },
ADD: { icao: 'HAAB', name: 'Bole International', city: 'Addis Ababa', country: 'Ethiopia', lat: 8.9779, lon: 38.7993, region: 'africa' },
CPT: { icao: 'FACT', name: 'Cape Town International', city: 'Cape Town', country: 'South Africa', lat: -33.9715, lon: 18.6021, region: 'africa' },
// Added airports
YVR: { icao: 'CYVR', name: 'Vancouver International', city: 'Vancouver', country: 'Canada', lat: 49.1947, lon: -123.1792, region: 'americas' },
SCL: { icao: 'SCEL', name: 'Arturo Merino Benítez', city: 'Santiago', country: 'Chile', lat: -33.3930, lon: -70.7858, region: 'americas' },
DUB: { icao: 'EIDW', name: 'Dublin Airport', city: 'Dublin', country: 'Ireland', lat: 53.4264, lon: -6.2499, region: 'europe' },
LIS: { icao: 'LPPT', name: 'Humberto Delgado Airport', city: 'Lisbon', country: 'Portugal', lat: 38.7756, lon: -9.1354, region: 'europe' },
ATH: { icao: 'LGAV', name: 'Athens International', city: 'Athens', country: 'Greece', lat: 37.9364, lon: 23.9445, region: 'europe' },
WAW: { icao: 'EPWA', name: 'Warsaw Chopin Airport', city: 'Warsaw', country: 'Poland', lat: 52.1657, lon: 20.9671, region: 'europe' },
CAN: { icao: 'ZGGG', name: 'Guangzhou Baiyun International', city: 'Guangzhou', country: 'China', lat: 23.3924, lon: 113.2988, region: 'apac' },
TPE: { icao: 'RCTP', name: 'Taiwan Taoyuan International', city: 'Taipei', country: 'Taiwan', lat: 25.0797, lon: 121.2342, region: 'apac' },
MNL: { icao: 'RPLL', name: 'Ninoy Aquino International', city: 'Manila', country: 'Philippines', lat: 14.5086, lon: 121.0197, region: 'apac' },
AMM: { icao: 'OJAI', name: 'Queen Alia International', city: 'Amman', country: 'Jordan', lat: 31.7226, lon: 35.9932, region: 'mena' },
KWI: { icao: 'OKBK', name: 'Kuwait International', city: 'Kuwait City', country: 'Kuwait', lat: 29.2266, lon: 47.9689, region: 'mena' },
CMN: { icao: 'GMMN', name: 'Mohammed V International', city: 'Casablanca', country: 'Morocco', lat: 33.3675, lon: -7.5898, region: 'mena' },
};
const REGION_MAP = {
americas: 'AIRPORT_REGION_AMERICAS',
europe: 'AIRPORT_REGION_EUROPE',
apac: 'AIRPORT_REGION_APAC',
mena: 'AIRPORT_REGION_MENA',
africa: 'AIRPORT_REGION_AFRICA',
};
const DELAY_TYPE_MAP = {
ground_stop: 'FLIGHT_DELAY_TYPE_GROUND_STOP',
ground_delay: 'FLIGHT_DELAY_TYPE_GROUND_DELAY',
departure_delay: 'FLIGHT_DELAY_TYPE_DEPARTURE_DELAY',
arrival_delay: 'FLIGHT_DELAY_TYPE_ARRIVAL_DELAY',
general: 'FLIGHT_DELAY_TYPE_GENERAL',
closure: 'FLIGHT_DELAY_TYPE_CLOSURE',
};
const SEVERITY_MAP = {
normal: 'FLIGHT_DELAY_SEVERITY_NORMAL',
minor: 'FLIGHT_DELAY_SEVERITY_MINOR',
moderate: 'FLIGHT_DELAY_SEVERITY_MODERATE',
major: 'FLIGHT_DELAY_SEVERITY_MAJOR',
severe: 'FLIGHT_DELAY_SEVERITY_SEVERE',
};
function aviationDetermineSeverity(avgDelay, delayedPct) {
if (avgDelay >= 60 || (delayedPct && delayedPct >= 60)) return 'severe';
if (avgDelay >= 45 || (delayedPct && delayedPct >= 45)) return 'major';
if (avgDelay >= 30 || (delayedPct && delayedPct >= 30)) return 'moderate';
if (avgDelay >= 15 || (delayedPct && delayedPct >= 15)) return 'minor';
return 'normal';
}
function fetchAviationStackSingle(apiKey, iata) {
return new Promise((resolve) => {
const today = new Date().toISOString().slice(0, 10);
const url = `https://api.aviationstack.com/v1/flights?access_key=${apiKey}&dep_iata=${iata}&flight_date=${today}&limit=100`;
const req = https.get(url, {
headers: { 'User-Agent': CHROME_UA },
timeout: 5000,
family: 4,
}, (resp) => {
if (resp.statusCode !== 200) {
resp.resume();
logThrottled('warn', `aviation-http-${resp.statusCode}:${iata}`, `[Aviation] ${iata}: HTTP ${resp.statusCode}`);
return resolve({ ok: false, alert: null });
}
let body = '';
resp.on('data', (chunk) => { body += chunk; });
resp.on('end', () => {
try {
const json = JSON.parse(body);
if (json.error) {
logThrottled('warn', `aviation-api-err:${iata}`, `[Aviation] ${iata}: API error: ${json.error.message}`);
return resolve({ ok: false, alert: null });
}
const flights = json?.data ?? [];
const alert = aviationAggregateFlights(iata, flights);
resolve({ ok: true, alert });
} catch { resolve({ ok: false, alert: null }); }
});
});
req.on('error', (err) => {
logThrottled('warn', `aviation-err:${iata}`, `[Aviation] ${iata}: fetch error: ${err.message}`);
resolve({ ok: false, alert: null });
});
req.on('timeout', () => { req.destroy(); resolve({ ok: false, alert: null }); });
});
}
function aviationAggregateFlights(iata, flights) {
if (flights.length === 0) return null;
const meta = AIRPORT_META[iata];
if (!meta) return null;
let delayed = 0, cancelled = 0, totalDelay = 0, resolved = 0;
for (const f of flights) {
if (RESOLVED_STATUSES.has(f.flight_status || '')) resolved++;
if (f.flight_status === 'cancelled') cancelled++;
if (f.departure?.delay && f.departure.delay > 0) {
delayed++;
totalDelay += f.departure.delay;
}
}
const total = resolved >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE ? resolved : flights.length;
const cancelledPct = (cancelled / total) * 100;
const delayedPct = (delayed / total) * 100;
const avgDelay = delayed > 0 ? Math.round(totalDelay / delayed) : 0;
let severity, delayType, reason;
if (cancelledPct >= 80 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'severe'; delayType = 'closure';
reason = 'Airport closure / airspace restrictions';
} else if (cancelledPct >= 50 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'major'; delayType = 'ground_stop';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (cancelledPct >= 20 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'moderate'; delayType = 'ground_delay';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (cancelledPct >= 10 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'minor'; delayType = 'general';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (avgDelay > 0) {
severity = aviationDetermineSeverity(avgDelay, delayedPct);
delayType = avgDelay >= 60 ? 'ground_delay' : 'general';
reason = `Avg ${avgDelay}min delay, ${Math.round(delayedPct)}% delayed`;
} else {
return null;
}
if (severity === 'normal') return null;
return {
id: `avstack-${iata}`,
iata,
icao: meta.icao,
name: meta.name,
city: meta.city,
country: meta.country,
location: { latitude: meta.lat, longitude: meta.lon },
region: REGION_MAP[meta.region] || 'AIRPORT_REGION_UNSPECIFIED',
delayType: DELAY_TYPE_MAP[delayType] || 'FLIGHT_DELAY_TYPE_GENERAL',
severity: SEVERITY_MAP[severity] || 'FLIGHT_DELAY_SEVERITY_NORMAL',
avgDelayMinutes: avgDelay,
delayedFlightsPct: Math.round(delayedPct),
cancelledFlights: cancelled,
totalFlights: total,
reason,
source: 'FLIGHT_DELAY_SOURCE_AVIATIONSTACK',
updatedAt: Date.now(),
};
}
let aviationSeedInFlight = false;
let aviationRetryTimer = null;
async function seedAviationDelays() {
if (!AVIATIONSTACK_API_KEY) {
console.log('[Aviation] No AVIATIONSTACK_API key — skipping seed');
return;
}
if (aviationSeedInFlight) return;
aviationSeedInFlight = true;
if (aviationRetryTimer) { clearTimeout(aviationRetryTimer); aviationRetryTimer = null; }
const t0 = Date.now();
const alerts = [];
let succeeded = 0, failed = 0;
const deadline = Date.now() + 50_000;
try {
for (let i = 0; i < AVIATIONSTACK_AIRPORTS.length; i += AVIATION_BATCH_CONCURRENCY) {
if (Date.now() >= deadline) {
console.warn(`[Aviation] Deadline hit after ${succeeded + failed}/${AVIATIONSTACK_AIRPORTS.length} airports`);
break;
}
const chunk = AVIATIONSTACK_AIRPORTS.slice(i, i + AVIATION_BATCH_CONCURRENCY);
const results = await Promise.allSettled(
chunk.map((iata) => fetchAviationStackSingle(AVIATIONSTACK_API_KEY, iata))
);
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value.ok) { succeeded++; if (r.value.alert) alerts.push(r.value.alert); }
else failed++;
} else {
failed++;
}
}
}
const healthy = AVIATIONSTACK_AIRPORTS.length < 5 || failed <= succeeded;
if (!healthy) {
console.warn(`[Aviation] Systemic failure: ${failed}/${failed + succeeded} airports failed — extending TTL, retrying in 20min`);
try { await upstashExpire(AVIATION_REDIS_KEY, AVIATION_SEED_TTL); } catch {}
aviationRetryTimer = setTimeout(() => { seedAviationDelays().catch(() => {}); }, AVIATION_RETRY_MS);
return;
}
const ok = await envelopeWrite(AVIATION_REDIS_KEY, { alerts }, AVIATION_SEED_TTL, { recordCount: alerts.length, sourceVersion: 'aviationstack' });
await upstashSet('seed-meta:aviation:intl', { fetchedAt: Date.now(), recordCount: alerts.length }, 604800);
console.log(`[Aviation] Seeded ${alerts.length} alerts (${succeeded} ok, ${failed} failed, redis: ${ok ? 'OK' : 'FAIL'}) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
const severeAlerts = alerts.filter(a =>
a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' || a.severity === 'FLIGHT_DELAY_SEVERITY_MAJOR'
);
// Change detection: only notify for airports newly entering severe/major state.
// aviationPrevAlertedSet persists across polls in-memory; dedupTtl (4h) guards restarts.
const currentIatas = new Set(severeAlerts.map(a => a.iata).filter(Boolean));
const newAlerts = severeAlerts.filter(a => a.iata && !aviationPrevAlertedSet.has(a.iata));
aviationPrevAlertedSet.clear();
currentIatas.forEach(iata => aviationPrevAlertedSet.add(iata));
for (const a of newAlerts.slice(0, 3)) {
publishNotificationEvent({
eventType: 'aviation_closure',
payload: { title: `${a.iata}${a.city ? ` (${a.city})` : ''}: ${a.reason || 'Airport disruption'}`, source: 'AviationStack' },
severity: a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' ? 'critical' : 'high',
variant: undefined,
dedupTtl: 14400, // 4h — well above the 30min poll interval
}).catch(e => console.warn('[Notify] Aviation publish error:', e?.message));
}
} catch (e) {
console.warn('[Aviation] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
try { await upstashExpire(AVIATION_REDIS_KEY, AVIATION_SEED_TTL); } catch {}
aviationRetryTimer = setTimeout(() => { seedAviationDelays().catch(() => {}); }, AVIATION_RETRY_MS);
} finally {
aviationSeedInFlight = false;
}
}
async function startAviationSeedLoop() {
if (!UPSTASH_ENABLED) {
console.log('[Aviation] Disabled (no Upstash Redis)');
return;
}
if (!AVIATIONSTACK_API_KEY) {
console.log('[Aviation] Disabled (no AVIATIONSTACK_API key)');
return;
}
console.log(`[Aviation] Seed loop starting (interval ${AVIATION_SEED_INTERVAL_MS / 1000 / 60 / 60}h, airports: ${AVIATIONSTACK_AIRPORTS.length})`);
seedAviationDelays().catch((e) => console.warn('[Aviation] Initial seed error:', e?.message || e));
setInterval(() => {
seedAviationDelays().catch((e) => console.warn('[Aviation] Seed error:', e?.message || e));
}, AVIATION_SEED_INTERVAL_MS).unref?.();
}
// ─────────────────────────────────────────────────────────────
// NOTAM Closures Seed — Railway fetches ICAO NOTAMs → writes to Redis
// so Vercel handler and map layer serve from cache (ICAO API times out from edge)
// ─────────────────────────────────────────────────────────────
const NOTAM_SEED_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2h — reduced from 30min to stay within ICAO free-tier quota (~1000 calls/month)
const NOTAM_SEED_TTL = 21600; // 6h — 3x interval
const NOTAM_RETRY_MS = 20 * 60 * 1000;
const NOTAM_QUOTA_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24h backoff when ICAO quota is exhausted
const NOTAM_REDIS_KEY = 'aviation:notam:closures:v2';
const NOTAM_CLOSURE_QCODES = new Set(['FA', 'AH', 'AL', 'AW', 'AC', 'AM']);
const notamPrevClosed = new Set();
let notamStateLoaded = false; // true after first Redis load — prevents false positives on restart
const aviationPrevAlertedSet = new Set(); // tracks IATA codes currently in severe/major state
const cyberPrevAlertedIds = new Set(); // tracks indicators notified this session; cleared at 500 entries
const ucdpPrevAlertedIds = new Set(); // tracks UCDP event IDs notified; cleared at 500 entries
const NOTAM_MONITORED_ICAO = [
// MENA
'OEJN', 'OERK', 'OEMA', 'OEDF', 'OMDB', 'OMAA', 'OMSJ',
'OTHH', 'OBBI', 'OOMS', 'OKBK', 'OLBA', 'OJAI', 'OSDI',
'ORBI', 'OIIE', 'OISS', 'OIMM', 'OIKB', 'HECA', 'GMMN',
'DTTA', 'DAAG', 'HLLT',
// Europe
'EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF', 'LTFM',
'LSZH', 'LOWW', 'EKCH', 'ENGM', 'ESSA', 'EFHK', 'EPWA',
// Americas
'KJFK', 'KLAX', 'KORD', 'KATL', 'KDFW', 'KDEN', 'KSFO',
'CYYZ', 'MMMX', 'SBGR', 'SCEL', 'SKBO',
// APAC
'RJTT', 'RKSI', 'VHHH', 'WSSS', 'VTBS', 'VIDP', 'YSSY',
'ZBAA', 'ZPPP', 'WMKK',
// Africa
'FAOR', 'DNMM', 'HKJK', 'GABS',
];
// Returns: Array of NOTAMs on success, null on quota exhaustion, [] on other errors
function fetchIcaoNotams() {
return new Promise((resolve) => {
if (!ICAO_API_KEY) return resolve([]);
const locations = NOTAM_MONITORED_ICAO.join(',');
const apiUrl = `https://dataservices.icao.int/api/notams-realtime-list?api_key=${ICAO_API_KEY}&format=json&locations=${locations}`;
const req = https.get(apiUrl, {
headers: { 'User-Agent': CHROME_UA },
timeout: 30000,
}, (resp) => {
const chunks = [];
resp.on('data', (c) => chunks.push(c));
resp.on('end', () => {
const body = Buffer.concat(chunks).toString();
// Detect quota exhaustion regardless of status code
if (/reach call limit/i.test(body) || /quota.?exceed/i.test(body)) {
console.warn('[NOTAM-Seed] ICAO quota exhausted ("Reach call limit") — backing off 24h');
return resolve(null);
}
if (resp.statusCode !== 200) {
console.warn(`[NOTAM-Seed] ICAO HTTP ${resp.statusCode}`);
return resolve([]);
}
const ct = resp.headers['content-type'] || '';
if (ct.includes('text/html')) {
console.warn('[NOTAM-Seed] ICAO returned HTML (challenge page)');
return resolve([]);
}
try {
const data = JSON.parse(body);
resolve(Array.isArray(data) ? data : []);
} catch {
console.warn('[NOTAM-Seed] Invalid JSON from ICAO');
resolve([]);
}
});
});
req.on('error', (err) => { console.warn(`[NOTAM-Seed] Fetch error: ${err.message}`); resolve([]); });
req.on('timeout', () => { req.destroy(); console.warn('[NOTAM-Seed] Timeout (30s)'); resolve([]); });
});
}
let notamSeedInFlight = false;
let notamRetryTimer = null;
async function seedNotamClosures() {
if (!ICAO_API_KEY) {
console.log('[NOTAM-Seed] No ICAO_API_KEY — skipping');
return;
}
if (notamSeedInFlight) return;
notamSeedInFlight = true;
if (notamRetryTimer) { clearTimeout(notamRetryTimer); notamRetryTimer = null; }
const t0 = Date.now();
try {
const notams = await fetchIcaoNotams();
// null = quota exhausted — touch seed-meta so health.js stays green, back off 24h
if (notams === null) {
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
try { await upstashSet('seed-meta:aviation:notam', { fetchedAt: Date.now(), recordCount: 0, quotaExhausted: true }, 604800); } catch {}
console.log('[NOTAM-Seed] Quota exhausted — extended TTL, wrote seed-meta, backing off 24h');
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_QUOTA_BACKOFF_MS);
return;
}
if (notams.length === 0) {
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
console.log('[NOTAM-Seed] No NOTAMs received — refreshed data key TTL, retrying in 20min');
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_RETRY_MS);
return;
}
const now = Math.floor(Date.now() / 1000);
const closedSet = new Set();
const reasons = {};
for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !NOTAM_MONITORED_ICAO.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;
const code23 = (n.code23 || '').toUpperCase();
const code45 = (n.code45 || '').toUpperCase();
const text = (n.iteme || '').toUpperCase();
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
if (isClosureCode || isClosureText) {
closedSet.add(icao);
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
}
}
const closedIcaos = [...closedSet];
const payload = { closedIcaos, reasons };
const ok = await envelopeWrite(NOTAM_REDIS_KEY, payload, NOTAM_SEED_TTL, { recordCount: closedIcaos.length, sourceVersion: 'icao-notam', zeroOk: true });
await upstashSet('seed-meta:aviation:notam', { fetchedAt: Date.now(), recordCount: closedIcaos.length }, 604800);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`[NOTAM-Seed] ${notams.length} raw NOTAMs, ${closedIcaos.length} closures (redis: ${ok ? 'OK' : 'FAIL'}) in ${elapsed}s`);
// On first run after a restart, pre-populate notamPrevClosed from Redis so
// airports that were already closed before the restart are not treated as new.
if (!notamStateLoaded) {
notamStateLoaded = true;
try {
const saved = await upstashGet('notam:prev-closed-state:v1');
if (Array.isArray(saved)) saved.forEach(icao => notamPrevClosed.add(icao));
} catch {}
}
const newClosures = closedIcaos.filter(icao => !notamPrevClosed.has(icao));
notamPrevClosed.clear();
closedIcaos.forEach(icao => notamPrevClosed.add(icao));
// Persist current closed set so next restart doesn't re-fire existing closures.
upstashSet('notam:prev-closed-state:v1', closedIcaos, NOTAM_SEED_TTL * 2).catch(() => {});
for (const icao of newClosures.slice(0, 3)) {
publishNotificationEvent({
eventType: 'notam_closure',
payload: { title: `NOTAM: ${icao}${reasons[icao] || 'Airport closure'}`, source: 'ICAO NOTAM' },
severity: 'high',
variant: undefined,
dedupTtl: 21600, // 6h — well above the 2h poll interval; guards across restarts
}).catch(e => console.warn('[Notify] NOTAM publish error:', e?.message));
}
} catch (e) {
console.warn('[NOTAM-Seed] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_RETRY_MS);
} finally {
notamSeedInFlight = false;
}
}
function startNotamSeedLoop() {
if (!UPSTASH_ENABLED) {
console.log('[NOTAM-Seed] Disabled (no Upstash Redis)');
return;
}
if (!ICAO_API_KEY) {
console.log('[NOTAM-Seed] Disabled (no ICAO_API_KEY)');
return;
}
console.log(`[NOTAM-Seed] Seed loop starting (interval ${NOTAM_SEED_INTERVAL_MS / 1000 / 60}min, airports: ${NOTAM_MONITORED_ICAO.length})`);
seedNotamClosures().catch((e) => console.warn('[NOTAM-Seed] Initial seed error:', e?.message || e));
setInterval(() => {
seedNotamClosures().catch((e) => console.warn('[NOTAM-Seed] Seed error:', e?.message || e));
}, NOTAM_SEED_INTERVAL_MS).unref?.();
}
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Cyber Threat Intelligence Seed — Railway fetches IOC feeds → writes to Redis // Cyber Threat Intelligence Seed — Railway fetches IOC feeds → writes to Redis
@@ -11002,8 +10530,9 @@ server.listen(PORT, () => {
startOrefPollLoop(); startOrefPollLoop();
startUcdpSeedLoop(); startUcdpSeedLoop();
startMarketDataSeedLoop(); startMarketDataSeedLoop();
startAviationSeedLoop(); // Aviation + NOTAM seeds — standalone Railway cron — scripts/seed-aviation.mjs
startNotamSeedLoop(); // Writes aviation:delays:intl:v3, aviation:delays:faa:v1, aviation:notam:closures:v2,
// aviation:news::24:v1 and publishes aviation_closure/notam_closure events.
// Energy spine seed — standalone Railway cron (0 6 * * *) — seed-energy-spine.mjs // Energy spine seed — standalone Railway cron (0 6 * * *) — seed-energy-spine.mjs
// Assembles per-country canonical energy keys from 6 domain sources daily. // Assembles per-country canonical energy keys from 6 domain sources daily.
// Cyber seed disabled — standalone cron seed-cyber-threats.mjs handles this // Cyber seed disabled — standalone cron seed-cyber-threats.mjs handles this

View File

@@ -1,312 +0,0 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, getRedisCredentials, acquireLockSafely, releaseLock, withRetry, writeFreshnessMetadata, logSeedResult, verifySeedKey, extendExistingTtl } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const FAA_CACHE_KEY = 'aviation:delays:faa:v1';
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v2';
const CACHE_TTL = 7200;
const FAA_URL = 'https://nasstatus.faa.gov/api/airport-status-information';
const ICAO_NOTAM_URL = 'https://dataservices.icao.int/api/notams-realtime-list';
const NOTAM_CLOSURE_QCODES = new Set(['FA', 'AH', 'AL', 'AW', 'AC', 'AM']);
const FAA_AIRPORTS = [
'ATL', 'ORD', 'DFW', 'DEN', 'LAX', 'JFK', 'SFO', 'SEA', 'LAS', 'MCO',
'EWR', 'CLT', 'PHX', 'IAH', 'MIA', 'BOS', 'MSP', 'DTW', 'FLL', 'PHL',
'LGA', 'BWI', 'SLC', 'SAN', 'IAD', 'DCA', 'MDW', 'TPA', 'HNL', 'PDX',
];
const MONITORED_AIRPORTS_ICAO = [
// MENA
'OEJN', 'OERK', 'OEMA', 'OEDF', 'OMDB', 'OMAA', 'OMSJ',
'OTHH', 'OBBI', 'OOMS', 'OKBK', 'OLBA', 'OJAI', 'OSDI',
'ORBI', 'OIIE', 'OISS', 'OIMM', 'OIKB', 'HECA', 'GMMN',
'DTTA', 'DAAG', 'HLLT',
// Europe
'EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF', 'LTFM',
'LSZH', 'LOWW', 'EKCH', 'ENGM', 'ESSA', 'EFHK', 'EPWA',
// Americas
'KJFK', 'KLAX', 'KORD', 'KATL', 'KDFW', 'KDEN', 'KSFO',
'CYYZ', 'MMMX', 'SBGR', 'SCEL', 'SKBO',
// APAC
'RJTT', 'RKSI', 'VHHH', 'WSSS', 'VTBS', 'VIDP', 'YSSY',
'ZBAA', 'ZPPP', 'WMKK',
// Africa
'FAOR', 'DNMM', 'HKJK', 'GABS',
];
function parseDelayTypeFromReason(reason) {
const r = reason.toLowerCase();
if (r.includes('ground stop')) return 'ground_stop';
if (r.includes('ground delay') || r.includes('gdp')) return 'ground_delay';
if (r.includes('departure')) return 'departure_delay';
if (r.includes('arrival')) return 'arrival_delay';
if (r.includes('clos')) return 'ground_stop';
return 'general';
}
function parseFaaXml(text) {
const delays = new Map();
const parseTag = (xml, tag) => {
const re = new RegExp(`<${tag}>(.*?)</${tag}>`, 'gs');
const matches = [];
let m;
while ((m = re.exec(xml))) matches.push(m[1]);
return matches;
};
const getVal = (block, tag) => {
const m = block.match(new RegExp(`<${tag}>(.*?)</${tag}>`));
return m ? m[1].trim() : '';
};
for (const gd of parseTag(text, 'Ground_Delay')) {
const arpt = getVal(gd, 'ARPT');
if (arpt) {
delays.set(arpt, {
airport: arpt,
reason: getVal(gd, 'Reason') || 'Ground delay',
avgDelay: parseInt(getVal(gd, 'Avg') || '30', 10),
type: 'ground_delay',
});
}
}
for (const gs of parseTag(text, 'Ground_Stop')) {
const arpt = getVal(gs, 'ARPT');
if (arpt) {
delays.set(arpt, {
airport: arpt,
reason: getVal(gs, 'Reason') || 'Ground stop',
avgDelay: 60,
type: 'ground_stop',
});
}
}
for (const d of parseTag(text, 'Delay')) {
const arpt = getVal(d, 'ARPT');
if (arpt) {
const existing = delays.get(arpt);
if (!existing || existing.type !== 'ground_stop') {
const min = parseInt(getVal(d, 'Min') || '15', 10);
const max = parseInt(getVal(d, 'Max') || '30', 10);
delays.set(arpt, {
airport: arpt,
reason: getVal(d, 'Reason') || 'Delays',
avgDelay: Math.round((min + max) / 2),
type: parseDelayTypeFromReason(getVal(d, 'Reason') || ''),
});
}
}
}
for (const ac of parseTag(text, 'Airport')) {
const arpt = getVal(ac, 'ARPT');
if (arpt && FAA_AIRPORTS.includes(arpt)) {
delays.set(arpt, {
airport: arpt,
reason: 'Airport closure',
avgDelay: 120,
type: 'ground_stop',
});
}
}
return delays;
}
function determineSeverity(avgDelay) {
if (avgDelay >= 90) return 'severe';
if (avgDelay >= 60) return 'major';
if (avgDelay >= 30) return 'moderate';
if (avgDelay >= 15) return 'minor';
return 'normal';
}
async function redisSet(url, token, key, value, ttl) {
const payload = JSON.stringify(value);
const cmd = ttl ? ['SET', key, payload, 'EX', ttl] : ['SET', key, payload];
const resp = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(cmd),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) throw new Error(`Redis SET ${key} failed: HTTP ${resp.status}`);
}
async function seedFaaDelays() {
console.log('[FAA] Fetching airport status...');
const resp = await fetch(FAA_URL, {
headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
throw new Error(`FAA HTTP ${resp.status}`);
}
const xml = await resp.text();
const faaDelays = parseFaaXml(xml);
const alerts = [];
for (const iata of FAA_AIRPORTS) {
const delay = faaDelays.get(iata);
if (delay) {
alerts.push({
id: `faa-${iata}`,
iata,
icao: '',
name: iata,
city: '',
country: 'USA',
location: { latitude: 0, longitude: 0 },
region: 'AIRPORT_REGION_AMERICAS',
delayType: `FLIGHT_DELAY_TYPE_${delay.type.toUpperCase()}`,
severity: `FLIGHT_DELAY_SEVERITY_${determineSeverity(delay.avgDelay).toUpperCase()}`,
avgDelayMinutes: delay.avgDelay,
delayedFlightsPct: 0,
cancelledFlights: 0,
totalFlights: 0,
reason: delay.reason,
source: 'FLIGHT_DELAY_SOURCE_FAA',
updatedAt: Date.now(),
});
}
}
console.log(`[FAA] ${alerts.length} alerts found`);
return { alerts };
}
async function seedNotamClosures() {
const apiKey = process.env.ICAO_API_KEY;
if (!apiKey) {
console.log('[NOTAM] No ICAO_API_KEY — skipping');
return null;
}
console.log(`[NOTAM] Fetching closures for ${MONITORED_AIRPORTS_ICAO.length} monitored airports...`);
const locations = MONITORED_AIRPORTS_ICAO.join(',');
const now = Math.floor(Date.now() / 1000);
let notams = [];
try {
// ICAO API only supports key via query param (no header auth)
const url = `${ICAO_NOTAM_URL}?api_key=${apiKey}&format=json&locations=${locations}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) {
console.warn(`[NOTAM] HTTP ${resp.status}`);
return null;
}
const contentType = resp.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
console.warn('[NOTAM] Got HTML instead of JSON');
return null;
}
const data = await resp.json();
if (Array.isArray(data)) notams = data;
} catch (err) {
console.warn(`[NOTAM] Fetch error: ${err.message}`);
return null;
}
console.log(`[NOTAM] ${notams.length} raw NOTAMs received`);
const closedSet = new Set();
const reasons = {};
for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !MONITORED_AIRPORTS_ICAO.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;
const code23 = (n.code23 || '').toUpperCase();
const code45 = (n.code45 || '').toUpperCase();
const text = (n.iteme || '').toUpperCase();
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
if (isClosureCode || isClosureText) {
closedSet.add(icao);
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
}
}
const closedIcaos = [...closedSet];
if (closedIcaos.length > 0) {
console.log(`[NOTAM] Closures: ${closedIcaos.join(', ')}`);
} else {
console.log('[NOTAM] No closures found');
}
return { closedIcaos, reasons };
}
async function main() {
const startMs = Date.now();
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const { url, token } = getRedisCredentials();
console.log('=== aviation:delays Seed ===');
const lockResult = await acquireLockSafely('aviation:delays', runId, 120_000, { label: 'aviation:delays' });
if (lockResult.skipped) {
process.exit(0);
}
if (!lockResult.locked) {
console.log(' SKIPPED: another seed run in progress');
process.exit(0);
}
let faaData, notamData;
try {
faaData = await withRetry(seedFaaDelays);
notamData = await seedNotamClosures();
} catch (err) {
await releaseLock('aviation:delays', runId);
console.error(` FETCH FAILED: ${err.message || err}`);
await extendExistingTtl([FAA_CACHE_KEY, NOTAM_CACHE_KEY, 'seed-meta:aviation:faa', 'seed-meta:aviation:notam'], CACHE_TTL);
console.log(`\n=== Failed gracefully (${Math.round(Date.now() - startMs)}ms) ===`);
process.exit(0);
}
try {
await redisSet(url, token, FAA_CACHE_KEY, faaData, CACHE_TTL);
console.log(` ${FAA_CACHE_KEY}: written`);
await writeFreshnessMetadata('aviation', 'faa', faaData.alerts.length, 'faa-asws');
const verified1 = await verifySeedKey(FAA_CACHE_KEY);
console.log(` FAA verified: ${verified1 ? 'yes' : 'NO'}`);
let notamCount = 0;
if (notamData) {
await redisSet(url, token, NOTAM_CACHE_KEY, notamData, CACHE_TTL);
console.log(` ${NOTAM_CACHE_KEY}: written`);
notamCount = notamData.closedIcaos.length;
await writeFreshnessMetadata('aviation', 'notam', notamCount, 'icao-notam');
const verified2 = await verifySeedKey(NOTAM_CACHE_KEY);
console.log(` NOTAM verified: ${verified2 ? 'yes' : 'NO'}`);
}
const durationMs = Date.now() - startMs;
logSeedResult('aviation', faaData.alerts.length + notamCount, durationMs);
console.log(`\n=== Done (${Math.round(durationMs)}ms) ===`);
} finally {
await releaseLock('aviation:delays', runId);
}
}
main().catch((err) => {
console.error(`PUBLISH FAILED: ${err.message || err}`);
process.exit(1);
});

File diff suppressed because it is too large Load Diff