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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 14:34:01 -08:00
1 parent 6bdedd8523
commit 8e63420f06
7 files changed
+129 -18

No files matched your search

+21 -3
View File
@@ -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,
};
},
};
@@ -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');
});
+28 -13
View File
@@ -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,
};
}
+14
View File
@@ -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);
});