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`
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.
The mission factory separates paths, replay, camera, panel, overlays and placement.
Launch Library pad coordinates retain unknown values: missing fields use the
+1
View File
@@ -446,6 +446,7 @@
"src/data/satellites.js",
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"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._denseIds.push(noradId);
layerState._denseStale ||= Boolean(res.stale);
added++;
}
// Yield to the event loop between chunks.
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._catalog.delete(noradId);
}
layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
+5 -2
View File
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
},
getStats() {
const stale = Boolean(layerState._coreStale || layerState._denseStale);
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
stale: false,
stale,
status:
layerState._lastError === 'CelesTrak unreachable'
? 'unavailable'
: layerState._lastError
? 'degraded'
: 'nominal',
: stale
? 'stale'
: 'nominal',
error: layerState._lastError,
};
},
+13 -2
View File
@@ -36,7 +36,12 @@ export function createIngestion({
if (!res.ok) return { ...groupDef, entries: [], ok: false };
const entries = parts.orbits.parseTLE(res.text);
updateSignal.throwIfAborted();
return { ...groupDef, entries, ok: entries.length > 0 };
return {
...groupDef,
entries,
ok: entries.length > 0,
stale: Boolean(res.stale),
};
} catch (error) {
if (updateSignal.aborted || error?.name === 'AbortError')
throw error;
@@ -91,6 +96,7 @@ export function createIngestion({
// catalog or those labels stay stale for the life of the session.
layerState._detectionObjects.clear();
layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0;
layerState._denseLoadController?.abort();
layerState._denseLoadController = null;
@@ -102,11 +108,13 @@ export function createIngestion({
// Process all TLE entries in CATALOG_GROUPS order
const allEntries = [];
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
const seen = new Set();
let coreStale = false;
for (const entry of allEntries) {
const satrec = twoline2satrec(entry.line1, entry.line2);
@@ -123,6 +131,8 @@ export function createIngestion({
group: entry.group,
});
coreStale ||= entry.stale;
// Propagate initial position
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
@@ -157,6 +167,7 @@ export function createIngestion({
parts.labels._syncIssOverlay();
}
layerState._coreStale = coreStale;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
layerState._lastUpdate = Date.now();
+4
View File
@@ -24,6 +24,8 @@ export function createLifecycle({
layerState._orbitPaths = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._trackedNorad = null;
parts.tracking._cancelPendingTrackingRestore();
layerState._trackedEntity = null;
@@ -162,6 +164,8 @@ export function createLifecycle({
layerState._rowControlsListener = null;
layerState._count = 0;
layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._lastError = null;
layerState._lastFocusUpdate = 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;
// Cache provenance belongs to admitted core and dense records separately.
state._coreStale = false;
state._denseStale = false;
state._activeUpdateControllers = new Set();
state._denseLoadController = null;