diff --git a/server/providers/overpass/osmFeaturesRoute.js b/server/providers/overpass/osmFeaturesRoute.js index 1c32fc675..e28ce80a5 100644 --- a/server/providers/overpass/osmFeaturesRoute.js +++ b/server/providers/overpass/osmFeaturesRoute.js @@ -6,7 +6,10 @@ import { OSM_FEATURE_LIMITS, summarizeOsmFeatures, } from './osmFeatures.js'; -import { resolveOverpassUpstreams } from './constants.js'; +import { + resolveOverpassUpstreams, + resolveOverpassUpstreamTag, +} from './constants.js'; import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js'; /** Places of interest change slowly: answers are kept a day. */ @@ -148,13 +151,16 @@ function sender(res) { * substitute for bulk place search. * * @param {{use: Function}} middlewares - * @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean}} [deps] + * @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean, upstreamTag?: () => string}} [deps] */ export function installOsmFeaturesRoute( middlewares, { fetchPayload = fetchOverpassPayload, configured = () => resolveOverpassUpstreams().length > 0, + // Keys each answer to the instance that gave it, as the other Overpass + // caches do, so repointing OVERPASS_UPSTREAMS never serves the old one's. + upstreamTag = resolveOverpassUpstreamTag, cacheRoot = path.join(process.cwd(), '.gev-cache'), featuresCache = createBoundedCache({ dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null, @@ -203,7 +209,7 @@ export function installOsmFeaturesRoute( res.once?.('close', abort); try { const answer = await features( - `${CACHE_VERSION}|features|${compiled.ql}`, + `${CACHE_VERSION}|features|${upstreamTag()}|${compiled.ql}`, compiled.ql, (parsed) => summarizeOsmFeatures(parsed, compiled), controller.signal, diff --git a/src/tooling/osmFeaturesRoute.test.mjs b/src/tooling/osmFeaturesRoute.test.mjs index 4e95ba54a..040e9903f 100644 --- a/src/tooling/osmFeaturesRoute.test.mjs +++ b/src/tooling/osmFeaturesRoute.test.mjs @@ -158,7 +158,10 @@ test('answers are compacted: centres, kept tags, truncation and counts', () => { assert.equal(count.count, 5); }); -function mount(fetchPayload, { featuresCache, configured = () => true } = {}) { +function mount( + fetchPayload, + { featuresCache, configured = () => true, upstreamTag } = {}, +) { const routes = new Map(); const writes = []; const memoryCache = () => { @@ -177,6 +180,7 @@ function mount(fetchPayload, { featuresCache, configured = () => true } = {}) { fetchPayload, cacheRoot: null, configured, + ...(upstreamTag ? { upstreamTag } : {}), featuresCache: featuresCache || memoryCache(), }, ); @@ -254,6 +258,35 @@ test('the route answers, caches, and runs one upstream query at a time', async ( assert.equal(refused.body.code, 'AREA_TOO_LARGE'); }); +test('a cached answer is served only by the Overpass instance that gave it', async () => { + let tag = 'instance-a'; + let upstream = 0; + const { request } = mount( + async () => { + upstream += 1; + return { + status: 200, + body: JSON.stringify({ + elements: [{ type: 'node', id: 7, lat: 1, lon: 1, tags: {} }], + }), + }; + }, + { upstreamTag: () => tag }, + ); + assert.equal((await request('preset=hospital&bbox=0,0,2,2')).status, 200); + const hit = await request('preset=hospital&bbox=0,0,2,2'); + assert.equal(hit.headers['X-OSM-Cache'], 'HIT'); + assert.equal(upstream, 1); + tag = 'instance-b'; + const switched = await request('preset=hospital&bbox=0,0,2,2'); + assert.equal(switched.headers['X-OSM-Cache'], 'MISS'); + assert.equal( + upstream, + 2, + 'the new instance is asked, not the old answer served', + ); +}); + test('an upstream refusal is not an empty answer', async () => { const { request } = mount(async () => ({ status: 429,