Warm neighbor cells server-side (/cell?neighbors=1) (#51)

cache.js contained a JavaScript clone of backend/grid.py's snapping math
(its own comment said so) to compute the 8 surrounding cells and fire 8
staggered prefetch requests — grid geometry had two homes, one per
language, plus a client-side guess at Nominatim pacing.

The server now owns it: grid.neighbors(cell) steps one cell width from
the center and re-snaps (adjacent rows have different longitude steps;
poles and the antimeridian handled by snap), and /api/v2/cell grew a
neighbors=1 flag that enqueues those cells for a single background
worker. The warm-only guarantee matches prefetch=1 — a cell with no
cached archive is skipped, so no weather-API quota is ever spent — and
reverse_geocode's own lock paces the at-most-one Nominatim call per
never-labeled cell. Re-enqueues are TTL-deduped; the worker starts from
the lifespan hook, so tests and offline importers never spawn it.

The client now sends its one conditional bundle request with
neighbors=1 (a warm spot costs an empty 304) instead of skipping the
bundle and firing 8 extra requests; the grid-math clone and the
now-unused hasFreshCache are deleted.

Tests (114): grid.neighbors mid-latitude/pole/antimeridian, the
neighbors=1 enqueue + TTL dedupe, _warm_cell materializing the
history-derived store rows, and the never-fetch-upstream guarantee.
Verified with the headless-Chromium smoke across all five pages.
This commit is contained in:
Emi Griffith 2026-07-11 13:47:31 -07:00 committed by GitHub
parent b7bad50e96
commit 431457132a

View file

@ -162,12 +162,6 @@ export async function chunkedFetch(urls, { ttl, concurrency = 4, isCurrent = ()
});
}
// Is there already a usable (fresh, same-day) cache entry for this URL? Lets the
// prefetch skip views that are already warm and only reach out for the ones missing.
export async function hasFreshCache(url, ttlMs) {
return isFresh(await cacheGet(url), ttlMs);
}
// Seed the cache for a URL from data that arrived by other means (a bundle
// slice), with the etag its own endpoint would have sent — so the entry is
// later revalidated per-view exactly as if it had been fetched directly.
@ -191,78 +185,41 @@ function viewFetches(q, today) {
// them is instant — with ONE /api/v2/cell bundle request instead of one per view.
// Its slices are the exact per-view payloads (each carrying the etag its endpoint
// would emit), seeded under the per-view URLs the views actually request. The
// bundle call itself is conditional via a remembered etag, so a still-valid spot
// costs an empty 304. Deduped per spot; afterwards the 8 surrounding grid cells
// are warmed server-side (see prefetchNeighbors).
// request is conditional via a remembered etag — a still-valid spot costs an
// empty 304 — and neighbors=1 has the server warm the 8 surrounding grid cells
// itself: it owns the snapping math (grid.py) and the Nominatim pacing, so the
// old client-side clone of both is gone. Deduped per spot per page load.
//
// `ownViews` lists the view names the calling page already loads in the
// foreground (so they're skipped) — the page declares what it owns instead of
// this module knowing every page.
// foreground (so they're skipped when seeding) — the page declares what it owns
// instead of this module knowing every page.
let _prefetched = "";
export function prefetchViews(lat, lon, ownViews) {
const tag = `${lat.toFixed(4)},${lon.toFixed(4)}`;
if (_prefetched === tag) return;
_prefetched = tag;
const q = `lat=${lat}&lon=${lon}`;
const own = new Set(ownViews);
// Yield first so the landing view finishes rendering before we fetch the rest.
setTimeout(async () => {
const fetches = viewFetches(q, localDay());
const own = new Set(ownViews);
let missing = false;
for (const name of Object.keys(fetches)) {
if (own.has(name)) continue;
if (!(await hasFreshCache(...fetches[name]))) { missing = true; break; }
}
if (missing) {
try {
const ek = "tg-cell-etag:" + q;
let prev = null;
try { prev = sessionStorage.getItem(ek); } catch (e) {}
const res = await fetch(`api/v2/cell?${q}`, prev ? { headers: { "If-None-Match": prev } } : {});
if (res.ok && res.status !== 304) {
const bundle = await res.json();
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
for (const [name, slice] of Object.entries(bundle.slices || {})) {
if (!slice || !slice.data) continue;
const url = name === "day"
? `api/v2/day?${q}&date=${bundle.today}` // the day slice targets today
: (fetches[name] || [])[0];
if (url) seedCache(url, slice.data, slice.etag);
}
try {
const ek = "tg-cell-etag:" + q;
let prev = null;
try { prev = sessionStorage.getItem(ek); } catch (e) {}
const res = await fetch(`api/v2/cell?${q}&neighbors=1`,
prev ? { headers: { "If-None-Match": prev } } : {});
if (res.ok && res.status !== 304) {
const bundle = await res.json();
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
const fetches = viewFetches(q, localDay());
for (const [name, slice] of Object.entries(bundle.slices || {})) {
if (own.has(name) || !slice || !slice.data) continue;
const url = name === "day"
? `api/v2/day?${q}&date=${bundle.today}` // the day slice targets today
: (fetches[name] || [])[0];
if (url) seedCache(url, slice.data, slice.etag);
}
} catch (e) {} // prefetch is a bonus; every view still fetches for itself
}
prefetchNeighbors(lat, lon);
}
} catch (e) {} // prefetch is a bonus; every view still fetches for itself
}, 350);
}
// Warm the server for the 8 grid cells around the selected one, so tapping a
// nearby spot lands on already-graded data. prefetch=1 is warm-only: the server
// never spends weather-API quota for it (a cold cell answers 204), and at most
// one reverse-geocode lookup happens per brand-new cell — hence the ≥1.1 s
// stagger, respecting Nominatim's 1-request/second policy. Responses aren't
// stored client-side (cache keys are coordinate-based); the value is server-side
// warmth. Mirrors backend/grid.py's snapping math.
const _warmedCells = new Set();
function prefetchNeighbors(lat, lon) {
const latStep = 1 / 34.5;
const i = Math.floor(lat / latStep);
const centerLat = (i + 0.5) * latStep;
const lonStep = latStep / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.05);
const j = Math.floor(lon / lonStep);
const centerLon = (j + 0.5) * lonStep;
let k = 0;
for (const di of [-1, 0, 1]) {
for (const dj of [-1, 0, 1]) {
if (!di && !dj) continue;
const nLat = centerLat + di * latStep;
if (Math.abs(nLat) > 90) continue; // no rows past the poles
const nLon = ((centerLon + dj * lonStep + 540) % 360) - 180; // wrap across the antimeridian
const cellTag = `${i + di}_${j + dj}`;
if (_warmedCells.has(cellTag)) continue;
_warmedCells.add(cellTag);
const nq = `lat=${nLat.toFixed(5)}&lon=${nLon.toFixed(5)}`;
setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++);
}
}
}