mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(overpass): do not serve cached answers from a different upstream (#932)
Both disk caches fed by OVERPASS_UPSTREAMS key only on the query — the Overpass proxy on the sanitized request body, the installations proxy on the bbox — so an entry written against one instance is a hit for the same query against another. After an operator repoints OVERPASS_UPSTREAMS, the previous upstream's answers keep being served for 7 days, 30 for boundary-class queries, and indefinitely through the serve-stale path. An install that predates #742 has a cache written from the public instances that were the default then, and configuring your own instance does not displace it. Cached answers now carry a tag identifying the configured upstream set, and a disk read refuses an entry that does not match it, on the stale path too. With nothing configured any entry is still accepted: there is no instance to contradict, and serving last-good data at any age is what the not-configured path exists for. The tag is a hash of the resolved endpoint list, never the URLs; it is written to disk and compared on read, and is not sent to clients or surfaced in errors. One line is logged once per process when a foreign entry is ignored, naming the variable and what will happen, as local-receivers.js already does for a rejected feed. Cost worth naming: on upgrade every existing entry is untagged, so the first outage after upgrading serves nothing where it previously served stale data, until each query is fetched once from the configured instance.
This commit is contained in:
1 parent
e1cc7afacf
commit
7485b16ded
6 files changed
+135
-7
No files matched your search
@@ -9,7 +9,10 @@ import {
|
|||||||
fetchOverpassPayload,
|
fetchOverpassPayload,
|
||||||
overpassNotConfigured,
|
overpassNotConfigured,
|
||||||
} from './overpass/transport.js';
|
} from './overpass/transport.js';
|
||||||
import { resolveOverpassUpstreams } from './overpass/constants.js';
|
import {
|
||||||
|
resolveOverpassUpstreams,
|
||||||
|
resolveOverpassUpstreamTag,
|
||||||
|
} from './overpass/constants.js';
|
||||||
import {
|
import {
|
||||||
_militaryInstallationCache,
|
_militaryInstallationCache,
|
||||||
trimMilitaryInstallationCache,
|
trimMilitaryInstallationCache,
|
||||||
@@ -74,7 +77,13 @@ function militaryInstallationsProxy() {
|
|||||||
retrievedAt: new Date().toISOString(),
|
retrievedAt: new Date().toISOString(),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
};
|
};
|
||||||
const entry = { payload, cachedAt: Date.now() };
|
// Tagged with the configured upstream set so the disk layer does not serve
|
||||||
|
// this answer back after the operator repoints (see overpass/cache.js).
|
||||||
|
const entry = {
|
||||||
|
payload,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
upstreams: resolveOverpassUpstreamTag(),
|
||||||
|
};
|
||||||
_militaryInstallationCache.set(key, entry);
|
_militaryInstallationCache.set(key, entry);
|
||||||
trimMilitaryInstallationCache();
|
trimMilitaryInstallationCache();
|
||||||
writeMilitaryInstallationDisk(key, entry);
|
writeMilitaryInstallationDisk(key, entry);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MILITARY_INSTALLATION_DISK_DIR,
|
MILITARY_INSTALLATION_DISK_DIR,
|
||||||
MILITARY_INSTALLATION_MAX_CACHE,
|
MILITARY_INSTALLATION_MAX_CACHE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
import { cachedByConfiguredUpstream } from '../overpass/constants.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
@@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk(
|
|||||||
const entry = JSON.parse(
|
const entry = JSON.parse(
|
||||||
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
|
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
|
||||||
);
|
);
|
||||||
|
// Same reason as the Overpass disk cache: the key is the bbox alone, so an
|
||||||
|
// entry written against a different OVERPASS_UPSTREAMS is otherwise a hit.
|
||||||
|
if (!cachedByConfiguredUpstream(entry)) return null;
|
||||||
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
|
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
|
||||||
return migrateMilitaryInstallationEntry(entry);
|
return migrateMilitaryInstallationEntry(entry);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
OVERPASS_DISK_DIR,
|
OVERPASS_DISK_DIR,
|
||||||
OVERPASS_CACHE_MS,
|
OVERPASS_CACHE_MS,
|
||||||
OVERPASS_CACHE_MAX_ENTRIES,
|
OVERPASS_CACHE_MAX_ENTRIES,
|
||||||
|
cachedByConfiguredUpstream,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
@@ -47,6 +48,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
|
|||||||
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
|
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
|
||||||
// them on both fresh and stale reads so an upgrade can recover immediately.
|
// them on both fresh and stale reads so an upgrade can recover immediately.
|
||||||
if (!overpassPayloadIsData(payload)) return null;
|
if (!overpassPayloadIsData(payload)) return null;
|
||||||
|
// The key is the query alone, so a file written by a different upstream —
|
||||||
|
// including the public instances that were the default before #742 — is a
|
||||||
|
// hit for the same query. Refuse it: the operator's configured instance
|
||||||
|
// must be what answers, on the stale path too.
|
||||||
|
if (!cachedByConfiguredUpstream(payload)) return null;
|
||||||
if (Date.now() - payload.cachedAt > maxAgeMs) return null;
|
if (Date.now() - payload.cachedAt > maxAgeMs) return null;
|
||||||
return payload;
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Overpass API proxy constants and cache state
|
// Overpass API proxy constants and cache state
|
||||||
@@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) {
|
|||||||
return endpoints.slice(0, 8);
|
return endpoints.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
let upstreamMemo = { raw: null, endpoints: [] };
|
let upstreamMemo = { raw: null, endpoints: [], tag: 'none' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short, stable identifier for the configured upstream set. Cached responses
|
||||||
|
* carry it so an answer produced by a different instance is not served after
|
||||||
|
* the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the
|
||||||
|
* configuration change by 7 days, a month for boundary queries, and forever on
|
||||||
|
* the serve-stale path. It is a hash, never the URLs, and it is only ever
|
||||||
|
* compared on read: it is not sent to clients and does not appear in errors.
|
||||||
|
*/
|
||||||
|
function upstreamTag(endpoints) {
|
||||||
|
if (!endpoints.length) return 'none';
|
||||||
|
return createHash('sha1')
|
||||||
|
.update(endpoints.join('\n'))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve after environment loading. Public Overpass instances are not used by default. */
|
/** Resolve after environment loading. Public Overpass instances are not used by default. */
|
||||||
function resolveOverpassUpstreams() {
|
function resolveOverpassUpstreams() {
|
||||||
const raw = process.env.OVERPASS_UPSTREAMS || '';
|
readUpstreamMemo();
|
||||||
if (upstreamMemo.raw !== raw)
|
|
||||||
upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) };
|
|
||||||
return [...upstreamMemo.endpoints];
|
return [...upstreamMemo.endpoints];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The configured-upstream tag stored alongside, and checked against, cache entries. */
|
||||||
|
function resolveOverpassUpstreamTag() {
|
||||||
|
return readUpstreamMemo().tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reportedForeignCache = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was this cached entry produced by the upstream set configured now?
|
||||||
|
*
|
||||||
|
* With nothing configured there is no instance to contradict, and serving
|
||||||
|
* last-good data at any age is the point of the cache (the proxy's
|
||||||
|
* not-configured path depends on it), so any entry is accepted. Once an
|
||||||
|
* operator does configure an instance, only its own answers are served: an
|
||||||
|
* entry with no tag, or one from a different instance, is refused rather than
|
||||||
|
* matched on the query alone. Shared by both caches fed by these upstreams.
|
||||||
|
*/
|
||||||
|
function cachedByConfiguredUpstream(entry) {
|
||||||
|
const tag = resolveOverpassUpstreamTag();
|
||||||
|
if (tag === 'none' || entry?.upstreams === tag) return true;
|
||||||
|
if (!reportedForeignCache) {
|
||||||
|
reportedForeignCache = true;
|
||||||
|
console.warn(
|
||||||
|
'[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUpstreamMemo() {
|
||||||
|
const raw = process.env.OVERPASS_UPSTREAMS || '';
|
||||||
|
if (upstreamMemo.raw !== raw) {
|
||||||
|
const endpoints = parseOverpassUpstreams(raw);
|
||||||
|
upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) };
|
||||||
|
}
|
||||||
|
return upstreamMemo;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TTL for FRESH cached Overpass responses (ms). Road geometry is static for
|
* TTL for FRESH cached Overpass responses (ms). Road geometry is static for
|
||||||
* months — the original 45 s TTL forced a public-mirror round-trip on nearly
|
* months — the original 45 s TTL forced a public-mirror round-trip on nearly
|
||||||
@@ -153,6 +206,8 @@ export {
|
|||||||
OVERPASS_MAX_RESPONSE_BYTES,
|
OVERPASS_MAX_RESPONSE_BYTES,
|
||||||
parseOverpassUpstreams,
|
parseOverpassUpstreams,
|
||||||
resolveOverpassUpstreams,
|
resolveOverpassUpstreams,
|
||||||
|
resolveOverpassUpstreamTag,
|
||||||
|
cachedByConfiguredUpstream,
|
||||||
OVERPASS_USER_AGENT,
|
OVERPASS_USER_AGENT,
|
||||||
OVERPASS_TIMEOUT_MS,
|
OVERPASS_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
OVERPASS_MAX_RESPONSE_BYTES,
|
OVERPASS_MAX_RESPONSE_BYTES,
|
||||||
resolveOverpassUpstreams,
|
resolveOverpassUpstreams,
|
||||||
|
resolveOverpassUpstreamTag,
|
||||||
OVERPASS_USER_AGENT,
|
OVERPASS_USER_AGENT,
|
||||||
OVERPASS_TIMEOUT_MS,
|
OVERPASS_TIMEOUT_MS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
@@ -187,6 +188,9 @@ async function fetchOverpassPayload(
|
|||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
// Never retain a secret-bearing endpoint in cache or response metadata.
|
// Never retain a secret-bearing endpoint in cache or response metadata.
|
||||||
endpoint: 'configured',
|
endpoint: 'configured',
|
||||||
|
// Which configured instance produced this, as a hash rather than a URL,
|
||||||
|
// so a cached answer is not reused after the operator repoints.
|
||||||
|
upstreams: resolveOverpassUpstreamTag(),
|
||||||
rateLimited: false,
|
rateLimited: false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js';
|
import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js';
|
||||||
|
import { resolveOverpassUpstreamTag } from '../server/providers/overpass/constants.js';
|
||||||
|
|
||||||
const DATA = { status: 200, body: '{"elements":[]}' };
|
const DATA = { status: 200, body: '{"elements":[]}' };
|
||||||
|
|
||||||
@@ -155,7 +156,13 @@ test('coalesced outage callers both receive last-good data, never a cached refus
|
|||||||
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
|
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
|
||||||
const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`);
|
const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`);
|
||||||
await mkdir(directory, { recursive: true });
|
await mkdir(directory, { recursive: true });
|
||||||
const stale = { ...DATA, cachedAt: Date.now() - 40 * 86400000 };
|
// Last-good data was fetched from the instance configured above, so it
|
||||||
|
// carries that instance's tag the way the transport now writes it.
|
||||||
|
const stale = {
|
||||||
|
...DATA,
|
||||||
|
cachedAt: Date.now() - 40 * 86400000,
|
||||||
|
upstreams: resolveOverpassUpstreamTag(),
|
||||||
|
};
|
||||||
await writeFile(file, JSON.stringify(stale));
|
await writeFile(file, JSON.stringify(stale));
|
||||||
const entered = Promise.withResolvers();
|
const entered = Promise.withResolvers();
|
||||||
const release = Promise.withResolvers();
|
const release = Promise.withResolvers();
|
||||||
@@ -188,3 +195,46 @@ test('coalesced outage callers both receive last-good data, never a cached refus
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a disk entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => {
|
||||||
|
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||||
|
t.after(() => {
|
||||||
|
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||||
|
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||||
|
});
|
||||||
|
const key = `overpass-upstream-switch-${randomUUID()}`;
|
||||||
|
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
|
||||||
|
const file = path.join(
|
||||||
|
directory,
|
||||||
|
`${createHash('sha1').update(key).digest('hex')}.json`,
|
||||||
|
);
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
try {
|
||||||
|
// Written while one instance was configured...
|
||||||
|
process.env.OVERPASS_UPSTREAMS = 'https://first.example/api/interpreter';
|
||||||
|
const first = resolveOverpassUpstreamTag();
|
||||||
|
await writeFile(
|
||||||
|
file,
|
||||||
|
JSON.stringify({ ...DATA, cachedAt: Date.now(), upstreams: first }),
|
||||||
|
);
|
||||||
|
assert.ok(await readOverpassDisk(key, 60000), 'its own entry is a hit');
|
||||||
|
|
||||||
|
// ...is not an answer once the operator points at a different one, on the
|
||||||
|
// stale path either: the key is the query alone, so it would otherwise hit.
|
||||||
|
process.env.OVERPASS_UPSTREAMS = 'https://second.example/api/interpreter';
|
||||||
|
assert.notEqual(resolveOverpassUpstreamTag(), first, 'tag follows the env');
|
||||||
|
assert.equal(await readOverpassDisk(key, 60000), null, 'fresh read refused');
|
||||||
|
assert.equal(
|
||||||
|
await readOverpassDisk(key, Infinity),
|
||||||
|
null,
|
||||||
|
'stale read refused',
|
||||||
|
);
|
||||||
|
|
||||||
|
// With nothing configured there is no instance to contradict, and serving
|
||||||
|
// last-good data is what the not-configured path exists for.
|
||||||
|
delete process.env.OVERPASS_UPSTREAMS;
|
||||||
|
assert.ok(await readOverpassDisk(key, Infinity), 'unconfigured still serves');
|
||||||
|
} finally {
|
||||||
|
await unlink(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user