mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1283 from rudycelekli/fix/satellite-cache-provenance-20261017
fix(satellites): retain cached catalog provenance
This commit is contained in:
9 files changed
+316
-5
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- Report cached CelesTrak satellite records as stale in the Data Layers row,
|
||||
preserving separate core and dense catalog provenance through refresh,
|
||||
removal and lifecycle cleanup (rudycelekli, #1283).
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Vendored
+5
-2
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user