mapsrv: cache headers, ETags, and a data version for tiles #32
No reviewers
Labels
No labels
claude-wip
map-report
map:is
map:se
needs-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
art/ismap!32
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "night/issue-4-cache-headers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #4
Issue #4 — cache headers and data versioning
Branch:
night/issue-4-cache-headers(commit85ba0d4, not pushed, not merged)State: done
What changed
New
mapsrv/cache.goplus small edits totiles.go,assets.go,main.go,sprite.goandcompress.go. One policy, applied per handler kind:public, max-age=60, stale-while-revalidate=604800+ weak ETaghtml/(index, style.json)no-cache, revalidated onhttp.FileServer'sLast-Modified/api/*no-storeFonts/sprites (the "trivial" half): the ETag is a hash of the bytes. They are a
pure function of the mapsrv binary, so content is its own version and a rebuild that
changes one sprite invalidates exactly that sprite. The glyph cache now stores an
asset(content type + body + etag) instead of a bare[]byte, so the hash iscomputed once per range rather than per request.
Tiles — the data versioning scheme. The tile ETag is a fingerprint of the
database, not of the tile, so a 304 is answered from a cached string with no
ST_AsMVTcall and no postgres round trip at all.The importer already produces the signal for free: every layer goes live by an atomic
swap that DROPs the old table and RENAMEs a freshly built one into its place
(
layer.swapSQL/swapSQLShared), so a live table's OID changes on every import,regeneralize or partition swap and at no other moment.
dataVersionSQLmd5s thoseOIDs together with the source text of the
zxy_*functions (which areCREATE OR REPLACEd, so they keep their OID while changing what they return).%_newtables are excluded so the version does not flap for the hours an importspends building them. mapsrv re-reads it at most every 30 s, by whichever request
finds it stale; everyone else keeps serving the version they have rather than
queueing behind a catalog read.
Why the catalog and not a metadata table the importer writes: it works on a
database imported before this code existed (including the current one — no reimport
is pending for this change), it needs no importer change at all, and there is no
second mechanism to keep in sync. Two accepted costs, both documented in CLAUDE.md:
it is one version for the whole database rather than per source (a tile mixes every
layer of its source anyway, and an import touches many layers), and it moves when a
staging table is rebuilt, which changes no served byte — one extra conditional GET
per tile, after an import that was going to invalidate them regardless.
Why a validator and a 60 s window rather than a long max-age: none of these URLs
carries a version.
/tiles/map/11/1096/553means "the current tile", and the onlyhonest way to keep it current without versioning the URL is to let the client ask.
max-age=60exists to keep a single panning session off the network, not to cacheacross imports.
Two smaller things fell out:
cacheable()after the queryreturns), so a 503 from a missing
zxy_*function is never stored under the tile'sURL.
Vary: Accept-Encodingis now set unconditionally by the gzip middleware, notonly when it compresses. It describes what the server looked at, and now that these
responses are cacheable a shared cache must not hand a gzipped tile to a client that
never asked for one. (Pre-existing bug, harmless until now.)
Documented in CLAUDE.md ("Caching, and the data version", under Running the server,
plus the two new files in the key-files list) and measured in
docs/performance.md.Verification
make build,cd mapsrv && go test ./...,go vet ./...— all clean.mapsrv/cache_test.go: weak/strong/list/*If-None-Match comparison; a tile304 served by a server with a nil pool (if the handler reached the database the
test would panic — that is the assertion that a 304 costs no query); data-version
TTL expiry; sprite revalidation and that the two scales do not share an ETag;
static
no-cache+Last-Modified→ 304;/api/*no-storeon both success anderror;
Varypresent with and withoutAccept-Encoding.:8099against the live database (the user's:8080serverwas left running its old binary and untouched). Every route checked with curl:
tile 200 →
ETag: W/"36959ccc…"+ policy; same ETag back →304, empty body;stale ETag → 200. Sprites, glyphs, style.json and
/api/issuesall as designed.psql. A rolled-back
BEGIN; CREATE TABLE …; ROLLBACKprobe confirmed thefingerprint moves when a public table appears and does not move for a
_newtable, and that the database was unchanged afterwards. Independently, the tables
with the highest OIDs in the live database are exactly
map_roads_*_pl, the onesthe last commit on main swapped — which is the mechanism working.
/?map=seloadedtwice: 66 style layers, 7315 rendered features, no console errors either time, and
sprites came from the browser cache on the second load. (Tile and glyph requests
are issued from MapLibre's worker, so they do not appear in the page session's
Network events — those were verified with curl instead.)
Left undone / uncertain
probe and by the OID evidence from the last commit's swap, but nobody has watched
the version actually move across a live
make import-*. Worth a glance at thenext import: the startup log line
mapsrv: data version <hash>should differbefore and after, and
make restartis needed for that log line (the value itselfrefreshes on its own within 30 s).
oversight. If per-source granularity is ever wanted, the honest way is for the
importer to record a version per layer and for mapsrv to learn which layers each
source has — which today it cannot, since
conf/mapsrv.yamllists sources only.max-age=60is a judgement call with no data behind it. It is the knob to turnif this ever goes behind a CDN, and it needs no ETag change.
mapsrv/sprite.gohas a pre-existinggofmtcomplaint (comment alignment in theshieldstable) that predates this branch; left alone to keep the diff honest.md5, is this 30 years ago? Yes, I know, this is not security, md5 is not a problem, except it is a problem because it keeps the algorithm around in the minds of people who don't know better.
It's like teaching bubble sort to students because it's easy to explain and because of that it's the only thing those students will remember and then I get to resolve an on-call cpu starvation issue at 3 in the morning by replacing a hand written bubble sort with std::sort. md5 is like that, but worse because you don't see the problems it causes until it's too late. I've seen a hand-rolled password hashing scheme with md5 as recently as last year, 29 years after we've known that md5 is useless for security and 21 years after we've definitely known.
85ba0d4cdatoe5e4ae2f02Fixed — the fingerprint is SHA-256 now (
e5e4ae2), and the branch is rebased onto main soit merges cleanly again.
You're right, and the reason you're right is the one you gave rather than anything about
this call site: a cache validator is not a security boundary, so md5 worked, but what a
codebase writes down is what gets copied out of it.
md5(...)sitting in the file someoneopens to learn how this project hashes things is exactly how the habit outlives its
justification. The comment now says that, so the next person doesn't "simplify" it back.
sha256()has been built into postgres since 11 (this box is 18.4), so no extension and nopgcrypto. It costs nothing here — the fingerprint is read at most once every 30 s, not per
request. The Go half of
cache.gowas already oncrypto/sha256for the asset ETags, sothe two halves now agree, which they didn't before.
Verified against the live database: the served ETag is
W/"b9096220b046b136e9243c26e20389393372fa061a1d377645d69a6f3f9f989a", byte-identical tothe same query run in
psql, and a conditional GET still answers 304 with noST_AsMVTcall.
make build,go vet,go test ./...clean.The rebase had one conflict, in
docs/performance.md: #33's HTTP/2 section and thisbranch's caching section were added at the same place. Both are kept. HTTP/2 stays first
because its "the transport was never the bottleneck —
ST_AsMVTis (see the sectionabove)" points at the cancellation section, and inserting the caching section between them
would have quietly redirected that reference.