mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #1404 from quanticsoul4772/upstream/perimeters-stale
fix(perimeters): flag stale WFIGS snapshots
This commit is contained in:
9 files changed
+210
-20
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
|
||||
the last snapshot is being shown (STALE, its age, "data may be stale"), and
|
||||
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
|
||||
@@ -3855,7 +3855,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
|
||||
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
|
||||
@@ -39,6 +39,8 @@ const API_URL =
|
||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||
const MAX_PAGES = 5;
|
||||
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
|
||||
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (previous) return { value: previous.value, stale: true };
|
||||
// A failed refresh serves the last answer, marked stale. A perimeter
|
||||
// snapshot only for a day: older than that, it is no picture of
|
||||
// current fires. (InciWeb entries keep their previous behaviour.)
|
||||
if (
|
||||
previous &&
|
||||
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
|
||||
)
|
||||
return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
// The proxy's last snapshot, served while WFIGS is unreachable.
|
||||
let _stale = false;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_enabled = false;
|
||||
console.log('[Data:FirePerimeters] Initialized');
|
||||
},
|
||||
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
|
||||
_request = request;
|
||||
refreshInciwebIndex(request.signal);
|
||||
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 WFIGS 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();
|
||||
|
||||
// Incident facts may change independently of the polygon's source clock.
|
||||
const signature = JSON.stringify(
|
||||
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
|
||||
// Refresh (or drop) the selected incident's card against the new feed.
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || msg('Perimeter source unavailable');
|
||||
// A failure now outranks the stale snapshot still drawn.
|
||||
_stale = false;
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
},
|
||||
|
||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
stale: _stale,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
|
||||
if (action !== 'destroy') h.layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
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('WFIGS HTTP 502');
|
||||
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||
},
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
fail = true;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
|
||||
});
|
||||
@@ -8,20 +8,35 @@ const msg = (text) => text;
|
||||
export function createWfigsPerimeterSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/**
|
||||
* The rows with how fresh they are: the proxy answers from its last
|
||||
* snapshot, marked stale, while WFIGS is unreachable.
|
||||
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||
*/
|
||||
async function getSnapshotDetail({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter 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/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter snapshot'));
|
||||
return payload.rows;
|
||||
/** The perimeter rows (see getSnapshotDetail for staleness). */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
};
|
||||
}
|
||||
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
|
||||
let clock = 1_000_000_000;
|
||||
let up = true;
|
||||
const plugin = firePerimetersProxy({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
if (!up) throw new Error('WFIGS unreachable');
|
||||
return Response.json(validPayload);
|
||||
},
|
||||
});
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_path, callback) => (handler = callback) },
|
||||
});
|
||||
const ask = async () => {
|
||||
const reply = {};
|
||||
await handler(
|
||||
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
|
||||
{
|
||||
writeHead(status, headers) {
|
||||
reply.status = status;
|
||||
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
|
||||
},
|
||||
end(body) {
|
||||
reply.body = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
return reply;
|
||||
};
|
||||
const fresh = await ask();
|
||||
assert.equal(fresh.status, 200);
|
||||
assert.equal(fresh.body.stale, undefined);
|
||||
up = false;
|
||||
clock += 23 * 60 * 60 * 1000;
|
||||
const stale = await ask();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.staleHeader, true);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
|
||||
clock += 2 * 60 * 60 * 1000;
|
||||
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
|
||||
});
|
||||
|
||||
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 9, 25, 12);
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||
rows: [],
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot(), []);
|
||||
});
|
||||
|
||||
test('getSnapshot works when taken off its source object', async () => {
|
||||
const { getSnapshot } = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [] }),
|
||||
});
|
||||
assert.deepEqual(await getSnapshot(), []);
|
||||
});
|
||||
@@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({
|
||||
requires: ['perimeters'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const rows = (await services.perimeters.getSnapshot({ signal }))
|
||||
const snapshot =
|
||||
typeof services.perimeters.getSnapshotDetail === 'function'
|
||||
? await services.perimeters.getSnapshotDetail({ signal })
|
||||
: { rows: await services.perimeters.getSnapshot({ signal }) };
|
||||
// The proxy answers from its last snapshot, marked stale, while WFIGS is
|
||||
// unreachable.
|
||||
const stale = snapshot.stale === true;
|
||||
const rows = snapshot.rows
|
||||
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||
.filter(({ point }) => point)
|
||||
@@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({
|
||||
}))
|
||||
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
||||
summary:
|
||||
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
|
||||
(stale ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
||||
stale,
|
||||
retrieved_at: isoTime(snapshot.fetchedAt),
|
||||
...capRows(rows, args.limit),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => {
|
||||
assert.equal(slow.data.names_pending, true);
|
||||
assert.match(slow.summary, /site names are still loading\)\.$/);
|
||||
});
|
||||
|
||||
test('stale fire perimeters say so, with when they were fetched', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 9, 22, 6);
|
||||
const ring = [
|
||||
[
|
||||
[-118.5, 35.2],
|
||||
[-118.4, 35.2],
|
||||
[-118.4, 35.3],
|
||||
[-118.5, 35.2],
|
||||
],
|
||||
];
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [],
|
||||
getSnapshotDetail: async () => ({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'held',
|
||||
name: 'Held Fire',
|
||||
acres: 300,
|
||||
polygons: [ring],
|
||||
},
|
||||
],
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
}),
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-120, 34, -117, 36] },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 mapped wildfire perimeter in the requested box (data may be stale).',
|
||||
);
|
||||
assert.equal(result.data.stale, true);
|
||||
assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString());
|
||||
});
|
||||
Reference in new issue
Block a user