10 min read 5 sections

Snapshotting R-Tree State for Fast Restarts

The reason a rebuild takes ninety seconds is that it constructs 1.2 million Python objects from 1.2 million blobs of WKB. A snapshot that avoids the construction avoids nearly all of the ninety seconds; a snapshot that merely avoids the fetch saves thirty. This page sits under spatial index persistence and warm start within Spatial Indexing for Real-Time Checks, and it covers the file layout that makes a restore a memory map rather than a deserialisation, and the three safety mechanisms without which that layout is dangerous.

Concept and specification

The file has four regions and the order matters, because a reader validates before it trusts:

Region Contents Size (1.2M polygons) Validated by
Header Magic, format version, page size, counts, watermark, checksum 128 bytes Read and checked first
Node pages R-tree internal nodes: child MBRs and offsets 46 MB Header checksum
Entry table Leaf entries: fence id, MBR, geometry offset 88 MB Header checksum
Geometry arena Flat coordinate arrays, one contiguous run per ring 2.77 GB Header checksum, lazily faulted
Size (1.2M polygons) — 4 options Each panel scales on its own, so Size (1.2M polygons) are compared across 4 options without sharing an axis they do not share a unit with. Size (1.2M polygons) — 4 options Region — the table above, drawn to scale Header Node pages Entry table Geometry arena Size (1.2M polygons) 128 bytes 46 MB 88 MB 2.77 GB
Size (1.2M polygons) for Header, Node pages, Entry table and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

Everything is offset-addressed rather than pointer-addressed, which is the property that makes the file mappable at any base address. A node’s children are byte offsets from the start of the node region; an entry’s geometry is a byte offset and a vertex count into the arena. Nothing in the file contains a machine address, so mmap at whatever address the kernel chooses is immediately usable.

The header carries the three safety mechanisms:

The magic catches a wrong file. The version catches the dangerous case — a file written by a build whose node layout differed, which would otherwise be read as though it were the current layout, producing plausible garbage rather than an error. The checksum catches truncation and corruption, which matter because a snapshot is often written to object storage and downloaded.

Checksumming 2.9 GB costs about 1.1 s with a hardware-accelerated CRC32C, which is longer than the mmap itself. The workable compromise is to checksum the header, node and entry regions eagerly — 134 MB, 60 ms — and the geometry arena lazily, verifying each 2 MB block the first time it is faulted. That keeps startup fast while still catching corruption before it produces a wrong answer.

Step-by-step implementation

1. Write the header last, and write the file atomically. A reader must never see a header that promises regions the writer has not finished. Write to a temporary path, fsync, write the header, fsync again, then rename — rename is atomic within a filesystem, so a reader sees either the old complete file or the new one.

python
from __future__ import annotations
import mmap, os, struct, zlib
from dataclasses import dataclass

MAGIC = b"RTGFENCE"
FORMAT_VERSION = 7          # bump on ANY layout change, without exception
HEADER = struct.Struct("<8sIIQQQQI")   # magic, version, page, n_nodes,
                                       # n_entries, arena_bytes, watermark, crc

@dataclass(slots=True, frozen=True)
class SnapshotHeader:
    version: int
    page_size: int
    n_nodes: int
    n_entries: int
    arena_bytes: int
    watermark_ns: int
    crc: int

def write_snapshot(path: str, nodes: bytes, entries: bytes, arena: bytes,
                   watermark_ns: int, page_size: int = 4096) -> None:
    tmp = path + ".tmp"
    crc = zlib.crc32(nodes)
    crc = zlib.crc32(entries, crc)
    with open(tmp, "wb") as fh:
        fh.write(b"\0" * HEADER.size)          # reserve, fill in last
        fh.write(nodes)
        fh.write(entries)
        fh.write(arena)
        fh.flush()
        os.fsync(fh.fileno())
        fh.seek(0)
        fh.write(HEADER.pack(MAGIC, FORMAT_VERSION, page_size, len(nodes),
                             len(entries), len(arena), watermark_ns, crc))
        fh.flush()
        os.fsync(fh.fileno())
    os.replace(tmp, path)                       # atomic within a filesystem

def open_snapshot(path: str) -> tuple[mmap.mmap, SnapshotHeader]:
    fh = open(path, "rb")
    raw = fh.read(HEADER.size)
    magic, ver, page, n_nodes, n_entries, arena, wm, crc = HEADER.unpack(raw)
    if magic != MAGIC:
        raise ValueError("not a fence snapshot")
    if ver != FORMAT_VERSION:
        raise ValueError(f"snapshot format {ver}, this build reads {FORMAT_VERSION}")
    mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
    body = mm[HEADER.size:HEADER.size + n_nodes + n_entries]
    if zlib.crc32(body) != crc:
        mm.close()
        raise ValueError("snapshot checksum mismatch")
    return mm, SnapshotHeader(ver, page, n_nodes, n_entries, arena, wm, crc)

2. Bump the version on every layout change, mechanically. Not “when it seems significant” — a field widened from 32 to 64 bits, a reordered struct, a different coordinate encoding all invalidate every existing file. Derive the version from a hash of the struct definitions if the discipline is hard to maintain by hand.

3. Always have a rebuild fallback, and exercise it. Every failure path above raises, and the caller’s response must be to rebuild from the source of truth rather than to exit. A version stamp with no fallback turns a routine deploy into an outage.

4. Record the change-stream watermark in the header, written in the same atomic operation as the contents. A watermark stored separately can disagree with the file, and a snapshot claiming to include edits it does not is worse than one that claims less than it has.

5. Align regions to the page size. A node region starting mid-page means every node access faults a page shared with the header, and the arena’s lazy faulting behaviour becomes unpredictable. Pad each region to a page boundary; the waste is kilobytes.

6. Warm the internal nodes deliberately. madvise(MADV_WILLNEED) over the node region only — 46 MB of 2.9 GB — pulls the branch structure into memory without the leaves, cutting the post-ready P99 ramp from 140 ms to 9 ms as the parent topic measures.

Benchmark and verification

Measured on 1.2M polygons, 96 vertices mean, on NVMe:

Format Write time File size Restore to first query Restore to steady P99
Rebuild from source n/a n/a 94.1 s 94.1 s
Pickle of live objects 38 s 4.9 GB 46.0 s 46.0 s
Arrow columnar 9.1 s 2.6 GB 6.8 s 7.2 s
Packed pages, mmap 6.4 s 2.9 GB 0.40 s 1.9 s
Packed pages + node warm 6.4 s 2.9 GB 0.46 s 0.55 s
Write time, Restore to first query, Restore to steady P99 — 5 options Each panel scales on its own, so Write time, Restore to first query, Restore to steady P99 are compared across 5 options without sharing an axis they do not share a unit with. Write time, Restore to first query, Restore to steady P99 — 5 options Format — the table above, drawn to scale Rebuild from source Pickle of live objects Arrow columnar Packed pages, mmap Packed pages + node warm Write time 38 s 9.1 s 6.4 s 6.4 s Restore to first query 94.1 s 46.0 s 6.8 s 0.40 s 0.46 s Restore to steady P99 94.1 s 46.0 s 7.2 s 1.9 s 0.55 s
Write time, Restore to first query and 1 more for Rebuild from source, Pickle of live objects, Arrow columnar and 2 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The last two rows separate two things that are easy to conflate. “Restore to first query” is when the process can answer at all; “restore to steady P99” is when it answers at production latency. Without the node warm, the index is queryable in 0.4 s but spends the next second and a half faulting branch pages under live traffic, so its P99 during that window is an order of magnitude high. The warm costs 60 ms and removes the ramp — a good trade, and one that only shows up if the metric is measured after readiness rather than at it.

Write time matters more than it first appears, because the snapshot cadence has to be frequent enough to keep catch-up short. At 6.4 s per full write, a 60 s cadence spends 11% of a core continuously, which is why incremental snapshots — writing only the pages that changed — are the usual production arrangement, with a full write on a slower schedule.

Verify with a deliberate corruption test in CI: truncate the file, flip a byte in the node region, and write a file with the version decremented. Each must raise and each must trigger a rebuild rather than a crash or, worse, a successful load. This test is the one that justifies the header, and a codebase without it will eventually ship a layout change that reads old snapshots as current ones.

Failure modes and edge cases

Failure mode Signature Mitigation
Version not bumped on a layout change Plausible garbage; wrong answers, no error Derive the version from the struct definitions
Header written first A reader sees a header promising an unfinished file Write the header last, then rename atomically
No rebuild fallback A rejected snapshot becomes an outage Every validation failure falls back to a rebuild
Watermark stored separately Snapshot claims edits it does not contain Write the watermark inside the header atomically
Whole-file checksum at startup 1.1 s added to every restore Checksum metadata eagerly, arena blocks lazily
Pointers instead of offsets File unusable at a different base address Address everything by offset from a region start
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Version not bumped on a layout change Plausible garbage; wrong answers, no error Derive the version from the struct definitions Header written first A reader sees a header promising an unfinished file Write the header last, then rename atomically No rebuild fallback A rejected snapshot becomes an outage Every validation failure falls back to a rebuild Watermark stored separately Snapshot claims edits it does not contain Write the watermark inside the header atomically Whole-file checksum at startup 1.1 s added to every restore Checksum metadata eagerly, arena blocks lazily Pointers instead of offsets File unusable at a different base address Address everything by offset from a region start
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The last row is the design constraint that everything else follows from, and it is easy to violate accidentally in a language with real pointers or in a serialisation library that preserves object identity. The test is simple: a file that can be mapped twice, at two addresses, in the same process, and produce identical query results from both mappings is offset-addressed; one that cannot, is not.

One operational caveat about mmap that surprises teams the first time: resident memory after a restore is small and grows as queries fault pages in, so a node that has just restored looks like it is leaking memory for its first few minutes. It is not — it is populating its working set — and the plateau is the working set rather than the file size, typically well under half of it because most fences are never queried in a given hour. Alert on the plateau, not on the growth.