fix(satellites): retain cached catalog provenance

This commit is contained in:
Rudy Celekli committed 2026-10-19 21:58:22 -04:00
1 parent de2138e84c
commit b43970eecc
8 files changed
+313 -5

No files matched your search

+5 -1
View File
@@ -817,7 +817,11 @@ Viewport limits, saturation retry, placement, query caps and controls are retain
Satellite and mission layers expose separate factories through `./layers/satellites` Satellite and mission layers expose separate factories through `./layers/satellites`
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
and teardown. Source adapters retain the existing CelesTrak and Launch Library and teardown. Satellite core and dense catalogs retain upstream stale-cache
provenance only for admitted records; duplicate or invalid entries cannot taint
a fresh winning record. Dense removal clears only its own provenance, and a
fresh core rebuild or lifecycle reset replaces the corresponding ownership.
Source adapters retain the existing CelesTrak and Launch Library
endpoints; standalone entries provide scene services and the satellite dependency. endpoints; standalone entries provide scene services and the satellite dependency.
The mission factory separates paths, replay, camera, panel, overlays and placement. The mission factory separates paths, replay, camera, panel, overlays and placement.
Launch Library pad coordinates retain unknown values: missing fields use the Launch Library pad coordinates retain unknown values: missing fields use the
+1
View File
@@ -446,6 +446,7 @@
"src/data/satellites.js", "src/data/satellites.js",
"src/data/rocketLaunches.js", "src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs", "src/layers/satellites/source.test.mjs",
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs", "src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs", "src/layers/launches/ingestion.test.mjs",
"src/layers/installations/lifecycle.js", "src/layers/installations/lifecycle.js",
+2
View File
@@ -139,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
}); });
layerState._points.set(noradId, point); layerState._points.set(noradId, point);
layerState._denseIds.push(noradId); layerState._denseIds.push(noradId);
layerState._denseStale ||= Boolean(res.stale);
added++; added++;
} }
// Yield to the event loop between chunks. // Yield to the event loop between chunks.
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._catalog.delete(noradId); layerState._catalog.delete(noradId);
} }
layerState._denseIds = []; layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0; layerState._denseCursor = 0;
layerState._count = layerState._points.size; layerState._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
+5 -2
View File
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
}, },
getStats() { getStats() {
const stale = Boolean(layerState._coreStale || layerState._denseStale);
return { return {
count: layerState._count, count: layerState._count,
lastUpdate: layerState._lastUpdate, lastUpdate: layerState._lastUpdate,
stale: false, stale,
status: status:
layerState._lastError === 'CelesTrak unreachable' layerState._lastError === 'CelesTrak unreachable'
? 'unavailable' ? 'unavailable'
: layerState._lastError : layerState._lastError
? 'degraded' ? 'degraded'
: 'nominal', : stale
? 'stale'
: 'nominal',
error: layerState._lastError, error: layerState._lastError,
}; };
}, },
+13 -2
View File
@@ -36,7 +36,12 @@ export function createIngestion({
if (!res.ok) return { ...groupDef, entries: [], ok: false }; if (!res.ok) return { ...groupDef, entries: [], ok: false };
const entries = parts.orbits.parseTLE(res.text); const entries = parts.orbits.parseTLE(res.text);
updateSignal.throwIfAborted(); updateSignal.throwIfAborted();
return { ...groupDef, entries, ok: entries.length > 0 }; return {
...groupDef,
entries,
ok: entries.length > 0,
stale: Boolean(res.stale),
};
} catch (error) { } catch (error) {
if (updateSignal.aborted || error?.name === 'AbortError') if (updateSignal.aborted || error?.name === 'AbortError')
throw error; throw error;
@@ -91,6 +96,7 @@ export function createIngestion({
// catalog or those labels stay stale for the life of the session. // catalog or those labels stay stale for the life of the session.
layerState._detectionObjects.clear(); layerState._detectionObjects.clear();
layerState._denseIds = []; layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0; layerState._denseCursor = 0;
layerState._denseLoadController?.abort(); layerState._denseLoadController?.abort();
layerState._denseLoadController = null; layerState._denseLoadController = null;
@@ -102,11 +108,13 @@ export function createIngestion({
// Process all TLE entries in CATALOG_GROUPS order // Process all TLE entries in CATALOG_GROUPS order
const allEntries = []; const allEntries = [];
for (const r of results) { for (const r of results) {
for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); for (const e of r.entries)
allEntries.push({ ...e, group: r.tag, stale: r.stale });
} }
// Deduplicate by NORAD ID — first (most specific) group tag wins // Deduplicate by NORAD ID — first (most specific) group tag wins
const seen = new Set(); const seen = new Set();
let coreStale = false;
for (const entry of allEntries) { for (const entry of allEntries) {
const satrec = twoline2satrec(entry.line1, entry.line2); const satrec = twoline2satrec(entry.line1, entry.line2);
@@ -123,6 +131,8 @@ export function createIngestion({
group: entry.group, group: entry.group,
}); });
coreStale ||= entry.stale;
// Propagate initial position // Propagate initial position
const pos = parts.orbits.propagatePosition(satrec, now); const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue; if (!pos) continue;
@@ -157,6 +167,7 @@ export function createIngestion({
parts.labels._syncIssOverlay(); parts.labels._syncIssOverlay();
} }
layerState._coreStale = coreStale;
layerState._count = layerState._points.size; layerState._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState._lastUpdate = Date.now(); layerState._lastUpdate = Date.now();
+4
View File
@@ -24,6 +24,8 @@ export function createLifecycle({
layerState._orbitPaths = new Map(); layerState._orbitPaths = new Map();
layerState._count = 0; layerState._count = 0;
layerState._lastUpdate = null; layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._trackedNorad = null; layerState._trackedNorad = null;
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
layerState._trackedEntity = null; layerState._trackedEntity = null;
@@ -162,6 +164,8 @@ export function createLifecycle({
layerState._rowControlsListener = null; layerState._rowControlsListener = null;
layerState._count = 0; layerState._count = 0;
layerState._lastUpdate = null; layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._lastError = null; layerState._lastError = null;
layerState._lastFocusUpdate = 0; layerState._lastFocusUpdate = 0;
layerState._activeFocusCount = 0; layerState._activeFocusCount = 0;
+279
View File
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter, once } from 'node:events';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createSatelliteSource } from './source.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createOrbits } from './orbits.js';
import { createLabels } from './labels.js';
import { createTracking } from './tracking.js';
import { createRendering } from './rendering.js';
import { createCatalog } from './catalog.js';
import { createLifecycle } from './lifecycle.js';
import { createInteraction } from './interaction.js';
import { createIngestion } from './ingestion.js';
import { layerFeedState } from '../../data/feedState.js';
const GROUPS = [
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
];
function tle(id, invalid = false) {
// Owned synthetic catalog IDs over one valid element set; never live requests.
const motion = invalid ? ' 0.00000000' : '15.48899044';
return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
}
async function fixture(t) {
const policy = {
stale: new Set(),
invalid: new Set(),
ids: {},
status: 200,
hold: false,
};
const held = new EventEmitter();
let heldCount = 0;
const server = http.createServer((req, res) => {
const group = req.url.split('/').at(-1);
if (policy.hold) {
held.emit('request', ++heldCount);
return;
}
res.writeHead(policy.status, {
'Content-Type': 'text/plain',
'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS',
});
res.end(
tle(
policy.ids[group] ?? 25545 + GROUPS.indexOf(group),
policy.invalid.has(group),
),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const source = createSatelliteSource({
fetchImpl: (path, options) =>
fetch(`http://127.0.0.1:${server.address().port}${path}`, options),
});
// Application host services and native EventTargets; actual Cesium collections,
// input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene.
const noop = () => {};
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
layerState: { isExplicitLayerStateOrigin: () => false },
focus: {
clearFocusTarget: noop,
publishFocusTargetFromCachedPosition: noop,
getFocusTarget: () => null,
},
context: {
clearTrackedSubjectContext: noop,
getContextStore: () => ({ entities: new Map() }),
refreshTrackedSubjectContext: noop,
selectTrackedSubjectContext: noop,
},
readout: { refreshTrackedReadout: noop },
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
};
const previousDocument = globalThis.document;
globalThis.document = new EventTarget();
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
for (const [name, factory] of Object.entries({
controls: createControls,
orbits: createOrbits,
labels: createLabels,
tracking: createTracking,
rendering: createRendering,
catalog: createCatalog,
interaction: createInteraction,
lifecycle: createLifecycle,
ingestion: createIngestion,
})) {
parts[name] = factory(context);
}
const viewer = {
scene: {
canvas: new EventTarget(),
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
},
trackedEntityChanged: new Cesium.Event(),
};
await parts.lifecycle.methods.init(viewer);
t.after(async () => {
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
viewer.scene.primitives.destroy();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
return {
state,
parts,
viewer,
policy,
update: (options) => parts.ingestion.methods.update(viewer, options),
stats: () => parts.controls.methods.getStats(),
feed: () => layerFeedState(parts.controls.methods.getStats()),
async waitHeld(count) {
while (heldCount < count)
await once(held, 'request', { signal: AbortSignal.timeout(3000) });
},
};
}
test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => {
const f = await fixture(t);
await f.update();
assert.equal(f.feed(), 'nominal');
assert.equal(f.stats().count, 6);
f.policy.stale = new Set(GROUPS.slice(0, 6));
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.feed(), 'stale');
f.policy.stale = new Set(['visual']);
await f.update();
assert.equal(f.feed(), 'stale');
f.policy.stale.clear();
await f.update();
assert.equal(f.stats().stale, false);
assert.equal(f.feed(), 'nominal');
});
test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => {
const f = await fixture(t);
f.policy.stale.add('visual');
f.policy.ids.visual = 25545;
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(f.state._catalog.get(25545).group, 'stations');
assert.equal(f.stats().stale, false);
assert.equal(f.feed(), 'nominal');
f.policy.stale = new Set(['stations']);
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(
f.stats().stale,
true,
'a later fresh duplicate does not replace the stale winner',
);
});
test('invalid stale orbital elements are not admitted as provenance', async (t) => {
const f = await fixture(t);
f.policy.stale.add('visual');
f.policy.invalid.add('visual');
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(f.state._catalog.has(25546), false);
assert.equal(f.stats().stale, false);
});
test('failed and aborted core refreshes keep provenance of retained records', async (t) => {
const f = await fixture(t);
f.policy.stale.add('stations');
await f.update();
const priorTime = f.stats().lastUpdate;
f.policy.status = 503;
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.stats().status, 'unavailable');
assert.equal(f.stats().lastUpdate, priorTime);
f.policy.hold = true;
const controller = new AbortController();
const work = f.update({ signal: controller.signal });
await f.waitHeld(6);
controller.abort();
await assert.rejects(work, { name: 'AbortError' });
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.stats().lastUpdate, priorTime);
assert.equal(f.state._activeUpdateControllers.size, 0);
});
test('dense provenance belongs to its admitted records and clears on removal', async (t) => {
const f = await fixture(t);
await f.update();
f.policy.stale.add('starlink');
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
assert.equal(f.stats().count, 7);
assert.equal(f.feed(), 'stale');
f.state._params.catalog = 'core';
f.parts.catalog._removeDenseCatalog();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
f.policy.stale = new Set(['stations']);
await f.update();
f.state._params.catalog = 'dense';
await f.parts.catalog._loadDenseCatalog();
f.state._params.catalog = 'core';
f.parts.catalog._removeDenseCatalog();
assert.equal(
f.feed(),
'stale',
'removing dense data must not clear core provenance',
);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
f.policy.stale.add('starlink');
f.state._params.catalog = 'dense';
await f.parts.catalog._loadDenseCatalog();
f.policy.hold = true;
const controller = new AbortController();
const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal });
await f.waitHeld(1);
controller.abort();
assert.equal((await work).status, 'cancelled');
assert.equal(f.stats().count, 7);
assert.equal(f.feed(), 'stale');
f.policy.hold = false;
f.policy.stale.clear();
f.state._params.catalog = 'core';
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
});
test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => {
const f = await fixture(t);
f.policy.stale.add('stations');
await f.update();
f.policy.hold = true;
const work = f.update();
await f.waitHeld(6);
const rejected = assert.rejects(work, { name: 'AbortError' });
f.parts.lifecycle.methods.destroy(f.viewer);
assert.equal(f.stats().count, 0);
assert.equal(f.stats().stale, false);
await f.parts.lifecycle.methods.init(f.viewer);
await rejected;
assert.equal(f.stats().stale, false);
f.policy.hold = false;
f.policy.stale.clear();
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
});
+4
View File
@@ -40,6 +40,10 @@ export function createState({ services }) {
state._lastError = null; state._lastError = null;
// Cache provenance belongs to admitted core and dense records separately.
state._coreStale = false;
state._denseStale = false;
state._activeUpdateControllers = new Set(); state._activeUpdateControllers = new Set();
state._denseLoadController = null; state._denseLoadController = null;