mapsrv: cache headers, ETags, and a data version for tiles #32

Merged
art merged 2 commits from night/issue-4-cache-headers into main 2026-08-02 06:54:44 +00:00
Collaborator

Closes #4


Issue #4 — cache headers and data versioning

Branch: night/issue-4-cache-headers (commit 85ba0d4, not pushed, not merged)
State: done

What changed

New mapsrv/cache.go plus small edits to tiles.go, assets.go, main.go,
sprite.go and compress.go. One policy, applied per handler kind:

tiles, glyphs, sprites public, max-age=60, stale-while-revalidate=604800 + weak ETag
html/ (index, style.json) no-cache, revalidated on http.FileServer's Last-Modified
/api/* no-store

Fonts/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 is
computed 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_AsMVT call 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. dataVersionSQL md5s those
OIDs together with the source text of the zxy_* functions (which are
CREATE OR REPLACEd, so they keep their OID while changing what they return).
%_new tables are excluded so the version does not flap for the hours an import
spends 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/553 means "the current tile", and the only
honest way to keep it current without versioning the URL is to let the client ask.
max-age=60 exists to keep a single panning session off the network, not to cache
across imports.

Two smaller things fell out:

  • Cache headers are set on the success path only (cacheable() after the query
    returns), so a 503 from a missing zxy_* function is never stored under the tile's
    URL.
  • Vary: Accept-Encoding is now set unconditionally by the gzip middleware, not
    only 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.
  • New mapsrv/cache_test.go: weak/strong/list/* If-None-Match comparison; a tile
    304 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-store on both success and
    error; Vary present with and without Accept-Encoding.
  • Ran a second mapsrv on :8099 against the live database (the user's :8080 server
    was 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/issues all as designed.
  • The fingerprint mapsrv logs at startup is byte-identical to the same query run in
    psql. A rolled-back BEGIN; CREATE TABLE …; ROLLBACK probe confirmed the
    fingerprint moves when a public table appears and does not move for a _new
    table, and that the database was unchanged afterwards. Independently, the tables
    with the highest OIDs in the live database are exactly map_roads_*_pl, the ones
    the last commit on main swapped — which is the mechanism working.
  • Headless Chrome over CDP (real time, no virtual-time budget), /?map=se loaded
    twice: 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.)
  • No import was run; none is needed for this change.

Left undone / uncertain

  • Not measured against a real import. The mechanism is verified by the rollback
    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 the
    next import: the startup log line mapsrv: data version <hash> should differ
    before and after, and make restart is needed for that log line (the value itself
    refreshes on its own within 30 s).
  • One version for the whole database is a deliberate simplification, not an
    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.yaml lists sources only.
  • max-age=60 is a judgement call with no data behind it. It is the knob to turn
    if this ever goes behind a CDN, and it needs no ETag change.
  • mapsrv/sprite.go has a pre-existing gofmt complaint (comment alignment in the
    shields table) that predates this branch; left alone to keep the diff honest.
Closes #4 --- # Issue #4 — cache headers and data versioning **Branch:** `night/issue-4-cache-headers` (commit `85ba0d4`, not pushed, not merged) **State:** done ## What changed New `mapsrv/cache.go` plus small edits to `tiles.go`, `assets.go`, `main.go`, `sprite.go` and `compress.go`. One policy, applied per handler kind: | | | |---|---| | tiles, glyphs, sprites | `public, max-age=60, stale-while-revalidate=604800` + weak ETag | | `html/` (index, style.json) | `no-cache`, revalidated on `http.FileServer`'s `Last-Modified` | | `/api/*` | `no-store` | **Fonts/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 is computed 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_AsMVT` call 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. `dataVersionSQL` md5s those OIDs together with the *source text* of the `zxy_*` functions (which are `CREATE OR REPLACE`d, so they keep their OID while changing what they return). `%_new` tables are excluded so the version does not flap for the hours an import spends 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/553` means "the current tile", and the only honest way to keep it current without versioning the URL is to let the client ask. `max-age=60` exists to keep a single panning session off the network, not to cache across imports. Two smaller things fell out: * Cache headers are set on the **success path only** (`cacheable()` after the query returns), so a 503 from a missing `zxy_*` function is never stored under the tile's URL. * `Vary: Accept-Encoding` is now set **unconditionally** by the gzip middleware, not only 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. * New `mapsrv/cache_test.go`: weak/strong/list/`*` If-None-Match comparison; a tile 304 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-store` on both success and error; `Vary` present with and without `Accept-Encoding`. * Ran a second mapsrv on `:8099` against the live database (the user's `:8080` server was 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/issues` all as designed. * The fingerprint mapsrv logs at startup is byte-identical to the same query run in psql. A **rolled-back** `BEGIN; CREATE TABLE …; ROLLBACK` probe confirmed the fingerprint moves when a public table appears and does *not* move for a `_new` table, and that the database was unchanged afterwards. Independently, the tables with the highest OIDs in the live database are exactly `map_roads_*_pl`, the ones the last commit on main swapped — which is the mechanism working. * Headless Chrome over CDP (real time, no virtual-time budget), `/?map=se` loaded twice: 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.) * No import was run; none is needed for this change. ## Left undone / uncertain * **Not measured against a real import.** The mechanism is verified by the rollback 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 the next import: the startup log line `mapsrv: data version <hash>` should differ before and after, and `make restart` is needed for that log line (the value itself refreshes on its own within 30 s). * **One version for the whole database** is a deliberate simplification, not an 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.yaml` lists sources only. * **`max-age=60` is a judgement call** with no data behind it. It is the knob to turn if this ever goes behind a CDN, and it needs no ETag change. * `mapsrv/sprite.go` has a pre-existing `gofmt` complaint (comment alignment in the `shields` table) that predates this branch; left alone to keep the diff honest.
Nothing mapsrv served was cacheable, so a reload re-ran every ST_AsMVT call
and re-sent every byte. Fonts, sprites and the style were the easy half; the
tiles needed a way to say "the data behind this has not changed".

The importer already produces that signal, for free and without knowing it.
Every layer goes live by an atomic swap that DROPs the old table and RENAMEs
a freshly built one into its place, so a live table's OID is different after
every import, regeneralize or partition swap and identical at every other
moment. dataVersionSQL md5s those OIDs together with the source text of the
zxy_* functions — which are CREATE OR REPLACEd, so they keep their OID while
changing what they return — and that is the tile ETag. `%_new` tables are
excluded so the version does not flap for the hours an import spends building
them; it moves at the swap, which is when the map changes.

Deriving the version from the catalog rather than recording it in a metadata
table is deliberate: it works on a database imported before this code existed,
it needs no importer change, and there is no second mechanism to keep in sync.
The costs are one version for the whole database (a tile mixes every layer of
its source anyway) and an invalidation 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.

The policy is a validator plus a short freshness window, not a long max-age:
none of these URLs carries a version, so /tiles/map/11/1096/553 means "the
current tile" and the only honest way to keep it current is to let the client
ask. The ETag makes asking nearly free — a tile 304 is answered from a cached
string with no database round trip at all. Measured on the live database, a
20-tile z13 viewport over Stockholm: 0.27 s and 541 kB cold, 0.001 s and 0
bytes revalidating, and the 304 cost is independent of what the tile cost to
build. html/ gets no-cache, because style.json and index.html are served from
disk precisely so they can be edited without a rebuild and a stale style would
make that a lie; /api/* gets no-store.

Two smaller things fall out. Cache headers are written on the success path
only, so a 503 from a missing zxy_* function is not stored under the tile's
URL. And Vary: Accept-Encoding is now set unconditionally rather than only
when we compress: 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.

Verified: go test ./... in mapsrv (new cache_test.go covers the tile 304
without a database, asset revalidation, static Last-Modified, /api/ no-store
and Vary); curl against the live database for every route; the fingerprint
mapsrv logs at startup matches the same query run in psql, and a rolled-back
CREATE TABLE probe confirms it moves for a public table and not for a _new
one; and the map still loads and renders 7315 features over two loads in
headless Chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner

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.

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.
art-bot force-pushed night/issue-4-cache-headers from 85ba0d4cda to e5e4ae2f02 2026-08-02 06:28:52 +00:00 Compare
Author
Collaborator

Fixed — the fingerprint is SHA-256 now (e5e4ae2), and the branch is rebased onto main so
it 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 someone
opens 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 no
pgcrypto. It costs nothing here — the fingerprint is read at most once every 30 s, not per
request. The Go half of cache.go was already on crypto/sha256 for the asset ETags, so
the two halves now agree, which they didn't before.

Verified against the live database: the served ETag is
W/"b9096220b046b136e9243c26e20389393372fa061a1d377645d69a6f3f9f989a", byte-identical to
the same query run in psql, and a conditional GET still answers 304 with no ST_AsMVT
call. make build, go vet, go test ./... clean.

The rebase had one conflict, in docs/performance.md: #33's HTTP/2 section and this
branch'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_AsMVT is (see the section
above)" points at the cancellation section, and inserting the caching section between them
would have quietly redirected that reference.

Fixed — the fingerprint is SHA-256 now (`e5e4ae2`), and the branch is rebased onto main so it 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 someone opens 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 no pgcrypto. It costs nothing here — the fingerprint is read at most once every 30 s, not per request. The Go half of `cache.go` was already on `crypto/sha256` for the asset ETags, so the two halves now agree, which they didn't before. Verified against the live database: the served ETag is `W/"b9096220b046b136e9243c26e20389393372fa061a1d377645d69a6f3f9f989a"`, byte-identical to the same query run in `psql`, and a conditional GET still answers 304 with no `ST_AsMVT` call. `make build`, `go vet`, `go test ./...` clean. The rebase had one conflict, in `docs/performance.md`: #33's HTTP/2 section and this branch'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_AsMVT` is (see the section above)" points at the cancellation section, and inserting the caching section between them would have quietly redirected that reference.
art merged commit e5e4ae2f02 into main 2026-08-02 06:54:44 +00:00
art deleted branch night/issue-4-cache-headers 2026-08-02 06:54:44 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!32
No description provided.