Skip to content

Cache parsed tiles on disk, and make the map's cost an operator setting (#133) - #134

Merged
CaYatur merged 2 commits into
mainfrom
feat/map-tile-cache
Jul 29, 2026
Merged

Cache parsed tiles on disk, and make the map's cost an operator setting (#133)#134
CaYatur merged 2 commits into
mainfrom
feat/map-tile-cache

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Cache parsed tiles on disk, and make the map's cost an operator setting (#133)

Measured on a real Paper 1.21.6 world before writing anything. One 5.4 MB region
of 811 chunks and 19 769 sections:

total 180ms | decompress 39ms | nbt parse 141ms

...before any surface extraction, and repeated every time the app starts,
because #119's cache was a Map in memory holding twelve regions. The disk half
was in that issue's scope and I only built the memory half. This is the rest.

The encoded form of a region is about 1 MB, gzips in 2-3 ms and gunzips in about
one. So a region now costs a few hundred milliseconds once, and single-digit
milliseconds forever after — parsed again only when the server rewrites it.

shared/tileCache.ts holds the format, pure, because this is where a mistake is
silent: a decoder that mis-reads its own file produces a plausible map rather
than an error, and then serves it until somebody deletes the cache by hand.
Fixed-width columns rather than a hand-rolled packing — the caller gzips it, and
gzip finds the runs better than a scheme I could get wrong.

The key carries a format version as well as the mtime. The mtime says the
world has not changed, which is true and beside the point when what changed is
how we draw it: without the version, every cache written before the colour and
foliage work would go on serving the old picture forever, and an operator
staring at it has no way to connect it to an update. Changing the colours, the
foliage rule or the shading and not bumping that number is the bug this field
exists to prevent.

Per-server settings, because a box running twenty servers and a laptop
running one want different answers:

  • Cache tiles on disk — on by default; this is the one that matters.
  • Regions in memory — the working set.
  • Delay between parses — the brake that keeps the main thread responsive.
  • Cache limit — oldest evicted first, plus a way to clear it outright.

They live on the server's own config so they survive a restart and apply to
every surface, and they are clamped on the way IN rather than on the way out: a
value only fixed when read is still a wrong number in the file, and every one of
these is a way to hang the process — a parse gap of zero removes the brake, a
memory limit of a million holds a whole world resident.

Asserted: the codec round-trips colours, heights across the full -64..319 range,
the transparent columns that must stay transparent, and structure marks with
their ids. A cache from an older format version is refused rather than decoded.
Wrong magic, an empty buffer, three different truncations and a chunk count that
runs past the end all answer null rather than throwing or returning half a map.
Proved failable — encoding transparent columns as present gives
FAIL - colour 0 of chunk 0: 0 vs -1, which is the void painted over every
ungenerated gap.

…ng (#133)

Measured on a real Paper 1.21.6 world before writing anything. One 5.4 MB region
of 811 chunks and 19 769 sections:

    total 180ms | decompress 39ms | nbt parse 141ms

...before any surface extraction, and repeated **every time the app starts**,
because #119's cache was a `Map` in memory holding twelve regions. The disk half
was in that issue's scope and I only built the memory half. This is the rest.

The encoded form of a region is about 1 MB, gzips in 2-3 ms and gunzips in about
one. So a region now costs a few hundred milliseconds once, and single-digit
milliseconds forever after — parsed again only when the server rewrites it.

`shared/tileCache.ts` holds the format, pure, because this is where a mistake is
silent: a decoder that mis-reads its own file produces a *plausible* map rather
than an error, and then serves it until somebody deletes the cache by hand.
Fixed-width columns rather than a hand-rolled packing — the caller gzips it, and
gzip finds the runs better than a scheme I could get wrong.

**The key carries a format version as well as the mtime.** The mtime says the
world has not changed, which is true and beside the point when what changed is
how we draw it: without the version, every cache written before the colour and
foliage work would go on serving the old picture forever, and an operator
staring at it has no way to connect it to an update. Changing the colours, the
foliage rule or the shading and not bumping that number is the bug this field
exists to prevent.

**Per-server settings**, because a box running twenty servers and a laptop
running one want different answers:

- *Cache tiles on disk* — on by default; this is the one that matters.
- *Regions in memory* — the working set.
- *Delay between parses* — the brake that keeps the main thread responsive.
- *Cache limit* — oldest evicted first, plus a way to clear it outright.

They live on the server's own config so they survive a restart and apply to
every surface, and they are clamped on the way IN rather than on the way out: a
value only fixed when read is still a wrong number in the file, and every one of
these is a way to hang the process — a parse gap of zero removes the brake, a
memory limit of a million holds a whole world resident.

Asserted: the codec round-trips colours, heights across the full -64..319 range,
the transparent columns that must stay transparent, and structure marks with
their ids. A cache from an older format version is refused rather than decoded.
Wrong magic, an empty buffer, three different truncations and a chunk count that
runs past the end all answer null rather than throwing or returning half a map.
Proved failable — encoding transparent columns as present gives
`FAIL - colour 0 of chunk 0: 0 vs -1`, which is the void painted over every
ungenerated gap.
Copilot AI review requested due to automatic review settings July 29, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… of this

**The clear button cleared everyone's.** Cache files were named by a hash of the
region path alone, so there was nothing in a filename to say which server it
belonged to — and a hash cannot be reversed. "Clear this server's cache" deleted
every cached region on the machine while claiming to clear one. The server id is
a visible prefix now, so the question is answerable from the filenames.

**The sweep ran after every region.** It stats every file in the cache
directory, so writing a region meant a directory scan — a cost inside the thing
that exists to remove cost. It runs once per 32 MB added instead.

**And I put the dials only in the desktop**, one PR after merging the three maps
and one after re-splitting them. The panel is the admin surface an operator
reaches from anywhere, and this is a persisted server setting rather than a view
preference, so it belongs there too: `GET`/`POST /servers/{id}/map/perf` and
`DELETE /servers/{id}/map/cache`, all on `settings`, with the same controls in
the map tab.

The panel shows what was **stored**, not what was typed. The server clamps, and
a field that keeps displaying a refused number is lying about the setting.
@CaYatur

CaYatur commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Self-review: "clear cache" wiped every server, and the panel had none of this

The clear button cleared everyone's. Cache files were named by a hash of the
region path alone, so there was nothing in a filename to say which server it
belonged to — and a hash cannot be reversed. "Clear this server's cache" deleted
every cached region on the machine while claiming to clear one. The server id is
a visible prefix now, so the question is answerable from the filenames.

The sweep ran after every region. It stats every file in the cache
directory, so writing a region meant a directory scan — a cost inside the thing
that exists to remove cost. It runs once per 32 MB added instead.

And I put the dials only in the desktop, one PR after merging the three maps
and one after re-splitting them. The panel is the admin surface an operator
reaches from anywhere, and this is a persisted server setting rather than a view
preference, so it belongs there too: GET/POST /servers/{id}/map/perf and
DELETE /servers/{id}/map/cache, all on settings, with the same controls in
the map tab.

The panel shows what was stored, not what was typed. The server clamps, and
a field that keeps displaying a refused number is lying about the setting.

@CaYatur
CaYatur merged commit 737aa9c into main Jul 29, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/map-tile-cache branch July 29, 2026 10:22
CaYatur added a commit that referenced this pull request Jul 29, 2026
* Load the map at the speed the cache made possible (#136)

Three causes, each undoing part of what #134 bought.

**The parse brake was throttling cache hits.** The 250 ms gap exists so
back-to-back region PARSES do not hold the main thread — and it was applied to
every job in the queue, including the ones that read a cached region in about a
millisecond. After the disk cache landed, most reads are cache hits, so the
brake had ended up throttling almost exclusively the fast path. That is why
loading felt no quicker with the cache than without it. The queue now asks
whether a job will actually parse before waiting for anything.

**The client only asked again on the two-second position poll.** `mapFetchTiles`
runs from `mapDraw`, so a viewport needing six requests filled in over twelve
seconds, in visible bands — which is the "it loads chunk by chunk" in the
report. While anything is still pending it asks again in 180 ms.

**Empty chunks were requested forever.** A chunk was marked "known empty" only
when the whole response reported nothing pending, and on a busy viewport
something always is — so a chunk the server had read and found empty was never
marked and came back on every draw. The response now names them: `empty` is "I
read that region and there is nothing there", which is a different fact from
"not read yet", and the client can tell them apart. This is the "sometimes it
never loads".

**And an empty area now says so.** Ungenerated chunks were black, which is
indistinguishable from still-loading and from broken — an operator was waiting
for a load that was never coming. They are drawn as a faint hatch in the site's
own accent colour, with a line saying nobody has been there, once enough of the
view is empty to be worth explaining.

* Self-review: measure the parse instead of predicting it, and stop an infinite poll

I gated the brake behind a `willParse()` that re-stat'd the region file and
checked for a cache file — to guess at work the very next call was about to do
anyway. Two stats per queued job, thousands of jobs. The queue runs the job and
then looks at whether `lastParseAt` moved: no prediction, no extra filesystem
work, and the brake applies exactly when a parse happened rather than when one
was expected.

Worse, and mine from this PR: asking again every 180 ms while anything is
pending turns an unreadable region into a permanent polling loop. `loadRegion`
returned null on a read failure, `peekChunkTile` therefore answered "not read
yet" forever, and the client would have asked for that chunk every 180 ms for as
long as the page stayed open. A region that cannot be read is remembered as
EMPTY — which is honest, there is nothing to draw — and terminates.

Also dropped a `pending` counter I computed in the ungenerated-area note and
never used.
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.

2 participants