mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
6bdedd8523
commit
8e63420f06
7 files changed
+129
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user