From 60a230b2ffecffca6f3a65b64a0aa01e13e8cdcf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 20:33:13 +0400 Subject: [PATCH] Keep a bikeshare city's last known stations, marked stale, when it comes back into range during a source outage --- .../bikeshare/failureReporting.test.mjs | 39 +++++++++++++++++++ src/layers/bikeshare/viewport.js | 6 +++ 2 files changed, 45 insertions(+) diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 90c7b86f9..42efe4e0e 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -375,3 +375,42 @@ test('every status poll failing over cached points reads STALE, not fresh', asyn assert.equal(stats.stale, true); assert.equal(stats.lastUpdate, loadedAt, 'a failed poll advanced freshness'); }); + +test('a city that leaves range and comes back during a status outage keeps its last stations, STALE', async () => { + const layer = makeLiveLayer(); + await layer.moveTo(TORONTO_ONLY); + await layer.enable(); + const loadedAt = layer.stats().lastUpdate; + assert.equal(layer.stats().count, 1); + + layer.source.deadStatus.add(TORONTO); + await layer.moveTo(BUFFALO_ONLY); + const beforeReturn = layer.stats().lastUpdate; + await layer.moveTo(TORONTO_ONLY); + + const stats = layer.stats(); + assert.equal(stats.count, 1, 'the last known stations stay drawn'); + assert.equal(stats.error, undefined); + assert.equal(stats.stale, true, 'they are marked stale'); + assert.equal(stats.lastUpdate, beforeReturn, 'the outage advanced freshness'); + assert.ok(layer.state._activeCityIds.has(TORONTO), 'the city stays active'); + const [station] = layer.state._stationRenderMap.values(); + assert.equal(station.cityId, TORONTO); + assert.equal(station.bikesAvailable, 3, 'the last known status stays'); + + // The next poll that reaches the source clears the outage. + layer.source.deadStatus.delete(TORONTO); + await new Promise((resolve) => setTimeout(resolve, 5)); + await layer.poll(); + assert.equal(layer.stats().stale, undefined); + assert.ok(layer.stats().lastUpdate > loadedAt); +}); + +test('a city whose status never loaded is still removed when its status fails', async () => { + const layer = makeLiveLayer(); + layer.source.deadStatus.add(TORONTO); + await layer.moveTo(TORONTO_ONLY); + await layer.enable(); + assert.equal(layer.stats().count, 0, 'no status ever seen: nothing to keep'); + assert.equal(layer.stats().error, 'GBFS fetch error'); +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 41b7cd0e5..8bb08b859 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -94,6 +94,8 @@ export function createViewport({ state: layerState, services, parts, camera }) { async function activateCity(cityId, generation) { if (!layerState._enabled || !layerState._activeCityIds.has(cityId)) return; + // Set once the city's last known stations and status are back on the map. + let showingLastKnown = false; try { const stationMap = await parts.ingestion.loadCityStationInfo( cityId, @@ -113,6 +115,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { const cachedStatus = layerState._statusCache.get(cityId)?.statusMap; if (cachedStatus) { parts.rendering?.applyStatusToPoints(cityId, cachedStatus); + showingLastKnown = layerState._cityRuntime.has(cityId); } const statusMap = await parts.ingestion.loadCityStationStatus( @@ -137,6 +140,9 @@ export function createViewport({ state: layerState, services, parts, camera }) { return; console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); layerState._failedCityIds.add(cityId); + // During a source outage a city coming back into range keeps its last + // known stations and status, marked stale; the status poll retries it. + if (showingLastKnown) return; deactivateCity(cityId); layerState._activeCityIds.delete(cityId); } finally {