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); +});