Files
gods-eye-view/server/providers/mapillary/tiles.js
T
Said TurkseverandClaude Opus 5.5 d7d5bec752 fix(mapillary): tile flights own their abort, callers only wait on them
fetchTile coalesced concurrent requests for one tile onto a single
upstream fetch, but that fetch ran on the first caller's signal. When the
first request was abandoned (an older pan), every joined request lost a
tile it still needed; and a joined caller that aborted stayed subscribed
until the shared fetch finished.

A flight now owns its AbortController and counts waiters, the pattern the
weather and wind providers use. Each caller's own abort rejects that caller
at once; the upstream fetch is cancelled only when the last waiter leaves,
and a later request after that starts a fresh fetch.

Regression tests (fail on the previous tiles.js, pass now): a joined
request survives the first caller aborting with one upstream request; a
joined caller that aborts leaves at once while the first still gets the
tile; both waiters leaving cancels the owned fetch.

Reported by kvnloo in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:53:40 +01:00

255 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { stripTileLayers } from './trim.js';
import {
MAPILLARY_TILE_HOST,
TILE_LAYERS,
TILE_DISK_DIR,
TILE_DISK_TTL_MS,
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_FETCH_TIMEOUT_MS,
mapillaryToken,
} from './constants.js';
/** Thrown for a request this proxy refuses before contacting Mapillary. */
export class TileRequestError extends Error {
constructor(message, status = 400) {
super(message);
this.name = 'TileRequestError';
this.status = status;
}
}
/** Thrown when Mapillary answered with an error status. */
export class TileUpstreamError extends Error {
constructor(status, message) {
super(message || `Mapillary tiles HTTP ${status}`);
this.name = 'TileUpstreamError';
this.status = status;
}
}
/**
* Validate and normalize a tile address. Layer names are the proxy's public
* ids (coverage/points/signs); zoom must be inside the layer's published range.
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
*/
export function normalizeTileAddress({ layer, z, x, y }) {
const spec = TILE_LAYERS[layer];
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
const zi = Number(z);
const xi = Number(x);
const yi = Number(y);
if (
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
zi < spec.minZoom ||
zi > spec.maxZoom
)
throw new TileRequestError(
`Tile zoom for ${layer} must be ${spec.minZoom}–${spec.maxZoom}`,
);
const n = 2 ** zi;
if (xi >= n || yi >= n)
throw new TileRequestError('Tile address out of range');
return {
layer,
upstream: spec.upstream,
dropLayers: spec.dropLayers || [],
z: zi,
x: xi,
y: yi,
key: `${layer}/${zi}/${xi}/${yi}`,
};
}
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
const _memory = new Map();
let _memoryBytes = 0;
/**
* Upstream fetches in progress, shared by every request for the same tile.
* A flight owns its controller; callers only count as waiters, and the last
* waiter to leave cancels it (see `joinFlight`).
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
*/
const _inFlight = new Map();
function memoryGet(key) {
const hit = _memory.get(key);
if (!hit) return null;
// Re-insert to mark as most recently used.
_memory.delete(key);
_memory.set(key, hit);
return hit.bytes;
}
function memoryPut(key, bytes) {
if (bytes.length > TILE_MEMORY_BUDGET_BYTES / 2) return;
const existing = _memory.get(key);
if (existing) _memoryBytes -= existing.bytes.length;
_memory.set(key, { bytes, at: Date.now() });
_memoryBytes += bytes.length;
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
const [oldest, entry] = _memory.entries().next().value;
_memory.delete(oldest);
_memoryBytes -= entry.bytes.length;
}
}
function diskPath({ layer, z, x, y }) {
return path.join(TILE_DISK_DIR, layer, String(z), `${x}-${y}.pbf`);
}
async function readDisk(address) {
const file = diskPath(address);
try {
const stat = await fsp.stat(file);
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
return await fsp.readFile(file);
} catch {
return null;
}
}
function writeDisk(address, bytes) {
const file = diskPath(address);
fsp
.mkdir(path.dirname(file), { recursive: true })
.then(() => fsp.writeFile(`${file}.tmp`, bytes))
.then(() => fsp.rename(`${file}.tmp`, file))
.catch((error) =>
console.warn(
'[Mapillary Proxy] tile cache write failed:',
error?.message || error,
),
);
}
async function fetchUpstream(address, signal) {
const token = mapillaryToken();
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
headers: { Accept: 'application/x-protobuf' },
});
if (response.status === 404 || response.status === 204) {
await response.body?.cancel().catch(() => {});
return Buffer.alloc(0);
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new TileUpstreamError(response.status);
}
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) {
await response.body?.cancel().catch(() => {});
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > TILE_MAX_BYTES)
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
return bytes;
}
/** Strip the layers this proxy never serves for the address's layer spec. */
function trim(address, bytes) {
if (!address.dropLayers.length || !bytes.length) return bytes;
try {
return stripTileLayers(bytes, address.dropLayers);
} catch (error) {
console.warn(
'[Mapillary Proxy] tile trim failed, serving raw:',
error?.message || error,
);
return bytes;
}
}
/**
* Fetch one tile through memory, disk and in-flight coalescing, then
* Mapillary. Returns the raw protobuf bytes (empty for a tile with no data).
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
*/
export async function fetchTile(request, { signal } = {}) {
const address = normalizeTileAddress(request);
const memory = memoryGet(address.key);
if (memory) return { bytes: memory, source: 'memory', address };
const disk = await readDisk(address);
if (disk) {
// Older cache files may still hold the untrimmed tile: trim and replace.
const bytes = trim(address, disk);
if (bytes !== disk) writeDisk(address, bytes);
memoryPut(address.key, bytes);
return { bytes, source: 'disk', address };
}
let flight = _inFlight.get(address.key);
const joined = Boolean(flight) && !flight.controller.signal.aborted;
if (!joined) flight = startFlight(address);
const bytes = await joinFlight(flight, signal);
return { bytes, source: joined ? 'inflight' : 'upstream', address };
}
/** Start the one upstream fetch for a tile, owned by the flight itself. */
function startFlight(address) {
const controller = new AbortController();
const flight = { controller, waiters: 0, promise: null };
flight.promise = fetchUpstream(address, controller.signal)
.then((raw) => trim(address, raw))
.then((bytes) => {
memoryPut(address.key, bytes);
writeDisk(address, bytes);
return bytes;
})
.finally(() => {
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
});
// Every waiter may have left before it settles; nobody awaits it then.
flight.promise.catch(() => {});
_inFlight.set(address.key, flight);
return flight;
}
/**
* Wait on a shared flight as one caller. The caller's own abort rejects it at
* once without disturbing the others; the upstream fetch is cancelled only
* when the last waiter leaves.
* @param {{controller: AbortController, promise: Promise<Buffer>, waiters: number}} flight
* @param {AbortSignal} [signal]
* @returns {Promise<Buffer>}
*/
function joinFlight(flight, signal) {
signal?.throwIfAborted();
flight.waiters++;
return new Promise((resolve, reject) => {
let done = false;
const leave = () => {
if (done) return false;
done = true;
signal?.removeEventListener('abort', onAbort);
flight.waiters--;
return true;
};
const onAbort = () => {
if (!leave()) return;
if (flight.waiters === 0) flight.controller.abort();
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
flight.promise.then(
(bytes) => leave() && resolve(bytes),
(error) => leave() && reject(error),
);
});
}
/** Test seam: forget every cached tile held in memory. */
export function _resetTileMemoryForTest() {
_memory.clear();
_memoryBytes = 0;
_inFlight.clear();
}