diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 22307a3..00f052b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -428,9 +428,12 @@ caller code does not block structural maintenance. For stable read transactions, `Tree::view(prefix, |view| ...)` copies one root frame and shares descendants with the live tree. -Subsequent live writes fork only snapshot-visible frames. Cloned views -and owned cursors keep the snapshot epoch lease alive after the callback -or main snapshot handle returns, until the final derived handle drops. +Subsequent live writes validate the parent edge and fork only +snapshot-visible frames on demand. Cloned views, range builders, and owned +cursors keep the process-local snapshot epoch lease alive after the callback +or main snapshot handle returns, until the final derived handle drops. Epoch +retirement only makes detached frames eligible for later checkpoint/GC +reclamation. ## 8. BlobStore abstraction diff --git a/ROADMAP.md b/ROADMAP.md index dbfab4a..f21ff73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -382,8 +382,10 @@ single-node embedded library, no always-on MVCC version chains. - `Tree::view(prefix, |view| ...)` performs one root-frame copy, shares descendants, and serves stable point reads/scans while first writes fork - snapshot-visible frames. Cloned views and owned cursors extend the epoch - lease beyond the callback until their final handle drops. + snapshot-visible frames after validating their parent edges. Cloned views, + range builders, and owned cursors extend the process-local epoch lease beyond + the callback until their final handle drops; retirement then makes detached + frames eligible for checkpoint/GC reclamation. - Ordinary `range()` / `range_keys()` remain the hot restart-on-conflict iterators; `View` is the explicit stable-read path for list/readdir. diff --git a/benches/cow_snapshot.rs b/benches/cow_snapshot.rs index 5e073cd..bc1240f 100644 --- a/benches/cow_snapshot.rs +++ b/benches/cow_snapshot.rs @@ -1,9 +1,11 @@ //! Copy-on-write snapshot benchmark. //! -//! Quantifies the CoW [`Tree::snapshot`] against the full-copy -//! [`Tree::view`], and the cost of holding a snapshot: +//! Compares the owned [`Tree::snapshot`] and scoped [`Tree::view`] API paths, +//! which use the same copy-on-write capture, and measures the cost of holding +//! a snapshot: //! -//! - `cow_create` — snapshot creation (O(1 frame)) vs view (O(tree)). +//! - `cow_create` — owned snapshot vs scoped-view capture; each copies one +//! root frame and initially shares descendants. //! - `cow_write` — put throughput with no snapshot vs one held (the //! fork-on-write + route-cache-disabled overhead). //! - `cow_read` — point read on the live tree vs on a snapshot. @@ -22,8 +24,7 @@ const KEY_COUNT: usize = 20_000; const VALUE_LEN: usize = 200; /// Path-shaped keys + fixed-size bodies, sized so the tree spans many -/// blob frames (so `view`'s O(tree) copy is visible against `snapshot`'s -/// single-frame copy). +/// blob frames and captured reads cross shared descendants. fn dataset() -> Vec<(Vec, Vec)> { (0..KEY_COUNT) .map(|i| { @@ -65,7 +66,7 @@ fn bench_create(c: &mut Criterion) { std::hint::black_box(&snap); }); }); - group.bench_function("view_full_copy", |b| { + group.bench_function("view_scoped", |b| { b.iter(|| { tree.view(b"", |v| { std::hint::black_box(v); diff --git a/src/api/db.rs b/src/api/db.rs index 1638b37..97d2292 100644 --- a/src/api/db.rs +++ b/src/api/db.rs @@ -420,6 +420,9 @@ impl DB { /// tree's exclusive mutation gate, releases the live DB, then /// invokes `read` with an immutable [`DBView`]. Writes committed /// after the capture are invisible to every captured tree view. + /// Cloned tree views and their range builders or owned cursors may escape + /// the callback and retain their process-local snapshot epoch leases until + /// the final derived handle is dropped. /// /// Scopes are explicit so callers choose exactly which catalog /// trees participate in the consistent read view. @@ -1171,7 +1174,9 @@ impl DB { /// /// Created by [`DB::view`]. Each captured tree is exposed as a /// normal [`View`], so point lookup and range/list APIs stay the -/// same as single-tree snapshots. +/// same as single-tree snapshots. Each view owns a copied root, initially +/// shares descendants with its live tree, and retains a process-local epoch +/// lease through any cloned view, range builder, or owned cursor. pub struct DBView { trees: HashMap, } diff --git a/src/api/snapshot.rs b/src/api/snapshot.rs index 8077118..ce9286d 100644 --- a/src/api/snapshot.rs +++ b/src/api/snapshot.rs @@ -10,9 +10,10 @@ //! Creation is O(one frame copy); the per-write cost is zero while no //! snapshot is live, and bounded by the root→leaf frame path length on //! the first write to each region while one is. Dropping the handle (or -//! calling [`Snapshot::retire`]) releases its lease; the global fork -//! barrier lowers after every cloned view and owned cursor derived from -//! that snapshot has also been dropped. +//! calling [`Snapshot::retire`]) releases that handle's process-local lease +//! reference; the global fork barrier can lower only after every cloned view, +//! range builder, and owned cursor derived from that snapshot has also been +//! dropped. use super::view::View; use std::ops::Deref; @@ -24,8 +25,10 @@ use std::ops::Deref; /// writes. All [`View`] read operations are available through `Deref` /// (`snapshot.get(..)`, `snapshot.range()`, `snapshot.scan(..)`, …). /// -/// The snapshot epoch is retired after this handle and all cloned views or -/// owned cursors derived from it are dropped. +/// The snapshot epoch is retired after this handle and all cloned views, +/// range builders, or owned cursors derived from it are dropped. This lease is +/// process-local lifetime bookkeeping, not a persistent or cross-process +/// snapshot lease. /// Persisted copy-on-write frames are not reclaimed inline on handle drop. /// A later standalone or DB checkpoint reclaims a bounded exact batch, while /// [`crate::Tree::gc`] and [`crate::DB::gc`] provide complete reachability @@ -59,8 +62,8 @@ impl Snapshot { } /// Release this snapshot handle now. The fork-barrier epoch retires - /// after the last cloned [`View`] or owned range cursor derived from - /// it is also dropped. + /// after the last cloned [`View`], range builder, or owned cursor derived + /// from it is also dropped. pub fn retire(mut self) { drop(self.view.take()); } diff --git a/src/api/tree.rs b/src/api/tree.rs index 432c51d..73fa262 100644 --- a/src/api/tree.rs +++ b/src/api/tree.rs @@ -1899,8 +1899,9 @@ impl Tree { /// later live writes fork the frames this view references). Writes /// committed after the capture are invisible to all reads made through /// the view, and point lookup / range / list keep using the ART walker. - /// A cloned [`View`] or owned cursor returned through `R` may outlive the - /// callback and keeps the snapshot epoch leased until it is dropped. + /// A cloned [`View`], range builder, or owned cursor returned through `R` + /// may outlive the callback and keeps the process-local snapshot epoch + /// leased until it is dropped. /// /// A view is scoped: reads outside `prefix` return /// [`Error::OutsideViewScope`]. Use `prefix = b""` only when a @@ -1925,9 +1926,9 @@ impl Tree { /// /// Reads outside `prefix` return [`Error::OutsideViewScope`]; use /// `prefix = b""` for a whole-tree snapshot. Dropping the main handle (or - /// calling [`Snapshot::retire`]) releases that handle; cloned views and - /// owned cursors remain valid and keep the epoch leased until they too are - /// dropped. + /// calling [`Snapshot::retire`]) releases that handle; cloned views, + /// range builders, and owned cursors remain valid and keep the + /// process-local epoch leased until they too are dropped. pub fn snapshot(&self, prefix: &[u8]) -> Result { let _maintenance = self.maintenance_gate.enter_shared(); self.ensure_live()?; diff --git a/src/api/view.rs b/src/api/view.rs index 0c09e6d..018affc 100644 --- a/src/api/view.rs +++ b/src/api/view.rs @@ -1,8 +1,18 @@ //! Scoped read transaction. //! -//! `View` captures a prefix subtree as immutable blob frames, then -//! reads from that private frame set. It gives stable list/readdir -//! semantics without keeping a live-tree read lock or MVCC chains. +//! A `View` is backed by the same copy-on-write capture as [`crate::Snapshot`]: +//! capture copies the root frame to a fresh process-local identity while its +//! descendants initially remain shared with the live tree. Before a live write +//! mutates a shared descendant, Holt validates the exact parent edge under its +//! exclusive latch, forks that frame, and repoints the live parent so the view +//! continues to reach the frozen image. +//! +//! The view, its clones, and derived range builders and owned cursors share a +//! process-local snapshot-epoch lease. Only the final derived handle releases +//! the lease and permits epoch retirement; persisted detached frames remain +//! subject to the checkpoint/GC reclaim frontier. This gives stable +//! list/readdir semantics without holding a live-tree read lock or retaining +//! MVCC chains. use std::sync::Arc; @@ -16,9 +26,11 @@ use crate::store::{BufferManager, CachedBlob, SnapshotLease}; /// Immutable read transaction over one captured prefix. /// -/// Created by [`crate::Tree::view`]. Subsequent live-tree writes do -/// not affect it. Clones and owned range cursors retain the underlying -/// snapshot epoch until the final derived handle is dropped. +/// Created by [`crate::Tree::view`] or exposed by [`crate::Snapshot::view`]. +/// Its root frame is a private process-local copy, while descendants are +/// initially shared and protected by write-time copy-on-write. Clones and +/// derived range builders or owned cursors retain the underlying epoch lease; +/// the epoch can retire only after the final such handle is dropped. #[derive(Clone)] pub struct View { scope: Vec, @@ -80,6 +92,9 @@ impl View { } /// Open a record range over the view's captured prefix. + /// + /// The returned builder owns the view's epoch lease and may safely outlive + /// the `View` from which it was derived. pub fn range(&self) -> ViewRangeBuilder { ViewRangeBuilder { inner: self.range_builder(&self.scope), @@ -87,6 +102,9 @@ impl View { } /// Open a record range for a narrower prefix inside the view. + /// + /// The returned builder owns the view's epoch lease and may safely outlive + /// the `View` from which it was derived. pub fn scan(&self, prefix: &[u8]) -> Result { self.ensure_in_scope(prefix)?; Ok(ViewRangeBuilder { @@ -95,6 +113,9 @@ impl View { } /// Open a key-only range over the view's captured prefix. + /// + /// The returned builder owns the view's epoch lease and may safely outlive + /// the `View` from which it was derived. pub fn range_keys(&self) -> ViewKeyRangeBuilder { ViewKeyRangeBuilder { inner: KeyRangeBuilder::new(self.range_builder(&self.scope)), @@ -102,6 +123,9 @@ impl View { } /// Open a key-only range for a narrower prefix inside the view. + /// + /// The returned builder owns the view's epoch lease and may safely outlive + /// the `View` from which it was derived. pub fn scan_keys(&self, prefix: &[u8]) -> Result { self.ensure_in_scope(prefix)?; Ok(ViewKeyRangeBuilder { @@ -174,6 +198,11 @@ impl View { } /// Record range builder scoped to a [`View`]. +/// +/// The builder retains the view's process-local epoch lease. Consuming it +/// transfers that lease to the owned iterator, so neither shared descendants +/// nor their retired copy-on-write images may be reclaimed while either +/// handle is live. #[must_use = "ViewRangeBuilder is lazy — call `.into_iter()` or use it in a `for` loop"] pub struct ViewRangeBuilder { inner: RangeBuilder, @@ -203,6 +232,11 @@ impl IntoIterator for ViewRangeBuilder { } /// Key-only range builder scoped to a [`View`]. +/// +/// The builder retains the view's process-local epoch lease. Consuming it +/// transfers that lease to the owned iterator, so neither shared descendants +/// nor their retired copy-on-write images may be reclaimed while either +/// handle is live. #[must_use = "ViewKeyRangeBuilder is lazy — call `.into_iter()`, `.visit()`, or use it in a `for` loop"] pub struct ViewKeyRangeBuilder { inner: KeyRangeBuilder, diff --git a/tests/snapshot.rs b/tests/snapshot.rs index cf0a792..15fde08 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -2,11 +2,11 @@ //! //! Exercises only the public surface. Snapshot tests cover //! creation, the scoped read path (including across blob-frame -//! boundaries), epoch advancement, and isolation from *root-local* -//! live writes — which hold without fork-on-write because the live -//! root frame is never shared (a snapshot takes a full copy of it). -//! Multi-blob isolation under mutation (the fork-on-write gate) is -//! added alongside that machinery. +//! boundaries), epoch advancement, and isolation from both root-local +//! and cross-frame live writes. Capture copies the root frame; descendants +//! remain shared until a live mutation validates the parent edge and forks +//! the affected shared frame. Escaped views, builders, and cursors keep the +//! process-local epoch lease alive until the final handle is dropped. use std::sync::Arc;