Skip to main content

uni_store/runtime/
l0_manager.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::runtime::l0::L0Buffer;
5use crate::runtime::wal::WriteAheadLog;
6use parking_lot::RwLock;
7use std::sync::Arc;
8
9/// Per-generation pin marker for snapshot isolation (Component C1).
10///
11/// Held by exactly two classes: the [`L0Manager`] keeps one clone for the
12/// current generation, and every live [`SnapshotView`] holds one. So
13/// `Arc::strong_count` on the manager's clone is `1 + (live snapshots of the
14/// current generation)`, which [`L0Manager::is_current_pinned`] uses to decide
15/// whether a commit must freeze the generation aside before mutating it. The
16/// private field stops any other code from minting a token and breaking that
17/// invariant.
18///
19/// Always compiled (so the inert threading types exist in every build); it is
20/// only ever *minted* by [`L0Manager::pin_snapshot`], which a transaction calls
21/// only when `UniConfig::ssi_enabled` is `true`.
22#[derive(Debug)]
23pub struct PinToken(());
24
25/// An isolated, reference-counted view of the L0 tier captured at a point in time.
26///
27/// Reads built from a `SnapshotView` see the L0 generation(s) that were visible
28/// at capture, not later commits: while any view of a generation is alive a
29/// commit that would mutate it first freezes it aside
30/// ([`L0Manager::freeze_current_for_snapshot`]), so the buffers behind `main`
31/// and `extra` are never mutated after capture. Dropping the view releases its
32/// pin; `Arc` reference counting reclaims a frozen generation once no view holds
33/// it. `started_at_version` is captured for the future C2 base-pinning hook and
34/// is not yet consulted.
35///
36/// Always compiled so it can thread through the executor as an inert
37/// `Option<SnapshotView>` in every build; it is only ever *constructed* by
38/// [`L0Manager::pin_snapshot`], which a transaction calls only when
39/// `UniConfig::ssi_enabled` is `true`, so with SSI off the threaded option is
40/// always `None`.
41#[derive(Clone)]
42pub struct SnapshotView {
43    /// The pinned main L0 generation at capture time.
44    pub main: Arc<RwLock<L0Buffer>>,
45    /// Generations being flushed at capture time, read after `main` (oldest visible state).
46    pub extra: Vec<Arc<RwLock<L0Buffer>>>,
47    /// Pin marker keeping the captured generation freeze-on-commit.
48    pin: Arc<PinToken>,
49    /// Main-L0 version at capture (the C2 hwm fed into `pinned_storage`).
50    pub started_at_version: u64,
51    /// C2: a `StorageManager` clone pinned to `started_at_version`
52    /// (`StorageManager::pinned_at_version`), so L1 scans filter to
53    /// `_version <= started_at_version` and an L0→L1 flush completing
54    /// mid-transaction cannot leak post-snapshot rows. Installed by the
55    /// transaction at begin (one per transaction — the pinned manager
56    /// carries a fresh `AdjacencyManager`); `None` for snapshots taken
57    /// without a storage pin.
58    pub pinned_storage: Option<Arc<crate::storage::manager::StorageManager>>,
59}
60
61impl std::fmt::Debug for SnapshotView {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        // Avoid requiring `L0Buffer: Debug` and dumping buffer contents.
64        f.debug_struct("SnapshotView")
65            .field("extra_generations", &self.extra.len())
66            .field("pins", &Arc::strong_count(&self.pin))
67            .field("started_at_version", &self.started_at_version)
68            .finish_non_exhaustive()
69    }
70}
71
72pub struct L0Manager {
73    // The current active L0 buffer.
74    // Outer RwLock protects the Arc (swapping L0s).
75    // Inner RwLock protects the L0Buffer content (concurrent reads/writes).
76    current: RwLock<Arc<RwLock<L0Buffer>>>,
77    // L0 buffers currently being flushed to L1.
78    // These remain visible to reads until flush completes successfully.
79    // This prevents data loss if L1 writes fail after rotation.
80    pending_flush: RwLock<Vec<Arc<RwLock<L0Buffer>>>>,
81    // Snapshot-isolation pin token for the current generation (Component C1).
82    // Reset on every rotate so a fresh generation starts unpinned. Read/cloned
83    // only under the `current` lock so a snapshot captures a buffer and token
84    // from the same generation. See `PinToken`.
85    current_pin: RwLock<Arc<PinToken>>,
86    // Plugin registry for registry-dispatched CRDT merges, stamped onto every
87    // buffer this manager mints (initial buffer via `set_plugin_registry`,
88    // rotated buffers via `rotate`). `None` preserves native `try_merge`.
89    plugin_registry: RwLock<Option<Arc<uni_plugin::PluginRegistry>>>,
90}
91
92impl L0Manager {
93    pub fn new(start_version: u64, wal: Option<Arc<WriteAheadLog>>) -> Self {
94        let l0 = L0Buffer::new(start_version, wal);
95        Self {
96            current: RwLock::new(Arc::new(RwLock::new(l0))),
97            pending_flush: RwLock::new(Vec::new()),
98            current_pin: RwLock::new(Arc::new(PinToken(()))),
99            plugin_registry: RwLock::new(None),
100        }
101    }
102
103    /// Install the plugin registry for registry-dispatched CRDT merges.
104    ///
105    /// Stamps the current buffer immediately and every buffer minted by a
106    /// later [`Self::rotate`], so commit-time property merges route custom
107    /// CRDT kinds through a registered provider. Called once at writer
108    /// construction from the owning `StorageManager`'s registry. A `None`
109    /// registry (never installed) preserves native `try_merge` behavior.
110    pub fn set_plugin_registry(&self, registry: Arc<uni_plugin::PluginRegistry>) {
111        *self.plugin_registry.write() = Some(registry.clone());
112        // Stamp the buffer that already exists (minted by `new` before the
113        // registry was known).
114        self.current.read().write().set_plugin_registry(registry);
115    }
116
117    /// Create a read-only snapshot L0Manager from existing buffers.
118    ///
119    /// Used by the algorithm execution path to provide L0 visibility
120    /// without owning the actual L0 lifecycle (rotation, flush, WAL).
121    pub fn from_snapshot(
122        current: Arc<RwLock<L0Buffer>>,
123        pending_flush: Vec<Arc<RwLock<L0Buffer>>>,
124    ) -> Self {
125        Self {
126            current: RwLock::new(current),
127            pending_flush: RwLock::new(pending_flush),
128            current_pin: RwLock::new(Arc::new(PinToken(()))),
129            // Read-only snapshot manager: buffers already carry their registry
130            // and this manager never rotates, so no stamping is needed.
131            plugin_registry: RwLock::new(None),
132        }
133    }
134
135    /// Get the current L0 buffer.
136    pub fn get_current(&self) -> Arc<RwLock<L0Buffer>> {
137        self.current.read().clone()
138    }
139
140    /// Get all L0 buffers that should be visible to reads.
141    /// This includes the current L0 plus any L0s being flushed.
142    pub fn get_all_readable(&self) -> Vec<Arc<RwLock<L0Buffer>>> {
143        let current = self.get_current();
144        let pending = self.pending_flush.read().clone();
145        let mut all = vec![current];
146        all.extend(pending);
147        all
148    }
149
150    /// Get L0 buffers currently being flushed (for QueryContext).
151    pub fn get_pending_flush(&self) -> Vec<Arc<RwLock<L0Buffer>>> {
152        self.pending_flush.read().clone()
153    }
154
155    /// Rotate L0. Returns the OLD L0 buffer.
156    /// The new L0 is initialized with `next_version` and `new_wal`.
157    pub fn rotate(
158        &self,
159        next_version: u64,
160        new_wal: Option<Arc<WriteAheadLog>>,
161    ) -> Arc<RwLock<L0Buffer>> {
162        let mut guard = self.current.write();
163        let old_l0 = guard.clone();
164
165        let mut new_l0 = L0Buffer::new(next_version, new_wal);
166        // Carry the registry onto the fresh generation so its commit-time
167        // merges route through any registered CRDT provider.
168        if let Some(reg) = self.plugin_registry.read().as_ref() {
169            new_l0.set_plugin_registry(reg.clone());
170        }
171        *guard = Arc::new(RwLock::new(new_l0));
172
173        // A fresh generation starts unpinned. Reset the pin token while still
174        // holding the `current` write guard: `pin_snapshot` clones the buffer
175        // and token under `current.read()`, so this serializes against it and a
176        // snapshot can never capture a buffer/token from different generations.
177        *self.current_pin.write() = Arc::new(PinToken(()));
178
179        old_l0
180    }
181
182    /// Begin flush: rotate L0 and add old L0 to pending flush list.
183    /// The old L0 remains visible to reads until `complete_flush` is called.
184    /// Returns the old L0 buffer to be flushed.
185    pub fn begin_flush(
186        &self,
187        next_version: u64,
188        new_wal: Option<Arc<WriteAheadLog>>,
189    ) -> Arc<RwLock<L0Buffer>> {
190        let old_l0 = self.rotate(next_version, new_wal);
191        self.pending_flush.write().push(old_l0.clone());
192        old_l0
193    }
194
195    /// Complete flush: remove the flushed L0 from pending list.
196    /// Call this only after L1 writes have succeeded.
197    pub fn complete_flush(&self, l0: &Arc<RwLock<L0Buffer>>) {
198        let mut pending = self.pending_flush.write();
199        pending.retain(|x| !Arc::ptr_eq(x, l0));
200    }
201
202    /// Captures an isolated snapshot of the current L0 (strategy D).
203    ///
204    /// Freezes the current buffer by rotating it aside — writers re-fetch
205    /// `get_current()` at write time, so they move to the fresh buffer and can
206    /// never mutate the frozen one — and keeps it readable via the pending
207    /// list. Returns the `(frozen_main, pending)` pair used to build a
208    /// [`QueryContext`] whose reads are isolated from later writes. Capture is
209    /// O(1): one empty-buffer allocation and an `Arc` move, with no deep copy.
210    ///
211    /// The caller must coordinate with the commit path (e.g. hold the writer's
212    /// `flush_lock`) so the rotation does not race an in-flight merge into the
213    /// current buffer. The frozen generation currently rides the pending-flush
214    /// list; a dedicated generation list with reader-count GC is the production
215    /// follow-up (see the proposal's open questions).
216    ///
217    /// [`QueryContext`]: crate::runtime::QueryContext
218    pub fn snapshot_isolated(
219        &self,
220        next_version: u64,
221        new_wal: Option<Arc<WriteAheadLog>>,
222    ) -> (Arc<RwLock<L0Buffer>>, Vec<Arc<RwLock<L0Buffer>>>) {
223        // Capture pending before freezing so the frozen buffer becomes the
224        // snapshot's main view rather than one of its pending peers.
225        let pending = self.pending_flush.read().clone();
226        let frozen = self.rotate(next_version, new_wal);
227        // Keep the frozen generation visible to latest (non-snapshot) reads.
228        self.pending_flush.write().push(frozen.clone());
229        (frozen, pending)
230    }
231
232    /// Pins an isolated view of the current L0 tier for a transaction.
233    ///
234    /// O(1): clones the current buffer handle, the pending-flush set, and the
235    /// generation's pin token. No freeze happens here — the current buffer keeps
236    /// taking writes; it is frozen aside lazily, and only if still pinned, when a
237    /// commit would next mutate it (see [`Self::freeze_current_for_snapshot`] and
238    /// [`Self::is_current_pinned`]). Holds the `current` read lock across the
239    /// buffer and token clones so both come from the same generation even if a
240    /// rotate races. Does not require the writer's `flush_lock`.
241    ///
242    /// # Examples
243    /// ```ignore
244    /// let snap = writer.l0_manager().pin_snapshot();
245    /// // build a QueryContext from `snap.main` + `snap.extra`
246    /// ```
247    pub fn pin_snapshot(&self) -> SnapshotView {
248        // Hold `current` read across both clones: a concurrent `rotate` needs
249        // `current.write()` and resets the pin token under it, so it cannot
250        // interleave and split the buffer/token across generations.
251        let current_guard = self.current.read();
252        let main = current_guard.clone();
253        let pin = self.current_pin.read().clone();
254        let started_at_version = main.read().current_version;
255        let extra = self.pending_flush.read().clone();
256        drop(current_guard);
257        SnapshotView {
258            main,
259            extra,
260            pin,
261            started_at_version,
262            pinned_storage: None,
263        }
264    }
265
266    /// Returns `true` if any live [`SnapshotView`] pins the current generation.
267    ///
268    /// `strong_count > 1` means a snapshot besides the manager holds the token.
269    /// Call under the writer's `flush_lock` at commit so the decision and any
270    /// resulting freeze are atomic with respect to the merge.
271    pub fn is_current_pinned(&self) -> bool {
272        Arc::strong_count(&self.current_pin.read()) > 1
273    }
274
275    /// Clones the current (pinned) generation aside so a commit can mutate a
276    /// fresh buffer without the pinning snapshots observing the write — lazy
277    /// copy-on-write, performed only when [`Self::is_current_pinned`] holds.
278    ///
279    /// The outgoing buffer — which the pinning [`SnapshotView`]s hold via `main`
280    /// — becomes immutable: a deep copy carrying the same data is installed as
281    /// the new current, the commit merges into that copy, and the original is
282    /// never mutated again. `L0Buffer::clone` drops the WAL handle, so the
283    /// original's WAL (already flushed at this commit's WAL step) is handed to
284    /// the copy; the frozen original keeps none, as it takes no more writes. The
285    /// original is **not** placed on the pending-flush list — it is reclaimed by
286    /// `Arc` refcount once the last snapshot drops, so nothing leaks. The new
287    /// generation starts unpinned (the pin token is reset). Must be called under
288    /// the writer's `flush_lock`, since it swaps the current buffer.
289    pub fn freeze_current_for_snapshot(&self) {
290        let mut guard = self.current.write();
291        let frozen = guard.clone();
292        let mut new_buf = frozen.read().clone();
293        // Hand the WAL from the now-frozen original to the writable copy.
294        new_buf.wal = frozen.write().wal.take();
295        *guard = Arc::new(RwLock::new(new_buf));
296        // The fresh generation starts unpinned; reset under the `current` write
297        // guard (consistent with `rotate`, which a non-clone path would use).
298        *self.current_pin.write() = Arc::new(PinToken(()));
299    }
300
301    /// Minimum `wal_lsn_at_start` among pending-flush L0s other than `except`.
302    ///
303    /// This is the floor below which every WAL entry is durable in L1: a pending
304    /// flush — one still streaming, or one whose flush FAILED and left the buffer
305    /// in `pending_flush` — holds committed WAL entries strictly above its start
306    /// that are not yet in L1. WAL truncation and the published
307    /// `wal_high_water_mark` must not advance past this floor, or that buffer's
308    /// committed-but-unflushed data is silently dropped by the next (e.g.
309    /// shutdown) flush. Using the high watermark (`wal_lsn_at_flush`) here was the
310    /// lost-commit bug: it truncated / checkpointed past the pending buffer's own
311    /// entries.
312    ///
313    /// `except` is the buffer the caller is itself flushing — its data IS entering
314    /// the new snapshot, so it must not constrain the floor. At truncation time it
315    /// has already been removed via `complete_flush`, so passing it is a harmless
316    /// no-op; during the stream phase it is still pending and the exclusion is
317    /// load-bearing.
318    ///
319    /// Returns `None` when no other pending flush exists.
320    pub fn min_pending_wal_lsn_start(&self, except: &Arc<RwLock<L0Buffer>>) -> Option<u64> {
321        self.pending_flush
322            .read()
323            .iter()
324            .filter(|l0_arc| !Arc::ptr_eq(l0_arc, except))
325            .map(|l0_arc| l0_arc.read().wal_lsn_at_start)
326            .min()
327    }
328}
329
330#[cfg(test)]
331mod snapshot_tests {
332    use super::*;
333    use crate::runtime::QueryContext;
334    use crate::runtime::l0_visibility::lookup_vertex_prop;
335    use uni_common::core::id::Vid;
336    use uni_common::{Properties, Value};
337
338    fn named(name: &str) -> Properties {
339        let mut props = Properties::new();
340        props.insert("name".to_string(), Value::String(name.to_string()));
341        props
342    }
343
344    fn name_of(vid: Vid, ctx: &QueryContext) -> Option<String> {
345        match lookup_vertex_prop(vid, "name", Some(ctx)) {
346            Some(Value::String(s)) => Some(s),
347            _ => None,
348        }
349    }
350
351    /// A strategy-D snapshot must not observe writes that land after capture,
352    /// while a fresh latest view must, and frozen data must stay visible.
353    #[test]
354    fn snapshot_isolated_from_later_writes() {
355        let mgr = L0Manager::new(0, None);
356        let alice = Vid::from(1_u64);
357        let bob = Vid::from(2_u64);
358        let labels = ["Node".to_string()];
359
360        // Pre-snapshot state.
361        {
362            let current = mgr.get_current();
363            let mut guard = current.write();
364            guard.insert_vertex_with_labels(alice, named("alice"), &labels);
365            guard.insert_vertex_with_labels(bob, named("bob"), &labels);
366        }
367
368        // Freeze-rotate snapshot.
369        let (frozen, pending) = mgr.snapshot_isolated(1, None);
370        let snap = QueryContext::new_with_pending(frozen, None, pending);
371
372        // Post-snapshot write into the fresh current buffer.
373        mgr.get_current()
374            .write()
375            .insert_vertex_with_labels(alice, named("alice2"), &labels);
376
377        // The snapshot is isolated: it still sees the pre-write value.
378        assert_eq!(name_of(alice, &snap).as_deref(), Some("alice"));
379
380        // A fresh latest view sees the new value...
381        let latest =
382            QueryContext::new_with_pending(mgr.get_current(), None, mgr.get_pending_flush());
383        assert_eq!(name_of(alice, &latest).as_deref(), Some("alice2"));
384
385        // ...and the untouched vertex remains visible via the frozen generation.
386        assert_eq!(name_of(bob, &latest).as_deref(), Some("bob"));
387    }
388
389    /// A pin marks the current generation; dropping the snapshot releases it.
390    #[test]
391    fn pin_marks_current_generation() {
392        let mgr = L0Manager::new(0, None);
393        assert!(!mgr.is_current_pinned());
394        let snap = mgr.pin_snapshot();
395        assert!(mgr.is_current_pinned());
396        drop(snap);
397        assert!(
398            !mgr.is_current_pinned(),
399            "dropping the snapshot releases the pin"
400        );
401    }
402
403    /// Clone-on-freeze: after a pinned generation is frozen aside, the snapshot
404    /// still observes its captured state while the new generation takes writes,
405    /// and the new generation starts unpinned.
406    #[test]
407    fn clone_freeze_isolates_pinned_snapshot() {
408        let mgr = L0Manager::new(0, None);
409        let alice = Vid::from(1_u64);
410        let labels = ["Node".to_string()];
411        mgr.get_current()
412            .write()
413            .insert_vertex_with_labels(alice, named("alice"), &labels);
414
415        let snap = mgr.pin_snapshot();
416        assert!(mgr.is_current_pinned());
417
418        // Commit-equivalent: freeze the pinned generation aside, then mutate the
419        // fresh current (where a real commit's merge would land).
420        mgr.freeze_current_for_snapshot();
421        assert!(
422            !mgr.is_current_pinned(),
423            "the fresh generation starts unpinned"
424        );
425        mgr.get_current()
426            .write()
427            .insert_vertex_with_labels(alice, named("alice2"), &labels);
428
429        // The snapshot still sees the pre-freeze value (isolated).
430        let snap_ctx = QueryContext::new_with_pending(snap.main.clone(), None, snap.extra.clone());
431        assert_eq!(name_of(alice, &snap_ctx).as_deref(), Some("alice"));
432
433        // A fresh latest view sees the post-freeze value.
434        let latest =
435            QueryContext::new_with_pending(mgr.get_current(), None, mgr.get_pending_flush());
436        assert_eq!(name_of(alice, &latest).as_deref(), Some("alice2"));
437
438        // Dropping the snapshot releases its hold on the frozen generation.
439        drop(snap);
440    }
441}