mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(street-level): bound the tile proxy's disk cache and keep tiles' age
From the IC8 review of #768 (P2s), each with a test that fails before: - A tile read from disk keeps its real age in memory, and the trim rewrite keeps the file's mtime, so nothing is served past the 24 h TTL. - The disk cache is swept in the background at most every 15 min: expired tiles go, then the oldest past 1 GiB. Coverage zooms the app never requests (z6-10) are refused with a 400, and layer names are matched as own keys only. - The tests run against a temporary cache directory (a test seam) instead of writing into and deleting from the developer's real .gev-cache. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
539d514adb
commit
0a3dc4c4ae
4 files changed
+311
-54
No files matched your search
@@ -1,20 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import test, { after } from 'node:test';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { mapillaryProxy } from 'gods-eye-view/server/providers/mapillary';
|
||||
import {
|
||||
listTileLayers,
|
||||
stripTileLayers,
|
||||
} from '../../server/providers/mapillary/trim.js';
|
||||
import * as tiles from '../../server/providers/mapillary/tiles.js';
|
||||
import {
|
||||
fetchTile,
|
||||
normalizeTileAddress,
|
||||
TileRequestError,
|
||||
_resetTileMemoryForTest,
|
||||
_setTileCacheDirForTest,
|
||||
} from '../../server/providers/mapillary/tiles.js';
|
||||
|
||||
// Every tile these tests cache lands in a throwaway directory, never in the
|
||||
// developer's .gev-cache.
|
||||
const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-'));
|
||||
_setTileCacheDirForTest(cacheDir);
|
||||
// The directory is not switched back: a background write still landing must
|
||||
// never sweep the real cache.
|
||||
after(() => fsp.rm(cacheDir, { recursive: true, force: true }));
|
||||
const tileFile = ({ z, x, y }, root = cacheDir) =>
|
||||
path.join(root, 'coverage', String(z), `${x}-${y}.pbf`);
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** Write a cache file dated `ageMs` ago. */
|
||||
async function writeAged(file, bytes, ageMs) {
|
||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||
await fsp.writeFile(file, bytes);
|
||||
const at = new Date(Date.now() - ageMs);
|
||||
await fsp.utimes(file, at, at);
|
||||
}
|
||||
|
||||
/** Poll until `check()` holds (background cache writes are not awaited). */
|
||||
async function waitFor(check, what) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (await check()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
const exists = (file) =>
|
||||
fsp.stat(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
/** Mount the plugin and return a caller keyed by route. */
|
||||
function install(plugin, mode = 'configureServer') {
|
||||
const routes = new Map();
|
||||
@@ -119,8 +156,8 @@ test('tile route validates the path and refuses to proxy without a token', async
|
||||
|
||||
/**
|
||||
* Drive the tile route against a scripted upstream: `answer(call)` returns the
|
||||
* Response for each upstream fetch. Coordinates use z9 (never requested by
|
||||
* the app) and refusals are never cached, so no disk tile is written.
|
||||
* Response for each upstream fetch. Refusals are never cached, so no disk tile
|
||||
* is written.
|
||||
*/
|
||||
async function withUpstream(answer, run) {
|
||||
const savedFetch = globalThis.fetch;
|
||||
@@ -147,7 +184,7 @@ for (const status of [401, 403])
|
||||
await withUpstream(
|
||||
() => new Response('{}', { status }),
|
||||
async ({ calls, call }) => {
|
||||
for (const tile of ['/coverage/9/5/5', '/coverage/9/6/6']) {
|
||||
for (const tile of ['/coverage/14/5/5', '/coverage/14/6/6']) {
|
||||
const res = await call('/api/mapillary/tiles', tile);
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.deepEqual(json(res), {
|
||||
@@ -159,7 +196,7 @@ for (const status of [401, 403])
|
||||
assert.equal(calls.length, 1, 'the second miss is answered locally');
|
||||
// A new token is asked at once.
|
||||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|new|token';
|
||||
await call('/api/mapillary/tiles', '/coverage/9/7/7');
|
||||
await call('/api/mapillary/tiles', '/coverage/14/7/7');
|
||||
assert.equal(calls.length, 2);
|
||||
},
|
||||
);
|
||||
@@ -169,19 +206,19 @@ test('a 429 passes its Retry-After on and holds misses until it is over (review
|
||||
await withUpstream(
|
||||
() => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }),
|
||||
async ({ calls, call }) => {
|
||||
const first = await call('/api/mapillary/tiles', '/coverage/9/5/5');
|
||||
const first = await call('/api/mapillary/tiles', '/coverage/14/5/5');
|
||||
assert.equal(first.statusCode, 429);
|
||||
assert.equal(first.headers['retry-after'], '30');
|
||||
assert.deepEqual(json(first), {
|
||||
error: 'Mapillary is rate-limiting tile requests',
|
||||
retryAfter: 30,
|
||||
});
|
||||
const held = await call('/api/mapillary/tiles', '/coverage/9/6/6');
|
||||
const held = await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||
assert.equal(held.statusCode, 429);
|
||||
assert.equal(calls.length, 1, 'held: Mapillary is not asked again');
|
||||
const now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now + 31_000);
|
||||
await call('/api/mapillary/tiles', '/coverage/9/6/6');
|
||||
await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||
assert.equal(calls.length, 2, 'asked again once the wait is over');
|
||||
},
|
||||
);
|
||||
@@ -213,6 +250,40 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', (
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeTileAddress refuses prototype keys as layer names', () => {
|
||||
for (const layer of ['constructor', '__proto__', 'toString'])
|
||||
assert.throws(
|
||||
() => normalizeTileAddress({ layer, z: 1, x: 0, y: 0 }),
|
||||
TileRequestError,
|
||||
layer,
|
||||
);
|
||||
});
|
||||
|
||||
test('coverage zooms the app never requests (z6–10) are refused with a 400 (review P2)', async () => {
|
||||
for (const z of [6, 7, 8, 9, 10])
|
||||
assert.throws(
|
||||
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
|
||||
TileRequestError,
|
||||
`z${z}`,
|
||||
);
|
||||
for (const z of [0, 5, 11, 14])
|
||||
assert.equal(
|
||||
normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z,
|
||||
z,
|
||||
);
|
||||
await withUpstream(
|
||||
() => new Response(null, { status: 204 }),
|
||||
async ({ calls, call }) => {
|
||||
const res = await call('/api/mapillary/tiles', '/coverage/9/5/5');
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.deepEqual(json(res), {
|
||||
error: 'Tile zoom for coverage must be 0–5 or 11–14',
|
||||
});
|
||||
assert.equal(calls.length, 0, 'Mapillary is not asked');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchTile serves from memory after one upstream fetch and strips the image layer', async () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
@@ -239,7 +310,7 @@ test('fetchTile serves from memory after one upstream fetch and strips the image
|
||||
upstreamCalls++;
|
||||
assert.match(
|
||||
String(url),
|
||||
/tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/10\/0\/0\?access_token=/,
|
||||
/tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/14\/0\/0\?access_token=/,
|
||||
);
|
||||
return new Response(tile, {
|
||||
status: 200,
|
||||
@@ -247,21 +318,13 @@ test('fetchTile serves from memory after one upstream fetch and strips the image
|
||||
});
|
||||
};
|
||||
try {
|
||||
// z10 is never requested by the app (overview z0–5, sequences z11–14), and
|
||||
// the file is removed first so an earlier test run cannot leave a disk hit.
|
||||
await fsp
|
||||
.rm(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache/mapillary/tiles/coverage/10/0-0.pbf',
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
const first = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||||
// The file is removed first so an earlier test cannot leave a disk hit.
|
||||
await fsp.rm(tileFile({ z: 14, x: 0, y: 0 }), { force: true });
|
||||
const first = await fetchTile({ layer: 'coverage', z: 14, x: 0, y: 0 });
|
||||
assert.equal(first.source, 'upstream');
|
||||
assert.deepEqual(listTileLayers(first.bytes), ['sequence']);
|
||||
assert.ok(first.bytes.length < 100, 'image layer stripped in transit');
|
||||
const second = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||||
const second = await fetchTile({ layer: 'coverage', z: 14, x: 0, y: 0 });
|
||||
assert.equal(second.source, 'memory');
|
||||
assert.equal(upstreamCalls, 1);
|
||||
} finally {
|
||||
@@ -273,7 +336,7 @@ test('fetchTile serves from memory after one upstream fetch and strips the image
|
||||
});
|
||||
|
||||
test('a tile held in memory past the 24 h TTL is fetched again', async (t) => {
|
||||
const tile = { layer: 'coverage', z: 9, x: 3, y: 3 };
|
||||
const tile = { layer: 'coverage', z: 14, x: 3, y: 3 };
|
||||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||||
const first = fetchTile(tile);
|
||||
await settle();
|
||||
@@ -290,25 +353,116 @@ test('a tile held in memory past the 24 h TTL is fetched again', async (t) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a disk tile keeps its age in memory and through a retrim rewrite (review P2)', async (t) => {
|
||||
const address = { layer: 'coverage', z: 14, x: 8, y: 8 };
|
||||
const file = tileFile(address);
|
||||
const untrimmed = tile([
|
||||
{ name: 'sequence' },
|
||||
{ name: 'image', payload: Buffer.alloc(4000, 1) },
|
||||
]);
|
||||
await writeAged(file, untrimmed, 23 * HOUR);
|
||||
const writtenAt = (await fsp.stat(file)).mtimeMs;
|
||||
await withUpstream(
|
||||
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
async ({ calls }) => {
|
||||
const first = await fetchTile(address);
|
||||
assert.equal(first.source, 'disk');
|
||||
assert.deepEqual(listTileLayers(first.bytes), ['sequence']);
|
||||
// The untrimmed file is replaced by the trimmed tile, still dated when
|
||||
// Mapillary served it.
|
||||
await waitFor(
|
||||
async () => (await fsp.stat(file)).size === first.bytes.length,
|
||||
'the trimmed rewrite',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs((await fsp.stat(file)).mtimeMs - writtenAt) < 1000,
|
||||
'the rewrite keeps the original mtime',
|
||||
);
|
||||
const now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now + 2 * HOUR);
|
||||
const later = await fetchTile(address);
|
||||
assert.equal(later.source, 'upstream', '25 h old: no cache serves it');
|
||||
assert.equal(calls.length, 1);
|
||||
// Let the refetched tile land before the next test moves the cache.
|
||||
await waitFor(
|
||||
async () => (await fsp.stat(file)).mtimeMs > writtenAt + HOUR,
|
||||
'the refetched tile write',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Point the disk cache at a fresh directory for one test. */
|
||||
async function withCacheDir(run) {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-sweep-'));
|
||||
tiles._setTileCacheDirForTest(dir);
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
tiles._setTileCacheDirForTest(cacheDir);
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const expired = tileFile({ z: 14, x: 1, y: 1 }, dir);
|
||||
const fresh = tileFile({ z: 14, x: 2, y: 2 }, dir);
|
||||
await writeAged(expired, Buffer.alloc(100, 1), 25 * HOUR);
|
||||
await writeAged(fresh, Buffer.alloc(100, 2), 23 * HOUR);
|
||||
const result = await tiles.sweepTileDisk();
|
||||
assert.equal(result.removed, 1);
|
||||
assert.equal(await exists(expired), false);
|
||||
assert.equal(await exists(fresh), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('the disk sweep evicts the oldest tiles past the size cap (review P2)', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const files = [3, 2, 1].map((age) => {
|
||||
const file = tileFile({ z: 13, x: age, y: age }, dir);
|
||||
return { file, age };
|
||||
});
|
||||
for (const { file, age } of files)
|
||||
await writeAged(file, Buffer.alloc(100, age), age * HOUR);
|
||||
const result = await tiles.sweepTileDisk({ maxBytes: 250 });
|
||||
assert.deepEqual(result, { removed: 1, bytes: 200 });
|
||||
assert.equal(await exists(files[0].file), false, 'the oldest goes first');
|
||||
assert.equal(await exists(files[1].file), true);
|
||||
assert.equal(await exists(files[2].file), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('caching a tile sweeps expired files off the disk in the background (review P2)', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const expired = tileFile({ z: 12, x: 1, y: 1 }, dir);
|
||||
const fresh = tileFile({ z: 12, x: 2, y: 2 }, dir);
|
||||
await writeAged(expired, Buffer.alloc(100, 1), 30 * HOUR);
|
||||
await writeAged(fresh, Buffer.alloc(100, 2), HOUR);
|
||||
await withUpstream(
|
||||
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
async () => {
|
||||
const address = { layer: 'coverage', z: 14, x: 4, y: 4 };
|
||||
assert.equal((await fetchTile(address)).source, 'upstream');
|
||||
await waitFor(() => exists(tileFile(address, dir)), 'the tile write');
|
||||
await waitFor(async () => !(await exists(expired)), 'the sweep');
|
||||
assert.equal(await exists(fresh), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A deferred upstream for in-flight tests: every fetch waits until released,
|
||||
* and records whether its signal was aborted. Coordinates use z9, which the
|
||||
* app never requests, and their disk files are removed first.
|
||||
* and records whether its signal was aborted. Their disk files are removed
|
||||
* first.
|
||||
*/
|
||||
async function withDeferredUpstream(tiles, run) {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token';
|
||||
_resetTileMemoryForTest();
|
||||
for (const { z, x, y } of tiles)
|
||||
await fsp
|
||||
.rm(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
`.gev-cache/mapillary/tiles/coverage/${z}/${x}-${y}.pbf`,
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
for (const tile of tiles) await fsp.rm(tileFile(tile), { force: true });
|
||||
const body = (() => {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
@@ -354,7 +508,7 @@ async function withDeferredUpstream(tiles, run) {
|
||||
}
|
||||
|
||||
test('a joined tile request survives the first caller abandoning it', async () => {
|
||||
const tile = { layer: 'coverage', z: 9, x: 1, y: 1 };
|
||||
const tile = { layer: 'coverage', z: 14, x: 1, y: 1 };
|
||||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||||
const first = new AbortController();
|
||||
const second = new AbortController();
|
||||
@@ -375,7 +529,7 @@ test('a joined tile request survives the first caller abandoning it', async () =
|
||||
});
|
||||
|
||||
test('a joined caller that aborts leaves at once and the first still gets the tile', async () => {
|
||||
const tile = { layer: 'coverage', z: 9, x: 2, y: 2 };
|
||||
const tile = { layer: 'coverage', z: 14, x: 2, y: 2 };
|
||||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||||
const first = new AbortController();
|
||||
const second = new AbortController();
|
||||
@@ -392,7 +546,7 @@ test('a joined caller that aborts leaves at once and the first still gets the ti
|
||||
});
|
||||
|
||||
test('the upstream tile fetch is cancelled once every waiter has left', async () => {
|
||||
const tile = { layer: 'coverage', z: 9, x: 3, y: 3 };
|
||||
const tile = { layer: 'coverage', z: 14, x: 3, y: 3 };
|
||||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||||
const first = new AbortController();
|
||||
const second = new AbortController();
|
||||
|
||||
Reference in new issue
Block a user