diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..1a4952093 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..8812790e2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index fd136c82d..097d9e3be 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.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++; diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 9a56daac3..f3327b332 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -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, }; }, diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index f8ca1db56..01653611c 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -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(); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 7c0d36670..642a6879f 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -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; diff --git a/src/layers/satellites/stale.test.mjs b/src/layers/satellites/stale.test.mjs new file mode 100644 index 000000000..89b4d2dbf --- /dev/null +++ b/src/layers/satellites/stale.test.mjs @@ -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'); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 106802ba6..50a127b54 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -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;