fix(shipwrecks): the page expires each source's wrecks by its own clock, from the age the proxy reports

The page compared the proxy's fetchedAt with its own clock, so a skewed
browser clock cleared or kept wrecks wrongly; a snapshot without a timestamp
never expired; and one old source cleared the other's fresh wrecks. The proxy
now reports each source's age on its own clock and its seven-day limit; the
page turns them into per-source deadlines when the snapshot arrives (seven
days from arrival when no age is given) and, on a failed refresh, removes only
the sources past theirs. A refused short refresh is logged.
This commit is contained in:
Sameh Khamis committed 2026-11-10 13:38:31 -08:00
1 parent 6e4dd6299e
commit 7704de5075
6 files changed
+119 -8

No files matched your search

+2
View File
@@ -199,6 +199,8 @@ test('one source failing keeps its own last records while the other refreshes',
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
assert.equal(later.body.stale, true);
assert.equal(upstream.callsTo('emodnet'), 2, 'EMODnet was refreshed');
assert.deepEqual(later.body.ages, { emodnet: 0, wikidata: 25 * HOUR });
assert.equal(later.body.maxAgeMs, 7 * 24 * HOUR);
});
test('a failed source waits before it is asked again', async () => {
+38 -6
View File
@@ -31,6 +31,22 @@ const CARD_HOST_OPTIONS = Object.freeze({
const MAX_CARD_LINES = 14;
/** Wrecks not refreshed for this long leave the map (the proxy's own limit). */
const MAX_SHOWN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/**
* When each source's wrecks leave the map, on this page's clock: the age the
* proxy reports (its own clock) is subtracted from the limit, so the two
* clocks are never compared. With no age reported, the snapshot counts as
* fresh when it arrives.
*/
export function sourceDeadlines(snapshot, at) {
const limit = snapshot.maxAgeMs ?? MAX_SHOWN_AGE_MS;
const deadlines = {};
for (const name of ['emodnet', 'wikidata']) {
if (snapshot.sources?.[name] === false) continue;
deadlines[name] = at + limit - (snapshot.ages?.[name] ?? 0);
}
return deadlines;
}
/**
* Marker size in pixels: grows with the death toll, from 16 px (no deaths known) to 36 px (about a thousand or more).
*/
@@ -67,9 +83,9 @@ export function createShipwrecksLayer({
picking = null,
pointer = null,
openExternal = null,
now = () => Date.now(),
// One collection for every wreck marker (thousands of them): far cheaper to
// draw and rebuild than an entity each. Tests pass a fake.
now = () => Date.now(),
createMarkers = (scene) =>
new Cesium.BillboardCollection({
scene,
@@ -85,6 +101,7 @@ export function createShipwrecksLayer({
let _records = new Map();
let _pins = [];
let _snapshot = null;
let _deadlines = {};
let _lastError = null;
let _clickHandler = null;
let _rowListener = null;
@@ -385,6 +402,7 @@ export function createShipwrecksLayer({
snapshot.version === _snapshot?.version &&
_pins.length > 0;
_snapshot = snapshot;
_deadlines = sourceDeadlines(snapshot, now());
_lastError =
snapshot.sources.emodnet && snapshot.sources.wikidata
? null
@@ -399,14 +417,27 @@ export function createShipwrecksLayer({
return false;
console.warn('[Data:Shipwrecks] Fetch error:', error);
_lastError = error?.message || msg('Shipwrecks unavailable');
// What is on the map was not refreshed: say so, and drop it once it
// is older than the proxy itself would serve.
// What is on the map was not refreshed: say so, and drop each
// source's wrecks once they are older than the proxy would serve.
if (_snapshot) {
const age = now() - (_snapshot.fetchedAt ?? now());
if (age > MAX_SHOWN_AGE_MS) {
const at = now();
const expired = Object.keys(_deadlines).filter(
(name) => _deadlines[name] <= at,
);
for (const name of expired) delete _deadlines[name];
if (!Object.keys(_deadlines).length) {
_snapshot = null;
rebuild([]);
} else _snapshot = { ..._snapshot, stale: true };
} else {
_snapshot = { ..._snapshot, stale: true };
if (expired.length) {
_snapshot.records = _snapshot.records.filter(
(record) => !expired.includes(record.source),
);
_snapshot.version = null;
rebuild(_snapshot.records);
}
}
}
notifyRow();
return false;
@@ -427,6 +458,7 @@ export function createShipwrecksLayer({
_records = new Map();
_pins = [];
_snapshot = null;
_deadlines = {};
_viewer = null;
_rowListener = null;
},
+57
View File
@@ -291,3 +291,60 @@ test('a failed refresh marks the shown wrecks stale, and clears them after seven
assert.equal(h.layer.getStats().count, 0, 'expired wrecks leave the map');
assert.equal(h.markers.billboards.length, 0);
});
test('expiry runs on the page clock, per source, even without timestamps', async () => {
const DAY = 24 * 60 * 60 * 1000;
const down = () => new Error('Shipwrecks unavailable (HTTP 502)');
// A page clock far from the proxy's: only the reported ages count.
let clock = 5e12;
const h = harness({
now: () => clock,
snapshots: [
{
...snapshot([
wreck('fresh', { source: 'emodnet' }),
wreck('old', { source: 'wikidata' }),
]),
fetchedAt: null,
ages: { emodnet: 0, wikidata: 6.5 * DAY },
maxAgeMs: 7 * DAY,
},
down(),
down(),
down(),
],
});
h.layer.enable();
await h.layer.update();
clock += 0.25 * DAY;
await h.layer.update();
assert.equal(h.layer.getStats().count, 2, 'both still inside their limit');
clock += 0.5 * DAY;
await h.layer.update();
assert.deepEqual(
h.markers.billboards.map((billboard) => billboard.id),
['shipwreck:fresh'],
'only the source past its limit leaves',
);
assert.equal(h.layer.getStats().stale, true);
clock += 7 * DAY;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
});
test('a snapshot with no timestamp or ages still expires seven days after it arrived', async () => {
const DAY = 24 * 60 * 60 * 1000;
let clock = 100;
const h = harness({
now: () => clock,
snapshots: [
{ ...snapshot([wreck('a')]), fetchedAt: null },
new Error('down'),
],
});
h.layer.enable();
await h.layer.update();
clock += 8 * DAY;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
});
+8
View File
@@ -1,6 +1,8 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeShipwreckSnapshot } from './records.js';
const age = (value) => (Number.isFinite(value) && value >= 0 ? value : null);
/** Same-origin source for the proxy's EMODnet + Wikidata shipwreck snapshot. */
export function createShipwrecksSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -27,6 +29,12 @@ export function createShipwrecksSource({
stale: payload.stale === true,
// Changes whenever either source's data does; null from an older proxy.
version: typeof payload.version === 'string' ? payload.version : null,
// Each source's age on the proxy's clock (null when unknown).
ages: {
emodnet: age(payload.ages?.emodnet),
wikidata: age(payload.ages?.wikidata),
},
maxAgeMs: age(payload.maxAgeMs),
sources: {
emodnet: payload.sources?.emodnet !== false,
wikidata: payload.sources?.wikidata !== false,