fix(rail): trip-update delays are as old as the server's read

The browser stamped every trip-updates answer as just read. While an
operator's feed fails, the rail proxy replays its last good copy for up to
ten minutes, and a cached copy can be a TTL old, so those delays never
reached RAIL_UPDATES_MAX_AGE_MS and a selected train's card showed them as
current. Delays now take their age from the snapshot's fetchedAt (capped
at now), as positions do in ingestion.js, so they stop applying 75 s after
the server last read the operator.
This commit is contained in:
Sameh Khamis committed 2026-11-11 14:57:02 -08:00
1 parent ace2160002
commit 283890cee0
2 files changed
+58 -1

No files matched your search

+9 -1
View File
@@ -287,8 +287,16 @@ export function createRailSchedules({
if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`);
const body = await response.json();
if (slot.controller !== controller) return;
// Delays are as old as the server's read of the operator (`fetchedAt`),
// not this answer: a cached copy, or the last good one replayed while
// the operator fails, stops applying RAIL_UPDATES_MAX_AGE_MS after it,
// as positions do (ingestion.js applySnapshot).
const at = now();
slot.delays = delaysFromUpdates(body);
slot.at = now();
slot.at = Math.min(
Number.isFinite(body?.fetchedAt) ? body.fetchedAt : at,
at,
);
onChange(feedId);
} catch (error) {
if (error?.name !== 'AbortError')
+49
View File
@@ -372,6 +372,55 @@ test('delays are polled every 30 s only while a train on the feed is selected',
assert.equal(h.timers.size, 0);
});
test('delays are as old as the server’s read, even when it replays them', async () => {
let readAt = null;
const h = harness({
updates: () =>
reply({
fetchedAt: readAt ?? h.now,
trips: [
{
tripId: 'T1',
delaySec: 240,
stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }],
},
],
}),
});
const train = liveTrain({
id: 'v1',
tripId: 'T1',
routeId: 'R1',
lat: LAT,
lon: 0.05,
});
h.provider.onSelect(train);
await flush();
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240);
// The operator fails: each poll is answered with the server's last copy,
// still stamped with the time it was read.
readAt = h.now;
const poll = [...h.timers.values()][0].fn;
for (let i = 0; i < 3; i++) {
h.now += RAIL_UPDATES_POLL_MS;
poll();
await flush();
}
assert.equal(h.calls.updates, 4);
assert.ok(3 * RAIL_UPDATES_POLL_MS > RAIL_UPDATES_MAX_AGE_MS);
assert.equal(h.provider.delaysFor(FEED), null);
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0);
// A fresh read applies again; a server clock ahead counts as now.
readAt = h.now + 5_000;
poll();
await flush();
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240);
h.now += RAIL_UPDATES_MAX_AGE_MS;
assert.notEqual(h.provider.delaysFor(FEED), null);
});
test('a selected train’s delays are not polled when its feed has no timetable', async () => {
const h = harness({
schedule: () => reply({ error: 'No rail schedule' }, 404),