Quantize Overpass query bbox to align cache keys across clients

Near-identical viewports from different collaborators (slightly
different pan/zoom because Yjs awareness doesn't enforce pixel-exact
alignment) previously produced byte-different Overpass queries and
therefore different server-side cache keys. Each client missed cache
and triggered its own upstream fetch.

buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding
outward (south/west floor, north/east ceil) so the viewport is always
covered, and formats coords with 3 decimals for a stable string.

Trade-offs:
- Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on
  each side. For a zoom-12 viewport (~11 km wide) that's <10% extra
  data; bounded by the existing `[maxsize:1048576]` + `out 100` limits.
- Coordinates in the query are now 3-decimal strings regardless of the
  caller's precision — existing buildQuery test updated to match.

Two new tests cover the cache-alignment property and the outward-
expansion invariant. Hit ratio impact will be visible on the Grafana
"Overpass Cache Hit Ratio" stat added in the prior PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-18 02:18:46 +02:00
parent 97ad15db98
commit 6c1148d5b7
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
2 changed files with 74 additions and 3 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts"; import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts";
import { poiCategories } from "@trails-cool/map-core"; import { poiCategories } from "@trails-cool/map-core";
describe("buildQuery", () => { describe("buildQuery", () => {
@ -9,7 +9,7 @@ describe("buildQuery", () => {
const query = buildQuery(bbox, categories); const query = buildQuery(bbox, categories);
expect(query).toContain("[out:json]"); expect(query).toContain("[out:json]");
expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]"); expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
expect(query).toContain('amenity"="drinking_water"'); expect(query).toContain('amenity"="drinking_water"');
expect(query).toContain("out center qt 100"); expect(query).toContain("out center qt 100");
}); });
@ -22,6 +22,45 @@ describe("buildQuery", () => {
expect(query).toContain("drinking_water"); expect(query).toContain("drinking_water");
expect(query).toContain("camp_site"); expect(query).toContain("camp_site");
}); });
it("produces identical queries for near-identical viewports within one grid cell", () => {
const categories = poiCategories.filter((c) => c.id === "drinking_water");
const a = buildQuery(
{ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
categories,
);
const b = buildQuery(
{ south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 },
categories,
);
expect(a).toBe(b);
});
it("expands the bbox outward so the original viewport is always covered", () => {
const categories = poiCategories.filter((c) => c.id === "drinking_water");
const query = buildQuery(
{ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
categories,
);
expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
});
});
describe("quantizeBbox", () => {
it("snaps south and west down, north and east up", () => {
const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01);
expect(q.south).toBeCloseTo(52.5, 10);
expect(q.west).toBeCloseTo(13.3, 10);
expect(q.north).toBeCloseTo(52.6, 10);
expect(q.east).toBeCloseTo(13.5, 10);
});
it("is idempotent on already-aligned coordinates", () => {
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
const q = quantizeBbox(bbox, 0.01);
expect(q.south).toBeCloseTo(52.5, 10);
expect(q.north).toBeCloseTo(52.6, 10);
});
}); });
describe("parseResponse", () => { describe("parseResponse", () => {

View file

@ -2,6 +2,18 @@ import type { PoiCategory } from "@trails-cool/map-core";
const OVERPASS_PROXY = "/api/overpass"; const OVERPASS_PROXY = "/api/overpass";
// Snap bbox coordinates to this grid before building the query so that two
// users looking at nearly-identical viewports produce byte-identical queries
// and therefore share a server-side cache entry. Expansion is outward
// (south/west floor, north/east ceil) so the user's viewport is always
// covered. ~0.01° ≈ 1 km at mid-latitudes.
const BBOX_GRID_STEP = 0.01;
// Decimals used when formatting the quantized bbox into the query string.
// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP,
// so the string is a stable representation of the quantized cell.
const BBOX_DECIMALS = 3;
export interface Poi { export interface Poi {
id: number; id: number;
lat: number; lat: number;
@ -18,11 +30,31 @@ export interface BBox {
east: number; east: number;
} }
/**
* Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward
* so the original bbox is fully contained. Returns a new BBox whose
* coordinates are cell-aligned.
*/
export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox {
return {
south: Math.floor(bbox.south / step) * step,
west: Math.floor(bbox.west / step) * step,
north: Math.ceil(bbox.north / step) * step,
east: Math.ceil(bbox.east / step) * step,
};
}
/** /**
* Build an Overpass QL query combining all enabled categories into a union. * Build an Overpass QL query combining all enabled categories into a union.
* The bbox is quantized to a fixed grid and formatted with a fixed number of
* decimals so near-identical viewports produce a byte-identical query which
* the `/api/overpass` server-side cache keys on.
*/ */
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string { export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`; const q = quantizeBbox(bbox);
const bboxStr = [q.south, q.west, q.north, q.east]
.map((n) => n.toFixed(BBOX_DECIMALS))
.join(",");
const unions = categories.map((c) => c.query).join(""); const unions = categories.map((c) => c.query).join("");
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`; return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
} }