Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 7 additions & 6 deletions benches/cow_snapshot.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<u8>, Vec<u8>)> {
(0..KEY_COUNT)
.map(|i| {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion src/api/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, Snapshot>,
}
Expand Down
17 changes: 10 additions & 7 deletions src/api/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down
11 changes: 6 additions & 5 deletions src/api/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Snapshot> {
let _maintenance = self.maintenance_gate.enter_shared();
self.ensure_live()?;
Expand Down
46 changes: 40 additions & 6 deletions src/api/view.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<u8>,
Expand Down Expand Up @@ -80,13 +92,19 @@ 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),
}
}

/// 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<ViewRangeBuilder> {
self.ensure_in_scope(prefix)?;
Ok(ViewRangeBuilder {
Expand All @@ -95,13 +113,19 @@ 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)),
}
}

/// 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<ViewKeyRangeBuilder> {
self.ensure_in_scope(prefix)?;
Ok(ViewKeyRangeBuilder {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions tests/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down