Skip to content

blob storage for large atoms#985

Open
matthew-levan wants to merge 27 commits into
ml/64from
ml/bob
Open

blob storage for large atoms#985
matthew-levan wants to merge 27 commits into
ml/64from
ml/bob

Conversation

@matthew-levan

@matthew-levan matthew-levan commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Blob Store for Large Atoms

Summary

Introduces a blob store that lets Urbit handle atoms larger than 32 MiB
without ever holding their bytes on the loom. Such atoms are stored as
content-addressed files under $pier/.urb/bob/ and referenced on the loom by a
bob atom — an indirect atom whose body points at small loom-resident
metadata instead of the bytes themselves. Serialization, the event log, IPC,
Clay sync, HTTP, and Ames all handle bob atoms transparently, materializing
bytes from disk (zero-copy via mmap) only when a jet actually needs them.

Motivation

The loom is a fixed-size memory-mapped region. Before this change, committing a
2 GiB file via |commit %base required allocating a 2 GiB atom on the loom,
jamming it for the event log, and shipping the jammed bytes over the king/serf
pipe — multiplying peak memory and making large-file support impractical.

Design

Bob atoms and the blob bank (pkg/noun/allocate.h, imprison.c, retrieve.c)

A bob atom is an indirect atom whose len_w MSB is set (u3a_blob_flag); its
buf_w[0] holds the loom offset of a u3a_blob metadata record rather than
data. The bank blb_p is a HAMT in u3v_home keyed by
bid = (mug_h << 32) | seq_h, marked during GC and rewritten during pack.

The design uses a single reference counter. A blob file is deletable iff
use_w == 0, where use_w = eve_w + les_h + live bob-atom cardinality:

  • eve_w — event-log refcount (rebuilt from the LMDB BLOBS table on
    replay/chop)
  • les_h — active king-held leases (durable in the LMDB LEASES table; the
    loom copy is zeroed every boot and rebuilt from that table)
  • atom cardinality — number of live bob atoms pointing at this record, updated
    only on atom alloc/free

u3i_blob(mug, seq) allocates a bob atom. Retrieve functions (u3r_met,
u3r_bytes, u3r_mug, u3r_sing, …) and the new zero-copy u3r_view detect
bob atoms and source bytes from disk on demand. _ca_take_atom masks len_w
with u3a_blob_mask so bob atoms copy correctly across road transitions.

Blob store (pkg/vere/blob.c, blob.h)

Content-addressed files at .urb/bob/<mug>/<seq>. Per-bucket lockfiles hold the
next sequence number under fcntl advisory locking; install dedups byte-for-byte
against existing files in the bucket. A staging area .urb/bob/stg/ holds
in-flight uploads (cleaned at boot). The API covers save, load, mmap/unmap,
wipe (removes empty buckets), walk (enumerates the store), and staging-file
install. Includes a Windows mmap shim. Earth (king) is the sole writer;
Mars (serf) is read-only.

Ram/tap serialization (pkg/noun/serial.c, serial.h)

ram extends jam's bit-encoding with a 2-bit tag — normal atom, cell, backref,
or blob ref (mat-encoded mug then seq) — so bob atoms cross IPC and the
event log as compact references, never as bytes. tap decodes, rebuilding bob
atoms via u3i_blob. The wire format prepends a 5-byte "RAM\0" + version
header. Jam/cue are unchanged; old VER1/VER2 jam data is still read via fallback.

A streaming cue (u3s_cue_xeno_blob + u3_blob_bsink) lets rock import
stream over-threshold atoms straight from the jam bitstream into the blob store
in 1 MiB chunks — the atom never touches the loom — so |cram/rock import of
large state no longer needs --loom headroom.

IPC and king leases (pkg/vere/newt.c, lord.c, mars.c, king.c, vere.h)

The newt wire protocol gains a version byte (0x00 jam, 0x01 ram); all sends
use ram, all receives try tap then fall back to cue.

Leases are king-acquired, mars-issued, keeping a blob alive only in the
window between a %blob install and the commit of the event that references it.
The king stages a large file and sends a %blob writ; mars installs it and
issues a lease (les_h += 1) with a 15-minute TTL tracked in a c-heap min-heap.
The king renews via %blas every 5 minutes for blobs it still references and
releases via %blrl when its last reference dies. Renewal never resurrects a
deleted blob's bank entry.

Leases are durable. Each is a row in an LMDB LEASES table (MDB_DUPSORT,
keyed by blob id; the value pairs the expiry with a unique lease id, so multiple
live leases coexist on one blob and a specific row can be deleted). The row is
committed before mars acks the %blob install, so a mars crash in the
install→commit window cannot lose a lease the king believes it holds. %blrl
and TTL expiry delete the corresponding row; %blrl retires exactly one lease
unit. At boot, _find_home zeroes the snapshot's les_h and _mars_play_leases
rebuilds it from the table, preserving each lease's remaining TTL and pruning
expired or file-missing rows. To make the durable row trustworthy,
u3_blob_move_stg now fsyncs the bucket directory after the staging rename.

LMDB ref tracking, chop, and boot GC (pkg/vere/disk.c, db/lmdb.c)

Each committed event's blob ids are written to a BLOBS table keyed by event
number. Replay and |chop rebuild eve_w from that table over the retained
log. u3_disk_blob_gc reconciles .urb/bob/ against the bank — deleting blobs
at use_w == 0 and on-disk files with no bank entry — and now runs both under
|chop and at boot (after replay rebuilds eve_w and _mars_play_leases
restores les_h). Durable leases are what make boot-time reclamation safe: a
genuinely orphaned file is no longer indistinguishable from one a live lease
still protects.

Versions and migration (pkg/noun/version.h, disk.c)

  • U3V_VER6 (home-road format): adds blb_p. _find_home migrates a v5
    snapshot in place and stamps ver_d; idempotent across a crash before the
    next checkpoint. Bitness migrations are only defined for U3V_VERLAT, keeping
    paths linear rather than a version×bitness grid; both disk entry points reject
    older snapshots with an actionable error.
  • U3E_VER3 (epoch format): image.bin + ram-encoded event log. VER2→VER3
    rolls over to a new epoch.

Blob-aware jets (pkg/noun/jets/...)

Atom jets that read raw bytes — cut, met, can, cat, end, rap,
rip, rsh, sew, swp, the hash/crypto jets (shax, sha1, blake, keccak,
ripe, muk, adler, crc, aes, chacha, ed25519), bytestream, zlib — source
bytes from blob files (via u3r_view mmap) instead of loom buffers. Adds a
jam_shax jet that fuses jam and shax for blob hashing.

IO: HTTP, Unix, Mesa (pkg/vere/io/{http,unix,mesa}.c)

  • HTTP: response bodies backed by bob atoms mmap the blob file and slice
    the mapping into the body chain — bytes never enter the king's loom; inbound
    bodies over threshold go straight to the blob store.
  • Unix/Clay: syncs of large files stage to .urb/bob/stg/ and install via
    the %blob writ, injecting %into from the install callback; %ergo
    write-back streams from the store file in fixed chunks; the soft "has it
    changed?" check compares mugs without reading bytes.
  • Mesa: reads packet atoms through u3r_view, and stores reassembled jumbo
    frames over threshold as blobs.

Meld, cram, and replay integrity

  • |meld/|pack: _melt_cmp_atoms compares bob atoms by bid (not a memcmp
    that would read off the end of the stub); a deep u3a_blob_sane() walk runs
    after every pack/meld, with a cheap floor check at u3e_save.
  • |cram: serializes through the blob-aware jam, expanding bob bytes (mmap'd
    from the store) into the rock so rocks stay portable; the home road is
    untouched. Deprecated u3u_meld and the unused _cu_* machinery are removed.
  • Replay: a blob file missing from the store while the log still references
    it now fails replay hard (_mars_play_blobs reports every missing file with
    its event number and exits nonzero) instead of silently deferring the error.

What this does not change

  • Jam/cue format and Nock semantics are unchanged; bob atoms are a pure runtime
    optimization with no Hoon/Arvo changes.
  • Rocks remain fully self-contained and portable (cram expands blob bytes into
    the jam).
  • No protocol changes are visible outside the king/serf boundary.

Status

Implementation is feature-complete. Test suites accompany the major
subsystems: blob_tests.c (incl. _test_sane/_test_meld invariant checks and
_test_lease* covering durable-lease round-trip, persistence across a restart,
and the multi-lease-per-blob invariant), serial_tests.c (incl. streaming-cue
coverage), retrieve_tests.c, and newt_tests.c. CI runs full replay of
fixture piers in both 32- and 64-bit builds.

Known limitations

  • u3i_bytes takes a c3_w (uint32_t) length, capping a single materialized
    loom atom at 4 GiB. Blobs larger than this live on disk but cannot be fully
    realized on the loom; jets that stream via u3r_view are not subject to this.
  • Each retrieve maps the blob from disk; there is no cross-call materialization
    cache within a single Nock event. u3r_view (zero-copy mmap) keeps this
    cheap, but repeated access still re-maps.

Future work

This PR establishes the blob store but only populates it at I/O ingress. Several
follow-ups extend it into a general large-atom memory tier:

Big-atom arithmetic / computation. Operating on a blob atom currently
round-trips the whole thing through the loom: u3i_vint, u3r_mp, and friends
call u3r_blob_load to materialize before computing. We want operators and jets
that compute directly against the mmap'd blob via the existing u3r_view
zero-copy path, and — where a result is itself large — write it straight back to
the store instead of materializing on the loom. The cheap single-pass cases come
first (increment via a one-pass carry over the mmap, comparison, slicing/cut,
which already read zero-copy), expanding toward the operations that big atoms
actually flow through.

A %blob hint. A Nock 11 hint, emitted from Hoon with ~>, that lets app
developers tell the runtime "atoms produced in this scope are expected to be
large." It plumbs through the existing dynamic-hint machinery — whitelisted in
_n_bint, handled in _n_hint_fore / _n_hint_hind (pkg/noun/nock.c) —
alongside hints like %bout and %hela. It is purely advisory (no semantic
change to the computation): the runtime uses it to bias allocation and
blobification decisions for results computed under the hint, e.g.
blobify-on-produce even below the size threshold, or to prime the automatic
detector below.

Automatic blob detection in the event loop. Nothing blobifies during normal
event processing today. We want a post-poke pass — around _mars_sure_feck
(pkg/vere/mars.c), after u3v_poke runs the kernel and before effects route
to drivers — that walks the new state and effects and externalizes atoms over
the threshold straight to the store, so a computation that produces a large atom
doesn't bloat the loom. This reuses u3_blob_save (pkg/vere/blob.c),
including its byte-for-byte _blob_dedup, so a recomputed identical atom maps
back onto the existing (mug, seq) rather than writing a duplicate. The %blob
hint feeds this pass as an explicit signal.

A blobify sweep in |pack. Extend |pack (u3m_pack, pkg/noun/manage.c)
— or a sibling command — with a pass that, during loom compaction, scans
reachable atoms and blobifies any exceeding U3_BLOB_THRESH, reclaiming loom
space. It fits naturally between the existing seek (u3a_pack_seek) and move
(u3a_pack_move) phases. Unlike the inline detector, this runs over everything
reachable rather than just freshly-produced atoms, so it catches large atoms that
accumulated before the detector existed or slipped below its per-event view.

Together these let large atoms originate, persist, and be computed on without
ever being forced to occupy the loom in full.

a bob atom is an indirect atom flagged with `u3a_blob_flag` in `len_w`
whose `buf_w[0]` is the loom offset of its `u3a_blob` metadata.
single-counter design: a blob file is deletable iff `use_w` == 0, where
`use_w` is the sum of `eve_w` (event-log refs), `les_h` (king-held
leases), and live bob-atom cardinality.

the blob bank `blb_p` is a hamt in `u3v_home` keyed by
`bid = (mug << 32) | seq`, marked during gc and rewritten during pack.
`les_h` is transient ipc state and is zeroed at every boot.
content-addressed files at `.urb/bob/<mug>/<seq>` with bucket lockfiles
and dedup on install; staging area `.urb/bob/stg/` for in-flight
uploads, cleaned at boot. apis: save, load, `mmap`/unmap, wipe (removes
empty buckets), walk (enumerates the store, skipping staging and
lockfiles), and staging-file install. adds windows `mmap` support.
`ram` extends jam's bit-encoding with a 2-bit tag: normal atom, cell,
backref, or blob ref (mat-encoded `mug` then `seq`) — bob atoms cross
ipc as references, never as bytes. `tap` is the decoding side,
materializing bob atoms via `u3i_blob`. wire format adds a 5-byte
`"RAM\0"` + version header; `newt` carries the version byte.
atom jets that read raw bytes (`cut`, `met`, `can`, `cat`, `end`,
`rap`, `rip`, `rsh`, `sew`, `swp`, hash and crypto jets, `bytestream`,
`zlib`, ...) learn to source bytes from blob files instead of loom
buffers. adds a `jam_shax` jet fusing jam and shax for blob hashing.
each committed event's blob ids are written to a `BLOBS` table keyed
by event number. chop rebuilds `eve_w` for retained epochs (zero, scan,
delete at `use_w` == 0) and reconciles `.urb/bob/` against the bank via
`u3_blob_walk`, deleting orphaned files no accounting can reach.
leases are king-acquired, mars-issued: they keep a blob file alive only
in the window before mars holds durable refs (event log / snapshot).

the king stages a large file and sends a `%blob` writ; mars installs it
and issues a lease (`les_h` += 1) with a 15-min ttl, tracked in a
c-heap min-heap. the king renews via `%blas` every 5 min for every blob
it still references, so a blob-bearing event pending in mars cannot
outlive the ttl; renewal never resurrects a deleted blob's bank entry.
the king releases via `%blrl` when its last reference dies. replay
rebuilds `eve_w` per batch after the disk walk closes (a nested read
txn on one thread fails with `MDB_BAD_RSLOT`), and commit warns loudly
if an event references a blob missing from the bank.
http response bodies backed by bob atoms `mmap` the blob file and slice
the mapping into the body chain, never materializing the bytes in the
king's loom; inbound bodies over `U3_BLOB_THRESH` go straight to the
blob store. unix syncs of large files stage to `.urb/bob/stg/` and
install via the `%blob` writ, injecting the `%into` event from the
install callback. mesa reads packet atoms through `u3r_view` (`mmap`
when blob-backed) and stores large reassembled jumbo frames as blobs.
replaces the lazy zero-buffer init of `blb_p` (and the mid-v5 road
additions `lop_p` and `cax.for_p`) with a versioned migration: v6 is an
append-only change within the home page, so `_find_home` migrates a v5
snapshot in place and stamps `ver_d`. idempotent across a crash before
the next checkpoint.

bitness migrations are only defined for `U3V_VERLAT` snapshots — older
snapshots must first be migrated by a same-bitness binary — keeping the
migration paths linear instead of a version x bitness grid; both
`disk.c` entry points reject older versions with an actionable error.
a blob file must survive until no event-log ref, lease, or loom atom
references it.  a fakezod lost a log-referenced blob after `|rm`,
`|tomb`, `|meld` + ford-cache clear; this hardens every path involved:

- `_mars_blob_del` refuses to delete while `eve_w` or `les_h` is
  nonzero even at `use_w` == 0 — that state is counter corruption,
  and we never destroy data on an accounting bug
- new `u3a_blob_sane()` verifies `use_w` == `eve_w` + `les_h` + live
  bob-atom cardinality per bank entry: cheap tier (floor check) at
  `u3e_save` behind `u3o_check_corrupt`, deep tier (noun-graph walk
  with a c-heap verstable visited set, so the checker never allocates
  on the loom it checks) after every `|pack` and `|meld`
- `_melt_cmp_atoms` compares bob atoms by `buf_w[0]` (same `u3a_blob`
  => same bid) instead of memcmp with raw `len_w`, which carried
  `u3a_blob_flag` and read ~2^31 words off the end
- `u3u_cram` serializes through the blob-aware jam (`u3s_jam_xeno`):
  bob atoms are expanded into the rock as regular atoms, bytes mmap'd
  straight from the blob store; rocks stay portable and the loom —
  including `blb_p` — is untouched.  formerly it routed through
  `_cu_realloc`, which repaved the home road (wiping the bank) and
  read bob atoms' `len_w` raw
- deletes the deprecated `u3u_meld` and the now-unused `_cu_*`
  loom<->ur machinery
- tests: `_test_sane` (checker catches both corruption directions),
  `_test_meld` (real `u3_meld_all` over duplicate bob atoms:
  unification, intact counts, valid pointer post-pack, and no
  deletion request while the log ref is held)
rock import previously materialized cram-expanded blobs as plain loom
atoms (a 300mb blob needed 300mb of loom and bloated the snapshot),
and `u3u_uncram`'s repave wiped the blob bank, losing `eve_w` for
every log-referenced blob on the imported pier.

- serial: adds `u3s_bsink` and `u3s_cue_xeno_blob()`: when a sink is
  installed on a cue handle, atoms over the threshold stream from the
  jam bitstream through the sink in 1mb byte-multiple chunks
  (`ur_bsr_bytes_any` chunks concatenate exactly; the final chunk
  carries the bit remainder) — the atom never touches the loom.  the
  sink's `don_f` supplies the decoded atom, which enters the cue
  dictionary so backrefs share it.  sink failure fails the cue: the
  bits are consumed, so there is no fallback
- blob: adds `u3_blob_bsink`, the sink implementation: staging file
  under `.urb/bob/stg/`, then `u3_blob_move_stg` (mug, content dedup,
  rename) and `u3i_blob` — files surviving from before the import are
  re-linked rather than duplicated
- disk: adds `u3_disk_blob_refs()`, rebuilding `eve_w` from the lmdb
  `BLOBS` table over the retained log; the scan callback now creates
  missing bank entries (queu's repave empties the bank)
- queu: wires it together — sink + `U3_BLOB_THRESH` through
  `u3u_uncram`, blobified-count report, `eve_w` rebuild, and a deep
  `u3a_blob_sane()` check before the save.  importing a rock with
  large atoms no longer needs `--loom` headroom
- tests: `_test_cue_blob` covers streaming, backref sharing, byte
  round-trip, bank cardinality, and store dedup across re-cues

rocks stay fully self-contained (cram still expands blob bytes into
the jam) as a platform-independent way to move state; a ram-format
rock with blob refs was considered and rejected for that reason.
a blob file missing from the store while the log still references it
means the replayed range cannot reproduce its state.  previously the
`eve_w` rebuild silently recreated the bank entry and replay
"succeeded", deferring the failure to whenever something first read
the blob's bytes.

`_mars_play_blobs` now checks `u3_blob_live` for every blob ref in the
replayed range, reports each missing file with its event number (the
scan continues, so all missing blobs are listed), and returns `c3n`;
`_mars_play_batch` turns that into `_play_bad_e`, which exits nonzero.

with the deletion guard, chop orphan sweep, and lease renewal in
place, this state should only be reachable by external interference
(manual deletion, partial backup restore) — exactly what a hard
failure at boot is for.
`les_h` was the one leg of `use_w` with no durable backing: it lived
only in the loom snapshot and a C-heap PQ, and was zeroed on boot.  A
mars crash in the `%blob` install -> commit window therefore lost the
king's lease, and boot could not safely reconcile orphans (a leaked file
and a still-leased file were indistinguishable), so reclamation was
deferred to `|chop`.

- lmdb: adds a `LEASES` table (`MDB_DUPSORT`, keyed by `bid`, value
  `[exp, lea]`).  duplicate keys let multiple live leases coexist on one
  blob; `lea` is a unique id that disambiguates them so identical
  expiries do not collapse and a specific row can be deleted.  `maxdbs`
  3 -> 4.  apis: save, delete (exact row, idempotent), full-scan walk
- mars: `_mars_lease_take` commits the row before the install is acked,
  rolling back the in-memory bump on failure; the sweeper and `%blrl`
  drop the row when a lease expires or is released.  `%blrl` now retires
  exactly one live unit via `_mars_pq_kill_one`, fixing a latent
  double-decrement (it never actually marked its PQ entry dead)
- mars: `_mars_play_leases` rebuilds `les_h` from the table at boot,
  preserving each lease's remaining TTL and pruning expired/dead rows
  (collect-then-delete to avoid `MDB_BAD_RSLOT`)
- disk: factors chop's reconciliation into `u3_disk_blob_gc` and runs it
  at boot (after replay rebuilds `eve_w` and leases restore `les_h`), so
  unreferenced blobs and orphan files are reclaimed promptly
- blob: fsyncs the bucket directory after the staging rename, so a
  committed lease row can never reference a lost directory entry
- tests: `_test_lease{,_persist,_dups}` cover the round-trip, durability
  across a close/reopen, `BLOBS` coexistence, and the multi-lease-per-bid
  invariant
@matthew-levan matthew-levan marked this pull request as ready for review June 23, 2026 15:20
@matthew-levan matthew-levan requested a review from a team as a code owner June 23, 2026 15:20
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