diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index e00dadfc3..16c345db5 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -409,6 +409,15 @@ async function main() { status: 'ready', })); } + // CelesTrak SATCAT, which the satellite classes read beside the TLEs. + // An empty CSV (header only) keeps the run hermetic: the classes fall + // back to names and groups, as they do when SATCAT is unavailable. + if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) { + return Promise.resolve(new Response( + 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n', + { status: 200, headers: { 'Content-Type': 'text/csv' } }, + )); + } // CelesTrak TLE groups. Served from a fixed two-satellite catalog so // the satellite assertions are deterministic and the run never depends // on an upstream that rate-limits (it was answering 403 the night this diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index c69b14f30..dc20968a3 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -12,6 +12,13 @@ import { classifySatellite, parseSatcatCsv } from './classes.js'; */ export const SATCAT_WAIT_MS = 3000; +/** + * How long a complete, current SATCAT read is kept before the layer asks + * again, ms. SATCAT changes daily and the proxy caches it a day, so the + * five-minute TLE update does not read all eleven groups each time. + */ +export const SATCAT_KEEP_MS = 6 * 60 * 60 * 1000; + export function createIngestion({ state: layerState, services, @@ -42,6 +49,12 @@ export function createIngestion({ */ function _readSatcat(signal) { if (typeof source.readSatcat !== 'function') return Promise.resolve([]); + // A complete, current read is kept; a stale or failed one is asked again. + if ( + layerState._satcatState === 'current' && + Date.now() - layerState._satcatReadAtMs < SATCAT_KEEP_MS + ) + return Promise.resolve([]); return Promise.all( CATALOG_GROUPS.map((groupDef) => source @@ -86,6 +99,7 @@ export function createIngestion({ if (!groups.length) return; const changed = layerState._satcatState !== satcatState; layerState._satcatState = satcatState; + layerState._satcatReadAtMs = Date.now(); if (satcatState !== 'current') console.warn(`[Data:Satellites] SATCAT ${satcatState}: classes may lag`); // The row's info line says so (getRowControls). diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index c25b90158..3871e57a3 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js'; -import { SATCAT_WAIT_MS, createIngestion } from './ingestion.js'; +import { + SATCAT_KEEP_MS, + SATCAT_WAIT_MS, + createIngestion, +} from './ingestion.js'; import { createOrbits } from './orbits.js'; import { createControls } from './controls.js'; @@ -178,7 +182,9 @@ test('a satellite in visual and resource is imaging, whichever group shows it', t.mock.method(console, 'warn', () => {}); // A name no pattern knows, no UCS record and no SATCAT: only its groups. const { state, update } = ingestionFor((path) => - path === 'visual' || path === 'resource' ? tleSet('ZZ TESTSAT', '50001') : '', + path === 'visual' || path === 'resource' + ? tleSet('ZZ TESTSAT', '50001') + : '', ); await update(); const sat = state._catalog.get(50001); @@ -247,3 +253,40 @@ test('a failed SATCAT read is told on the layer row, not only the console', asyn }); assert.equal(current.methods.getRowControls().info, ''); }); + +test('a current SATCAT read is kept between updates; a failed one is asked again', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const header = + 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE\n'; + let reads = 0; + let ok = true; + const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50003'), { + source: { + readSatcat: async () => { + reads++; + return ok + ? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50003,PAY,-` } + : { ok: false, text: '' }; + }, + }, + }); + await update(); + const perUpdate = reads; + assert.ok(perUpdate > 0); + await update(); + assert.equal(reads, perUpdate, 'a fresh read is not asked again'); + assert.equal( + state._catalog.get(50003).satelliteClass, + 'debris', + 'the held records still class the rebuilt catalog', + ); + + state._satcatReadAtMs -= SATCAT_KEEP_MS; + ok = false; + await update(); + assert.equal(reads, 2 * perUpdate, 'an old read is asked again'); + assert.equal(state._satcatState, 'unavailable'); + await update(); + assert.equal(reads, 3 * perUpdate, 'a failed read is asked again'); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 16c210a77..42823ee00 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -48,6 +48,9 @@ export function createState({ services }) { // How current the SATCAT records behind the classes are: null before any // read, then 'current' | 'stale' | 'unavailable' (see ingestion.js). state._satcatState = null; + // When _satcatState was last set (Date.now()); a current read is kept + // SATCAT_KEEP_MS (src/layers/satellites/ingestion.js). + state._satcatReadAtMs = Number.NEGATIVE_INFINITY; state._denseStale = false; state._activeUpdateControllers = new Set();