plugmem_arena/blob.rs
1//! Append-only heap for variable-length byte blobs.
2//!
3//! The counterpart to [`Arena`](crate::Arena)'s fixed-size slots: fact
4//! texts, entity names, raw vectors — anything whose length varies — lives
5//! here as a contiguous run of bytes, addressed by a dense [`BlobId`].
6//!
7//! State is two flat sections (the byte pool and the `(offset, len)` index),
8//! so persisting a heap is a `memcpy` of both — the same snapshot contract
9//! as the arena. There is **no removal**: blobs stay until the owner runs a
10//! compaction pass (rewrite live blobs into a fresh heap and remap ids),
11//! which keeps `push`/`get` trivially O(1) and the memory image dense in the
12//! common append-mostly workload.
13//!
14//! # Overlay opens
15//!
16//! [`BlobHeap::load_borrowed`] (and its alias [`BlobHeap::load_overlay`])
17//! open a heap whose existing bytes are **borrowed** from a longer-lived
18//! buffer — typically a memory-mapped file — while any later [`push`] lands
19//! in a small **owned tail**. Because the append-only heap never rewrites an
20//! existing blob, this needs no per-page copy-on-write: a blob is wholly in
21//! the borrowed base or wholly in the tail, so reads dispatch on a single
22//! length comparison and the base is never cloned. That is what lets a
23//! multi-gigabyte heap be opened and *appended to* without loading it into
24//! RAM (see `examples/overlay.rs`).
25//!
26//! [`push`]: BlobHeap::push
27
28use alloc::vec::Vec;
29use core::fmt;
30
31use crate::error::Error;
32
33/// Serialized metadata header of the dumped index: `[blobs u32]`
34/// (see [`BlobHeap::dump_index`]).
35const INDEX_HEADER: usize = core::mem::size_of::<u32>();
36
37/// Serialized width of one blob's length entry (little-endian `u32`).
38const LEN_BYTES: usize = core::mem::size_of::<u32>();
39
40/// Handle to one blob in a [`BlobHeap`]: a dense index assigned in push
41/// order, starting at 0.
42///
43/// Ids are plain `u32`s so owners can store them inside fixed-size
44/// [`Slot`](crate::Slot)s; the newtype only guards against mixing them with
45/// other integer ids.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct BlobId(pub u32);
49
50/// [`BlobHeap`] configuration.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub struct BlobHeapCfg {
54 /// Hard ceiling for the byte pool; a push that would grow past it fails
55 /// with [`Error::CapacityExceeded`]. The pool is otherwise bounded only by
56 /// `usize`: 4 GiB on a 32-bit target (e.g. wasm32 linear memory), the host
57 /// address space / RAM on a 64-bit one.
58 pub max_bytes: usize,
59 /// Per-blob length ceiling; a longer blob fails with
60 /// [`Error::BlobTooLarge`]. Guards against one runaway value swallowing
61 /// the pool.
62 pub max_blob: usize,
63}
64
65impl BlobHeapCfg {
66 /// Creates a config with no pool limit (beyond `usize`: 4 GiB on 32-bit,
67 /// the address space on 64-bit) and no per-blob limit.
68 pub const fn new() -> Self {
69 Self {
70 max_bytes: usize::MAX,
71 max_blob: usize::MAX,
72 }
73 }
74
75 /// Returns the config with `max_bytes` replaced.
76 pub const fn with_max_bytes(mut self, max_bytes: usize) -> Self {
77 self.max_bytes = max_bytes;
78 self
79 }
80
81 /// Returns the config with `max_blob` replaced.
82 pub const fn with_max_blob(mut self, max_blob: usize) -> Self {
83 self.max_blob = max_blob;
84 self
85 }
86}
87
88impl Default for BlobHeapCfg {
89 /// Same as [`BlobHeapCfg::new`].
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95/// Append-only byte heap addressed by dense [`BlobId`]s.
96///
97/// ```
98/// use plugmem_arena::{BlobHeap, BlobHeapCfg};
99///
100/// let mut heap = BlobHeap::new(BlobHeapCfg::new());
101/// let hello = heap.push(b"hello").unwrap();
102/// let world = heap.push(b"world").unwrap();
103/// assert_eq!(heap.get(hello), b"hello");
104/// assert_eq!(heap.get(world), b"world");
105/// assert_eq!(heap.len(), 2);
106/// ```
107#[derive(Clone)]
108pub struct BlobHeap<'a> {
109 /// Borrowed base bytes: the blobs present at open time, e.g. an mmap'd
110 /// snapshot section (`load_borrowed`). Empty (`&[]`) on the fully-owned
111 /// path (`new`/`load`), where every byte lives in `tail`. Never mutated
112 /// after open — that is what keeps the borrow zero-copy.
113 base: &'a [u8],
114 /// Owned append tail: bytes pushed after open. A [`BlobHeap::push`]
115 /// always extends this and never touches `base`, so a heap opened over a
116 /// borrowed base grows without cloning it. The logical pool is
117 /// `base` followed by `tail`; blob offsets are cumulative over that
118 /// concatenation, so each blob is wholly in one segment.
119 tail: Vec<u8>,
120 /// `BlobId` -> `(offset, len)` into the logical `base ++ tail` pool. The
121 /// offset is the cumulative logical position (a `usize`, so a 64-bit host
122 /// addresses a pool past 4 GiB; a 32-bit host caps at its address space),
123 /// independent of where the base/tail boundary falls. The length stays a
124 /// `u32` — a single blob is bounded by `max_blob` — which keeps the entry
125 /// compact and the dumped index format unchanged.
126 index: Vec<(usize, u32)>,
127 cfg: BlobHeapCfg,
128}
129
130impl<'a> BlobHeap<'a> {
131 /// Creates an empty heap. Allocates nothing until the first push.
132 pub const fn new(cfg: BlobHeapCfg) -> Self {
133 Self {
134 base: &[],
135 tail: Vec::new(),
136 index: Vec::new(),
137 cfg,
138 }
139 }
140
141 /// Total bytes of the logical pool (`base` + `tail`).
142 fn pool_len(&self) -> usize {
143 self.base.len() + self.tail.len()
144 }
145
146 /// Bytes of one blob given its logical `(offset, len)`. A blob never
147 /// straddles the base/tail boundary — its offset is either below
148 /// `base.len()` (wholly in `base`) or at/above it (wholly in `tail`) —
149 /// so this dispatches on a single comparison and returns a contiguous
150 /// slice.
151 fn slice(&self, offset: usize, len: usize) -> &[u8] {
152 let base_len = self.base.len();
153 if offset < base_len {
154 &self.base[offset..offset + len]
155 } else {
156 let at = offset - base_len;
157 &self.tail[at..at + len]
158 }
159 }
160
161 /// Appends a blob and returns its id. Zero-length blobs are valid.
162 ///
163 /// The bytes always land in the owned `tail`; a heap opened over a
164 /// borrowed base (`load_borrowed`) is appended to without cloning that
165 /// base.
166 ///
167 /// # Errors
168 ///
169 /// - [`Error::BlobTooLarge`] if `bytes` is longer than
170 /// [`BlobHeapCfg::max_blob`];
171 /// - [`Error::CapacityExceeded`] if the pool would grow past
172 /// [`BlobHeapCfg::max_bytes`] or the `usize` pool ceiling (4 GiB on a
173 /// 32-bit target).
174 pub fn push(&mut self, bytes: &[u8]) -> Result<BlobId, Error> {
175 if bytes.len() > self.cfg.max_blob {
176 return Err(Error::BlobTooLarge {
177 len: bytes.len(),
178 max_blob: self.cfg.max_blob,
179 });
180 }
181 let capacity_exceeded = Error::CapacityExceeded {
182 max_bytes: self.cfg.max_bytes,
183 };
184 let offset = self.pool_len();
185 // `checked_add` bounds the cumulative offset to `usize`, which is the
186 // pool ceiling on a 32-bit host (4 GiB); on 64-bit only `max_bytes`
187 // and RAM bind. The blob length itself stays within `u32` via the
188 // `max_blob` check above.
189 let end = offset.checked_add(bytes.len()).ok_or(capacity_exceeded)?;
190 if end > self.cfg.max_bytes {
191 return Err(capacity_exceeded);
192 }
193 // Ids are u32 with `u32::MAX` excluded so `id + 1` always fits (the
194 // interner's table relies on that); unreachable in practice before
195 // the pool ceiling unless the heap holds billions of zero-length
196 // blobs.
197 let id = u32::try_from(self.index.len())
198 .ok()
199 .filter(|&i| i != u32::MAX)
200 .ok_or(capacity_exceeded)?;
201 self.tail.extend_from_slice(bytes);
202 self.index.push((offset, bytes.len() as u32));
203 Ok(BlobId(id))
204 }
205
206 /// Returns the bytes of a blob.
207 ///
208 /// # Panics
209 ///
210 /// Panics if `id` was not returned by this heap's [`BlobHeap::push`] —
211 /// a dangling id is a caller bug, not a runtime condition.
212 pub fn get(&self, id: BlobId) -> &[u8] {
213 let (offset, len) = self.index[id.0 as usize];
214 self.slice(offset, len as usize)
215 }
216
217 /// Number of blobs stored.
218 pub fn len(&self) -> usize {
219 self.index.len()
220 }
221
222 /// `true` when no blob has been pushed.
223 pub fn is_empty(&self) -> bool {
224 self.index.is_empty()
225 }
226
227 /// Total bytes of blob content (the logical pool size).
228 pub fn pool_bytes(&self) -> usize {
229 self.pool_len()
230 }
231
232 /// Iterates over all blobs in id order as `(id, bytes)` pairs.
233 ///
234 /// This is the substrate for the owner-driven compaction pass: walk the
235 /// blobs, copy the live ones into a fresh heap, record the id remapping.
236 pub fn iter(&self) -> impl Iterator<Item = (BlobId, &[u8])> {
237 self.index
238 .iter()
239 .enumerate()
240 .map(|(i, &(offset, len))| (BlobId(i as u32), self.slice(offset, len as usize)))
241 }
242
243 /// Appends the heap's index section to `out`.
244 ///
245 /// Layout (little-endian): `[blobs u32]` then one `len u32` per blob.
246 /// Offsets are not stored — blobs are contiguous in push order, so the
247 /// lengths alone reconstruct the index (one redundancy less to
248 /// validate). Together with [`BlobHeap::dump_pool`] this is the
249 /// complete state; dumps are canonical.
250 pub fn dump_index(&self, out: &mut Vec<u8>) {
251 out.reserve(INDEX_HEADER + self.index.len() * LEN_BYTES);
252 out.extend_from_slice(&(self.index.len() as u32).to_le_bytes());
253 for &(_, len) in &self.index {
254 out.extend_from_slice(&len.to_le_bytes());
255 }
256 }
257
258 /// Appends the heap's pool section to `out` — a straight
259 /// copy of the logical pool (`base` then `tail`): every pool byte is
260 /// initialized blob content, so an overlay heap dumps byte-identically
261 /// to the owned heap holding the same blobs.
262 pub fn dump_pool(&self, out: &mut Vec<u8>) {
263 out.reserve(self.pool_len());
264 out.extend_from_slice(self.base);
265 out.extend_from_slice(&self.tail);
266 }
267
268 /// Rebuilds a heap from its two dumped sections.
269 ///
270 /// The input is **untrusted** — validation is O(blobs), never panics
271 /// on arbitrary bytes: exact index length, per-blob length within
272 /// `cfg.max_blob`, and the lengths summing exactly to the pool size
273 /// (which itself must fit `cfg.max_bytes` and the `usize` pool ceiling).
274 /// Blob *content* is the owner's data and is not interpreted.
275 ///
276 /// # Errors
277 ///
278 /// [`Error::Corrupt`] for any inconsistency.
279 pub fn load(cfg: BlobHeapCfg, index: &[u8], pool: &[u8]) -> Result<Self, Error> {
280 let rebuilt = validate_index(cfg, index, pool.len())?;
281 Ok(Self {
282 base: &[],
283 tail: pool.to_vec(),
284 index: rebuilt,
285 cfg,
286 })
287 }
288
289 /// Rebuilds a heap that **borrows** its base pool from a longer-lived
290 /// buffer (a memory-mapped snapshot) instead of copying it.
291 /// The index is validated and rebuilt exactly as in [`BlobHeap::load`];
292 /// no base byte is copied, so opening an 8 GiB heap this way touches
293 /// only the pages the reader dereferences.
294 ///
295 /// Unlike the old whole-pool copy-on-write, this heap **stays borrowed
296 /// even under mutation**: a later [`BlobHeap::push`] appends to an owned
297 /// tail, never cloning the base. A read-only caller simply never pushes.
298 /// See also [`BlobHeap::load_overlay`].
299 ///
300 /// # Errors
301 ///
302 /// [`Error::Corrupt`] for any inconsistency (same gates as `load`).
303 pub fn load_borrowed(cfg: BlobHeapCfg, index: &[u8], pool: &'a [u8]) -> Result<Self, Error> {
304 let rebuilt = validate_index(cfg, index, pool.len())?;
305 Ok(Self {
306 base: pool,
307 tail: Vec::new(),
308 index: rebuilt,
309 cfg,
310 })
311 }
312
313 /// Opens a heap over a borrowed base for the **overlay** write path: the
314 /// base is mapped read-only and appends accumulate in an owned tail. For
315 /// an append-only heap this is exactly [`BlobHeap::load_borrowed`] — the
316 /// alias exists so overlay-mode callers read uniformly across the flat
317 /// structures (the in-place [`Arena`](crate::Arena) and
318 /// [`ChunkPool`](crate::ChunkPool) take a distinct page-copy-on-write
319 /// `load_overlay`).
320 ///
321 /// # Errors
322 ///
323 /// [`Error::Corrupt`] for any inconsistency (same gates as `load`).
324 pub fn load_overlay(cfg: BlobHeapCfg, index: &[u8], pool: &'a [u8]) -> Result<Self, Error> {
325 Self::load_borrowed(cfg, index, pool)
326 }
327}
328
329/// The **index side** of a [`BlobHeap`], built without holding the pool.
330///
331/// A [`BlobHeap`] keeps two things: the concatenated blob bytes (the pool) and
332/// a per-blob length table (the index). When the pool is streamed somewhere
333/// else — a file, a staging area — and never held in RAM, this builder tracks
334/// just the index: [`push_len`](BlobHeapBuilder::push_len) records one blob of a
335/// given length (validating exactly as [`BlobHeap::push`] would) and hands back
336/// its [`BlobId`], and [`dump_index`](BlobHeapBuilder::dump_index) emits the
337/// section **byte-identically** to [`BlobHeap::dump_index`]. Pair its index with
338/// the separately-streamed pool bytes and [`BlobHeap::load_borrowed`]
339/// reconstructs the exact same heap.
340///
341/// It is flat and allocation-lean — one `u32` per blob — so the index of a
342/// multi-gigabyte heap stays small while the pool lives on disk.
343#[derive(Clone, Debug, PartialEq, Eq)]
344pub struct BlobHeapBuilder {
345 /// Per-blob length, in push order — the whole state of the index.
346 lens: Vec<u32>,
347 /// Running total of the pool the lengths describe.
348 pool_len: usize,
349 cfg: BlobHeapCfg,
350}
351
352impl BlobHeapBuilder {
353 /// An empty index builder for a pool with the given config.
354 pub const fn new(cfg: BlobHeapCfg) -> Self {
355 Self {
356 lens: Vec::new(),
357 pool_len: 0,
358 cfg,
359 }
360 }
361
362 /// Records a blob of `len` bytes and returns its id — the streaming
363 /// counterpart of [`BlobHeap::push`], validating identically so a builder
364 /// and a heap fed the same lengths accept and reject in lockstep.
365 ///
366 /// # Errors
367 ///
368 /// - [`Error::BlobTooLarge`] if `len` exceeds [`BlobHeapCfg::max_blob`];
369 /// - [`Error::CapacityExceeded`] if the pool would grow past
370 /// [`BlobHeapCfg::max_bytes`] or the `usize` pool ceiling (4 GiB on a
371 /// 32-bit target), or if the blob count would reach `u32::MAX`.
372 pub fn push_len(&mut self, len: usize) -> Result<BlobId, Error> {
373 if len > self.cfg.max_blob {
374 return Err(Error::BlobTooLarge {
375 len,
376 max_blob: self.cfg.max_blob,
377 });
378 }
379 let capacity_exceeded = Error::CapacityExceeded {
380 max_bytes: self.cfg.max_bytes,
381 };
382 // Same ceiling as `BlobHeap::push`: `checked_add` caps the pool at
383 // `usize` (4 GiB on a 32-bit host), `max_bytes` binds on 64-bit.
384 let end = self.pool_len.checked_add(len).ok_or(capacity_exceeded)?;
385 if end > self.cfg.max_bytes {
386 return Err(capacity_exceeded);
387 }
388 let id = u32::try_from(self.lens.len())
389 .ok()
390 .filter(|&i| i != u32::MAX)
391 .ok_or(capacity_exceeded)?;
392 self.lens.push(len as u32);
393 self.pool_len = end;
394 Ok(BlobId(id))
395 }
396
397 /// Number of blobs recorded.
398 pub fn len(&self) -> usize {
399 self.lens.len()
400 }
401
402 /// `true` when no blob has been recorded.
403 pub fn is_empty(&self) -> bool {
404 self.lens.is_empty()
405 }
406
407 /// Total bytes of the pool the recorded lengths describe.
408 pub fn pool_bytes(&self) -> usize {
409 self.pool_len
410 }
411
412 /// Appends the index section — `[blobs u32]` then one `len u32` per blob —
413 /// byte-identically to [`BlobHeap::dump_index`].
414 pub fn dump_index(&self, out: &mut Vec<u8>) {
415 out.reserve(INDEX_HEADER + self.lens.len() * LEN_BYTES);
416 out.extend_from_slice(&(self.lens.len() as u32).to_le_bytes());
417 for &len in &self.lens {
418 out.extend_from_slice(&len.to_le_bytes());
419 }
420 }
421}
422
423/// Two heaps are equal when they hold the same blobs — compared over the
424/// **logical** pool, so an owned heap and an overlay heap (borrowed base +
425/// tail) built from the same pushes compare equal despite the different
426/// base/tail split.
427impl PartialEq for BlobHeap<'_> {
428 fn eq(&self, other: &Self) -> bool {
429 self.cfg == other.cfg
430 && self.index == other.index
431 && self
432 .base
433 .iter()
434 .chain(&self.tail)
435 .eq(other.base.iter().chain(&other.tail))
436 }
437}
438
439impl Eq for BlobHeap<'_> {}
440
441/// Validates a dumped blob index against the pool length and rebuilds the
442/// `(offset, len)` table. Shared by [`BlobHeap::load`] and
443/// [`BlobHeap::load_borrowed`]; the input is untrusted (see `load`).
444fn validate_index(
445 cfg: BlobHeapCfg,
446 index: &[u8],
447 pool_len: usize,
448) -> Result<Vec<(usize, u32)>, Error> {
449 if index.len() < INDEX_HEADER {
450 return Err(Error::Corrupt("blob index shorter than its header"));
451 }
452 let blobs = u32::from_le_bytes(index[0..4].try_into().unwrap());
453 if blobs == u32::MAX {
454 return Err(Error::Corrupt("blob count overflows the id space"));
455 }
456 if index.len() as u64 != INDEX_HEADER as u64 + u64::from(blobs) * LEN_BYTES as u64 {
457 return Err(Error::Corrupt("blob index length mismatch"));
458 }
459 // `pool_len` is a real `&[u8]` length, so on a 32-bit host it can never
460 // exceed the address space — the 4 GiB cap is enforced by `usize` itself.
461 // On 64-bit only `max_bytes` binds.
462 if pool_len > cfg.max_bytes {
463 return Err(Error::Corrupt("blob pool exceeds the configured ceiling"));
464 }
465 let mut rebuilt = Vec::with_capacity(blobs as usize);
466 let mut offset: usize = 0;
467 for i in 0..blobs as usize {
468 let at = INDEX_HEADER + i * LEN_BYTES;
469 let len = u32::from_le_bytes(index[at..at + LEN_BYTES].try_into().unwrap());
470 if len as usize > cfg.max_blob {
471 return Err(Error::Corrupt(
472 "blob length exceeds the configured max_blob",
473 ));
474 }
475 rebuilt.push((offset, len));
476 // `checked_add` keeps the cumulative offset within `usize`; the
477 // `> pool_len` gate then rejects any lengths overrunning the pool
478 // (and, on 32-bit, an overflow that would wrap).
479 offset = offset
480 .checked_add(len as usize)
481 .filter(|&o| o <= pool_len)
482 .ok_or(Error::Corrupt("blob lengths overrun the pool"))?;
483 }
484 if offset != pool_len {
485 return Err(Error::Corrupt("blob lengths do not cover the pool"));
486 }
487 Ok(rebuilt)
488}
489
490impl fmt::Debug for BlobHeap<'_> {
491 /// Summary only — blob contents are the owner's data, not ours to dump.
492 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493 f.debug_struct("BlobHeap")
494 .field("blobs", &self.index.len())
495 .field("pool_bytes", &self.pool_len())
496 .finish()
497 }
498}