fix(traffic): flag stale cached TomTom tiles in get_traffic_flow (#1239)

Report stale traffic tiles and their fetch time, and release pending freshness metadata when tile requests fail or are cancelled.

Contribution by @quanticsoul4772.
This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-25 08:26:26 -07:00
1 parent f6e7f63a63
commit 6bbf9b94c4
8 files changed
+299 -18

No files matched your search

+69 -8
View File
@@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
/**
* Whether the proxy answered a tile from its cache past the refresh window
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
* fails), and when the proxy fetched it.
*/
function tileFreshness(response) {
const status = response.headers?.get?.('x-tomtom-cache') || '';
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
return {
stale: status.startsWith('STALE'),
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
};
}
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
// Freshness travels in response headers, but the tile cache keeps only
// decoded segments: hold each response's freshness until its tile decodes,
// then key it by the decoded tile, which the cache hands back on a hit.
const pending = new Map();
const freshness = new WeakMap();
const tiles = createVectorTileSource({
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
allowedOrigin: 'http://localhost',
decode: (bytes, z, x, y) =>
decodeFlowTile(bytes, z, x, y, { strict: true })
decode: (bytes, z, x, y) => {
// Take the mark before decoding, so a tile that fails to decode
// leaves nothing behind.
const path = FLOW_TILE_PATH(z, x, y);
const entry = pending.get(path);
const mark = entry?.mark;
entry?.release();
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment)
.flatMap((segment) =>
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
),
fetchImpl,
);
if (mark) freshness.set(segments, mark);
return segments;
},
fetchImpl: async (url, init) => {
const response = await fetchImpl(url, init);
if (response.ok && !init.signal.aborted) {
const path = String(url);
const entry = {
mark: tileFreshness(response),
release() {
if (pending.get(path) === entry) pending.delete(path);
init.signal.removeEventListener('abort', entry.release);
},
};
pending.get(path)?.release();
pending.set(path, entry);
// The vector source aborts this signal for failed/cancelled body reads,
// including size-limit failures that never reach decode.
init.signal.addEventListener('abort', entry.release, { once: true });
}
return response;
},
ttlMs: 120_000,
maxTiles: 16,
});
let partial = false;
/**
* Flow segments for bounds, with whether any covering tile failed to load
* for this request.
* for this request, whether any came from the proxy's stale cache, and when
* the oldest tile was fetched.
*/
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
@@ -44,7 +93,16 @@ export function createFlowTileSource({
coords,
})),
);
return { segments, partial: result.partial === true };
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
const times = marks
.map((mark) => mark.fetchedAt)
.filter((at) => Number.isFinite(at));
return {
segments,
partial: result.partial === true,
stale: marks.some((mark) => mark.stale === true),
oldestTileAt: times.length ? Math.min(...times) : null,
};
}
return {
fetchFlowDetail,
@@ -52,6 +110,9 @@ export function createFlowTileSource({
return (await fetchFlowDetail(bounds, options)).segments;
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
resetFlowTileCache: () => {
for (const entry of pending.values()) entry.release();
tiles.clear();
},
};
}
+153
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { getEventListeners } from 'node:events';
import { createFlowTileSource } from './flowSource.js';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
@@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti
assert.ok(publicUrls.length >= 4);
assert.ok(apiUrls.length >= 4);
});
test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => {
const fetchedAt = Date.UTC(2026, 9, 16, 8);
const reply = (status, at) => async () =>
new Response(fixture, {
headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) },
});
const stale = createTrafficSource({
fetchImpl: reply('STALE-BUDGET', fetchedAt),
});
const first = await stale.fetchFlowDetail(bounds);
assert.ok(first.segments.length > 0);
assert.equal(first.stale, true);
assert.equal(first.oldestTileAt, fetchedAt);
// The decoded tile is served from cache now, and keeps its mark.
const again = await stale.fetchFlowDetail(bounds);
assert.equal(stale.getFlowSessionStats().tilesFetched, 1);
assert.equal(again.stale, true);
assert.equal(again.oldestTileAt, fetchedAt);
const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) });
const live = await fresh.fetchFlowDetail(bounds);
assert.equal(live.stale, false);
assert.equal(live.oldestTileAt, fetchedAt);
const unmarked = createTrafficSource({
fetchImpl: async () => new Response(fixture),
});
const plain = await unmarked.fetchFlowDetail(bounds);
assert.equal(plain.stale, false);
assert.equal(plain.oldestTileAt, null);
});
// Capture constructor-owned Maps only while constructing the source, so tests
// can assert retained metadata without adding a diagnostic API to production.
function flowSourceWithMaps(fetchImpl) {
const maps = [];
const NativeMap = globalThis.Map;
let source;
try {
globalThis.Map = class extends NativeMap {
constructor(...args) {
super(...args);
maps.push(this);
}
};
source = createFlowTileSource({ fetchImpl });
} finally {
globalThis.Map = NativeMap;
}
return { source, pending: maps[0] };
}
test('flow freshness is released after body failures, size rejection, decode and reset', async () => {
for (const failure of ['body', 'size', 'decode']) {
let requestSignal;
let fail = true;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({
'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS',
'x-tomtom-fetched-at': fail ? '1000' : '2000',
...(fail && failure === 'size'
? { 'content-length': '99999999' }
: {}),
}),
async arrayBuffer() {
if (fail && failure === 'body') throw new Error('body disconnected');
return fail && failure === 'decode' ? new Uint8Array([255]) : fixture;
},
};
});
await assert.rejects(source.fetchFlowDetail(bounds));
assert.equal(pending.size, 0, `${failure} leaves no pending metadata`);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
fail = false;
const fresh = await source.fetchFlowDetail(bounds);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal(pending.size, 0, 'successful decode releases metadata');
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
source.resetFlowTileCache();
assert.equal(pending.size, 0);
}
});
test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => {
let resolveOld;
let oldSignal;
let calls = 0;
const { source, pending } = flowSourceWithMaps((url, { signal }) => {
if (++calls === 1) {
oldSignal = signal;
return new Promise((resolve) => {
resolveOld = resolve;
});
}
return Promise.resolve(
new Response(fixture, {
headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' },
}),
);
});
const old = source.fetchFlowDetail(bounds);
const rejected = assert.rejects(old, { name: 'AbortError' });
while (!resolveOld) await new Promise((resolve) => setImmediate(resolve));
source.resetFlowTileCache();
const fresh = await source.fetchFlowDetail(bounds);
resolveOld(
new Response(fixture, {
headers: {
'x-tomtom-cache': 'STALE-ERROR',
'x-tomtom-fetched-at': '1000',
},
}),
);
await rejected;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(pending.size, 0);
assert.equal(getEventListeners(oldSignal, 'abort').length, 0);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000);
});
test('cancelling a flow body releases its freshness before a new request', async () => {
const controller = new AbortController();
let requestSignal;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }),
async arrayBuffer() {
assert.equal(pending.size, 1);
controller.abort();
return fixture;
},
};
});
await assert.rejects(
source.fetchFlowDetail(bounds, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(pending.size, 0);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
});
+5 -4
View File
@@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
let now = Date.UTC(2026, 8, 12, 12);
const fetchedAt = String(now);
t.mock.method(Date, 'now', () => now);
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
@@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(json(await request('/status')).dailyCount, 1);
assert.equal(calls, 1);
now += 120001;
assert.equal(
(await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],
'STALE-BUDGET',
);
const staleTile = await request('/flow/8/1/1.pbf');
assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET');
// The stale copy still says when TomTom produced it.
assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt);
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
assert.equal(calls, 1);
now += 86400000;
+11
View File
@@ -473,11 +473,22 @@ export const getTrafficFlow = defineTool({
: `No live traffic is reported in ${area.label}.`) +
(flow.partial
? ' Some map tiles did not load, so these figures are partial.'
: '') +
// Past the daily TomTom budget, or while TomTom fails, the proxy
// serves its last copy of a tile however old it is.
(flow.stale
? ' Some tiles are cached copies' +
(isoTime(flow.oldestTileAt)
? `, the oldest from ${isoTime(flow.oldestTileAt)}`
: '') +
', so traffic may be out of date.'
: ''),
data: {
view: suggestView(services, { area, layers: ['traffic'] }),
area: area.label,
partial: flow.partial,
stale: flow.stale === true,
oldest_tile_at: isoTime(flow.oldestTileAt),
speed_pct_of_free_flow: speedPct,
measured_km: round(measuredKm, 1),
congested_km: round(congestedKm, 1),
+41
View File
@@ -555,6 +555,47 @@ test('traffic counts only roads inside a radius area and reports missing tiles',
assert.match(partial.summary, /Some map tiles did not load/);
});
test('traffic says when the proxy served cached tiles, and how old the oldest is', async () => {
const road = [
{
coords: [
[-0.2, 0],
[0.2, 0],
],
trafficLevel: 0.9,
},
];
const call = (detail) =>
composeCatalog({
tools: coreTools,
services: {
traffic: {
getStatus: async () => ({ hasKey: true }),
fetchFlowDetail: async () => ({
segments: road,
partial: false,
...detail,
}),
},
},
}).call('get_traffic_flow', { area: { lat: 0, lon: 0, radius_km: 10 } });
const oldest = Date.UTC(2026, 9, 16, 8);
const stale = await call({ stale: true, oldestTileAt: oldest });
assert.equal(stale.data.stale, true);
assert.equal(stale.data.oldest_tile_at, '2026-10-16T08:00:00.000Z');
assert.match(
stale.summary,
/cached copies, the oldest from 2026-10-16T08:00:00\.000Z, so traffic may be out of date\./,
);
const live = await call({ stale: false, oldestTileAt: oldest });
assert.equal(live.data.stale, false);
assert.doesNotMatch(live.summary, /out of date/);
// A source that can't tell (no freshness fields) isn't reported as stale.
const unknown = await call({});
assert.equal(unknown.data.stale, false);
assert.equal(unknown.data.oldest_tile_at, null);
});
test('a whole-world bike-share search names every system it skipped', async () => {
const empty = { data: { stations: [] } };
const result = await composeCatalog({