Skip to content

feat(rendering): configurable size of MapLibre Native's ambient cache - #13

Draft
ManuelLR wants to merge 6 commits into
mainfrom
feat/rendering-ambient-cache
Draft

ManuelLR wants to merge 6 commits into
mainfrom
feat/rendering-ambient-cache

Conversation

@ManuelLR

@ManuelLR ManuelLR commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

What

A new option, styles.rendering.ambient_cache_size_mb, sets the size of MapLibre Native's ambient cache for the render pools, in MB (1000 × 1000 bytes, like Martin's other size_mb options). 0 disables it. Unset keeps MapLibre Native's default (50 MiB, in memory), so nothing changes unless configured.

styles:
  rendering:
    enabled: true
    ambient_cache_size_mb: 0

The value reaches every tile and static renderer as ImageRendererBuilder::with_resource_options(ResourceOptions::default().with_maximum_cache_size(bytes)). Unset, the builder is left alone, which is the same ResourceOptions::default() (maplibre_native 0.10.0, renderer/builder.rs).

Why

Renderers fetch a style's tiles, glyphs and sprites through MapLibre Native's file source. Every response is compressed and stored in an in-memory SQLite database (OfflineDatabase::putTile), and every fetch looks it up there first (getTile, then decompress). When the style's sources are Martin's own (http://127.0.0.1:3000/<source>), the tile is already a local read away, so that cache only costs CPU and memory.

It also spares fewer requests than it seems: MainResourceLoader always goes on to the network after a cache hit, and OnlineFileSource only holds that request back while the cached response's Expires/Cache-Control says it is fresh. Martin sends no Cache-Control unless cache_control is configured, so with Martin's own sources every resource is requested again anyway.

From MapLibre Native core a33d3f00 (the one maplibre_native 0.10.0 builds against):

  • OfflineDatabase::disabled() (platform/default/src/mln/storage/offline_database.cpp:107) is true when maximumAmbientCacheSize == 0 and no offline region exists (Martin creates none); get/put then return before reading, compressing or writing anything (lines 259, 295). Only the listRegions() query inside disabled() is left.
  • The default is util::DEFAULT_MAX_CACHE_SIZE = 50 × 1024 × 1024 (src/mln/storage/resource_options.cpp:13).
  • Cache lookup then network: platform/default/src/mln/storage/main_resource_loader.cpp, lines ~85-110.

It is unrelated to Martin's cache.size_mb (Martin's own tile cache) and to MBTiles' SQLite. A CDN in front does not change the picture: it caches the rendered PNG, while this cache holds the vector tiles a renderer reads.

Measured with this branch's binary

basemaps-server image with Martin built from this branch (release, --features pmtiles,mbtiles,rendering), 24 PNG styles, tile_size: 256, max_pixel_ratio: 2, renderers_per_worker: 400, 4 render workers on 4 vCPU (--cpuset-cpus=0-3). Load: 12,000 raster URLs sampled from production's shield misses (2026-10-07 12:00-12:20 UTC), 8 concurrent requests. Figures are the second half of each run (6,000 URLs, warm page cache); runs interleaved base, 0, base, 0 on an otherwise idle VM (load average 1.4 and falling when the first run started, after a build).

Unset (50 MiB) ambient_cache_size_mb: 0
Tiles/s 81.1 / 79.3 92.4 / 94.5 (+17 %)
CPU per tile 45.4 / 46.2 ms 39.1 / 38.4 ms (−15 %)
p50 / p95 / p99 85 / 197 / 308 ms, 86 / 200 / 308 ms 75 / 166 / 277 ms, 74 / 161 / 276 ms
RSS 11,272 / 11,122 MiB 10,980 / 10,897 MiB (−2 %)

The same 13 URLs per half answer 404 in every run (tiles the styles do not serve); they are left out of the latency figures. An earlier measurement with an environment-gated build making the same call gave −12 % CPU and +13 % tiles/s.

Tests

  • Config (martin/src/config/file/resources/styles.rs): unset gives None, 0 gives 0 bytes, 128 gives 128,000,000 bytes; -1 is rejected.
  • e2e (e2e-tests/tests/rendering.rs, the_ambient_cache_answers_a_reloaded_renderer): one worker with renderers_per_worker: 1 renders maplibre_demo, maptiler_basic, maplibre_demo, so the third request loads maplibre_demo into a new renderer. With the cache, its tile reaches the test server once; with ambient_cache_size_mb: 0, twice. Each tile also matches its reference image. Passed 5 runs in a row.
  • To show that, the cassette gets Cassette::serving_fresh_for(hosts, max_age), which adds Cache-Control: max-age to its answers (with a unit test). Without it MapLibre Native requests the cached tile again (checked: a plain second GET, no conditional headers), and the count is 2 either way. Two renderers of one style at @1x and @2x do not work for this either: the cache keys tiles by pixel ratio.

Checks run

In an Ubuntu 24.04 container with Rust 1.98.1, the prebuilt MapLibre Native core and lavapipe, CARGO_BUILD_WARNINGS=deny, CI=1:

  • cargo fmt --all -- --check
  • just clippy (CI's lint job) and cargo clippy --workspace --all-targets --features martin/rendering: clean.
  • cargo clippy -p martin -p martin-core --all-targets --no-default-features --features rendering and cargo clippy -p martin-e2e-tests --all-targets --features test-rendering: only findings already on main (martin/build.rs, martin/src/config/file/process.rs, martin/src/config/file/main/lifecycle.rs, e2e-tests/tests/rendering.rs:762). CI runs neither.
  • just test-rendering: all pass. cargo nextest run -p martin-e2e-tests --lib: all pass.
  • cargo nextest run -p martin-core --features rendering --lib: all pass.
  • cargo nextest run -p martin --features rendering --lib and just test-packages-ci: all pass except init_warns_about_an_unreadable_directory_and_publishes_its_siblings, which needs a non-root user (the container runs as root, which can read a chmod 000 directory).
  • just spellcheck, markdownlint-cli2 with the repo config: clean.

Not run: the PostgreSQL, S3, COG and DuckDB suites (untouched), just check (cargo-hack over every feature), gen-schemas (the schema build has no rendering).

Before upstream

Upstream asks for an issue first and small PRs. Open questions for the maintainers:

  • Name: Martin 2.0 moved every *_cache_size_mb to a nested *.cache.size_mb / directory_cache.size_mb (migration guide). ambient_cache: { size_mb: 0 } would follow that.
  • Default: arguably 0 when every source of a style is Martin's own, but that changes behavior, so it is left to them.
  • martin-core: StyleSources::enable_rendering and RenderPools::new gain an argument, as they did for renderers_per_worker and tile_size.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o

ManuelLR and others added 6 commits October 8, 2026 17:19
Renderers fetch the tiles, glyphs and sprites of a style through
MapLibre Native, which compresses and stores every response in an
in-memory SQLite cache of its own (50 MiB) and looks each fetch up
there first. When the style's sources are Martin's own, that work buys
nothing: the tile is a local read away.

`styles.rendering.ambient_cache_size_mb` sets its size; `0` disables
it. Unset keeps MapLibre Native's default, so nothing changes unless
configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o
… caches

Every other `size_mb` in Martin is 1000 * 1000 bytes; this one was MiB.
The conversion moves to `RendererConfig::ambient_cache_bytes`, tested
unset, at 0 and at 128, and a negative size is rejected. The static
worker gets a constructor, like the tile worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o
MapLibre Native asks the server again for any cached resource that is
not fresh, and Martin sends no `Cache-Control` unless configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o
With one renderer per worker, cycling two styles reloads the first into
a new renderer. With the ambient cache its tile reaches the upstream
once; with `ambient_cache_size_mb: 0`, twice. The cassette can now mark
its answers fresh (`Cassette::serving_fresh_for`): without
`Cache-Control`, MapLibre Native requests a cached resource again
anyway, so the cache makes no difference to the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o
The example listed 50, which reads as the default but is 50 MB, not the
50 MiB MapLibre Native uses when the option is unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWEkV2RXSxbJr1vx7W5n5o
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant