Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 44 additions & 36 deletions server/worldmonitor/military/v1/get-theater-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
TheaterPosture,
} from '../../../../src/generated/server/worldmonitor/military/v1/service_server';

import { getCachedJson, setCachedJson } from '../../../_shared/redis';
import { getCachedJson, setCachedJson, cachedFetchJson } from '../../../_shared/redis';
import {
isMilitaryCallsign,
isMilitaryHex,
Expand Down Expand Up @@ -183,43 +183,51 @@ function calculatePostures(flights: RawFlight[]): TheaterPosture[] {
// RPC handler
// ========================================================================

async function fetchTheaterPostureFresh(): Promise<GetTheaterPostureResponse> {
// Race both sources in parallel instead of sequential fallback (H-6 fix)
let flights: RawFlight[];
const [openskyResult, wingbitsResult] = await Promise.allSettled([
fetchMilitaryFlightsFromOpenSky(),
fetchMilitaryFlightsFromWingbits(),
]);

if (openskyResult.status === 'fulfilled' && openskyResult.value.length > 0) {
flights = openskyResult.value;
} else if (wingbitsResult.status === 'fulfilled' && wingbitsResult.value && wingbitsResult.value.length > 0) {
flights = wingbitsResult.value;
} else {
throw new Error('Both OpenSky and Wingbits unavailable');
}

const theaters = calculatePostures(flights);
const result: GetTheaterPostureResponse = { theaters };

// Write stale/backup tiers in background
Promise.all([
setCachedJson(STALE_CACHE_KEY, result, STALE_TTL),
setCachedJson(BACKUP_CACHE_KEY, result, BACKUP_TTL),
]).catch(() => {});

return result;
}

export async function getTheaterPosture(
_ctx: ServerContext,
_req: GetTheaterPostureRequest,
): Promise<GetTheaterPostureResponse> {
const cached = (await getCachedJson(CACHE_KEY)) as GetTheaterPostureResponse | null;
if (cached) return cached;

try {
// Race both sources in parallel instead of sequential fallback (H-6 fix)
let flights: RawFlight[];
const [openskyResult, wingbitsResult] = await Promise.allSettled([
fetchMilitaryFlightsFromOpenSky(),
fetchMilitaryFlightsFromWingbits(),
]);

if (openskyResult.status === 'fulfilled' && openskyResult.value.length > 0) {
flights = openskyResult.value;
} else if (wingbitsResult.status === 'fulfilled' && wingbitsResult.value && wingbitsResult.value.length > 0) {
flights = wingbitsResult.value;
} else {
throw new Error('Both OpenSky and Wingbits unavailable');
}

const theaters = calculatePostures(flights);
const result: GetTheaterPostureResponse = { theaters };

await Promise.all([
setCachedJson(CACHE_KEY, result, CACHE_TTL),
setCachedJson(STALE_CACHE_KEY, result, STALE_TTL),
setCachedJson(BACKUP_CACHE_KEY, result, BACKUP_TTL),
]);
return result;
} catch {
const stale = (await getCachedJson(STALE_CACHE_KEY)) as GetTheaterPostureResponse | null;
if (stale) return stale;
const backup = (await getCachedJson(BACKUP_CACHE_KEY)) as GetTheaterPostureResponse | null;
if (backup) return backup;
return { theaters: [] };
}
// cachedFetchJson coalesces concurrent requests — only one upstream fetch
// runs at a time, eliminating the stampede that was hammering Wingbits.
const result = await cachedFetchJson<GetTheaterPostureResponse>(
CACHE_KEY,
CACHE_TTL,
fetchTheaterPostureFresh,
);
if (result) return result;

// Upstream failed and nothing was cached — fall back to stale/backup tiers
const stale = (await getCachedJson(STALE_CACHE_KEY)) as GetTheaterPostureResponse | null;
if (stale) return stale;
const backup = (await getCachedJson(BACKUP_CACHE_KEY)) as GetTheaterPostureResponse | null;
if (backup) return backup;
return { theaters: [] };
}