Refresh live Data Library datasets once older than cacheSeconds

A dataset layer loaded its features once and kept them until it was
removed: the parsed set survives disable/enable, so NWS alerts, GDACS and
EONET showed what they held when first switched on for the whole session.

A library dataset now asks the manager for a refresh every cacheSeconds.
Once its data is that old it loads again, and swaps the new set in only
when it is ready, so the old features stay drawn while the request runs
and stay if it fails. An enable after cacheSeconds also loads afresh.
Disable and destroy cancel a refresh in flight.
This commit is contained in:
Sameh Khamis committed 2026-11-12 15:48:14 -08:00
1 parent 1a2589d4ef
commit d58ba23fba
3 files changed
+227 -2

No files matched your search

+9
View File
@@ -1102,6 +1102,15 @@ export function createLocalGeoJsonLayer(
disable: disableLayer,
/**
* Forget the parsed features so the next enable() loads them again: a
* caller-owned live loader (the Data Library) uses it once its data is
* older than the source allows.
*/
dropCachedFeatures: () => {
_cachedFeatures = null;
},
destroy: (viewer) => {
if (_destroyed) return;
_destroyed = true;
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import { createLibraryDatasetLayer } from './layers.js';
const entry = {
id: 'test-alerts',
kind: 'dataset',
title: 'Test alerts',
icon: '!',
provider: 'Test',
display: { color: '#ff0000' },
source: { cacheSeconds: 60 },
attribution: { text: 'Test' },
licence: { name: 'CC0', url: 'https://example.org/licence' },
};
const point = (name) => ({
type: 'Feature',
properties: { name },
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[0.01, 0],
[0, 0.01],
[0, 0],
],
],
},
});
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
};
function harness(t, loads) {
const previousWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
const sources = new Set();
const event = { addEventListener: () => () => {} };
const viewer = {
dataSources: {
async add(source) {
sources.add(source);
return source;
},
remove(source) {
return sources.delete(source);
},
},
scene: { canvas: {}, preRender: event, requestRender() {} },
camera: { moveEnd: event },
};
let calls = 0;
const source = {
async loadDataset(_entry, { signal }) {
const answer = loads[calls++];
return typeof answer === 'function' ? answer(signal) : answer;
},
};
const layer = createLibraryDatasetLayer(entry, {
services: {
...localGeoJsonServices,
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
},
source,
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
t.after(() => {
layer.destroy(viewer);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
return { layer, viewer, sources, calls: () => calls };
}
const result = (...names) => ({
features: names.map(point),
truncated: false,
});
test('a library dataset refreshes on the manager tick once older than cacheSeconds', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
const env = harness(t, [result('a'), result('b', 'c')]);
assert.equal(env.layer.refreshInterval, 60_000);
await env.layer.enable(env.viewer);
assert.equal(env.layer.getStats().count, 1);
await env.layer.update(env.viewer);
assert.equal(env.calls(), 1, 'fresh data is not refetched');
t.mock.timers.tick(60_000);
await env.layer.update(env.viewer);
assert.equal(env.calls(), 2);
assert.equal(env.layer.getStats().count, 2);
assert.equal(env.sources.size, 1, 'the old source is replaced, not kept');
});
test('a stale library dataset reloads on the next enable', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
const env = harness(t, [result('a'), result('b', 'c')]);
await env.layer.enable(env.viewer);
env.layer.disable(env.viewer);
await env.layer.enable(env.viewer);
assert.equal(env.calls(), 1, 'a re-enable within cacheSeconds reuses the data');
env.layer.disable(env.viewer);
t.mock.timers.tick(60_000);
await env.layer.enable(env.viewer);
assert.equal(env.calls(), 2);
assert.equal(env.layer.getStats().count, 2);
});
test('a failed refresh keeps the drawn features', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
const env = harness(t, [
result('a'),
() => Promise.reject(new Error('HTTP 503')),
]);
await env.layer.enable(env.viewer);
t.mock.timers.tick(60_000);
await assert.rejects(env.layer.update(env.viewer), /HTTP 503/);
assert.equal(env.layer.getStats().count, 1);
assert.equal(env.sources.size, 1);
});
test('disable during a refresh cancels it and draws nothing late', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
const pending = deferred();
let refreshSignal = null;
const env = harness(t, [
result('a'),
(signal) => {
refreshSignal = signal;
return pending.promise;
},
]);
await env.layer.enable(env.viewer);
t.mock.timers.tick(60_000);
const refreshing = env.layer.update(env.viewer);
env.layer.disable(env.viewer);
assert.equal(refreshSignal.aborted, true);
pending.resolve(result('b', 'c'));
await refreshing;
assert.equal(env.sources.size, 0);
});
+64 -2
View File
@@ -147,13 +147,27 @@ export function libraryCredit(entry) {
*/
export function createLibraryDatasetLayer(
entry,
{ services, source, onZoom = null, onRemove = null },
{
services,
source,
onZoom = null,
onRemove = null,
screenSpaceEventHandlerFactory,
},
) {
let truncated = false;
let loaded = 0;
let extent = null;
let rowListener = null;
let legendCounts = null;
// Live data: features older than the source's cacheSeconds are loaded
// again, on the next enable and on a timer while the layer is on.
const maxAgeMs = entry.source.cacheSeconds * 1000;
let loadedAt = 0;
let prefetched = null;
let refreshController = null;
let on = false;
const stale = () => Date.now() - loadedAt >= maxAgeMs;
const colorBy = entry.display.colorBy || null;
const layer = createLocalGeoJsonLayer(
{
@@ -164,12 +178,18 @@ export function createLibraryDatasetLayer(
icon: entry.icon,
source: entry.provider,
labels: true,
...(screenSpaceEventHandlerFactory
? { screenSpaceEventHandlerFactory }
: {}),
featureColor: colorBy
? (properties) =>
colorBy.colors[String(properties?.__gevColorKey ?? '')] || null
: null,
loadFeatures: async ({ signal }) => {
const result = await source.loadDataset(entry, { signal });
const result =
prefetched ?? (await source.loadDataset(entry, { signal }));
prefetched = null;
loadedAt = Date.now();
truncated = result.truncated;
loaded = result.features.length;
extent = featureExtent(result.features);
@@ -181,13 +201,55 @@ export function createLibraryDatasetLayer(
services,
);
const enable = layer.enable;
const disable = layer.disable;
const destroy = layer.destroy;
const getStats = layer.getStats;
const cancelRefresh = () => {
refreshController?.abort();
refreshController = null;
prefetched = null;
};
return Object.assign(layer, {
libraryEntryId: entry.id,
refreshInterval: maxAgeMs,
async enable(viewer, ...rest) {
on = true;
registerDynamicCredit(viewer, libraryCredit(entry));
if (stale()) layer.dropCachedFeatures();
return enable(viewer, ...rest);
},
/**
* The manager's refresh tick: once the data is stale, load it again and
* swap it in only when the new set is ready, so the old features stay
* drawn while the request runs and stay if it fails.
*/
async update(viewer) {
if (!on || !stale() || refreshController) return;
const current = new AbortController();
refreshController = current;
try {
const result = await source.loadDataset(entry, {
signal: current.signal,
});
if (!on || current.signal.aborted) return;
prefetched = result;
layer.dropCachedFeatures();
disable(viewer);
await enable(viewer);
} finally {
if (refreshController === current) refreshController = null;
}
},
disable(viewer, ...rest) {
on = false;
cancelRefresh();
return disable(viewer, ...rest);
},
destroy(viewer, ...rest) {
on = false;
cancelRefresh();
return destroy(viewer, ...rest);
},
getStats() {
const stats = getStats();
// A capped dataset reads PARTIAL ("incomplete snapshot"), never as