source-cooperative / source-cooperative/data.source.coop

[Proposed Feature] Serve PMTiles archives as Z/X/Y tiles, cached at the edge

Open
#226 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
24
Forks
6
Avg merge
1h 32m
Merged PRs (30d)
1

Description

Description of Feature:

Serve PMTiles archives as Z/X/Y tiles from the proxy, and cache the tiles at the
edge.

Today a map client reads a .pmtiles archive with HTTP range requests, and none
of them are cached. Every request reaches the origin, including identical repeats:

$ for i in 1 2 3; do curl -so /dev/null -r 0-16383 \
    -w '%{http_code} ttfb=%{time_starttransfer}s\n' \
    https://data.source.coop/<repo>/fire-2025.pmtiles; done
206 ttfb=0.849s      cf-cache-status: DYNAMIC   server-timing: backend;dur=735
206 ttfb=1.825s      cf-cache-status: DYNAMIC   server-timing: backend;dur=751
206 ttfb=1.357s      cf-cache-status: DYNAMIC   server-timing: backend;dur=761

The same 16 KB range, three times, never cached, ~750 ms at the origin each
time. A map pan issues one range per tile plus directory reads, so this is the
dominant cost of loading a map — and it is paid again by every user, for the
same bytes.

Why range requests are not cached

This is not an oversight, and it cannot be fixed by adding a header. Two
independent mechanisms make a 206 uncacheable at the edge:

  1. Cloudflare's Cache API refuses to store a 206. cache.put() throws when
    the response status is 206 Partial Content
    (docs). A
    partial response is structurally not a cache entry.
  2. The proxy deliberately bypasses the subrequest cache on any ranged read.
    ForwardRequest::should_bypass_cache() in multistore returns true whenever
    the forwarded request carries a Range header, and WorkerBackend::forward
    then sets RequestCache::NoStore. The stated reason is correct: a 206 must
    never be written to, or served from, the full-object cache entry for the same
    URL.

Worth noting for anyone reading the trace above: cf-cache-status: DYNAMIC
appears on every response from this proxy, ranged or not. A Worker on a route
runs in front of the cache, so its responses are not CDN-cached unless the
Worker itself calls the Cache API — which it currently does only for Source API
metadata (src/source_api/cache.rs), never for object bytes. The header is not
evidence about ranges specifically.

Plain, un-ranged GETs of public objects are left cacheable on the backend
leg by design (should_bypass_cache returns false for them), but they are still
not cached on the client-facing leg, and they do not help a map client, which
only ever issues ranges.

So the fix has to change the shape of the request: a tile is a small, whole,
200-able resource, and that is exactly what a cache can hold.

Why neither Protomaps recipe is a drop-in

The Cloudflare recipe is a
JavaScript Worker that resolves /NAME/{z}/{x}/{y}.mvt against the archive and
stores the result with the Cache API, defaulting to public, max-age=86400. The
AWS recipe is the same idea as a Lambda
behind CloudFront, with CACHE_CONTROL / CACHE_MAX_AGE environment variables
and a TILESET.json TileJSON endpoint.

The obvious objection to the Cloudflare recipe is that it reads from R2. That is
the lesser problem, and this proxy is not S3-only anyway — it resolves S3, GCS,
Azure and R2 connections through object_store, so a tile endpoint built on that
layer is backend-agnostic for free. The real blockers are:

  • This is a Rust/WASM Worker, not a JavaScript one. crate-type = ["cdylib"], built with worker-build, entrypoint src/lib.rs. There is no
    JS request path to drop the pmtiles JS library into.
  • Tiles must resolve through the existing authorization pipeline. The
    Protomaps recipes front a single bucket that the operator owns. Here, every
    archive belongs to a product with its own visibility and its own data
    connection, and an edge cache is shared across all callers.

The shape still fits — what is missing is the tile addressing, a Rust PMTiles
reader, and the cache.put.

Feasibility: verified

The load-bearing question was whether a Rust PMTiles reader compiles for
wasm32-unknown-unknown. It does. cargo check --target wasm32-unknown-unknown
passes clean with:

pmtiles = { version = "0.24", default-features = false, features = ["object-store", "tilejson"] }

against AsyncPmTilesReader::try_from_cached_source, HashMapCache,
get_tile_decompressed and parse_tilejson. The whole transitive tree
(async-compressionflate2/miniz_oxide, tokio with io-util only,
fast_hilbert, varint-rs, tilejson) is wasm-clean.

Two details that make this work and are easy to trip over:

  • AsyncBackend::read requires impl Future + Send, and AsyncPmTilesReader
    requires B: AsyncBackend + Send + Sync. Worker fetch futures are !Send.
    The object-store backend sidesteps this entirely, because
    multistore-cf-workers' FetchConnector already bridges the !Send JS
    boundary with spawn_local + a oneshot channel.
  • pmtiles::ObjectStoreBackend::new wants a Box<dyn ObjectStore>.
    WorkerBackend::create_paginated_store builds exactly the right store but
    erases it to Box<dyn PaginatedListStore>. The handler can rebuild it from
    the resolved BucketConfig with multistore::backend::create_builder +
    .with_http_connector(FetchConnector) + RetryConfig { max_retries: 0, .. },
    matching what that method already does.

Proposed shape

  1. Route. Register on the rewritten path, after PathMapping has folded
    /{account}/{product}/… into account:product. A matchit pattern of
    /{bucket}/{*key} coexists with the existing /{bucket}
    (AccountListHandler) — verified against matchit 0.8:

    /acct:prod                            -> acctlist  bucket="acct:prod"
    /acct:prod/fire.pmtiles/5/10/20.mvt   -> tiles     key="fire.pmtiles/5/10/20.mvt"
    /acct:prod/a/b/c.tif                  -> tiles     key="a/b/c.tif"
    

    Note the third line: {*key} catches every object request, for every
    method, not just tile ones. The handler must decline (return None) on
    anything that is not a tile GET/HEAD, which falls through to the normal
    pipeline — the same trick AccountListHandler already relies on. That puts
    this handler on the hot path for 100% of proxy traffic, so the decline check
    must be a cheap string test with no I/O, and a bug in it breaks the whole
    proxy rather than just tiles. A distinct URL namespace (see below) avoids
    this entirely and is probably worth it for that reason alone.

  2. Resolve. Fetch the product via SourceCoopRegistry, build the
    ObjectStore, open the archive at backend_prefix + key.

  3. Serve. cache.get first; on a miss read the tile and cache.put it under
    a synthetic key with cache-control: public, max-age=….

  4. TileJSON at a sibling path so clients can point straight at it.

Range requests on the .pmtiles URL keep working unchanged; this adds a second
way in.

What else is needed

Most of these need a decision rather than just code.

Public products only — this is a security boundary. multistore dispatches
route handlers before identity resolution (ProxyGateway::handle_request
calls self.router.dispatch(req) first, and RequestInfo carries no
ResolvedIdentity). A tile handler is therefore inherently anonymous. That is
the right fail-safe, but it has to be explicit: the handler must serve only
products where SourceProduct::is_public() holds, and must refuse everything
else. A shared edge cache holding tiles from a restricted product is a data
leak, and the cache has no notion of who asked. Anonymous resolution already
fails closed — the subject-less Source API fetch 404s on a non-public product —
but relying on that alone is too subtle for a security property; the check
should be written down. Authenticated access to private tilesets is out of scope
for this route by construction.

Cache invalidation. Objects here are mutable — the proxy supports PUT. A
one-day tile TTL means a re-uploaded archive serves stale tiles for a day, with
no signal to the client. Options: put the archive's ETag in the cache key
(costs a HEAD, itself cacheable), keep the TTL short, or expose an explicit
purge. This is the same staleness problem as #225, one layer down: #225 is about what
the proxy tells downstream caches, this is about what the proxy's own cache
holds.

Directory caching, or the subrequest count gets worse. A cold tile costs 2–4
range reads: header + root directory, possibly a leaf directory, then the tile
itself. Without a directory cache, every tile pays all of them — plausibly
slower than today for a cache-cold viewport. pmtiles::HashMapCache handles
this, but only if it outlives the request: a reader constructed per request
caches nothing. It needs to live in an isolate-level static, keyed by archive
(the cache is keyed by byte offset, so it must not be shared across archives) —
the same pattern lib.rs already uses for JWKS_CACHE and OIDC_PROVIDER.
Cross-isolate sharing would need the Cache API or KV; RFC-001 §11 flags the
Workers caching stack as an open TODO.

Serve tiles gzipped, don't decompress in WASM. MVT tiles inside a PMTiles
archive are already gzip-compressed. get_tile returns them as stored; serving
those bytes with content-encoding: gzip skips a decompress per tile in WASM
and is what the Protomaps deployments do. get_tile_decompressed is the
convenient call but the wrong one here.

Analytics. log_analytics and location::maybe_broadcast_location key on
(account, product, key), and lib.rs logs everything whose path does not start
with /.. Left alone, tile traffic lands one analytics row per tile with key = "fire-2025.pmtiles/5/10/20.mvt". Decide whether to normalize tile keys back to
the archive, sample them harder, or skip them — ADR-008 already notes the
dataset is sampled and not a billing record, but a map pan is ~20 rows per user
per viewport. Note also that route handlers bypass after_dispatch.

URL namespace collision. fire-2025.pmtiles/5/10/20.mvt is a legal S3 key.
Someone can already have an object there. Decide precedence explicitly, or put
tiles behind a distinct prefix or query parameter rather than overloading the
object path.

No retries on the backend read. object_store retries are disabled on wasm
(max_retries: 0) because its retry path calls tokio::time::sleep, which
panics on this target. A single flaky range read fails the tile. Worth deciding
whether a tile handler should retry once itself.

Bundle size. Measure worker-build --release output before and after. I was
not able to link a wasm binary locally to get a number (broken rust-lld in my
toolchain, unrelated to this change), so this is unquantified.

Relationship to #188 and #225

This should land on top of #188, not instead of it, and not before it.

#188 (chunk-aligned ranged-GET caching) already specifies the general fix:
normalize client ranges to aligned blocks, cache each block as a 200 under a
synthetic key that includes the object ETag, assemble the client's 206 from
blocks. That is the right primitive, it is not PMTiles-specific, and it helps
every cloud-native format Source Cooperative hosts — COG overviews, GeoParquet
row groups, Zarr chunks, FlatGeobuf.

It also solves, generically, two of the hard parts listed above:

  • Invalidation — ETag in the chunk key makes overwrites self-invalidating,
    with no purge machinery. A tile endpoint should reuse that scheme rather than
    invent a second one.
  • Directory reads — PMTiles header and directory reads are small, hot, and
    read by every client on every archive open. They fall inside the first block
    or two of the file and would be permanent edge hits under #188 alone, without
    any tile-specific caching.

So a useful sequencing is: #188 first, then this on top of it. With #188 landed,
this issue shrinks to tile addressing, the PMTiles reader, and TileJSON — the
caching layer is already there.

What #188 does not give, and this issue does: Z/X/Y compatibility. A client
that cannot read PMTiles at all — older MapLibre and Leaflet builds, QGIS XYZ
layers, anything that takes a tile URL template — is not helped by making range
requests faster. That is the part of this proposal that stands on its own.

#225 (Cache-Control) is a third, separate layer: what the proxy tells
downstream caches. Tiles served from this endpoint would need their own
cache-control, and it should be set deliberately here rather than inherited
from whatever the .pmtiles object carries.

Also adjacent: #57 (suffix byte ranges, Range: bytes=-N). Some PMTiles
readers use a suffix range to locate the header; this endpoint sidesteps that,
but direct .pmtiles range access still hits it.

Open questions

  • Tile URL scheme: overload the object path, or a separate namespace?
  • Default TTL, and how staleness after a re-upload is handled.
  • Is a public-only tile endpoint acceptable, or does this need to wait for a
    route-handler API that can see the resolved identity?
  • Should this wait on #188? (I think yes — see above.)

What value is this feature adding to Source Cooperative?

  • Latency. ~750 ms per range read today, from any region, on every request.
    A cache hit is served from the nearest edge. This is the difference between a
    map that feels instant and one that visibly fills in. (#188 delivers most of
    this for PMTiles clients specifically; the tile endpoint extends it to clients
    that cannot speak PMTiles.)
  • Compatibility. Z/X/Y works with clients that cannot read PMTiles — older
    MapLibre and Leaflet builds, QGIS XYZ layers, anything expecting a tile URL.
    Today those users cannot consume a Source Cooperative tileset at all.
  • Cost, possibly. Every tile request is an origin GET today, and a cache hit
    is none. Whether that is material depends on the storage arrangement behind
    opendata.source.coop, which we cannot see from outside — if egress is
    sponsored, the saving is the GET charges and the origin load rather than
    bandwidth. Worth checking against your own bill before treating it as a
    reason. Note the directory-cache point above: done carelessly, a tile endpoint
    increases origin GETs.

Happy to test against a real tileset — we publish ~15 GB of PMTiles here and
the maps are public.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with #188, then read ProxyGateway::handle_request, PathMapping, src/lib.rs, and the existing cache path in src/source_api/cache.rs. Run cargo check --target wasm32-unknown-unknown and inspect the SourceCoopRegistry and object-store setup. Done means a public-only PMTiles tile and TileJSON route serves cacheable tiles without disrupting normal object requests, with the caching and invalidation decisions resolved.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust, wasm
Domain
api, backend, cloud, performance
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.