Skip to main content

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}