mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(terrain): bound the client terrain-height cache with LRU eviction
The in-memory cache in the terrain-height resolver had no eviction, so a session retained every coordinate it ever resolved for the lifetime of the page. The 5-decimal rounding documented in the file header makes repeated visits to one place share a key, but it does not bound how many distinct places a session visits. Cap it at 20 000 entries, overridable per instance so the bound is testable without minting twenty thousand coordinates. Eviction is least-recently-used rather than insertion-ordered because revisits here are spatial: the cell the camera is parked on must not be dropped for having been resolved early. The bound sits far above a city-scale session at the ~111 m floor grid, so normal use never evicts and issues no additional proxy requests. Batch results are assembled from what the call resolved, so eviction during a large batch cannot report a just-resolved point as unresolved.
This commit is contained in:
1 parent
2746ec753c
commit
fa9af39e7b
5 files changed
+236
-9
No files matched your search
@@ -1,7 +1,19 @@
|
||||
import { ensureGeoidReady, geoidHeight } from '../data/geoid.js';
|
||||
|
||||
/** Construct an instance-owned terrainHeights service with explicit dependencies. */
|
||||
export function createTerrainHeights({ source, signal }) {
|
||||
/** Default live-entry ceiling for the in-memory cache (see CACHE_MAX_ENTRIES). */
|
||||
export const DEFAULT_MAX_CACHE_ENTRIES = 20_000;
|
||||
|
||||
/**
|
||||
* Construct an instance-owned terrainHeights service with explicit dependencies.
|
||||
* `maxCacheEntries` overrides the cache bound; it exists so the bound is
|
||||
* testable without minting twenty thousand coordinates, mirroring the injectable
|
||||
* `maxBytes` on the GBFS reader.
|
||||
*/
|
||||
export function createTerrainHeights({
|
||||
source,
|
||||
signal,
|
||||
maxCacheEntries = DEFAULT_MAX_CACHE_ENTRIES,
|
||||
}) {
|
||||
if (typeof source?.getHeights !== 'function')
|
||||
throw new TypeError('Terrain heights require a source');
|
||||
signal?.throwIfAborted();
|
||||
@@ -45,15 +57,71 @@ export function createTerrainHeights({ source, signal }) {
|
||||
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
|
||||
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Live entry ceiling for the cache below. The rounding in point 2 of the file
|
||||
* header makes repeated visits to one place share a key; it does nothing to
|
||||
* bound the number of DISTINCT places a session visits, and this cache had no
|
||||
* eviction, so a long session kept every coordinate it ever resolved for the
|
||||
* lifetime of the page.
|
||||
*
|
||||
* This is a bound, not a budget. At the ~111 m floor grid a city-scale
|
||||
* session stays well under it, so eviction is the pathological case rather
|
||||
* than the normal one and a well-behaved session sees no extra network. Sized
|
||||
* above the sibling caches (adsb.lol points 80, track history 200, TomTom
|
||||
* tiles 256) because a miss here costs a proxy round trip, not just RAM.
|
||||
*/
|
||||
const CACHE_MAX_ENTRIES =
|
||||
Number.isFinite(maxCacheEntries) && maxCacheEntries > 0
|
||||
? Math.floor(maxCacheEntries)
|
||||
: DEFAULT_MAX_CACHE_ENTRIES;
|
||||
|
||||
/**
|
||||
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
|
||||
* Module-scoped (not exported) — the only reads are through
|
||||
* `cachedEllipsoidalGround` and the internal lookup in
|
||||
* `resolveEllipsoidalGround`.
|
||||
* `resolveEllipsoidalGround`. Bounded at CACHE_MAX_ENTRIES; write through
|
||||
* `cacheStore` and read a consumer-facing hit through `cacheTouch` so the
|
||||
* eviction order stays least-recently-used.
|
||||
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
|
||||
*/
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Store an entry and drop the coldest ones once past the cap. `Map` iterates
|
||||
* in insertion order, so deleting the first key evicts the least recently
|
||||
* used provided every write and every consumer read re-inserts its key.
|
||||
*
|
||||
* Recency rather than plain insertion order because revisits here are
|
||||
* spatial: a session comes back to cells near where it already was, so the
|
||||
* cell it is parked on must not be evicted merely for having been resolved
|
||||
* early.
|
||||
* @param {string} key
|
||||
* @param {{ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}} entry
|
||||
*/
|
||||
function cacheStore(key, entry) {
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
while (cache.size > CACHE_MAX_ENTRIES) {
|
||||
const coldest = cache.keys().next().value;
|
||||
if (coldest === undefined) break;
|
||||
cache.delete(coldest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an entry and promote it to newest, so a coordinate a consumer keeps
|
||||
* asking about survives eviction. Returns undefined on a miss.
|
||||
* @param {string} key
|
||||
* @returns {{ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}|undefined}
|
||||
*/
|
||||
function cacheTouch(key) {
|
||||
const entry = cache.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the rounded cache key shared between the in-memory cache and the
|
||||
* `points` string sent to the proxy (see file header, point 2).
|
||||
@@ -74,7 +142,7 @@ export function createTerrainHeights({ source, signal }) {
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function cachedEllipsoidalGround(lat, lon) {
|
||||
const entry = cache.get(cacheKey(lat, lon));
|
||||
const entry = cacheTouch(cacheKey(lat, lon));
|
||||
return entry ? entry.ellipsoid : null;
|
||||
}
|
||||
|
||||
@@ -91,7 +159,7 @@ export function createTerrainHeights({ source, signal }) {
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function cachedRealEllipsoidalGround(lat, lon) {
|
||||
const entry = cache.get(cacheKey(lat, lon));
|
||||
const entry = cacheTouch(cacheKey(lat, lon));
|
||||
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
|
||||
}
|
||||
|
||||
@@ -213,6 +281,12 @@ export function createTerrainHeights({ source, signal }) {
|
||||
uncached.push(item);
|
||||
}
|
||||
|
||||
// What this call resolved, so the assembly below can report a point even
|
||||
// if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a
|
||||
// batch big enough to evict its own earlier chunks would report points as
|
||||
// unresolved that it had in fact just resolved.
|
||||
const resolvedThisCall = new Map();
|
||||
|
||||
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
|
||||
// failure is isolated to that chunk's points (geoid fallback), so a single
|
||||
// bad chunk doesn't lose results for the rest of a large batch.
|
||||
@@ -230,7 +304,9 @@ export function createTerrainHeights({ source, signal }) {
|
||||
// one contact frozen at the geoid while its neighbors resolved).
|
||||
// An omitted point now caches nothing and retries on the next warm.
|
||||
if (Number.isFinite(ellipsoid)) {
|
||||
cache.set(item.key, { ellipsoid, source: 'reearth' });
|
||||
const entry = { ellipsoid, source: 'reearth' };
|
||||
resolvedThisCall.set(item.key, entry);
|
||||
cacheStore(item.key, entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -247,11 +323,13 @@ export function createTerrainHeights({ source, signal }) {
|
||||
item.lon,
|
||||
item.sourceOrthometricM,
|
||||
);
|
||||
cache.set(item.key, {
|
||||
const entry = {
|
||||
ellipsoid,
|
||||
source: 'geoid-fallback',
|
||||
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
|
||||
});
|
||||
};
|
||||
resolvedThisCall.set(item.key, entry);
|
||||
cacheStore(item.key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +338,7 @@ export function createTerrainHeights({ source, signal }) {
|
||||
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
|
||||
// retries later): report it unresolved instead of throwing.
|
||||
return work.map((item) => {
|
||||
const entry = cache.get(item.key);
|
||||
const entry = cache.get(item.key) || resolvedThisCall.get(item.key);
|
||||
return entry
|
||||
? { ellipsoid: entry.ellipsoid, source: entry.source }
|
||||
: { ellipsoid: null, source: 'unresolved' };
|
||||
|
||||
Reference in new issue
Block a user