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:
parent
b7bad50e96
commit
431457132a
1 changed files with 25 additions and 68 deletions
|
|
@ -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++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue