From c18af6ad1c6e36fcbaf4886f2afed2989f638246 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 8 Oct 2026 16:20:50 +0400 Subject: [PATCH] fix(street-level): run at most six Mapillary tile fetches at once Tiles a camera move leaves behind keep downloading after the client drops them; the cap bounds their memory and quota, as main's proxy did. Co-Authored-By: Claude Opus 5.5 --- server/providers/mapillary.js | 4 ++-- server/providers/mapillary/tiles.js | 24 +++++++++++++++++++++++- src/tooling/mapillaryProvider.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/server/providers/mapillary.js b/server/providers/mapillary.js index dc78f3611..a11af616c 100644 --- a/server/providers/mapillary.js +++ b/server/providers/mapillary.js @@ -8,8 +8,8 @@ import { } from './mapillary/tiles.js'; /** - * Tile requests per client IP per minute, cache hits included: 24 new 25-tile - * views a minute, more than a person flying the camera reaches. + * Tile requests per client IP per minute, cache hits included: over 60 new + * 9-tile views a minute, more than a person flying the camera reaches. */ export const TILE_ROUTE_MAX_PER_MIN = 600; /** The longest upstream Retry-After passed on, in seconds. */ diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 89d434875..d71e928aa 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -17,6 +17,11 @@ const DROP_LAYERS = ['image']; /** A z14 tile with its image layer is ~11 MB; anything past this is wrong. */ export const TILE_MAX_BYTES = 48 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; +/** + * Upstream fetches at once; more wait their turn. A client drops the tiles a + * camera move leaves behind, but their fetches run on, so this bounds them. + */ +export const TILE_UPSTREAM_CONCURRENCY = 6; /** Tiles change only when new imagery is processed. */ export const TILE_TTL_MS = 24 * 60 * 60 * 1000; export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; @@ -91,6 +96,23 @@ async function fetchUpstream({ z, x, y }) { } } +let upstreamActive = 0; +/** @type {Array<() => void>} fetches waiting for a slot, oldest first */ +const upstreamWaiting = []; + +/** Run `task` once an upstream slot is free. */ +async function withUpstreamSlot(task) { + while (upstreamActive >= TILE_UPSTREAM_CONCURRENCY) + await new Promise((resolve) => upstreamWaiting.push(resolve)); + upstreamActive++; + try { + return await task(); + } finally { + upstreamActive--; + upstreamWaiting.shift()?.(); + } +} + /** @type {Map} insertion-ordered LRU */ const memory = new Map(); let memoryBytes = 0; @@ -137,7 +159,7 @@ export async function fetchTile(address) { inFlight, address.key, async () => { - const bytes = await fetchUpstream(address); + const bytes = await withUpstreamSlot(() => fetchUpstream(address)); remember(address.key, bytes); return bytes; }, diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 949950f1f..6b627d80f 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -12,6 +12,7 @@ import { import { TILE_MAX_BYTES, TILE_MEMORY_BUDGET_BYTES, + TILE_UPSTREAM_CONCURRENCY, TILE_TTL_MS, _resetTileCacheForTest, _tileMemoryForTest, @@ -389,6 +390,26 @@ test('identical concurrent tile requests share one upstream fetch', async () => ); }); +test('at most a few upstream fetches run at once; the rest wait their turn', async () => { + let release; + const gate = new Promise((resolve) => (release = resolve)); + await withUpstream( + async () => (await gate, ok()), + async ({ calls, call }) => { + const tiles = TILE_UPSTREAM_CONCURRENCY + 4; + const pending = Array.from({ length: tiles }, (_, i) => + call('/api/mapillary/tiles', `/coverage/14/${i}/3`), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(calls.length, TILE_UPSTREAM_CONCURRENCY); + release(); + const done = await Promise.all(pending); + assert.ok(done.every((res) => res.statusCode === 200)); + assert.equal(calls.length, tiles, 'the waiting ones ran too'); + }, + ); +}); + test('a tile held in memory past its TTL is fetched again', async (t) => { await withUpstream( () => ok(),