diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index b78d6a042..e79cc6eff 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -5,3 +5,5 @@ cards on click. `/api/ghostmaps` watches the repository's branch head every 15 s and pushes each new commit to open pages, which reload only the products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index db3ee9750..52fdb1e78 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5128,6 +5128,7 @@ are omitted rather than framing the wrong part of the globe. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent. - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js index dd30fd986..853fd35e3 100644 --- a/server/providers/ghostMaps.js +++ b/server/providers/ghostMaps.js @@ -172,7 +172,13 @@ export function createGhostMapsWatcher({ await loadDisk(); const head = await readHead(); state.checkedAt = now(); - if (head === state.commit) return false; + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } const next = await readCommit(head); const previous = new Map(state.products.map((p) => [p.id, p.version])); const changed = next.products diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 6251bf4de..5ddef6aa5 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -178,6 +178,58 @@ test('the catalog survives a restart from disk, and upstream failures are report assert.match(catalog.error, /HTTP 503/); })); +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + test('poll interval is configurable with a 5 s floor', () => { assert.equal(ghostMapsPollMs(undefined), 15000); assert.equal(ghostMapsPollMs('30'), 30000);