Skip to main content

uni_store/fork/
scope.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! `ForkScope` — read-only state shared by every component of a forked
5//! session.
6//!
7//! A `ForkScope` is owned by a forked `Session`'s `UniInner` and carries
8//! everything `StorageManager` and `SchemaManager` need to resolve fork-
9//! aware reads:
10//!
11//! - `fork_info` — registry record, including the dataset → branch map
12//!   used to route Lance reads through the fork's branches.
13//! - `overlay` — `SchemaDelta` merged on top of primary's schema by
14//!   `UniInner::at_fork` at construction time.
15//! - `registry` — back-reference for liveness queries; holders are
16//!   tracked here so drop refuses while sessions are alive.
17//! - `_holder` — RAII guard that decrements the holder count when the
18//!   scope is dropped.
19//!
20//! `fork_info` is wrapped in plain `Arc` (no fork-side mutation today
21//! — datasets only grow through `register_dynamic_branch` which goes
22//! through the registry, not through `fork_info`). `overlay` is wrapped
23//! in `ArcSwap` so fork-local strict-schema additions can be applied
24//! atomically without rebuilding the scope.
25
26// Rust guideline compliant
27
28use std::collections::HashSet;
29use std::sync::Arc;
30
31use anyhow::Context;
32use arc_swap::ArcSwap;
33use dashmap::DashMap;
34use tokio::sync::Mutex as AsyncMutex;
35use uni_common::core::fork::{ForkId, ForkInfo, SchemaDelta};
36use uni_common::core::schema::{EdgeTypeMeta, LabelMeta};
37
38use super::registry::{ForkHolderGuard, ForkRegistryHandle};
39
40/// Phase 5a: tag for the fork-local index registry on `ForkScope`.
41/// Phase 5b extends with `Vector` and `FullText` for lossy fusion.
42///
43/// `#[non_exhaustive]` so additional kinds (e.g. inverted-set,
44/// JSON path) can land additively without breaking match sites.
45#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
46#[non_exhaustive]
47pub enum ForkLocalIndexKind {
48    /// Scalar BTree on a property — union fusion (Phase 5a-impl).
49    ScalarBtree,
50    /// Sorted on a property (range / ORDER BY) — k-way merge fusion (Phase 5a-impl).
51    Sorted,
52    /// VID/UID lookup index — fork-first fusion (Phase 5a-impl).
53    VidUid,
54    /// Vector (IVF/HNSW) index — top-k merge + rerank fusion (Phase 5b).
55    Vector,
56    /// Lance native FTS / inverted index — RRF fusion (Phase 5b).
57    FullText,
58    /// Learned-sparse (SPLADE) dot-product index — `SparseDot` rerank fusion
59    /// (issue #95 Task #4).
60    ///
61    /// v1 retrieval on a fork is a brute-force branch scan re-scored by
62    /// `sparse_dot` (see [`crate::storage::StorageManager::sparse_search`]); this
63    /// variant is a planner/EXPLAIN marker that switches `uni.sparse.query` to the
64    /// fused operator. A dedicated fork-local sparse postings dataset (Approach B)
65    /// is deferred behind the M5 benchmark.
66    Sparse,
67}
68
69/// Read-only scope identifying a forked session.
70///
71/// Constructed by `Session::fork(name).build()` (Day 7) via
72/// [`ForkScope::new`]. Once built, both `fork_info` and `overlay` are
73/// immutable for the scope's lifetime — Phase 1 forks are read-only.
74pub struct ForkScope {
75    fork_id: ForkId,
76    fork_info: Arc<ForkInfo>,
77    /// Schema additions on top of primary's schema. Mutable so that
78    /// `Session::fork_schema()` can introduce fork-local labels and
79    /// edge types without touching primary's `catalog/schema.json`.
80    /// `ArcSwap` makes reads cheap and atomic; the `overlay_lock`
81    /// below serializes the read-modify-write on the persistence side.
82    ///
83    /// # Invariant: fork-origin numeric ids are fork-local (L7)
84    ///
85    /// The overlay is frozen at fork time, so a label/edge-type id minted
86    /// inside a fork (via `max(existing)+1`) does not observe primary's
87    /// later additions and **can collide** with a primary id allocated
88    /// after the fork point. This is benign because nothing trusts a
89    /// fork-origin id across the fork↔primary boundary: promote
90    /// (`uni_fork::diff`) re-creates by NAME, primary re-allocates its own
91    /// id, and storage keys rows by label name. A fork-origin numeric id
92    /// MUST NOT be trusted outside the fork's own view.
93    overlay: Arc<ArcSwap<SchemaDelta>>,
94    /// Serializes overlay updates *within a single fork* so two
95    /// concurrent `add_label_to_overlay` calls don't clobber each
96    /// other's persisted state. Held across the registry PUT and the
97    /// `ArcSwap::store`. Cross-fork updates remain parallel.
98    overlay_lock: Arc<AsyncMutex<()>>,
99    registry: Arc<ForkRegistryHandle>,
100    /// Branches created after fork construction, e.g. by
101    /// [`crate::backend::BranchedBackend`] when the fork's writer
102    /// flushes to a label whose dataset wasn't branched at fork-point.
103    /// Consulted alongside `fork_info.datasets` by [`Self::branch_for`]
104    /// so reads on the same session see writes through the same
105    /// branch that produced them. Persisted out-of-band via
106    /// [`ForkRegistryHandle::register_dataset_branch`] so a restart
107    /// recovers the same mapping.
108    dynamic_branches: Arc<DashMap<String, String>>,
109    /// Phase 5a: per-table row count contributed by this fork's
110    /// writes. Bumped by `BranchedBackend` after each successful
111    /// flush. Read by `IndexRebuildManager` to decide whether to
112    /// schedule a fork-local index build for the table. In-memory
113    /// only — a process restart resets the counter, so the trigger
114    /// re-fires on the next flush. The on-disk row count is the
115    /// ground truth; this counter is only a flush-time accumulator.
116    fragment_counts: Arc<DashMap<String, u64>>,
117    /// Phase 5a: registry of completed fork-local index builds.
118    /// Keyed on `(label, column)`; value is the SET of index kinds
119    /// built for that pair. A single column can carry several kinds
120    /// simultaneously (e.g. a `ScalarBtree` for equality plus a
121    /// `FullText` for search), so this must be a set — a single-value
122    /// map would let one build clobber another and the auto-builder
123    /// would ping-pong between them forever. Read by the planner's
124    /// `has_fork_index` check to decide whether to emit a specific
125    /// `FusedIndexScan`. Written by the `IndexRebuildManager` after a
126    /// fork-local build completes. In-memory only — a restart
127    /// re-detects existing fork-local indexes by listing the fork's
128    /// branch directory once at `Uni::open` time (Phase 5a uses lazy
129    /// first-touch detection; see `repopulate_indexes_from_disk`).
130    fork_local_indexes: Arc<DashMap<(String, String), HashSet<ForkLocalIndexKind>>>,
131    /// RAII guard. Lifetime-tied to this `ForkScope`. Cloning the
132    /// containing `Arc<ForkScope>` does *not* increment the holder
133    /// count — only the constructor does, via `register_holder`.
134    _holder: ForkHolderGuard,
135}
136
137impl std::fmt::Debug for ForkScope {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("ForkScope")
140            .field("fork_id", &self.fork_id)
141            .field("fork_name", &self.fork_info.name)
142            .finish_non_exhaustive()
143    }
144}
145
146impl ForkScope {
147    /// Build a new fork scope, registering a holder on `registry`.
148    ///
149    /// `fork_info` must already be in `Active` status; callers should
150    /// have walked the registry's open-or-create flow before invoking.
151    /// `overlay` is the schema delta loaded from
152    /// `catalog/fork_schemas/{fork_id}.json`.
153    #[must_use]
154    pub fn new(
155        fork_info: Arc<ForkInfo>,
156        overlay: SchemaDelta,
157        registry: Arc<ForkRegistryHandle>,
158    ) -> Self {
159        let holder = registry.register_holder(fork_info.id);
160        Self {
161            fork_id: fork_info.id,
162            fork_info,
163            overlay: Arc::new(ArcSwap::from_pointee(overlay)),
164            overlay_lock: Arc::new(AsyncMutex::new(())),
165            registry,
166            dynamic_branches: Arc::new(DashMap::new()),
167            fragment_counts: Arc::new(DashMap::new()),
168            fork_local_indexes: Arc::new(DashMap::new()),
169            _holder: holder,
170        }
171    }
172
173    /// Phase 5a: record `rows_added` rows newly written through this
174    /// fork to `table_name`. Idempotent under repeated calls — the
175    /// counter is monotonically increasing within a process lifetime.
176    pub fn record_fork_fragment(&self, table_name: &str, rows_added: u64) {
177        if rows_added == 0 {
178            return;
179        }
180        self.fragment_counts
181            .entry(table_name.to_string())
182            .and_modify(|c| *c += rows_added)
183            .or_insert(rows_added);
184    }
185
186    /// Phase 5a: current accumulated row count for `table_name` on
187    /// this fork. Returns 0 if the fork has never written to it.
188    #[must_use]
189    pub fn fragment_count(&self, table_name: &str) -> u64 {
190        self.fragment_counts
191            .get(table_name)
192            .map(|r| *r.value())
193            .unwrap_or(0)
194    }
195
196    /// Phase 5a: snapshot of every (table, count) pair recorded on
197    /// this fork. Used by `IndexRebuildManager` to enumerate build
198    /// candidates each polling tick.
199    #[must_use]
200    pub fn all_fragment_counts(&self) -> Vec<(String, u64)> {
201        self.fragment_counts
202            .iter()
203            .map(|r| (r.key().clone(), *r.value()))
204            .collect()
205    }
206
207    /// Phase 5a: register a completed fork-local index build.
208    /// Called by `IndexRebuildManager` after the build lands on
209    /// the fork's branch.
210    pub fn register_fork_local_index(&self, label: &str, column: &str, kind: ForkLocalIndexKind) {
211        self.fork_local_indexes
212            .entry((label.to_string(), column.to_string()))
213            .or_default()
214            .insert(kind);
215    }
216
217    /// Phase 5a: whether a fork-local index of `kind` has been built
218    /// for a `(label, column)` pair. Distinct kinds coexist on one
219    /// column, so the planner asks for the specific kind it wants to
220    /// fuse rather than a single stored value. Returns `false` when
221    /// the planner should fall back to the inherited primary index
222    /// (or to a plain scan).
223    #[must_use]
224    pub fn has_fork_local_index(
225        &self,
226        label: &str,
227        column: &str,
228        kind: ForkLocalIndexKind,
229    ) -> bool {
230        self.fork_local_indexes
231            .get(&(label.to_string(), column.to_string()))
232            .is_some_and(|r| r.value().contains(&kind))
233    }
234
235    /// Phase 5a: snapshot of every registered fork-local index, one
236    /// `(label, column)` → `kind` tuple per built kind.
237    #[must_use]
238    pub fn all_fork_local_indexes(&self) -> Vec<((String, String), ForkLocalIndexKind)> {
239        self.fork_local_indexes
240            .iter()
241            .flat_map(|r| {
242                let key = r.key().clone();
243                r.value()
244                    .iter()
245                    .map(move |kind| (key.clone(), *kind))
246                    .collect::<Vec<_>>()
247            })
248            .collect()
249    }
250
251    /// Stable fork identifier.
252    #[must_use]
253    pub fn fork_id(&self) -> ForkId {
254        self.fork_id
255    }
256
257    /// Fork registry record (cheap `Arc::clone`).
258    #[must_use]
259    pub fn fork_info(&self) -> Arc<ForkInfo> {
260        self.fork_info.clone()
261    }
262
263    /// Parent fork id (Phase 3). `None` ⇒ parent is primary.
264    ///
265    /// Used by `UniInner::at_fork` to walk the ancestor chain for
266    /// overlay composition, and by `BranchedBackend` to route
267    /// on-the-fly dataset creation through the parent's branch.
268    #[must_use]
269    pub fn parent_fork_id(&self) -> Option<ForkId> {
270        self.fork_info.parent_fork_id
271    }
272
273    /// Schema delta to merge on top of primary's schema. Returns a
274    /// snapshot of the current overlay; subsequent
275    /// [`Self::add_label_to_overlay`] calls will not affect the
276    /// returned `Arc`.
277    #[must_use]
278    pub fn overlay(&self) -> Arc<SchemaDelta> {
279        self.overlay.load_full()
280    }
281
282    /// Branch name for a given Lance dataset, if this fork has one.
283    ///
284    /// Used by `StorageManager` dataset factories to route reads.
285    /// Consults both the immutable fork-point datasets map (set by
286    /// `finish_create`) and the dynamic-branches map (populated by
287    /// [`Self::register_dynamic_branch`] when a flush hits a dataset
288    /// that wasn't branched at fork-point). Returns `None` only if no
289    /// branch exists on either side — the BranchedBackend then either
290    /// creates one on the fly or surfaces an error.
291    #[must_use]
292    pub fn branch_for(&self, dataset_name: &str) -> Option<String> {
293        if let Some(b) = self.fork_info.datasets.get(dataset_name) {
294            return Some(b.clone());
295        }
296        self.dynamic_branches
297            .get(dataset_name)
298            .map(|r| r.value().clone())
299    }
300
301    /// Record a branch created after fork-point (e.g. for a dataset
302    /// that didn't exist on primary at fork creation, or for
303    /// compaction-only adjacency tables).
304    ///
305    /// In-memory only; the caller is responsible for persisting via
306    /// [`ForkRegistryHandle::register_dataset_branch`] so a restart
307    /// recovers the same mapping. Idempotent — re-registering an
308    /// existing entry is a no-op.
309    pub fn register_dynamic_branch(&self, dataset: String, branch: String) {
310        self.dynamic_branches.insert(dataset, branch);
311    }
312
313    /// Append a label to the fork-local schema overlay and persist
314    /// the new overlay to disk.
315    ///
316    /// Idempotent: if a label with the same name is already in the
317    /// overlay (or in primary's schema, accessible to the caller via
318    /// the merged `SchemaManager` not consulted here), the append
319    /// still records this entry — callers should check for duplicates
320    /// before invoking. The persistence-then-swap order means a
321    /// failed PUT leaves the in-memory `ArcSwap` untouched and the
322    /// returned error surfaces to the caller.
323    ///
324    /// Concurrency: serialized within a single fork by `overlay_lock`
325    /// so two concurrent appends don't clobber each other's
326    /// persisted state.
327    pub async fn add_label_to_overlay(&self, name: String, meta: LabelMeta) -> anyhow::Result<()> {
328        let _guard = self.overlay_lock.lock().await;
329        let mut next = (**self.overlay.load()).clone();
330        next.added_labels.push((name, meta));
331        self.registry
332            .update_schema_overlay(&self.fork_id, &next)
333            .await
334            .with_context(|| format!("persist schema overlay for fork {}", self.fork_id))?;
335        self.overlay.store(Arc::new(next));
336        Ok(())
337    }
338
339    /// Append an edge type to the fork-local schema overlay and
340    /// persist. Same semantics as [`Self::add_label_to_overlay`].
341    pub async fn add_edge_type_to_overlay(
342        &self,
343        name: String,
344        meta: EdgeTypeMeta,
345    ) -> anyhow::Result<()> {
346        let _guard = self.overlay_lock.lock().await;
347        let mut next = (**self.overlay.load()).clone();
348        next.added_edge_types.push((name, meta));
349        self.registry
350            .update_schema_overlay(&self.fork_id, &next)
351            .await
352            .with_context(|| format!("persist schema overlay for fork {}", self.fork_id))?;
353        self.overlay.store(Arc::new(next));
354        Ok(())
355    }
356
357    /// Registry handle (used by admin paths to e.g. compute holder counts).
358    #[must_use]
359    pub fn registry(&self) -> Arc<ForkRegistryHandle> {
360        self.registry.clone()
361    }
362}