fix(outlines): harden shared cache and provider lifecycle

This commit is contained in:
manjunath22466 committed 2026-10-02 22:32:46 +05:30
1 parent 9559e7a282
commit 177b5951a0
27 files changed
+3363 -248

No files matched your search

+174 -5
View File
@@ -1,6 +1,9 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
import { tileToBBox } from '../data/tomtomTiles.js';
@@ -13,6 +16,65 @@ const ROAD_TYPES = Object.freeze({
minor: 'residential',
});
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
});
const TILE_PROJECTIONS = new Map();
const MAX_PROJECTED_NUMBERS = 500_000;
/** Conservative, deterministic retained-size estimate with a hard geometry cap. */
export function estimateOpenFreeMapProjectionBytes(value) {
const seen = new Set();
const stack = [value];
let bytes = 0;
let numbers = 0;
while (stack.length) {
const item = stack.pop();
if (item == null) continue;
if (typeof item === 'number') {
numbers += 1;
if (numbers > MAX_PROJECTED_NUMBERS)
throw new Error('Vector tile projection coordinate limit exceeded');
bytes += 8;
} else if (typeof item === 'string') {
bytes += 16 + item.length * 2;
} else if (typeof item === 'boolean') {
bytes += 4;
} else if (typeof item === 'object') {
if (seen.has(item)) continue;
seen.add(item);
if (Array.isArray(item)) {
bytes += 24 + item.length * 8;
for (let i = 0; i < item.length; i += 1) stack.push(item[i]);
} else {
const entries = Object.entries(item);
bytes += 48 + entries.length * 16;
for (const [key, child] of entries) {
bytes += key.length * 2;
stack.push(child);
}
}
}
}
return bytes;
}
/** Register one consumer view over the parsed OpenFreeMap tile. */
export function registerOpenFreeMapProjection(name, project) {
if (!name || typeof project !== 'function')
throw new TypeError('Invalid OpenFreeMap tile projection');
const current = TILE_PROJECTIONS.get(name);
if (current && current !== project)
throw new Error(`OpenFreeMap tile projection already registered: ${name}`);
TILE_PROJECTIONS.set(name, project);
}
/** Explicit test/runtime reset; individual consumers never evict each other. */
export function clearSharedOpenFreeMapTiles() {
SHARED_DECODED_TILES.clear();
}
/**
* Surface traffic uses public motor-road classes only. Untyped service ways
* include car parks and grounds access, so none are used as through traffic.
@@ -142,8 +204,8 @@ export function decodeOpenFreeMapMilitaryTile(bytes, z, x, y) {
return decodeLayers(bytes, z, x, y, ['landuse']);
}
function decodeLayers(bytes, z, x, y, layerNames) {
const tile = new VectorTile(new PbfReader(bytes));
function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
const roads = [],
military = [];
const box = tileToBBox(z, x, y);
@@ -214,14 +276,121 @@ function decodeLayers(bytes, z, x, y, layerNames) {
return { roads, military };
}
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
const base = decodeLayers(
bytes,
z,
x,
y,
['transportation', 'landuse'],
tile,
);
const projections = new Map();
let baseBytes = 0;
let projectionBytes = 0;
let resizeCacheEntry = null;
let cacheBound = false;
const idleCallbacks = new Map();
const cancelIdleProjection = (name) => {
if (!idleCallbacks.has(name)) return;
const handle = idleCallbacks.get(name);
idleCallbacks.delete(name);
globalThis.cancelIdleCallback?.(handle);
};
const cancelIdleProjections = () => {
for (const name of [...idleCallbacks.keys()]) cancelIdleProjection(name);
};
const project = (name) => {
if (projections.has(name)) return projections.get(name);
cancelIdleProjection(name);
const projector = TILE_PROJECTIONS.get(name);
if (!projector)
throw new Error(`Unknown OpenFreeMap tile projection: ${name}`);
const value = projector(bytes, z, x, y, tile);
const retainedBytes = estimateOpenFreeMapProjectionBytes(value);
projections.set(name, value);
projectionBytes += retainedBytes;
resizeCacheEntry?.(baseBytes + projectionBytes);
return value;
};
const scheduleIdleProjections = () => {
// Scheduling is optional unless both lifecycle operations are available;
// otherwise eviction could not release the callback's decoded-tile closure.
if (
!cacheBound ||
typeof globalThis.requestIdleCallback !== 'function' ||
typeof globalThis.cancelIdleCallback !== 'function'
)
return;
for (const name of TILE_PROJECTIONS.keys())
if (!projections.has(name) && !idleCallbacks.has(name)) {
const handle = globalThis.requestIdleCallback(
() => {
if (!cacheBound || idleCallbacks.get(name) !== handle) return;
idleCallbacks.delete(name);
try {
project(name);
} catch {
// The explicit consumer path retries and reports projection errors.
}
},
{ timeout: 1000 },
);
idleCallbacks.set(name, handle);
}
};
return {
...base,
project,
bindSharedCacheEntry(resize, initialBytes) {
cancelIdleProjections();
resizeCacheEntry = resize;
baseBytes = initialBytes;
cacheBound = true;
resizeCacheEntry(baseBytes + projectionBytes);
scheduleIdleProjections();
},
releaseSharedCacheEntry() {
cacheBound = false;
cancelIdleProjections();
projections.clear();
baseBytes = 0;
projectionBytes = 0;
resizeCacheEntry = null;
},
};
}
/** Construct an immutable-version road/military tile source without starting I/O. */
export function createOpenFreeMapSource(options = {}) {
const {
projection = null,
decode,
sharedDecodedCache,
selectDecoded,
...sourceOptions
} = options;
const shared = !decode && !selectDecoded;
return createVectorTileSource({
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
allowedOrigin: 'https://tiles.openfreemap.org',
decode: decodeOpenFreeMapTile,
decode: shared
? decodeSharedOpenFreeMapTile
: decode || decodeOpenFreeMapTile,
...(shared
? {
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
selectDecoded: projection
? (value) => value.project(projection)
: ({ roads, military }) => ({ roads, military }),
}
: {
...(sharedDecodedCache ? { sharedDecodedCache } : {}),
...(selectDecoded ? { selectDecoded } : {}),
}),
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
...options,
...sourceOptions,
});
}
+27
View File
@@ -7,11 +7,38 @@ import {
polygonCentroid,
clipTileLine,
createOpenFreeMapSource,
estimateOpenFreeMapProjectionBytes,
} from './openFreeMap.js';
import { createVectorTileSource } from './vectorTiles.js';
const fixture = (name) =>
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
test('projected tile geometry has deterministic accounting and a coordinate cap', () => {
const small = {
streets: [
{
name: 'A',
coordinates: [
[0, 1],
[2, 3],
],
},
],
};
assert.equal(
estimateOpenFreeMapProjectionBytes(small),
estimateOpenFreeMapProjectionBytes(structuredClone(small)),
);
assert.ok(estimateOpenFreeMapProjectionBytes(small) > 4 * 8);
assert.throws(
() =>
estimateOpenFreeMapProjectionBytes({
coordinates: Array.from({ length: 250_001 }, () => [0, 0]),
}),
/coordinate limit/,
);
});
test('real z14 and z12 Austin tiles decode only drivable roads', () => {
for (const [z, x, y, name] of [
[14, 3743, 6745, 'ofm-austin-14-3743-6745.pbf'],
+42 -6
View File
@@ -1,18 +1,24 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { clipTileLine, createOpenFreeMapSource } from './openFreeMap.js';
import {
clipTileLine,
createOpenFreeMapSource,
registerOpenFreeMapProjection,
} from './openFreeMap.js';
import { tileToBBox } from '../data/tomtomTiles.js';
import {
approximateDistanceM,
ringAreaM2,
closeRing,
} from './featureGeometry.js';
import { phaseTiming } from './phaseTiming.js';
/**
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
* traffic layer reads. No new tile provider: the source below points at the
* same immutable tile URLs, so tiles traffic already loaded come from the
* browser cache. Nothing here is fetched without an explicit ask.
* same immutable tile URLs and bounded decoded owner. Traffic prepares the
* outline projection in idle time; nothing here starts provider I/O without
* an explicit traffic or outline ask.
*/
/** Tile zoom used for outlines (the highest OpenMapTiles zoom with buildings). */
@@ -54,10 +60,17 @@ const GROUNDS_AREA_CLASSES = Object.freeze({
});
/** Lines of one tile's `transportation_name` layer and polygons of `building`. */
export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
export function decodeOpenFreeMapOutlineTile(
bytes,
z,
x,
y,
parsedTile = null,
) {
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
const box = tileToBBox(z, x, y);
const streets = [];
const streetsStart = performance.now();
const names = tile.layers.transportation_name;
if (names) {
if (names.length > LAYER_FEATURE_LIMIT)
@@ -85,6 +98,17 @@ export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
});
}
}
phaseTiming(
'street-extract',
streetsStart,
{
tile: `${z}/${x}/${y}`,
features: names?.length || 0,
streets: streets.length,
},
'outlines',
);
const polygonsStart = performance.now();
const buildings = decodePolygons(
tile.layers.building,
z,
@@ -115,9 +139,21 @@ export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
),
);
}
phaseTiming(
'polygon-extract',
polygonsStart,
{
tile: `${z}/${x}/${y}`,
buildings: buildings.length,
areas: areas.length,
},
'outlines',
);
return { streets, buildings, areas };
}
registerOpenFreeMapProjection('outlines', decodeOpenFreeMapOutlineTile);
/** Tile pixel -> longitude/latitude, exactly as vector-tile's toGeoJSON projects. */
function pixelToLonLat(px, py, extent, z, x, y) {
const size = extent * 2 ** z;
@@ -192,7 +228,7 @@ function decodePolygons(layer, z, x, y, keep, describe) {
/** Construct the outline tile source: same OpenFreeMap tiles, outline decode. */
export function createOpenFreeMapOutlineSource(options = {}) {
return createOpenFreeMapSource({
decode: decodeOpenFreeMapOutlineTile,
projection: 'outlines',
maxEntries: 32,
maxCacheBytes: 16 * 1024 * 1024,
...options,
+198 -10
View File
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildingFromTiles,
createOpenFreeMapOutlineSource,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
mergeLineSegments,
@@ -12,6 +13,12 @@ import {
streetFromTiles,
streetNameMatches,
} from './openFreeMapOutlines.js';
import {
clearSharedOpenFreeMapTiles,
createOpenFreeMapSource,
registerOpenFreeMapProjection,
} from './openFreeMap.js';
import { createSharedDecodedTileCache } from './vectorTiles.js';
import { approximateDistanceM } from './featureGeometry.js';
const fixture = (y) =>
@@ -43,7 +50,10 @@ test('tiles decode named street lines and building polygons', () => {
test('street names compare with abbreviations and a leading direction only', () => {
assert.equal(normalizeStreetName('Congress Ave.'), 'congress avenue');
assert.equal(normalizeStreetName('Lombard St, San Francisco'), 'lombard street');
assert.equal(
normalizeStreetName('Lombard St, San Francisco'),
'lombard street',
);
assert.ok(streetNameMatches('Congress Avenue', 'congress ave'));
assert.ok(streetNameMatches('North Congress Avenue', 'Congress Avenue'));
assert.ok(!streetNameMatches('Congress Avenue', 'South Congress Avenue'));
@@ -52,21 +62,33 @@ test('street names compare with abbreviations and a leading direction only', ()
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
const street = streetFromTiles(TILES, { name: 'Congress Avenue', ...CAPITOL });
const street = streetFromTiles(TILES, {
name: 'Congress Avenue',
...CAPITOL,
});
assert.equal(street.name, 'Congress Avenue');
assert.equal(street.lines.length, 1, 'the tile pieces join into one line');
const line = street.lines[0];
const [start, end] = [line[0], line.at(-1)];
const spanM = approximateDistanceM(start[1], start[0], end[1], end[0]);
assert.ok(spanM > 1200, `Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`);
assert.ok(
spanM > 1200,
`Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`,
);
// South Congress is a different street and stays out.
const south = streetFromTiles(TILES, { name: 'South Congress Avenue', ...CAPITOL });
const south = streetFromTiles(TILES, {
name: 'South Congress Avenue',
...CAPITOL,
});
assert.ok(south);
assert.ok(south.lines.every((l) => l.every(([, lat]) => lat < 30.265)));
});
test('street lookups are bounded by radius and answer null for unknown names', () => {
assert.equal(streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }), null);
assert.equal(
streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }),
null,
);
assert.equal(
streetFromTiles(TILES, { name: 'Congress Avenue', lat: 30.4, lon: -97.74 }),
null,
@@ -75,9 +97,18 @@ test('street lookups are bounded by radius and answer null for unknown names', (
});
test('merge joins reversed and out-of-order pieces and keeps gaps apart', () => {
const a = [[0, 0], [0.001, 0]];
const b = [[0.002, 0], [0.001, 0]]; // reversed, shares a's end
const c = [[0.01, 0], [0.011, 0]]; // separate
const a = [
[0, 0],
[0.001, 0],
];
const b = [
[0.002, 0],
[0.001, 0],
]; // reversed, shares a's end
const c = [
[0.01, 0],
[0.011, 0],
]; // separate
const chains = mergeLineSegments([b, c, a]);
assert.equal(chains.length, 2);
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
@@ -92,7 +123,13 @@ test('the building containing a point is picked (Texas Capitol)', () => {
});
test('a point beside a building picks the nearest within 40 m, else none', () => {
const box = [[0, 0], [0.0002, 0], [0.0002, 0.0002], [0, 0.0002], [0, 0]];
const box = [
[0, 0],
[0.0002, 0],
[0.0002, 0.0002],
[0, 0.0002],
[0, 0],
];
const tiles = [{ buildings: [{ rings: [box], heightM: null }] }];
const near = buildingFromTiles(tiles, { lat: 0.0001, lon: 0.00045 }); // ~28 m east
assert.ok(near && !near.contains);
@@ -102,7 +139,10 @@ test('a point beside a building picks the nearest within 40 m, else none', () =>
test('the open area enclosing the Capitol is its grounds, not a building', () => {
const grounds = enclosingAreaFromTiles(TILES, CAPITOL);
assert.ok(grounds, 'the park-like cover around the Capitol is found');
assert.ok(grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000, `${grounds.areaM2} m²`);
assert.ok(
grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000,
`${grounds.areaM2} m²`,
);
assert.equal(grounds.class, 'park');
assert.equal(
enclosingAreaFromTiles(TILES, { ...CAPITOL, minAreaM2: 1e6 }),
@@ -110,3 +150,151 @@ test('the open area enclosing the Capitol is its grounds, not a building', () =>
'nothing grounds-sized above the floor',
);
});
test('traffic idle preparation gives outlines the same decoded tile without another request', async () => {
clearSharedOpenFreeMapTiles();
const idle = [];
const previousIdle = globalThis.requestIdleCallback;
const previousCancelIdle = globalThis.cancelIdleCallback;
globalThis.requestIdleCallback = (callback) => {
idle.push(callback);
return idle.length;
};
globalThis.cancelIdleCallback = () => {};
let requests = 0;
const bytes = readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
);
const fetchImpl = async () => {
requests++;
return new Response(bytes);
};
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const box = { south: 30.27, north: 30.28, west: -97.75, east: -97.73 };
try {
const traffic = createOpenFreeMapSource({
template:
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
fetchImpl,
});
const outlines = createOpenFreeMapOutlineSource({
template:
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
fetchImpl,
});
await traffic.fetchBounds(box, { zoom: 14, tiles });
assert.ok(idle.length > 0, 'traffic schedules the registered projection');
for (const callback of idle)
callback({ didTimeout: false, timeRemaining: () => 50 });
const result = await outlines.fetchBounds(box, { zoom: 14, tiles });
assert.ok(result.tiles[0].buildings.length > 0);
assert.equal(requests, 1);
} finally {
globalThis.requestIdleCallback = previousIdle;
globalThis.cancelIdleCallback = previousCancelIdle;
clearSharedOpenFreeMapTiles();
}
});
test('idle projections are retired on shared eviction, clear, and self-eviction', async () => {
const previousIdle = globalThis.requestIdleCallback;
const previousCancelIdle = globalThis.cancelIdleCallback;
const scheduled = new Map();
const cancelled = new Set();
let nextHandle = 1;
globalThis.requestIdleCallback = (callback) => {
const handle = nextHandle++;
scheduled.set(handle, callback);
return handle;
};
globalThis.cancelIdleCallback = (handle) => cancelled.add(handle);
let projectionRuns = 0;
registerOpenFreeMapProjection('idle-lifecycle-test', () => {
projectionRuns++;
return { coordinates: Array.from({ length: 20_000 }, (_, i) => [i, i]) };
});
const firstBytes = readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
import.meta.url,
),
);
const secondBytes = readFileSync(
new URL(
'../data/fixtures/ofm-outlines-austin-14-3743-6746.pbf',
import.meta.url,
),
);
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 1,
maxCacheBytes: 100_000,
});
const source = createOpenFreeMapSource({
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
sharedDecodedCache,
fetchImpl: async (url) =>
new Response(
String(url).includes('/6746.pbf') ? secondBytes : firstBytes,
),
});
const box = { south: 30.24, north: 30.29, west: -97.75, east: -97.73 };
try {
await source.fetchBounds(box, {
zoom: 14,
tiles: [{ z: 14, x: 3743, y: 6745 }],
});
const firstHandles = [...scheduled.keys()];
assert.ok(firstHandles.length >= 2);
await source.fetchBounds(box, {
zoom: 14,
tiles: [{ z: 14, x: 3743, y: 6746 }],
});
assert.ok(firstHandles.every((handle) => cancelled.has(handle)));
for (const handle of firstHandles) scheduled.get(handle)();
assert.equal(projectionRuns, 0, 'late callbacks after eviction are inert');
const secondHandles = [...scheduled.keys()].filter(
(handle) => !firstHandles.includes(handle),
);
sharedDecodedCache.clear();
assert.ok(secondHandles.every((handle) => cancelled.has(handle)));
for (const handle of secondHandles) scheduled.get(handle)();
assert.equal(projectionRuns, 0, 'late callbacks after clear are inert');
const selfEvictingCache = createSharedDecodedTileCache({
maxEntries: 1,
maxCacheBytes: 100_000,
});
const selfEvictingSource = createOpenFreeMapSource({
template: 'https://tiles.openfreemap.org/planet/v/self/{z}/{x}/{y}.pbf',
sharedDecodedCache: selfEvictingCache,
fetchImpl: async () => new Response(firstBytes),
});
const beforeSelfEviction = nextHandle;
await selfEvictingSource.fetchBounds(box, {
zoom: 14,
tiles: [{ z: 14, x: 3743, y: 6745 }],
});
const selfEvictingHandles = [...scheduled.keys()].filter(
(handle) => handle >= beforeSelfEviction,
);
scheduled.get(selfEvictingHandles.at(-1))();
assert.equal(projectionRuns, 1);
assert.deepEqual(selfEvictingCache.stats(), { entries: 0, bytes: 0 });
assert.ok(
selfEvictingHandles.slice(0, -1).every((handle) => cancelled.has(handle)),
);
for (const handle of selfEvictingHandles)
if (!cancelled.has(handle)) scheduled.get(handle)();
assert.equal(projectionRuns, 1, 'self-eviction retires sibling idle work');
} finally {
source.clear();
sharedDecodedCache.clear();
globalThis.requestIdleCallback = previousIdle;
globalThis.cancelIdleCallback = previousCancelIdle;
}
});
+2 -2
View File
@@ -1,7 +1,7 @@
/** Optional development phase measurements; no provider payloads or credentials. */
export function phaseTiming(phase, start, detail = {}) {
export function phaseTiming(phase, start, detail = {}, scope = 'roads') {
if (import.meta.env?.DEV)
performance.measure(`roads:${phase}`, {
performance.measure(`${scope}:${phase}`, {
start,
end: performance.now(),
detail,
+220 -43
View File
@@ -16,6 +16,131 @@ export function validTileBounds(box) {
);
}
/**
* Bounded decoded ownership shared by compatible vector-tile consumers.
* Entries are keyed by the immutable tile template plus XYZ identity; callers
* still keep their own consumer-shaped caches and request lifetimes.
*/
export function createSharedDecodedTileCache({
maxEntries = 192,
maxCacheBytes = 64 * 1024 * 1024,
} = {}) {
const cache = new Map();
const flights = new Map();
let cacheBytes = 0;
let generation = 0;
const release = (entry) => {
if (!entry || entry.released) return;
entry.released = true;
entry.value?.releaseSharedCacheEntry?.();
};
const remove = (key, expected = null) => {
const entry = cache.get(key);
if (!entry || (expected && entry !== expected)) return false;
cache.delete(key);
cacheBytes -= entry.size;
release(entry);
return true;
};
const evict = () => {
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
remove(oldest);
}
};
const resize = (key, value, entry, size) => {
if (!Number.isFinite(size) || size < 0) return;
const previousSize = entry.size;
entry.size = size;
if (cache.get(key) !== entry || entry.value !== value) return;
cacheBytes += size - previousSize;
evict();
};
const store = (key, value, size) => {
const entry = { value, size, released: false };
if (!Number.isFinite(size) || size < 0 || size > maxCacheBytes)
return entry;
remove(key);
cache.set(key, entry);
cacheBytes += size;
try {
value?.bindSharedCacheEntry?.(
(nextSize) => resize(key, value, entry, nextSize),
size,
);
} catch (error) {
remove(key, entry);
throw error;
}
evict();
return entry;
};
const subscribe = (flight, signal, key) => {
signal?.throwIfAborted();
flight.users += 1;
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
flight.promise
.then(resolve, reject)
.finally(() => signal?.removeEventListener('abort', abort));
}).finally(() => {
flight.users -= 1;
if (!flight.users && flights.get(key) === flight) {
flights.delete(key);
flight.controller.abort();
}
});
};
return {
get(key) {
const hit = cache.get(key);
if (!hit) return null;
cache.delete(key);
cache.set(key, hit);
return hit;
},
set(key, value, size) {
return store(key, value, size);
},
load(key, signal, loader) {
const hit = this.get(key);
if (hit) return Promise.resolve(hit);
let flight = flights.get(key);
if (!flight) {
flight = {
controller: new AbortController(),
users: 0,
promise: null,
generation,
};
flights.set(key, flight);
flight.promise = Promise.resolve()
.then(() => loader(flight.controller.signal))
.then(({ value, size }) => {
flight.controller.signal.throwIfAborted();
if (flight.generation !== generation)
throw new DOMException('Shared tile cache cleared', 'AbortError');
return store(key, value, size);
})
.finally(() => {
if (flights.get(key) === flight) flights.delete(key);
});
}
return subscribe(flight, signal, key);
},
clear() {
generation += 1;
for (const flight of flights.values()) flight.controller.abort();
flights.clear();
for (const entry of cache.values()) release(entry);
cache.clear();
cacheBytes = 0;
},
stats: () => ({ entries: cache.size, bytes: cacheBytes }),
};
}
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
export function createVectorTileSource({
tileJsonUrl,
@@ -31,6 +156,8 @@ export function createVectorTileSource({
maxResponseBytes = 4 * 1024 * 1024,
metadataCooldownMs = 5000,
now = Date.now,
sharedDecodedCache = null,
selectDecoded = (value) => value,
}) {
const cache = new Map();
const active = new Set();
@@ -44,13 +171,15 @@ export function createVectorTileSource({
let fetched = 0;
let activeWorkers = 0;
const waiters = [];
const consumerValue = (value) =>
sharedDecodedCache ? structuredClone(value) : value;
async function request(url, parentSignal, read) {
async function request(url, parentSignal, read, trackAsSource = true) {
const controller = new AbortController();
const abort = () => controller.abort(parentSignal?.reason);
parentSignal?.throwIfAborted();
parentSignal?.addEventListener('abort', abort, { once: true });
active.add(controller);
if (trackAsSource) active.add(controller);
const timer = setTimeout(
() =>
controller.abort(
@@ -88,7 +217,7 @@ export function createVectorTileSource({
} finally {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
active.delete(controller);
if (trackAsSource) active.delete(controller);
}
}
@@ -232,7 +361,7 @@ export function createVectorTileSource({
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
flight.promise
.then(resolve, reject)
.then((value) => resolve(consumerValue(value)), reject)
.finally(() => signal?.removeEventListener('abort', abort));
}).finally(() => {
if (--flight.users === 0 && flights.get(key) === flight) {
@@ -249,54 +378,102 @@ export function createVectorTileSource({
if (hit && now() - hit.at < ttlMs) {
cache.delete(key);
cache.set(key, hit);
return hit.value;
return consumerValue(hit.value);
}
if (hit) {
cache.delete(key);
cacheBytes -= hit.size;
}
const shared = sharedDecodedCache?.get(key);
if (shared) {
// A shared decode is authoritative cache state. Give each consumer its
// own value so traffic, outlines, or a future caller cannot mutate the
// cached object (including nested coordinate arrays) for everyone else.
const value = structuredClone(selectDecoded(shared.value, tile));
const size = shared.size;
if (epoch === generation && size <= maxCacheBytes) {
cache.set(key, { at: now(), value, size });
cacheBytes += size;
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).size;
cache.delete(oldest);
}
}
return consumerValue(value);
}
const loadDecoded = async (ownedSignal, sharedOwnership = false) => {
await acquire(ownedSignal);
try {
ownedSignal.throwIfAborted();
fetched++;
const url = meta.template
.replace('{z}', tile.z)
.replace('{x}', tile.x)
.replace('{y}', tile.y);
const fetchStart = performance.now();
const bytes = await request(
url,
ownedSignal,
async (res, requestSignal) => {
const bodyStart = performance.now();
const value = await readResponseBytesCapped(
res,
maxResponseBytes,
requestSignal,
);
phaseTiming('tile-body-read', bodyStart, {
source: allowedOrigin,
key,
bytes: value.byteLength,
});
return value;
},
!sharedOwnership,
);
phaseTiming('tile-fetch', fetchStart, {
source: allowedOrigin,
key,
bytes: bytes.byteLength,
});
const decodeStart = performance.now();
const decoded = decode(bytes, tile.z, tile.x, tile.y);
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
ownedSignal.throwIfAborted();
// Body-based decoded-storage estimate; projections may increase it.
return { value: decoded, size: bytes.byteLength * 4 };
} finally {
release();
}
};
let flight = flights.get(key);
if (!flight) {
flight = { controller: new AbortController(), users: 0 };
const ownedSignal = flight.controller.signal;
flights.set(key, flight);
flight.promise = (async () => {
await acquire(ownedSignal);
try {
ownedSignal.throwIfAborted();
if (epoch !== generation)
throw new DOMException('Source cleared', 'AbortError');
fetched++;
const url = meta.template
.replace('{z}', tile.z)
.replace('{x}', tile.x)
.replace('{y}', tile.y);
const fetchStart = performance.now();
const bytes = await request(url, ownedSignal, (res, requestSignal) =>
readResponseBytesCapped(res, maxResponseBytes, requestSignal),
);
phaseTiming('tile-fetch', fetchStart, {
source: allowedOrigin,
key,
bytes: bytes.byteLength,
});
const decodeStart = performance.now();
const value = decode(bytes, tile.z, tile.x, tile.y);
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
ownedSignal.throwIfAborted();
// Body-based decoded-storage estimate; never stringify geometry on the load path.
const size = bytes.byteLength * 4;
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
cache.set(key, { at: now(), value, size });
cacheBytes += size;
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).size;
cache.delete(oldest);
}
const decodedEntry = sharedDecodedCache
? await sharedDecodedCache.load(key, ownedSignal, (sharedSignal) =>
loadDecoded(sharedSignal, true),
)
: await loadDecoded(ownedSignal);
ownedSignal.throwIfAborted();
const selected = selectDecoded(decodedEntry.value, tile);
const value = sharedDecodedCache ? structuredClone(selected) : selected;
const currentShared = sharedDecodedCache?.get(key);
const size = currentShared?.size ?? decodedEntry.size;
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
cache.set(key, { at: now(), value, size });
cacheBytes += size;
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).size;
cache.delete(oldest);
}
return value;
} finally {
release();
}
return value;
})().finally(() => {
if (flights.get(key) === flight) flights.delete(key);
});
+239 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
const meta = { tiles: ['https://tiles.example/{z}/{x}/{y}.pbf'] };
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
const options = {
@@ -203,3 +206,238 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
'over-budget selection is rejected before fetching',
);
});
test('compatible consumers share bounded decoded ownership without sharing local lifetimes', async () => {
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 2,
maxCacheBytes: 100,
});
let decodes = 0,
requests = 0;
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
},
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const roads = createVectorTileSource({
...common,
selectDecoded: (value) => value.roads,
});
const outlines = createVectorTileSource({
...common,
selectDecoded: (value) => value.outlines,
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
assert.deepEqual((await roads.fetchBounds(box, { zoom: 14, tiles })).tiles, [
['road'],
]);
roads.clear();
assert.deepEqual(
(await outlines.fetchBounds(box, { zoom: 14, tiles })).tiles,
[['outline']],
);
assert.equal(requests, 1);
assert.equal(decodes, 1);
assert.deepEqual(sharedDecodedCache.stats(), { entries: 1, bytes: 12 });
});
test('shared decoded tiles are isolated from consumer mutation', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let decodes = 0,
requests = 0;
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
},
selectDecoded: (value) => value.roads,
fetchImpl: async () => {
requests++;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const first = createVectorTileSource(common);
const second = createVectorTileSource(common);
const third = createVectorTileSource(common);
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const firstResult = await first.fetchBounds(box, { zoom: 14, tiles });
firstResult.tiles[0][0].name = 'first mutation';
firstResult.tiles[0][0].coordinates[0][0] = 0;
const secondResult = await second.fetchBounds(box, { zoom: 14, tiles });
assert.equal(secondResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(secondResult.tiles[0][0].coordinates[0][0], -97);
secondResult.tiles[0][0].name = 'second mutation';
secondResult.tiles[0][0].coordinates[0][0] = 1;
const thirdResult = await third.fetchBounds(box, { zoom: 14, tiles });
assert.equal(thirdResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(thirdResult.tiles[0][0].coordinates[0][0], -97);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('one source isolates in-flight and completed cache results from consumer mutation', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let decodes = 0,
requests = 0,
releaseResponse;
const responseReady = new Promise((resolve) => {
releaseResponse = resolve;
});
let requestStarted;
const started = new Promise((resolve) => {
requestStarted = resolve;
});
const source = createVectorTileSource({
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
},
selectDecoded: (value) => value.roads,
fetchImpl: async () => {
requests++;
requestStarted();
await responseReady;
return new Response(new Uint8Array([1, 2, 3]));
},
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const firstPending = source.fetchBounds(box, { zoom: 14, tiles });
await started;
const concurrentPending = source.fetchBounds(box, { zoom: 14, tiles });
releaseResponse();
const firstResult = await firstPending;
const concurrentResult = await concurrentPending;
firstResult.tiles[0][0].name = 'first mutation';
firstResult.tiles[0][0].coordinates[0][0] = 0;
assert.equal(concurrentResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(concurrentResult.tiles[0][0].coordinates[0][0], -97);
concurrentResult.tiles[0][0].name = 'concurrent mutation';
concurrentResult.tiles[0][0].coordinates[0][0] = 1;
const cachedResult = await source.fetchBounds(box, { zoom: 14, tiles });
assert.equal(cachedResult.tiles[0][0].name, 'Congress Avenue');
assert.equal(cachedResult.tiles[0][0].coordinates[0][0], -97);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('compatible consumers share one in-flight fetch and survive one consumer clearing', async () => {
const sharedDecodedCache = createSharedDecodedTileCache();
let requests = 0,
decodes = 0,
releaseResponse;
const responseReady = new Promise((resolve) => {
releaseResponse = resolve;
});
let requestStarted;
const started = new Promise((resolve) => {
requestStarted = resolve;
});
const common = {
...options,
template: meta.tiles[0],
sharedDecodedCache,
decode: () => {
decodes++;
return { roads: ['road'], outlines: ['outline'] };
},
fetchImpl: async () => {
requests++;
requestStarted();
await responseReady;
return new Response(new Uint8Array([1, 2, 3]));
},
};
const roads = createVectorTileSource({
...common,
selectDecoded: (value) => value.roads,
});
const outlines = createVectorTileSource({
...common,
selectDecoded: (value) => value.outlines,
});
const tiles = [{ z: 14, x: 3743, y: 6745 }];
const roadResult = roads.fetchBounds(box, { zoom: 14, tiles });
const roadRejected = assert.rejects(roadResult, { name: 'AbortError' });
await started;
const outlineResult = outlines.fetchBounds(box, { zoom: 14, tiles });
await new Promise((resolve) => setImmediate(resolve));
roads.clear();
releaseResponse();
await roadRejected;
assert.deepEqual((await outlineResult).tiles, [['outline']]);
assert.equal(requests, 1);
assert.equal(decodes, 1);
});
test('shared ownership recharges materialized projections and evicts over budget', () => {
const shared = createSharedDecodedTileCache({ maxCacheBytes: 100 });
let resize,
releases = 0;
const decoded = {
bindSharedCacheEntry(update) {
resize = update;
},
releaseSharedCacheEntry() {
releases++;
},
};
const entry = shared.set('tile', decoded, 12);
assert.deepEqual(shared.stats(), { entries: 1, bytes: 12 });
resize(80);
assert.deepEqual(shared.stats(), { entries: 1, bytes: 80 });
resize(101);
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(entry.size, 101, 'the returned entry retains its real size');
assert.equal(releases, 1, 'self-eviction releases decoded ownership once');
shared.clear();
assert.equal(releases, 1, 'clearing after eviction does not release twice');
});
test('shared clear releases entries and prevents a pending loader from refilling', async () => {
const shared = createSharedDecodedTileCache();
let releases = 0;
const decoded = {
releaseSharedCacheEntry() {
releases++;
},
};
shared.set('resident', decoded, 12);
shared.clear();
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
assert.equal(releases, 1);
let loaderStarted, resolveLoader;
const started = new Promise((resolve) => {
loaderStarted = resolve;
});
const loaderResult = new Promise((resolve) => {
resolveLoader = resolve;
});
const pending = shared.load('late', null, async () => {
loaderStarted();
return loaderResult;
});
await started;
resolveLoader({ value: {}, size: 16 });
shared.clear();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
});