From 8e63420f06f7787ed8c4f1bf6352b7bf712b031c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:34:01 -0800 Subject: [PATCH] fix(drone-airspace): show stale FAA snapshots as stale and cap them at a day The source dropped the proxy's stale flag and fetch time, so the layer row read nominal and freshly updated through an outage, and the proxy served its last snapshot indefinitely. Carry stale and fetchedAt through the source as Fire Perimeters does, date a stale snapshot by its fetch, and stop serving a snapshot more than 24 hours old. --- docs/CURRENT-STATE.md | 3 +- server/providers/droneAirspace.js | 6 ++- src/data/droneAirspaceProxy.test.mjs | 22 +++++++++++ src/layers/droneAirspace/index.js | 24 ++++++++++-- src/layers/droneAirspace/ownership.test.mjs | 37 +++++++++++++++++++ src/layers/droneAirspace/source.js | 41 ++++++++++++++------- src/layers/droneAirspace/source.test.mjs | 14 +++++++ 7 files changed, 129 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 259b92ce6..5b37341cb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4100,7 +4100,8 @@ Fire Perimeters uses capped, timed server reads with stale-on-error caching and Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last -good payload as stale when a refresh fails. An answer with no valid zone, or +good payload as stale for at most 24 hours when a refresh fails; the layer row +then reads STALE, dated by that snapshot's fetch. An answer with no valid zone, or paging that ends while the service still flags more records, counts as a failed refresh. Client-supplied URLs are rejected. Polygons keep holes; pick ownership is deliberately unregistered so sibling diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js index 3109d5984..7d9ead55c 100644 --- a/server/providers/droneAirspace.js +++ b/server/providers/droneAirspace.js @@ -34,6 +34,8 @@ const API_URL = const MAX_PAGES = 5; const MIB = 1024 * 1024; const SNAPSHOT_TTL_MS = 3_600_000; +/** Longest a failed refresh keeps serving the last snapshot, marked stale. */ +const STALE_MAX_MS = 24 * 60 * 60 * 1000; const exceededTransferLimit = (payload) => payload?.exceededTransferLimit === true || @@ -100,7 +102,9 @@ export function droneAirspaceProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous.value, stale: true }; + // A failed refresh serves the last snapshot, marked stale, for a day. + if (previous && now() - previous.savedAt <= STALE_MAX_MS) + return { value: previous.value, stale: true }; throw error; } } diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs index e56da166c..52444cda0 100644 --- a/src/data/droneAirspaceProxy.test.mjs +++ b/src/data/droneAirspaceProxy.test.mjs @@ -213,3 +213,25 @@ test('a cold upstream failure is 502, not an empty snapshot', async () => { assert.equal(res.status, 502); assert.equal(res.body.error, 'drone_airspace_unavailable'); }); + +test('a stale snapshot is served for at most a day, then refused', async () => { + let now = 1_000_000_000; + let up = true; + const request = install({ + now: () => now, + fetchImpl: async () => { + if (!up) throw new Error('FAA unreachable'); + return Response.json({ features: [feature(7)] }); + }, + }); + const fresh = await request(); + assert.equal(fresh.status, 200); + up = false; + now += 23 * 60 * 60 * 1000; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + now += 2 * 60 * 60 * 1000; + assert.equal((await request()).status, 502, 'a day-old snapshot is refused'); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 6420af2bd..a031215d9 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -42,6 +42,7 @@ export function createDroneAirspaceLayer({ let _count = 0; let _lastUpdate = null; let _lastError = null; + let _stale = false; let _enabled = false; let _clickHandler = null; let _selectedId = null; @@ -153,6 +154,7 @@ export function createDroneAirspaceLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:DroneAirspace] Initialized'); }, @@ -179,9 +181,21 @@ export function createDroneAirspaceLayer({ const request = new AbortController(); _request = request; try { - const rows = await source.getSnapshot({ signal: request.signal }); + // The source contract is getSnapshot; a source that can also say how + // fresh its rows are (the FAA proxy) offers getSnapshotDetail. + const snapshot = + typeof source.getSnapshotDetail === 'function' + ? await source.getSnapshotDetail({ signal: request.signal }) + : { rows: await source.getSnapshot({ signal: request.signal }) }; if (request.signal.aborted || _request !== request || !_enabled) return false; + const { rows } = snapshot; + _stale = snapshot.stale === true; + // A stale snapshot is as old as its fetch, not this poll. + const updatedAt = + _stale && Number.isFinite(snapshot.fetchedAt) + ? snapshot.fetchedAt + : Date.now(); const signature = JSON.stringify( rows @@ -190,7 +204,7 @@ export function createDroneAirspaceLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -239,7 +253,7 @@ export function createDroneAirspaceLayer({ _snapshotSignature = signature; if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`, @@ -251,6 +265,8 @@ export function createDroneAirspaceLayer({ console.warn('[Data:DroneAirspace] Fetch error:', e); _lastError = e?.message || msg('Restricted airspace source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -273,6 +289,7 @@ export function createDroneAirspaceLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, getAnalystRecords(maxCount = 2000) { @@ -322,6 +339,7 @@ export function createDroneAirspaceLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index bc10318f7..6f5019309 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createDroneAirspaceLayer } from './index.js'; +import { layerFeedState } from '../../data/feedState.js'; function harness(source, { pick = () => null, cardHit = () => null } = {}) { const sources = []; @@ -159,3 +160,39 @@ test('the row legend names prohibited and restricted with the disclaimer', async assert.equal(legend[1].count, 1); assert.match(legend[0].blurb, /not a substitute/); }); + +test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clears it', async () => { + const fetchedAt = Date.now() - 3 * 60 * 60 * 1000; + let snapshot = { rows: [row], stale: true, fetchedAt }; + const h = harness({ + getSnapshot: async () => snapshot.rows, + getSnapshotDetail: async () => snapshot, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + assert.equal(h.layer.getStats().lastUpdate, fetchedAt); + assert.equal(layerFeedState(h.layer.getStats()), 'stale'); + snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.ok(h.layer.getStats().lastUpdate > fetchedAt); + assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); +}); + +test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => { + let fail = false; + const h = harness({ + getSnapshot: async () => [row], + getSnapshotDetail: async () => { + if (fail) throw new Error('FAA SUA HTTP 502'); + return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; + }, + }); + await h.layer.update(h.viewer); + assert.equal(layerFeedState(h.layer.getStats()), 'stale'); + fail = true; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); +}); diff --git a/src/layers/droneAirspace/source.js b/src/layers/droneAirspace/source.js index 17e67b3ae..bd5c4d40e 100644 --- a/src/layers/droneAirspace/source.js +++ b/src/layers/droneAirspace/source.js @@ -4,20 +4,35 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; export function createDroneAirspaceSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while the FAA service is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async function getSnapshotDetail({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/drone-airspace', { signal }); + if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed drone airspace snapshot'); + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null, + }; + } + return { - async getSnapshot({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/drone-airspace', { signal }); - if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); - const payload = await readResponseJsonCapped( - response, - 80 * 1024 * 1024, - signal, - ); - signal?.throwIfAborted(); - if (!Array.isArray(payload?.rows)) - throw new Error('Malformed drone airspace snapshot'); - return payload.rows; + /** The zone rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/droneAirspace/source.test.mjs b/src/layers/droneAirspace/source.test.mjs index 160fb1789..93644db1a 100644 --- a/src/layers/droneAirspace/source.test.mjs +++ b/src/layers/droneAirspace/source.test.mjs @@ -103,3 +103,17 @@ test('a malformed successful response is never accepted as an empty snapshot', a ); } }); + +test('the snapshot detail carries the proxy staleness and fetch time', async () => { + const fetchedAt = Date.UTC(2026, 10, 3, 12); + const rows = normalizeDroneAirspaceSnapshot(validPayload); + const source = createDroneAirspaceSource({ + fetchImpl: async () => Response.json({ rows, stale: true, fetchedAt }), + }); + assert.deepEqual(await source.getSnapshotDetail(), { + rows, + stale: true, + fetchedAt, + }); + assert.deepEqual(await source.getSnapshot(), rows); +});