Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

docs/v2/disks-caches-rendering.md

Disks, caches and rendering

This chapter covers three things. It says which disks each Gunnar box has and what lives on each mount. It lists every cache with its key, its budget, its eviction rule and what survives a restart. It explains how a cache knows that its source has changed. It ends with the lessons of the 2026-11-06 incident and a table of the owner's decisions.

The chapter was checked against gunnar df519085 (master, 2026-11-06), gunnar-ui 1075bc0 (main), znippy 29ec553 (znippy-plugin-git 0.2.5), and the live view of gunnar.rs (gunnar control storage and gunnar control topology, read on 2026-11-06). Work that has not merged yet is marked in progress.

1. The boxes and their disks

A Gunnar estate has three boxes. The front answers the internet. The main holds the repositories. The twin follows the main.

 internet ──► front :443/:2222 ──WireGuard──► main :50051/:2222 ──replication──► twin
              one system disk                 system disk + 4 data disks       system disk + 1 data disk
box shape on gunnar.rs disks
main 4 vCPU, 8 GB system disk 50 GB (/dev/vda): the ESP, the rootfs (1.0 GiB) and P3 = /serialdat (47.9 GiB, /dev/vda3). Data set: 4 × 10 GB MaxIOPS (/dev/vdb../dev/vde), md RAID0, XFS, mounted on /data (39.9 GiB).
front 2 vCPU, 4 GB one 40 GB system disk. The front's data root is /data on that disk.
twin 1 vCPU, 4 GB system disk 20 GB, data 1 × 20 GB HDD, XFS.

The two areas on the main are two failure domains, not "fast" and "slow" (crates/gunnar-server/src/slow.rs:9-20):

korp-installer decides the layout at install time and writes it to /boot/korp/layout.conf. Gunnar reads dir.<name>.path from that file and writes exactly there. Gunnar adds no placement logic of its own (slow.rs:21-33).

2. What lives where

The installer lays some directories physically on /serialdat and makes /data/<name> a symlink to each ("slow-linked"). A write to /data/log therefore lands on /serialdat without Gunnar knowing. The rows below are from gunnar.rs's live layout config and from the code.

On the main

what path Gunnar writes really on placement who writes it
git objects, packs, refs, LFS (the archives) /data/repos/<ns>/<repo>.git/ /data stripe fast the znippy engine
explode read cache /serialdat/explode/<ns>/<repo>.git/gunnar-git.znippy/<key>/ /serialdat own home (since gunnar df519085) the znippy engine, pointed there by explode_home.rs
metadata copy (roster, principals, enrolled keys) /serialdat/meta /serialdat slow-mirror: a second copy, no /data twin Gunnar
crash records and diagnostics /data/crash /serialdat/crash slow-linked Gunnar
audit trail and console log /data/log (audit-chain.jsonl inside it) /serialdat/log slow-linked Gunnar
gc / repack scratch /data/gc /serialdat/gc slow-linked, not wired nobody (see 2.1)
push quarantine /data/quarantine /serialdat/quarantine slow-linked, not wired nobody (see 2.1)
grow journal /serialdat/grow-journal /serialdat the boot stack (gunnar-grpc/src/grow_journal.rs:27)
repository mirror /serialdat/repos/<user> /serialdat named in slow.rs:112-115
typeset / PDF cache /data/typeset /serialdat/typeset slow-linked gunnar-ui's, not Gunnar's
installer generations /data/generations /serialdat/generations slow-linked korp-installer's
catalog, identity, settings /data/control.redb /data Gunnar

Hot and cold archives. The znippy engine keeps one archive per repository. Every push lands in it, and control storage counts it as hot. There is no cold tier today: the cold column is 0 with cold_measured: false, which means "not a reading", so the console shows N/A (control_plane.rs:5810-5818). On gunnar.rs, 47 repositories hold 2.1 GiB hot and 0 B cold.

The admin page's per-mount breakdown of /serialdat measures these known paths: metadata copy, crash record, audit trail, logs, grow journal, repository mirror and, when it is there, the read cache. Whatever the mount uses beyond them is other (control_plane.rs:5948-5975, capacity.rs:542-600).

On the front

what path kept on disk?
console state, including the README render cache /data/console/state.redb (gunnar-front/src/console.rs:60) yes
public-site pages and prerendered page parts memory no
git object cache memory no
render store (in progress, lane grender) the front's typeset dir yes

2.1 Is gc/repack scratch on /serialdat? No.

The answer on 2026-11-06 is no. /data/gc exists and links to /serialdat/gc, but nothing writes there.

Push quarantine is not wired for the same reason. The incoming-*.pack spool is created in the repository's own directory (storage.rs:1161-1164), so the final rename stays on one filesystem and costs nothing.

3. Every cache

 browser ──HTTP cache──► front ──────────────────────────────► main ──────────────────────────► disk
          (immutable     │ public-site pages   (memory)          │ handle cache  (memory)        /data/repos  archives
           hashed        │ prerendered parts   (memory)          │   └ redb page cache per handle
           assets)       │ git object cache    (memory)          │ explode read cache ──────────► /serialdat/explode
                         │ README render cache (state.redb)      │
                         │ render store (in progress, disk)      │
cache box, medium key budget eviction emptied by after a restart after a keep boot / live update
explode read cache main, /serialdat store key (16 hex) + object oid min(50 % of /serialdat, 8 GiB) per repository; 50 % of /serialdat in all oldest lazy segment first; disk line 80 % trips, 75 % releases gc, purge, a recreated repository (new key), the disk line kept kept (P3 is kept)
store handle cache main, memory repository ledger: MemAvailable × 20 %; count [8, 256] least recently used idle handle restart, delete, memory pressure empty; the warm-up refills the public set empty; the warm-up refills the public set
public-repository warm-up main, job — one open per 2 s — — runs once after boot runs once after boot
README render cache front, state.redb ns, name, blob oid, path, shape, renderer version 256 entries or 64 MiB insertion order (FIFO) a renderer version bump kept kept if the front's disk is kept
prerendered page parts front, memory page path 4096 parts, 64 MiB, 2 MiB a part expiry after 1 h a push or issue event for the repository; a feed change empty empty
public-site built pages front, memory — 200 repositories rebuilt hourly or on a push event — empty; rebuilt at the first request empty
git object cache front, memory repository, oid 64 MiB insertion order a console rebuild (feed change) empty empty
render store (in progress) front, typeset dir blob oid / commit oid + renderer version 75 % of the front's filesystem oldest first past 90 % used, down to 85 % a renderer version bump kept kept
HTTP caching browser URL — — — — —

3.1 The explode read cache

What it is. A read of a git object that sits deep in a delta chain costs several inflates. The cache keeps the inflated object so that the next read is one lookup. Every row can be rebuilt from the pack, so the cache is never data (exploded_cache.rs:1-16).

Two pools. The eager pool objects.exploded holds the commits and tags that the indexer writes at push time. It is never evicted, because reach and the graph walks need it. The lazy pool objects.exploded.c1, .c2, and so on is filled on the first read of an object. An object is cached only when it is worth it: delta depth over 2 (or an external base) and at most 1 MiB inflated (exploded_cache.rs:202-221).

Where. Since gunnar df519085 (release b52684ea, znippy-plugin-git 0.2.5), the cache lives at <serialdat.mountpoint>/explode when the layout config names a /serialdat that exists. Without one (a dev or KVM box), it stays beside each repository as before (explode_home.rs:1-34). The store keeps only its key file objects.exploded.key on /data.

Budget. Let S be the size of the home's filesystem, and let f = 50 %:

budget per repository  B = min(f · S, 8 GiB)          (floor 64 MiB)
line for all of them   G = f · S
disk line              trips when used(S) ≥ 80 %, releases when used(S) < 75 %

Without a home, the old rule applies on /data: f = 10 % (exploded_cache.rs:245-247, exploded_home.rs:56).

Eviction. The lazy pool is a row of segment files in first-read order. Eviction is one unlink of the oldest segment. An object that is still hot misses once, is rebuilt and is written into the newest segment. That makes it FIFO over segments with re-entry on a miss, which behaves like an LRU for a working set (exploded_cache.rs:39-62). Past the disk line the oldest segments go whatever the budget says, and the eager pool is never touched (exploded_cache.rs:270-291).

What empties it. A repo gc unlinks the lazy cache (gc_queue.rs:3-8). A purge unlinks the repository's directory under the home, and every boot sweeps the caches of repositories that are gone. A repository deleted and created again under the same name gets a new key, so its old cache is never read (exploded_home.rs:27-33).

Restarts. /serialdat is P3, which korp-installer keeps on every keep boot, and a live update does not touch it. A restart adopts the segments that the last process wrote. The in-memory oid index of the lazy pool is rebuilt at the first miss.

The move. The first open under the new release moves the old cache from /data into the key directory. Across two filesystems that is a copy (staged as .part, then renamed) and an unlink. A copy that fails drops the old cache, which is re-derivable.

Worked example (gunnar.rs main).

S = 47.9 GiB                      f · S = 23.9 GiB
B = min(23.9 GiB, 8 GiB) = 8 GiB per repository
G = 23.9 GiB for all 47 repositories together
disk line: trips at 0.80 · 47.9 = 38.3 GiB used, releases under 0.75 · 47.9 = 35.9 GiB
in use on 2026-11-06: 1.8 GiB

control storage prints the budget as "376.0 GiB". That figure is the sum of the per-repository caps (47 × 8 GiB). It is not a real limit. The line G binds first, at 23.9 GiB.

3.2 The store handle cache

What it is. An open repository handle holds the folded index, the commit graph and a redb page cache. Opening a cold repository took 27 s once on gunnar.rs. The cache keeps handles open between requests.

Price of one handle (budget.rs:205-222, measured 2026-08-31):

price(h) = 0.5 MiB + objects(h) · 768 B + min(tail_bytes(h), 64 MiB)

objects is the repository's own object count. tail_bytes is the size of its objects.tail database, because the redb page cache never holds more than the file.

Budget.

share    = MemAvailable at boot · 20 %
capacity = clamp((nofile − 256) / 3, 8, 256)          handles, by descriptors

Eviction rule (stores.rs:496-524). Sort the idle handles by last use. Drop the oldest while the count is over capacity, or while the summed price is over share and more than 8 handles are open. A handle in use is never dropped. When MemAvailable falls under 25 % of MemTotal, the bound is halved and the least recently used handles go (stores.rs:2425-2452). It never shrinks below 8.

Restarts. The cache is memory only. Every restart, keep boot or version bump starts it empty.

Worked example. On the 8 GB main, suppose MemAvailable is 7 GiB at boot.

share = 7 GiB · 0.20 = 1.4 GiB
nordisk/gunnar-sized repository: 12 849 objects, objects.tail 4 MiB
price = 0.5 + 12 849 · 768 B (9.4 MiB) + 4 = 13.9 MiB
1.4 GiB / 13.9 MiB ≈ 103 such handles fit; the descriptor capacity is 256
alice/linux-mirror: 13 948 244 objects → price ≈ 10.7 GB → over the share alone;
                    the floor of 8 still keeps it open

Before gunnar df519085, every handle was priced as a 100 000-object repository (137 MiB), so the same box kept about 11 handles. The 30 public repositories could not all stay open.

3.3 The warm-up of the public repositories

After a boot, the jera job warm-public-repos waits for the boot's disk test (readiness::is_ready). It then opens every public repository, one at a time, sorted by path, with a 2 s pause between opens (warm.rs:1-70). The handle cache's ledger decides what stays open. The first visitor of a public repository, often a crawler that read llms.txt, then pays no open.

3.4 The README render cache and the public-site prerender

README render cache (gunnar-ui-server/src/readme/cache.rs). It holds the typeset SVG of a README. The key is (namespace, name, blob oid, path, shape, renderer). The budget is 256 entries or 64 MiB, whichever binds first. A typeset SVG costs about 105 bytes per character of README, so a 3 KB README is about 350 KB. Eviction is in insertion order, because a true LRU would need a redb write on every hit. The cache lives in the front's state.redb, so it survives a front restart.

Prerendered page parts (gunnar-public-site/src/shell.rs:87). The front keeps the rendered parts of /<ns>/<repo>, /commits and /issues in memory: at most 4096 parts, 64 MiB, 2 MiB a part. A push or issue event drops the repository's parts and renders the three pages again. Parts older than 1 h expire at every refresh.

Public-site built pages (gunnar-public-site/src/lib.rs). llms.txt, the sitemap, robots.txt and the per-repository readme.md, tree.txt and issues.md are built in memory. The first build starts at the first request, then runs hourly, and at once on a push event. A refresh that fails or comes back empty is tried again after 10 s, doubling up to 5 min (lib.rs:156-162, RETRY_FIRST, RETRY_MAX).

The render store on the front (in progress, lane grender). The plan moves both renders to an on-disk store in the front's typeset dir:

budget      = 75 % of the front's filesystem
safety line = trips at 90 % used, releases at 85 % used (oldest entries go first)
key         = git blob oid (files and READMEs) or the commit oid a ref points to (pages)
              + the renderer version

Worked example: the front has one 40 GB disk, so the budget is about 30 GB. Eviction past the line starts at 36 GB used and stops under 34 GB. There is one copy only: the store replaces the in-memory and state.redb copies, it does not sit beside them.

3.5 HTTP caching

answer Cache-Control source
trunk's content-hashed wasm, js and css public, max-age=31536000, immutable gunnar-ui 86ab61b (serve.rs:922)
raw/<commit>/<path> public, max-age=31536000, immutable raw.rs:253
raw/<ref>/<path> public, max-age=300, ETag = blob oid (a 304 works) raw.rs:255-274
llms.txt, readme.md, tree.txt public, max-age=300 gunnar-public-site/src/lib.rs:568
a repository page's HTML no-cache public_site.rs:646,700
every /api/*, /auth/*, /login, /signup, /welcome no-store, Vary: cookie (identity-bearing) security.rs:60-80

A hashed name changes when its content changes, so the browser can keep it for a year. A name that points at a moving ref gets five minutes.

4. How a cache knows the source changed

Every cache that holds git content is keyed by a git object hash. A git object never changes under its hash.

new byte in a file  ⇒  new blob oid  ⇒  new key  ⇒  a miss, then one fresh render
new commit on a ref ⇒  the ref points at a new commit oid  ⇒  new page key
old key             ⇒  still right for the old content; it ages out by eviction

5. Lessons of 2026-11-06

Sources: ~/plan/GUNNAR-RS-ROOT-CAUSE-2026-11-06.md and ~/plan/GUNNAR-PAGES-FABLE-2026-11-06.md.

6. Decisions

date decision state
2026-11-06 The explode read cache moves to /serialdat (owner: "move the cache to serialdat"). done: gunnar df519085, znippy-plugin-git 0.2.5, release b52684ea
2026-11-06 The render store lives on the front, in the typeset dir, with a budget of 75 % of the front's filesystem and a safety line at 90/85 %. in progress (lane grender)
2026-11-06 No second copy. A render is stored once; there are no mirrored caches. rule
2026-11-06 The znippy archives stay on the striped data disks. They are real data and never go on a single disk. rule
2026-11-06 gc/repack scratch is not on /serialdat (section 2.1). Moving it is a storage-engine change and needs the owner's go. open, needs the owner's go
2026-11-06 The seccomp filter stays off until the KVM lifecycle passes with restart_syscall and getcpu in the profile. in force: gunnar 14fefa05
2026-11-06 The handle cache is priced by each repository's real object count, not by one token per box. done: gunnar df519085
2026-10-27 LOGIN-1: /login has no sign-up; sign-up is the front page's. in force unless the owner says otherwise