mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
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:
1 parent
6e4dd6299e
commit
7704de5075
6 files changed
+119
-8
No files matched your search
@@ -164,8 +164,9 @@ one that failed is asked again after 15 minutes. Each source keeps its own last
|
|||||||
good records: a source that fails, answers with nothing usable, or answers with
|
good records: a source that fails, answers with nothing usable, or answers with
|
||||||
less than half of its last good set keeps serving its earlier records as stale
|
less than half of its last good set keeps serving its earlier records as stale
|
||||||
for up to seven days, and never removes the other source's wrecks. When the
|
for up to seven days, and never removes the other source's wrecks. When the
|
||||||
page cannot refresh, the wrecks it shows are marked stale, and it clears them
|
page cannot refresh, the wrecks it shows are marked stale, and it removes each
|
||||||
once they are more than seven days old. EMODnet rows the portal took from
|
source's wrecks once they pass seven days, counted from the age the proxy
|
||||||
|
reported (so the page and server clocks are never compared). EMODnet rows the portal took from
|
||||||
wrecksite.eu are skipped (that site is not openly licensed). A Wikidata wreck
|
wrecksite.eu are skipped (that site is not openly licensed). A Wikidata wreck
|
||||||
with the same name within 3 km of an EMODnet wreck is merged into one record
|
with the same name within 3 km of an EMODnet wreck is merged into one record
|
||||||
that keeps the article link and takes EMODnet's year, sinking text, depth and
|
that keeps the article link and takes EMODnet's year, sinking text, depth and
|
||||||
|
|||||||
@@ -120,16 +120,25 @@ export function shipwrecksProxy({
|
|||||||
if (result.status === 'fulfilled' && !shrunk)
|
if (result.status === 'fulfilled' && !shrunk)
|
||||||
state.good = { records: result.value, fetchedAt: done };
|
state.good = { records: result.value, fetchedAt: done };
|
||||||
else state.retryAt = done + RETRY_MS;
|
else state.retryAt = done + RETRY_MS;
|
||||||
|
if (shrunk)
|
||||||
|
console.warn(
|
||||||
|
`[shipwrecks] ${names[i]} refresh refused: ${result.value.length} ` +
|
||||||
|
`records against ${state.good.records.length} last good`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The served snapshot: every source with usable records, merged once. */
|
/** The served snapshot: every source with usable records, merged once. */
|
||||||
function compose(at) {
|
function compose(at) {
|
||||||
const sourceFlags = {};
|
const sourceFlags = {};
|
||||||
|
// How old each source's records are, on this clock, so the page can
|
||||||
|
// expire them by its own clock without comparing the two.
|
||||||
|
const ages = {};
|
||||||
let stale = false;
|
let stale = false;
|
||||||
let fetchedAt = null;
|
let fetchedAt = null;
|
||||||
for (const [name, state] of Object.entries(sources)) {
|
for (const [name, state] of Object.entries(sources)) {
|
||||||
sourceFlags[name] = usable(state, at);
|
sourceFlags[name] = usable(state, at);
|
||||||
|
ages[name] = sourceFlags[name] ? at - state.good.fetchedAt : null;
|
||||||
if (!sourceFlags[name]) continue;
|
if (!sourceFlags[name]) continue;
|
||||||
if (!fresh(state, at)) stale = true;
|
if (!fresh(state, at)) stale = true;
|
||||||
fetchedAt = Math.min(fetchedAt ?? Infinity, state.good.fetchedAt);
|
fetchedAt = Math.min(fetchedAt ?? Infinity, state.good.fetchedAt);
|
||||||
@@ -155,6 +164,8 @@ export function shipwrecksProxy({
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
version: key,
|
version: key,
|
||||||
sources: sourceFlags,
|
sources: sourceFlags,
|
||||||
|
ages,
|
||||||
|
maxAgeMs: STALE_MAX_MS,
|
||||||
records: snapshot.records,
|
records: snapshot.records,
|
||||||
},
|
},
|
||||||
stale,
|
stale,
|
||||||
|
|||||||
@@ -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.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
|
||||||
assert.equal(later.body.stale, true);
|
assert.equal(later.body.stale, true);
|
||||||
assert.equal(upstream.callsTo('emodnet'), 2, 'EMODnet was refreshed');
|
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 () => {
|
test('a failed source waits before it is asked again', async () => {
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ const CARD_HOST_OPTIONS = Object.freeze({
|
|||||||
const MAX_CARD_LINES = 14;
|
const MAX_CARD_LINES = 14;
|
||||||
/** Wrecks not refreshed for this long leave the map (the proxy's own limit). */
|
/** Wrecks not refreshed for this long leave the map (the proxy's own limit). */
|
||||||
const MAX_SHOWN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
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).
|
* 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,
|
picking = null,
|
||||||
pointer = null,
|
pointer = null,
|
||||||
openExternal = null,
|
openExternal = null,
|
||||||
|
now = () => Date.now(),
|
||||||
// One collection for every wreck marker (thousands of them): far cheaper to
|
// One collection for every wreck marker (thousands of them): far cheaper to
|
||||||
// draw and rebuild than an entity each. Tests pass a fake.
|
// draw and rebuild than an entity each. Tests pass a fake.
|
||||||
now = () => Date.now(),
|
|
||||||
createMarkers = (scene) =>
|
createMarkers = (scene) =>
|
||||||
new Cesium.BillboardCollection({
|
new Cesium.BillboardCollection({
|
||||||
scene,
|
scene,
|
||||||
@@ -85,6 +101,7 @@ export function createShipwrecksLayer({
|
|||||||
let _records = new Map();
|
let _records = new Map();
|
||||||
let _pins = [];
|
let _pins = [];
|
||||||
let _snapshot = null;
|
let _snapshot = null;
|
||||||
|
let _deadlines = {};
|
||||||
let _lastError = null;
|
let _lastError = null;
|
||||||
let _clickHandler = null;
|
let _clickHandler = null;
|
||||||
let _rowListener = null;
|
let _rowListener = null;
|
||||||
@@ -385,6 +402,7 @@ export function createShipwrecksLayer({
|
|||||||
snapshot.version === _snapshot?.version &&
|
snapshot.version === _snapshot?.version &&
|
||||||
_pins.length > 0;
|
_pins.length > 0;
|
||||||
_snapshot = snapshot;
|
_snapshot = snapshot;
|
||||||
|
_deadlines = sourceDeadlines(snapshot, now());
|
||||||
_lastError =
|
_lastError =
|
||||||
snapshot.sources.emodnet && snapshot.sources.wikidata
|
snapshot.sources.emodnet && snapshot.sources.wikidata
|
||||||
? null
|
? null
|
||||||
@@ -399,14 +417,27 @@ export function createShipwrecksLayer({
|
|||||||
return false;
|
return false;
|
||||||
console.warn('[Data:Shipwrecks] Fetch error:', error);
|
console.warn('[Data:Shipwrecks] Fetch error:', error);
|
||||||
_lastError = error?.message || msg('Shipwrecks unavailable');
|
_lastError = error?.message || msg('Shipwrecks unavailable');
|
||||||
// What is on the map was not refreshed: say so, and drop it once it
|
// What is on the map was not refreshed: say so, and drop each
|
||||||
// is older than the proxy itself would serve.
|
// source's wrecks once they are older than the proxy would serve.
|
||||||
if (_snapshot) {
|
if (_snapshot) {
|
||||||
const age = now() - (_snapshot.fetchedAt ?? now());
|
const at = now();
|
||||||
if (age > MAX_SHOWN_AGE_MS) {
|
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;
|
_snapshot = null;
|
||||||
rebuild([]);
|
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();
|
notifyRow();
|
||||||
return false;
|
return false;
|
||||||
@@ -427,6 +458,7 @@ export function createShipwrecksLayer({
|
|||||||
_records = new Map();
|
_records = new Map();
|
||||||
_pins = [];
|
_pins = [];
|
||||||
_snapshot = null;
|
_snapshot = null;
|
||||||
|
_deadlines = {};
|
||||||
_viewer = null;
|
_viewer = null;
|
||||||
_rowListener = null;
|
_rowListener = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.layer.getStats().count, 0, 'expired wrecks leave the map');
|
||||||
assert.equal(h.markers.billboards.length, 0);
|
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);
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
import { normalizeShipwreckSnapshot } from './records.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. */
|
/** Same-origin source for the proxy's EMODnet + Wikidata shipwreck snapshot. */
|
||||||
export function createShipwrecksSource({
|
export function createShipwrecksSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
@@ -27,6 +29,12 @@ export function createShipwrecksSource({
|
|||||||
stale: payload.stale === true,
|
stale: payload.stale === true,
|
||||||
// Changes whenever either source's data does; null from an older proxy.
|
// Changes whenever either source's data does; null from an older proxy.
|
||||||
version: typeof payload.version === 'string' ? payload.version : null,
|
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: {
|
sources: {
|
||||||
emodnet: payload.sources?.emodnet !== false,
|
emodnet: payload.sources?.emodnet !== false,
|
||||||
wikidata: payload.sources?.wikidata !== false,
|
wikidata: payload.sources?.wikidata !== false,
|
||||||
|
|||||||
Reference in new issue
Block a user