uni_store/runtime/writer.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4#[cfg(feature = "lance-backend")]
5use crate::backend::table_names;
6use crate::backend::types::{CmpOp, FilterExpr, Scalar};
7use crate::runtime::context::QueryContext;
8use crate::runtime::embed_caps::is_multivector_property;
9use crate::runtime::flush_coordinator::{
10 FinalizeFn, FlushCoordinator, FlushOutcome as AsyncFlushOutcome, RotatedFlush, SharedFlushCtx,
11};
12use crate::runtime::id_allocator::IdAllocator;
13use crate::runtime::l0::{L0Buffer, serialize_constraint_key};
14use crate::runtime::l0_manager::L0Manager;
15use crate::runtime::property_manager::PropertyManager;
16use crate::runtime::wal::WriteAheadLog;
17use crate::storage::adjacency_manager::AdjacencyManager;
18use crate::storage::delta::{L1Entry, Op};
19use crate::storage::main_edge::MainEdgeDataset;
20use crate::storage::main_vertex::MainVertexDataset;
21use crate::storage::manager::StorageManager;
22use anyhow::{Result, anyhow};
23use chrono::Utc;
24use metrics;
25use parking_lot::{Mutex as PlMutex, RwLock};
26use std::collections::{BTreeMap, HashMap, HashSet};
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::sync::{Arc, OnceLock};
29use tracing::{debug, info, instrument};
30use uni_common::Properties;
31use uni_common::Value;
32use uni_common::config::UniConfig;
33use uni_common::core::fork::ForkId;
34use uni_common::core::id::{Eid, Vid};
35use uni_common::core::schema::{ConstraintTarget, ConstraintType, IndexDefinition};
36use uni_common::core::snapshot::{EdgeSnapshot, LabelSnapshot, SnapshotManifest};
37use uni_xervo::error::RuntimeError;
38use uni_xervo::runtime::ModelRuntime;
39use uni_xervo::traits::hybrid::{HeadSet, HybridEmbedResult};
40use uuid::Uuid;
41
42/// Convert per-token embedding vectors into the stored `List<Vector>` representation:
43/// a `Value::List` whose elements are each a `Value::List<Float>` (one token vector).
44fn multivec_to_value(tokens: &[Vec<f32>]) -> Value {
45 Value::List(
46 tokens
47 .iter()
48 .map(|tok| Value::List(tok.iter().map(|f| Value::Float(*f as f64)).collect()))
49 .collect(),
50 )
51}
52
53/// One coalesced auto-embed group: all targets that share an `alias` + `source_properties`,
54/// split into dense (`Vector`) vs multi-vector (`List<Vector>`) target columns. When a group
55/// has BOTH kinds, a single hybrid inference fills both (single forward pass).
56struct EmbedGroupSpec {
57 source_properties: Vec<String>,
58 document_prefix: Option<String>,
59 dense: Vec<String>,
60 multi: Vec<String>,
61 sparse: Vec<String>,
62}
63
64/// Convert one xervo sparse embedding (`(term_id, weight)` pairs, possibly
65/// unsorted / with duplicate terms) into a `Value::SparseVector`. Sorting (via
66/// `BTreeMap`) and summing duplicates yields the sorted-unique invariant the
67/// index and codec require; non-finite weights are dropped (never poison a row).
68fn sparse_pairs_to_value(pairs: &[(u32, f32)]) -> Value {
69 let mut by_term: std::collections::BTreeMap<u32, f32> = std::collections::BTreeMap::new();
70 for &(term, weight) in pairs {
71 if weight.is_finite() {
72 *by_term.entry(term).or_insert(0.0) += weight;
73 }
74 }
75 Value::SparseVector {
76 indices: by_term.keys().copied().collect(),
77 values: by_term.values().copied().collect(),
78 }
79}
80
81/// Run ONE embedding inference for a coalesced group of auto-embed targets sharing an alias +
82/// source text, returning `(dense_per_text, multi_vector_per_text)` (each `Some` iff that head
83/// was requested). When both heads are needed this uses the hybrid model
84/// (`hybrid_embedder`), so a multi-functional model (e.g. BGE-M3) produces the dense + ColBERT
85/// heads from a SINGLE forward pass; otherwise it uses the single-head dense / multi-vector
86/// embedder (today's behavior for non-mixed groups).
87#[allow(clippy::type_complexity)]
88async fn embed_group(
89 runtime: &ModelRuntime,
90 alias: &str,
91 texts: &[&str],
92 want_dense: bool,
93 want_multi: bool,
94 want_sparse: bool,
95) -> Result<(
96 Option<Vec<Vec<f32>>>,
97 Option<Vec<Vec<Vec<f32>>>>,
98 Option<Vec<Vec<(u32, f32)>>>,
99)> {
100 let heads_wanted = u8::from(want_dense) + u8::from(want_multi) + u8::from(want_sparse);
101 if heads_wanted > 1 {
102 // Single-pass hybrid: one model, one inference, all requested heads
103 // (e.g. BGE-M3 fills dense + sparse from one forward pass). A group with >1 head
104 // can only be served by a hybrid model (one alias = one task), so route directly.
105 let mut heads = HeadSet::empty();
106 if want_dense {
107 heads |= HeadSet::DENSE;
108 }
109 if want_multi {
110 heads |= HeadSet::MULTI_VECTOR;
111 }
112 if want_sparse {
113 heads |= HeadSet::SPARSE;
114 }
115 let embedder = runtime.hybrid_embedder(alias).await?;
116 let res = embedder.embed(texts, heads).await?;
117 let dense = if want_dense {
118 Some(res.dense.ok_or_else(|| {
119 anyhow!("hybrid model '{alias}' returned no dense head for a Vector target")
120 })?)
121 } else {
122 None
123 };
124 let multi = if want_multi {
125 Some(res.multi_vector.ok_or_else(|| {
126 anyhow!(
127 "hybrid model '{alias}' returned no multi-vector head for a List<Vector> target"
128 )
129 })?)
130 } else {
131 None
132 };
133 let sparse = if want_sparse {
134 Some(res.sparse.ok_or_else(|| {
135 anyhow!("hybrid model '{alias}' returned no sparse head for a SparseVector target")
136 })?)
137 } else {
138 None
139 };
140 Ok((dense, multi, sparse))
141 } else if want_multi {
142 // Lone multi-vector head: prefer the narrow facade, but fall back to a hybrid
143 // model on the same alias (issue #129) when it doesn't implement the narrow trait.
144 match runtime.multi_vector_embedder(alias).await {
145 Ok(embedder) => Ok((None, Some(embedder.embed(texts).await?.vectors), None)),
146 Err(e) if is_capability_mismatch(&e) => {
147 let multi = hybrid_single_head(runtime, alias, texts, HeadSet::MULTI_VECTOR)
148 .await?
149 .multi_vector
150 .ok_or_else(|| {
151 anyhow!(
152 "model '{alias}' exposes no multi-vector head for a List<Vector> target"
153 )
154 })?;
155 Ok((None, Some(multi), None))
156 }
157 Err(e) => Err(e.into()),
158 }
159 } else if want_sparse {
160 // Lone sparse head: narrow facade, else hybrid fallback (issue #129).
161 match runtime.sparse_embedder(alias).await {
162 Ok(embedder) => Ok((None, None, Some(embedder.embed(texts).await?.vectors))),
163 Err(e) if is_capability_mismatch(&e) => {
164 let sparse = hybrid_single_head(runtime, alias, texts, HeadSet::SPARSE)
165 .await?
166 .sparse
167 .ok_or_else(|| {
168 anyhow!("model '{alias}' exposes no sparse head for a SparseVector target")
169 })?;
170 Ok((None, None, Some(sparse)))
171 }
172 Err(e) => Err(e.into()),
173 }
174 } else {
175 // Lone dense head: narrow facade, else hybrid fallback (issue #129 — a hybrid
176 // model like BGE-M3 serving a single dense column on its own alias).
177 match runtime.embedding(alias).await {
178 Ok(embedder) => Ok((Some(embedder.embed(texts).await?.vectors), None, None)),
179 Err(e) if is_capability_mismatch(&e) => {
180 let dense = hybrid_single_head(runtime, alias, texts, HeadSet::DENSE)
181 .await?
182 .dense
183 .ok_or_else(|| {
184 anyhow!("model '{alias}' exposes no dense head for a Vector target")
185 })?;
186 Ok((Some(dense), None, None))
187 }
188 Err(e) => Err(e.into()),
189 }
190 }
191}
192
193/// Whether `err` means the alias's model does not implement the requested narrow
194/// embedding trait, so the hybrid model on the same alias should be tried instead.
195///
196/// `embedding()` reports a missing dense trait as `RuntimeError::CapabilityMismatch`;
197/// the sparse / multi-vector / hybrid facades report it as
198/// `RuntimeError::ProviderCapabilityMissing`. Both mean "wrong facade for this model",
199/// as opposed to a load or inference failure, which must propagate unchanged.
200fn is_capability_mismatch(err: &RuntimeError) -> bool {
201 matches!(
202 err,
203 RuntimeError::CapabilityMismatch(_) | RuntimeError::ProviderCapabilityMissing { .. }
204 )
205}
206
207/// Run ONE hybrid forward pass over `texts` producing only `head`, for the lone-head
208/// capability fallback (issue #129).
209///
210/// The hybrid embedder is cached per alias and the model is keyed by its task, so this
211/// reuses the model already loaded by the failed narrow attempt (no second load). The
212/// caller extracts the matching field of the result; a `None` there means the model does
213/// not expose `head` and must be surfaced as a hard error, never a silently-empty column.
214async fn hybrid_single_head(
215 runtime: &ModelRuntime,
216 alias: &str,
217 texts: &[&str],
218 head: HeadSet,
219) -> Result<HybridEmbedResult> {
220 let embedder = runtime.hybrid_embedder(alias).await?;
221 embedder.embed(texts, head).await.map_err(Into::into)
222}
223
224/// Group a label's auto-embed configs by `(alias, source_properties)`, classifying each target
225/// column as dense vs multi-vector. A group with both kinds is a single-pass hybrid source.
226fn collect_embed_groups(
227 schema: &uni_common::core::schema::Schema,
228 label: &str,
229) -> std::collections::BTreeMap<(String, Vec<String>), EmbedGroupSpec> {
230 use std::collections::BTreeMap;
231 let mut groups: BTreeMap<(String, Vec<String>), EmbedGroupSpec> = BTreeMap::new();
232 fn spec_for<'a>(
233 groups: &'a mut std::collections::BTreeMap<(String, Vec<String>), EmbedGroupSpec>,
234 emb: &uni_common::core::schema::EmbeddingConfig,
235 ) -> &'a mut EmbedGroupSpec {
236 groups
237 .entry((emb.alias.clone(), emb.source_properties.clone()))
238 .or_insert_with(|| EmbedGroupSpec {
239 source_properties: emb.source_properties.clone(),
240 document_prefix: emb.document_prefix.clone(),
241 dense: Vec::new(),
242 multi: Vec::new(),
243 sparse: Vec::new(),
244 })
245 }
246 for idx in &schema.indexes {
247 if let IndexDefinition::Vector(v_config) = idx
248 && v_config.label == label
249 && let Some(emb) = &v_config.embedding_config
250 {
251 let g = spec_for(&mut groups, emb);
252 if is_multivector_property(schema, label, &v_config.property) {
253 g.multi.push(v_config.property.clone());
254 } else {
255 g.dense.push(v_config.property.clone());
256 }
257 } else if let IndexDefinition::Sparse(s_config) = idx
258 && s_config.label == label
259 && let Some(emb) = &s_config.embedding_config
260 {
261 spec_for(&mut groups, emb)
262 .sparse
263 .push(s_config.property.clone());
264 }
265 }
266 groups
267}
268
269/// On a partial / `SET` write, when the write touches a *source* property of an
270/// auto-embed group, drop that group's target columns from `props` so the
271/// `!contains_key` guard in `process_embeddings_*` re-embeds them (otherwise the
272/// re-read old embedding is preserved → stale), and add the targets to
273/// `touched_keys` so the partial Lance write persists the refresh. Mirrors the
274/// MUVERA derived-column touched-keys handling. A target the caller set
275/// explicitly in the SAME write (already in `touched_keys`) is left intact.
276fn refresh_touched_embed_targets(
277 schema: &uni_common::core::schema::Schema,
278 props: &mut Properties,
279 touched_keys: &mut HashSet<String>,
280 labels: &[String],
281) {
282 let Some(label) = labels.first() else {
283 return;
284 };
285 let user_touched = touched_keys.clone();
286 for (_key, group) in collect_embed_groups(schema, label) {
287 if !group
288 .source_properties
289 .iter()
290 .any(|s| touched_keys.contains(s))
291 {
292 continue;
293 }
294 for target in group
295 .dense
296 .iter()
297 .chain(group.multi.iter())
298 .chain(group.sparse.iter())
299 {
300 if user_touched.contains(target) {
301 continue;
302 }
303 props.remove(target);
304 touched_keys.insert(target.clone());
305 }
306 }
307}
308
309#[derive(Clone, Debug)]
310
311pub struct WriterConfig {
312 pub max_mutations: usize,
313 /// Enable the partial-column MergeInsert path for SET-only flushes.
314 ///
315 /// When `true`, `Writer::insert_vertex_partial` records the touched
316 /// property keys into `L0Buffer::vertex_partial_keys` and the flush
317 /// routes those VIDs through Lance `MergeInsertBuilder` with a
318 /// subset-of-schema source, skipping the read of (and write of)
319 /// the unchanged columns — including wide ones like embeddings.
320 ///
321 /// When `false`, `insert_vertex_partial` falls back to the
322 /// read-modify-write `insert_vertex_with_labels` path (preserving
323 /// bit-for-bit equivalence with prior releases). Default `false`
324 /// for the first release; flip to `true` after telemetry on the
325 /// issue #72 ingest workload confirms the win.
326 ///
327 /// See the soundness probe at
328 /// `crates/uni-store/tests/common/storage/lance_merge_insert_probe.rs`.
329 pub partial_lance_writes: bool,
330}
331
332impl Default for WriterConfig {
333 fn default() -> Self {
334 Self {
335 max_mutations: 10_000,
336 partial_lance_writes: false,
337 }
338 }
339}
340
341/// Parent state captured atomically at a fork point under `flush_lock`.
342///
343/// Holds the allocator high-water marks and every existing dataset's
344/// Lance main-branch version at the instant the parent's L0 was
345/// flushed. Because [`Writer::flush_and_capture_fork_point`] reads these
346/// while still holding `flush_lock`, no concurrent commit or flush can
347/// advance the allocator or any dataset tip between the flush and the
348/// reads. A fork built from these values therefore cannot collide VIDs
349/// with the parent nor inherit rows committed after the fork point.
350#[derive(Clone, Debug, Default)]
351pub struct ForkPoint {
352 /// Next vertex id the parent would allocate at the fork point.
353 pub vid_hwm: u64,
354 /// Next edge id the parent would allocate at the fork point.
355 pub eid_hwm: u64,
356 /// `dataset_name` → Lance main-branch version at the fork point.
357 ///
358 /// Keys use the same dataset naming as the fork branch loop
359 /// (`vertices`, `edges`, `vertices_{label}`, `deltas_{type}_{dir}`,
360 /// `adjacency_{type}_{dir}`). A dataset with no `.lance` directory
361 /// on disk at the fork point has no entry.
362 pub dataset_versions: BTreeMap<String, u64>,
363 /// Parent's MVCC version high-water-mark at the fork point: the
364 /// largest `_version` any inherited row can carry. A fork bootstraps
365 /// its own version counter to this floor so a fork transaction's
366 /// `_version <= pin` read still sees inherited (base_paths) rows,
367 /// while the fork's own writes get versions above it.
368 pub version_hwm: u64,
369 /// `dataset_name` → parent **branch** version at the fork point, for a
370 /// nested fork (a fork of a fork). Empty when the parent is the primary DB.
371 ///
372 /// A nested fork must branch off its parent fork's Lance *branch* tip, which
373 /// advances independently of `main` (so `dataset_versions`, which tracks the
374 /// main-branch version, is not usable there). Captured under `flush_lock`
375 /// alongside `dataset_versions` so a concurrent parent commit+flush cannot
376 /// advance the tip between capture and branch creation.
377 pub parent_branch_versions: BTreeMap<String, u64>,
378}
379
380/// RAII latch on [`StorageManager::flush_in_progress`].
381///
382/// Sets the flag to `true` on construction (via CAS) and back to `false` on
383/// drop, so any `?` early-exit inside `flush_to_l1` cannot leave the flag
384/// stuck. Returns `None` if a flush is already in progress, providing
385/// forward-compatible exclusion once the outer writer-RwLock is removed in
386/// Phase 4 of the concurrent-writer refactor.
387// FlushInProgressGuard moved to storage/manager.rs so flush_coordinator.rs
388// can hold it on RotatedFlush without a writer.rs back-import cycle.
389pub use crate::storage::manager::FlushInProgressGuard;
390
391/// Output of [`Writer::flush_l0_rotate`]: the to-be-flushed L0 buffer,
392/// captured WAL LSN, current_version, and the in-progress guard whose
393/// lifetime spans the full flush (including the future async stream
394/// phase that runs on a spawned task).
395struct RotateOutput {
396 old_l0_arc: Arc<RwLock<L0Buffer>>,
397 wal_lsn: u64,
398 current_version: u64,
399 flush_in_progress_guard: FlushInProgressGuard,
400}
401
402/// Project a property map to a subset selected by `keys`. Used to
403/// run `touched_needs_full_read` against just the SET-touched keys
404/// when the caller passes a fully-merged `props` map.
405fn props_subset(props: &Properties, keys: &HashSet<String>) -> Properties {
406 let mut out = Properties::new();
407 for k in keys {
408 if let Some(v) = props.get(k) {
409 out.insert(k.clone(), v.clone());
410 }
411 }
412 out
413}
414
415/// Output of [`Writer::flush_stream_l1`]: the built (but not yet
416/// published) snapshot manifest and its id. Finalize is responsible
417/// for `save_snapshot` + `set_latest_snapshot` + `cached_manifest`
418/// update.
419struct FlushOutcome {
420 manifest: SnapshotManifest,
421 snapshot_id: String,
422}
423
424pub struct Writer {
425 pub l0_manager: Arc<L0Manager>,
426 pub storage: Arc<StorageManager>,
427 pub schema_manager: Arc<uni_common::core::schema::SchemaManager>,
428 pub allocator: Arc<IdAllocator>,
429 pub config: UniConfig,
430 /// Optional embedding runtime. `OnceLock` so the initializer can run
431 /// on `&self` after the `Writer` has been wrapped in `Arc<Writer>`
432 /// (Phase 4 of concurrent_writer.md). Read through
433 /// [`Writer::xervo_runtime`] — the field itself is private to keep
434 /// callers oblivious to the OnceLock representation.
435 xervo_runtime: OnceLock<Arc<ModelRuntime>>,
436 /// Property manager for cache invalidation after flush
437 pub property_manager: Option<Arc<PropertyManager>>,
438 /// Adjacency manager for dual-write (edges survive flush).
439 adjacency_manager: Arc<AdjacencyManager>,
440 /// Timestamp of last flush or creation. Interior-mutable so that
441 /// `&self` callers can update it; uncontended in practice because all
442 /// writes happen inside the single-flusher critical section.
443 /// Arc-wrapped so it can travel into the SharedFlushCtx that the
444 /// async-flush coordinator passes to spawned stream/finalize tasks.
445 last_flush_time: Arc<PlMutex<std::time::Instant>>,
446 /// Background compaction task handle (prevents concurrent compaction races)
447 compaction_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
448 /// Optional index rebuild manager for post-flush automatic rebuild scheduling.
449 /// `OnceLock` for the same reason as `xervo_runtime`.
450 /// Wrapped in `Arc` so the async-flush finalize path can read it
451 /// from a spawned task via `SharedFlushCtx`.
452 index_rebuild_manager: Arc<OnceLock<Arc<crate::storage::index_rebuild::IndexRebuildManager>>>,
453 /// Cached snapshot manifest from the last flush. Avoids re-reading from
454 /// object store on every flush_to_l1 call. Wrapped in a `Mutex` for
455 /// `&self` access; uncontended because all access is inside the
456 /// single-flusher critical section.
457 cached_manifest: Arc<PlMutex<Option<SnapshotManifest>>>,
458 /// Identifier of the fork this writer serves, if any. `None` for
459 /// primary's writer. Set by [`crate::fork::writer_factory::new_for_fork`]
460 /// and read in `flush_to_l1` to emit fork-tagged metrics and to fire
461 /// the fragment-count guard rail (Phase 2 Day 12).
462 pub fork_id: Option<ForkId>,
463 /// Number of `flush_to_l1` calls since this writer was constructed.
464 /// Used as a proxy for L1 fragment growth on the fork's branches:
465 /// each flush typically appends ~1 fragment per touched dataset, so
466 /// the count tracks the order of magnitude of fragment accumulation.
467 /// Reading the actual `Dataset::manifest().fragments.len()` per
468 /// flush would add a per-dataset object-store roundtrip on the hot
469 /// commit path; the proxy keeps the guard rail purely observational
470 /// (Phase 5 introduces fork compaction proper). Only meaningful when
471 /// `fork_id.is_some()`. `Relaxed` is sufficient — observational only.
472 fork_flush_count: Arc<AtomicU64>,
473 /// Whether the fork-fragment warning has already fired at the
474 /// configured threshold. One-shot per writer lifetime. `Relaxed` is
475 /// sufficient — observational only.
476 fork_fragment_warn_fired: Arc<AtomicBool>,
477 /// Dedicated lock for the genuinely-exclusive flush path. Acquired by
478 /// the [`Writer::flush_to_l1`] entry and by `commit_transaction_l0`
479 /// across its WAL-append + L0-merge window. Replaces the outer
480 /// `Arc<RwLock<Writer>>` for flush exclusion once Phase 4 drops it.
481 /// Arc-wrapped so async-flush coordinator's finalize path can
482 /// re-acquire it from a spawned task via SharedFlushCtx.
483 flush_lock: Arc<tokio::sync::Mutex<()>>,
484 /// Coordinator for async-flush pipeline. Owns the back-pressure
485 /// semaphore, rotate-order sequence, single-finalizer task, and
486 /// pending-flush counter. Always present even when async flush is
487 /// disabled — the sync `flush_to_l1` path uses it for the future
488 /// `FlushInProgressGuard`/permit ownership model.
489 /// Coordinator is `None` when `async_flush_enabled = false`. The
490 /// coordinator's finalizer task captures `SharedFlushCtx` which
491 /// includes `Arc<StorageManager>`; on a fork-scoped Writer that
492 /// also pins the fork's `ForkScope` via `storage.fork_scope`, so
493 /// the holder count never drops. Constructing it only when the
494 /// feature is actually on avoids that side-effect for all
495 /// existing sync-flush paths. When async-flush graduates from
496 /// opt-in to default (Commit 12), `drop_fork` (Commit 8) handles
497 /// the drain explicitly.
498 #[allow(dead_code)] // first production use lands in Commit 6/7
499 pub(crate) flush_coordinator: Option<Arc<crate::runtime::flush_coordinator::FlushCoordinator>>,
500 /// Optimistic-concurrency commit-sequence counter (SSI). Incremented once
501 /// per successful commit under `flush_lock`; a transaction captures the
502 /// current value at begin as its read sequence (`L0Buffer::occ_read_seq`).
503 ///
504 /// Always allocated; consulted only when `config.ssi_enabled` is `true`.
505 ///
506 /// Typed through the [`crate::runtime::sync`] shim so the OCC commit core can
507 /// be model-checked under loom/shuttle; aliases to `std::AtomicU64` normally.
508 commit_sequence: Arc<crate::runtime::sync::AtomicU64>,
509 /// Bounded log of recently-committed write-sets for OCC conflict detection.
510 /// Read and updated only under `flush_lock`.
511 ///
512 /// Always allocated; consulted only when `config.ssi_enabled` is `true`.
513 committed_writes: Arc<PlMutex<crate::runtime::occ::CommitRegistry>>,
514 /// Per-row pessimistic locks for `FOR UPDATE` (SSI escape hatch), keyed by
515 /// canonical (label, key-props) bytes. A transaction holds the lock from
516 /// MATCH until commit/rollback, serializing concurrent `FOR UPDATE` writers
517 /// on the same key (avoiding optimistic abort-retry on hot keys).
518 ///
519 /// Always allocated; populated only when `config.ssi_enabled` is `true`.
520 for_update_locks: Arc<dashmap::DashMap<Vec<u8>, Arc<tokio::sync::Mutex<()>>>>,
521}
522
523/// Number of recent commits retained for OCC conflict detection. Large enough
524/// that under-run — and the resulting conservative abort — is rare in practice;
525/// each entry is a small set of touched ids.
526const OCC_REGISTRY_CAPACITY: usize = 4096;
527
528impl Writer {
529 pub async fn new(
530 storage: Arc<StorageManager>,
531 schema_manager: Arc<uni_common::core::schema::SchemaManager>,
532 start_version: u64,
533 ) -> Result<Self> {
534 Self::new_with_config(
535 storage,
536 schema_manager,
537 start_version,
538 UniConfig::default(),
539 None,
540 None,
541 )
542 .await
543 }
544
545 pub async fn new_with_config(
546 storage: Arc<StorageManager>,
547 schema_manager: Arc<uni_common::core::schema::SchemaManager>,
548 start_version: u64,
549 config: UniConfig,
550 wal: Option<Arc<WriteAheadLog>>,
551 allocator: Option<Arc<IdAllocator>>,
552 ) -> Result<Self> {
553 let allocator = if let Some(a) = allocator {
554 a
555 } else {
556 let store = storage.store();
557 let path = object_store::path::Path::from("id_allocator.json");
558 Arc::new(IdAllocator::new(store, path, 1000).await?)
559 };
560
561 let l0_manager = Arc::new(L0Manager::new(start_version, wal));
562 // Route commit-time CRDT merges through the DB's plugin registry (if
563 // installed on the StorageManager). Behavior-preserving when absent.
564 if let Some(registry) = storage.plugin_registry() {
565 l0_manager.set_plugin_registry(Arc::clone(registry));
566 }
567
568 let property_manager = Some(Arc::new(PropertyManager::new(
569 storage.clone(),
570 schema_manager.clone(),
571 1000,
572 )));
573
574 let adjacency_manager = storage.adjacency_manager();
575
576 // Hoist the Arc'd fields so we can both stash them on Writer and
577 // hand the same Arcs to the SharedFlushCtx that FlushCoordinator
578 // captures. Single-source-of-truth for each piece of mutable
579 // shared state.
580 let last_flush_time = Arc::new(PlMutex::new(std::time::Instant::now()));
581 let cached_manifest = Arc::new(PlMutex::new(None));
582 let fork_flush_count = Arc::new(AtomicU64::new(0));
583 let fork_fragment_warn_fired = Arc::new(AtomicBool::new(false));
584 let flush_lock = Arc::new(tokio::sync::Mutex::new(()));
585 let compaction_handle = Arc::new(RwLock::new(None));
586 let index_rebuild_manager: Arc<
587 OnceLock<Arc<crate::storage::index_rebuild::IndexRebuildManager>>,
588 > = Arc::new(OnceLock::new());
589
590 let flush_coordinator = if config.async_flush_enabled {
591 let shared = SharedFlushCtx {
592 storage: storage.clone(),
593 l0_manager: l0_manager.clone(),
594 adjacency_manager: adjacency_manager.clone(),
595 property_manager: property_manager.clone(),
596 schema_manager: schema_manager.clone(),
597 cached_manifest: cached_manifest.clone(),
598 last_flush_time: last_flush_time.clone(),
599 fork_id: None,
600 fork_flush_count: fork_flush_count.clone(),
601 fork_fragment_warn_fired: fork_fragment_warn_fired.clone(),
602 fork_fragment_warn_threshold: config.fork_fragment_warn_threshold,
603 flush_lock: flush_lock.clone(),
604 index_rebuild_manager: index_rebuild_manager.clone(),
605 compaction_handle: compaction_handle.clone(),
606 compaction_config: config.compaction.clone(),
607 index_rebuild_config: config.index_rebuild.clone(),
608 auto_rebuild_enabled: config.index_rebuild.auto_rebuild_enabled,
609 };
610 let finalize_fn: Arc<dyn FinalizeFn> = Arc::new(WriterFinalizer);
611 Some(Arc::new(FlushCoordinator::new(
612 config.max_pending_flushes,
613 config.flush_stream_timeout,
614 shared,
615 finalize_fn,
616 )))
617 } else {
618 None
619 };
620
621 let commit_sequence = Arc::new(crate::runtime::sync::AtomicU64::new(0));
622 let committed_writes = Arc::new(PlMutex::new(crate::runtime::occ::CommitRegistry::new(
623 OCC_REGISTRY_CAPACITY,
624 )));
625 let for_update_locks = Arc::new(dashmap::DashMap::new());
626
627 Ok(Self {
628 l0_manager,
629 storage,
630 schema_manager,
631 allocator,
632 config,
633 xervo_runtime: OnceLock::new(),
634 property_manager,
635 adjacency_manager,
636 last_flush_time,
637 compaction_handle,
638 index_rebuild_manager,
639 cached_manifest,
640 fork_id: None,
641 fork_flush_count,
642 fork_fragment_warn_fired,
643 flush_lock,
644 flush_coordinator,
645 commit_sequence,
646 committed_writes,
647 for_update_locks,
648 })
649 }
650
651 /// Returns the shared pessimistic lock handle for a `FOR UPDATE` row key,
652 /// creating it on first use. The caller `.lock_owned().await`s the returned
653 /// mutex and holds the guard for the transaction's lifetime.
654 pub fn row_lock_handle(&self, key: &[u8]) -> Arc<tokio::sync::Mutex<()>> {
655 self.for_update_locks
656 .entry(key.to_vec())
657 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
658 .clone()
659 }
660
661 /// Prunes `FOR UPDATE` lock-map entries for `keys` that no live transaction
662 /// holds anymore, so the map does not grow without bound across the keyspace.
663 ///
664 /// Called when a transaction ends, **after** its guards have been dropped.
665 /// `remove_if` evaluates its predicate under the DashMap shard lock, which is
666 /// the same lock `row_lock_handle` takes to clone an entry — so the check
667 /// `strong_count == 1` (only the map holds the `Arc`) is race-free: a
668 /// concurrent acquirer either already cloned the `Arc` (count ≥ 2 → we skip
669 /// removal) or has not yet taken the shard lock (it will mint a fresh entry
670 /// after we remove). Either way no two transactions ever lock different
671 /// `Mutex` instances for the same key.
672 pub fn release_for_update_locks(&self, keys: &[Vec<u8>]) {
673 for key in keys {
674 self.for_update_locks
675 .remove_if(key, |_, handle| Arc::strong_count(handle) == 1);
676 }
677 }
678
679 /// Number of live entries in the `FOR UPDATE` lock map. Introspection for
680 /// tests that the map does not leak entries across transactions (G5).
681 pub fn for_update_lock_count(&self) -> usize {
682 self.for_update_locks.len()
683 }
684
685 /// The current OCC commit sequence. A `FOR UPDATE` acquisition re-stamps a
686 /// fresh transaction's `occ_read_seq` to this so its conflict-detection
687 /// baseline advances to lock-acquisition time (read-latest under the lock).
688 pub fn current_commit_sequence(&self) -> u64 {
689 self.commit_sequence
690 .load(crate::runtime::sync::Ordering::Relaxed)
691 }
692
693 /// Build a fresh `SharedFlushCtx` from this Writer's current state.
694 /// Used by the async-flush stream/finalize paths to pass into spawned
695 /// tasks without smuggling `Arc<Writer>` (which would create a cycle
696 /// with `flush_coordinator -> FinalizeFn -> Writer`).
697 pub(crate) fn shared_ctx(&self) -> SharedFlushCtx {
698 SharedFlushCtx {
699 storage: self.storage.clone(),
700 l0_manager: self.l0_manager.clone(),
701 adjacency_manager: self.adjacency_manager.clone(),
702 property_manager: self.property_manager.clone(),
703 schema_manager: self.schema_manager.clone(),
704 cached_manifest: self.cached_manifest.clone(),
705 last_flush_time: self.last_flush_time.clone(),
706 fork_id: self.fork_id,
707 fork_flush_count: self.fork_flush_count.clone(),
708 fork_fragment_warn_fired: self.fork_fragment_warn_fired.clone(),
709 fork_fragment_warn_threshold: self.config.fork_fragment_warn_threshold,
710 flush_lock: self.flush_lock.clone(),
711 index_rebuild_manager: self.index_rebuild_manager.clone(),
712 compaction_handle: self.compaction_handle.clone(),
713 compaction_config: self.config.compaction.clone(),
714 index_rebuild_config: self.config.index_rebuild.clone(),
715 auto_rebuild_enabled: self.config.index_rebuild.auto_rebuild_enabled,
716 }
717 }
718
719 /// Borrow the flush coordinator if async flush is enabled.
720 /// Returns `None` when `config.async_flush_enabled = false`.
721 /// External callers (`drop_fork`) use this to drain pending streams.
722 pub fn flush_coordinator(
723 &self,
724 ) -> Option<&Arc<crate::runtime::flush_coordinator::FlushCoordinator>> {
725 self.flush_coordinator.as_ref()
726 }
727
728 /// Set the index rebuild manager for post-flush automatic rebuild scheduling.
729 ///
730 /// One-shot: returns `Err` if already set. The receiver is `&self` so this
731 /// can be called after the `Writer` has been wrapped in `Arc<Writer>`.
732 pub fn set_index_rebuild_manager(
733 &self,
734 manager: Arc<crate::storage::index_rebuild::IndexRebuildManager>,
735 ) -> Result<()> {
736 self.index_rebuild_manager
737 .set(manager)
738 .map_err(|_| anyhow!("index_rebuild_manager already set"))
739 }
740
741 /// Replay WAL mutations into the current L0 buffer.
742 pub async fn replay_wal(&self, wal_high_water_mark: u64) -> Result<usize> {
743 let l0 = self.l0_manager.get_current();
744 let wal = l0.read().wal.clone();
745
746 if let Some(wal) = wal {
747 wal.initialize().await?;
748 let mutations = wal.replay_since(wal_high_water_mark).await?;
749 let count = mutations.len();
750
751 if count > 0 {
752 log::info!(
753 "Replaying {} mutations from WAL (LSN > {})",
754 count,
755 wal_high_water_mark
756 );
757 let mut l0_guard = l0.write();
758 l0_guard.replay_mutations(mutations)?;
759 // Rebuild the UNIQUE constraint index over the recovered rows
760 // (Bug #9 Mechanism B). `replay_mutations` restores
761 // vertices/properties/labels but never repopulates
762 // `constraint_index` (its only other caller is the live insert
763 // path). Without this, a unique key that lives only in the WAL
764 // (committed but not yet flushed to Lance) is invisible to
765 // `check_unique_constraint_multi` after recovery and a
766 // duplicate of it could be created.
767 self.rebuild_constraint_index(&mut l0_guard);
768 // Null out wrong-dimension vector values recovered from a WAL written
769 // before dimension enforcement (issue #137). The flush path now fails
770 // closed on a dimension mismatch, so without this pass a single
771 // pre-fix value would make every flush of its label fail forever,
772 // leaving the database unopenable (the ghost-commit hazard documented
773 // on `commit_transaction_l0`). Nulling preserves exactly the pre-fix
774 // flush outcome for these values, with a loud signal.
775 self.sanitize_replayed_vector_dims(&mut l0_guard);
776 }
777
778 Ok(count)
779 } else {
780 Ok(0)
781 }
782 }
783
784 /// Nulls wrong-dimension vector values in a recovered L0 buffer (issue #137).
785 ///
786 /// Only runs during WAL replay, where the offending value was written by a
787 /// version without write-time dimension enforcement and can no longer be
788 /// rejected. Live writes never reach this — they fail in the validation and
789 /// coercion guards.
790 fn sanitize_replayed_vector_dims(&self, l0_guard: &mut L0Buffer) {
791 let schema = self.schema_manager.schema();
792
793 // Collect fixes first: `vertex_labels` / `edge_types` are borrowed immutably
794 // from the same guard the property maps are mutated through.
795 let mut vertex_fixes: Vec<(Vid, String)> = Vec::new();
796 for (&vid, props) in &l0_guard.vertex_properties {
797 let Some(labels) = l0_guard.vertex_labels.get(&vid) else {
798 continue;
799 };
800 for label in labels {
801 let Some(props_meta) = schema.properties.get(label) else {
802 continue;
803 };
804 for (prop_name, meta) in props_meta {
805 if let Some(value) = props.get(prop_name)
806 && let Err(e) = meta.r#type.check_vector_dims(value)
807 {
808 log::warn!(
809 "WAL replay: nulling wrong-dimension value for '{}.{}' on vid {:?} \
810 ({}) — written before dimension enforcement (issue #137)",
811 label,
812 prop_name,
813 vid,
814 e
815 );
816 vertex_fixes.push((vid, prop_name.clone()));
817 }
818 }
819 }
820 }
821 for (vid, prop) in vertex_fixes {
822 if let Some(props) = l0_guard.vertex_properties.get_mut(&vid) {
823 props.insert(prop, Value::Null);
824 }
825 }
826
827 let mut edge_fixes: Vec<(Eid, String)> = Vec::new();
828 for (&eid, props) in &l0_guard.edge_properties {
829 let Some(type_name) = l0_guard.edge_types.get(&eid) else {
830 continue;
831 };
832 let Some(props_meta) = schema.properties.get(type_name) else {
833 continue;
834 };
835 for (prop_name, meta) in props_meta {
836 if let Some(value) = props.get(prop_name)
837 && let Err(e) = meta.r#type.check_vector_dims(value)
838 {
839 log::warn!(
840 "WAL replay: nulling wrong-dimension value for '{}.{}' on eid {:?} \
841 ({}) — written before dimension enforcement (issue #137)",
842 type_name,
843 prop_name,
844 eid,
845 e
846 );
847 edge_fixes.push((eid, prop_name.clone()));
848 }
849 }
850 }
851 for (eid, prop) in edge_fixes {
852 if let Some(props) = l0_guard.edge_properties.get_mut(&eid) {
853 props.insert(prop, Value::Null);
854 }
855 }
856 }
857
858 /// Rebuild the UNIQUE constraint index on a recovered L0 buffer.
859 ///
860 /// Scans every recovered vertex's properties and, for each enabled UNIQUE
861 /// constraint whose target label the vertex carries and whose member
862 /// properties are all present, inserts the same constraint key the live
863 /// insert path builds (`serialize_constraint_key`). Tombstoned vertices are
864 /// skipped. Called after [`L0Buffer::replay_mutations`] under the buffer's
865 /// write lock; the schema is already loaded on the `Writer`.
866 fn rebuild_constraint_index(&self, l0_guard: &mut L0Buffer) {
867 let schema = self.schema_manager.schema();
868 // Collect entries first to avoid borrowing `vertex_properties`
869 // immutably while mutating `constraint_index` through the same guard.
870 let mut keys: Vec<(Vec<u8>, Vid)> = Vec::new();
871 for (&vid, props) in &l0_guard.vertex_properties {
872 if l0_guard.vertex_tombstones.contains(&vid) {
873 continue;
874 }
875 let Some(labels) = l0_guard.vertex_labels.get(&vid) else {
876 continue;
877 };
878 for label in labels {
879 for constraint in &schema.constraints {
880 if !constraint.enabled {
881 continue;
882 }
883 let ConstraintTarget::Label(l) = &constraint.target else {
884 continue;
885 };
886 if l != label {
887 continue;
888 }
889 // Rebuild keys for both Unique and NodeKey (uniqueness half).
890 let Some(unique_props) = constraint.constraint_type.unique_properties() else {
891 continue;
892 };
893 let mut key_values = Vec::new();
894 let mut all_present = true;
895 for prop in unique_props {
896 if let Some(val) = props.get(prop) {
897 key_values.push((prop.clone(), val.clone()));
898 } else {
899 all_present = false;
900 break;
901 }
902 }
903 if all_present {
904 keys.push((serialize_constraint_key(label, &key_values), vid));
905 }
906 }
907 }
908 }
909 for (key, vid) in keys {
910 l0_guard.insert_constraint_key(key, vid);
911 }
912
913 // Same rebuild for edge unique/nodekey keys: `replay_mutations` restores
914 // edge properties/types but never repopulates `edge_constraint_index`, so
915 // a WAL-committed-but-unflushed unique edge key would otherwise be invisible
916 // to the edge full-horizon probe after recovery (edge counterpart of Bug #9
917 // Mechanism B).
918 let mut edge_keys: Vec<(Vec<u8>, Eid)> = Vec::new();
919 for (&eid, props) in &l0_guard.edge_properties {
920 if l0_guard.tombstones.contains_key(&eid) {
921 continue;
922 }
923 let Some(edge_type) = l0_guard.edge_types.get(&eid) else {
924 continue;
925 };
926 for constraint in &schema.constraints {
927 if !constraint.enabled {
928 continue;
929 }
930 let ConstraintTarget::EdgeType(t) = &constraint.target else {
931 continue;
932 };
933 if t != edge_type {
934 continue;
935 }
936 let Some(unique_props) = constraint.constraint_type.unique_properties() else {
937 continue;
938 };
939 let mut key_values = Vec::new();
940 let mut all_present = true;
941 for prop in unique_props {
942 match props.get(prop) {
943 Some(val) if !val.is_null() => key_values.push((prop.clone(), val.clone())),
944 _ => {
945 all_present = false;
946 break;
947 }
948 }
949 }
950 if all_present && !key_values.is_empty() {
951 edge_keys.push((serialize_constraint_key(edge_type, &key_values), eid));
952 }
953 }
954 }
955 for (key, eid) in edge_keys {
956 l0_guard.insert_edge_constraint_key(key, eid);
957 }
958 }
959
960 /// Allocates the next VID (pure auto-increment).
961 pub async fn next_vid(&self) -> Result<Vid> {
962 self.allocator.allocate_vid().await
963 }
964
965 /// Allocates multiple VIDs at once for bulk operations.
966 /// This is more efficient than calling next_vid() in a loop.
967 pub async fn allocate_vids(&self, count: usize) -> Result<Vec<Vid>> {
968 self.allocator.allocate_vids(count).await
969 }
970
971 /// Allocates the next EID (pure auto-increment).
972 pub async fn next_eid(&self, _type_id: u32) -> Result<Eid> {
973 self.allocator.allocate_eid().await
974 }
975
976 /// Allocates multiple EIDs at once for bulk operations.
977 /// This is more efficient than calling next_eid() in a loop.
978 pub async fn allocate_eids(&self, count: usize) -> Result<Vec<Eid>> {
979 self.allocator.allocate_eids(count).await
980 }
981
982 /// Install the embedding runtime exactly once. Receiver is `&self` so it
983 /// can be called after the `Writer` has been wrapped in `Arc<Writer>`.
984 pub fn set_xervo_runtime(&self, runtime: Arc<ModelRuntime>) -> Result<()> {
985 self.xervo_runtime
986 .set(runtime)
987 .map_err(|_| anyhow!("xervo_runtime already set"))
988 }
989
990 pub fn xervo_runtime(&self) -> Option<Arc<ModelRuntime>> {
991 self.xervo_runtime.get().cloned()
992 }
993
994 /// Create a new empty L0 buffer for transaction-scoped mutations.
995 ///
996 /// Only reads the current version — no exclusive lock required on Writer.
997 /// The returned buffer has no WAL reference; mutations are logged at
998 /// commit time via [`Self::commit_transaction_l0`].
999 pub fn create_transaction_l0(&self) -> Arc<RwLock<L0Buffer>> {
1000 let current_version = self.l0_manager.get_current().read().current_version;
1001 // Transaction mutations are logged to WAL at COMMIT time, not during the transaction.
1002 let buf = L0Buffer::new(current_version, None);
1003 // SSI: stamp the OCC read sequence at begin so commit can detect any
1004 // transaction that committed since. Gated on the runtime `ssi_enabled`
1005 // toggle — when off, `occ_read_set` stays `None` and every downstream
1006 // read-set recording / commit validation self-gates to a no-op.
1007 let buf = if self.config.ssi_enabled {
1008 let mut buf = buf;
1009 buf.occ_read_seq = self
1010 .commit_sequence
1011 .load(crate::runtime::sync::Ordering::Relaxed);
1012 // The read path records observed ids here for SSI antidependency
1013 // detection; commit consults it.
1014 buf.occ_read_set = Some(Arc::new(parking_lot::Mutex::new(
1015 crate::runtime::l0::OccReadSet::default(),
1016 )));
1017 buf
1018 } else {
1019 buf
1020 };
1021 Arc::new(RwLock::new(buf))
1022 }
1023
1024 /// Resolve the target L0 buffer for a mutation.
1025 ///
1026 /// When `tx_l0` is `Some`, the mutation targets a transaction-private buffer.
1027 /// When `None`, it targets the global L0 from the manager.
1028 fn resolve_l0(&self, tx_l0: Option<&Arc<RwLock<L0Buffer>>>) -> Arc<RwLock<L0Buffer>> {
1029 tx_l0
1030 .cloned()
1031 .unwrap_or_else(|| self.l0_manager.get_current())
1032 }
1033
1034 fn update_metrics(&self) {
1035 let l0 = self.l0_manager.get_current();
1036 let size = l0.read().estimated_size;
1037 metrics::gauge!("l0_buffer_size_bytes").set(size as f64);
1038 }
1039
1040 /// Overlay-aware issue-#77 edge-endpoint validation.
1041 ///
1042 /// The current buffer alone does not hold all committed-but-unflushed
1043 /// tombstones — a flush rotation moves them onto `pending_flush` until
1044 /// the Lance write completes. A vertex is effectively deleted iff,
1045 /// walking newest-first (tx → current → pending newest→oldest), the
1046 /// first buffer that knows the vid says "tombstoned" (an insert clears
1047 /// the tombstone within a buffer, so props/tombstone are mutually
1048 /// exclusive per buffer).
1049 ///
1050 /// Must run under `flush_lock` so the overlay cannot change before the
1051 /// merge, and BEFORE the durable WAL flush (see the call site).
1052 fn validate_edge_endpoints_overlay(&self, tx_l0: &L0Buffer) -> Result<()> {
1053 let chain = self.l0_manager.get_pending_flush();
1054 let current = self.l0_manager.get_current();
1055 let effectively_deleted = |vid: &Vid| -> bool {
1056 if tx_l0.vertex_properties.contains_key(vid) {
1057 return false;
1058 }
1059 if tx_l0.vertex_tombstones.contains(vid) {
1060 return true;
1061 }
1062 {
1063 let cur = current.read();
1064 if cur.vertex_properties.contains_key(vid) {
1065 return false;
1066 }
1067 if cur.vertex_tombstones.contains(vid) {
1068 return true;
1069 }
1070 }
1071 for frozen in chain.iter().rev() {
1072 let g = frozen.read();
1073 if g.vertex_properties.contains_key(vid) {
1074 return false;
1075 }
1076 if g.vertex_tombstones.contains(vid) {
1077 return true;
1078 }
1079 }
1080 false
1081 };
1082 for (eid, (src_vid, dst_vid, _etype)) in &tx_l0.edge_endpoints {
1083 if tx_l0.tombstones.contains_key(eid) {
1084 continue; // a deletion, not an insertion — never resurrects a vertex
1085 }
1086 if effectively_deleted(src_vid) {
1087 anyhow::bail!(
1088 "Cannot insert edge {}: source vertex {} has been deleted (issue #77)",
1089 eid,
1090 src_vid
1091 );
1092 }
1093 if effectively_deleted(dst_vid) {
1094 anyhow::bail!(
1095 "Cannot insert edge {}: destination vertex {} has been deleted (issue #77)",
1096 eid,
1097 dst_vid
1098 );
1099 }
1100 }
1101 Ok(())
1102 }
1103
1104 /// Seed `main_l0` (the current buffer) with the newest pending-overlay
1105 /// value for each CRDT property the transaction writes, so the commit
1106 /// merge MERGES against the committed CRDT state instead of shadowing it
1107 /// (the carve-out lets concurrent CRDT writers commit on the assumption
1108 /// that the merge sees the committed value — true only while that value
1109 /// lives in current, not mid-flush on `pending_flush`). No-op when
1110 /// nothing is pending or the property already exists in current. Vertex
1111 /// properties only, mirroring the carve-out itself.
1112 fn seed_crdt_state_from_chain(&self, tx_l0: &L0Buffer, main_l0: &mut L0Buffer) {
1113 let chain = self.l0_manager.get_pending_flush();
1114 if chain.is_empty() {
1115 return;
1116 }
1117 for (vid, props) in &tx_l0.vertex_properties {
1118 Self::seed_crdt_props(&chain, *vid, props, main_l0);
1119 }
1120 }
1121
1122 /// Per-vertex CRDT seeding (see [`Self::seed_crdt_state_from_chain`]).
1123 /// Also used by the non-transactional vertex write path, which CRDT-merges
1124 /// into the current buffer directly and has the same shadowing hazard
1125 /// during a flush window.
1126 fn seed_crdt_props(
1127 chain: &[Arc<RwLock<L0Buffer>>],
1128 vid: Vid,
1129 props: &Properties,
1130 target: &mut L0Buffer,
1131 ) {
1132 for (key, value) in props {
1133 if crate::runtime::l0::try_as_crdt(value).is_none() {
1134 continue;
1135 }
1136 if target
1137 .vertex_properties
1138 .get(&vid)
1139 .is_some_and(|p| p.contains_key(key))
1140 {
1141 continue;
1142 }
1143 // Newest generation first: the first hit is the live state.
1144 for frozen in chain.iter().rev() {
1145 let g = frozen.read();
1146 if let Some(v) = g.vertex_properties.get(&vid).and_then(|p| p.get(key)) {
1147 if crate::runtime::l0::try_as_crdt(v).is_some() {
1148 target
1149 .vertex_properties
1150 .entry(vid)
1151 .or_default()
1152 .insert(key.clone(), v.clone());
1153 }
1154 break;
1155 }
1156 }
1157 }
1158 }
1159
1160 /// Commit an externally-owned transaction L0 buffer.
1161 ///
1162 /// Writes mutations to WAL, flushes, merges into main L0, and replays
1163 /// edges into the AdjacencyManager. Returns the WAL LSN of the commit
1164 /// (0 when no WAL is configured).
1165 /// Commit a transaction's private L0 buffer into main L0.
1166 ///
1167 /// Returns `(wal_lsn, flush_pending)`. When `flush_pending == true`, the
1168 /// post-commit `should_flush()` predicate fired but no flush ran — the
1169 /// caller is expected to spawn a background `flush_to_l1`. This is the
1170 /// shape used when `UniConfig::async_flush_enabled` is set, so commits
1171 /// don't block on L1-streaming I/O.
1172 pub async fn commit_transaction_l0(
1173 self: &Arc<Self>,
1174 tx_l0_arc: Arc<RwLock<L0Buffer>>,
1175 ) -> Result<(u64, bool)> {
1176 // No lock-acquisition timeout (tests and internal callers).
1177 self.commit_transaction_l0_with_lock_timeout(tx_l0_arc, None)
1178 .await
1179 }
1180
1181 /// Like [`Self::commit_transaction_l0`] but bounds ONLY the `flush_lock`
1182 /// acquisition by `lock_timeout` (contention with another in-progress
1183 /// commit). Once the lock is held, the durable WAL flush, the main-L0 merge,
1184 /// and the inline post-commit L0→L1 flush all run to completion UNCANCELLED.
1185 ///
1186 /// This is the load-bearing correctness property: a caller must NOT wrap the
1187 /// whole future in `tokio::time::timeout`, because that would cancel past the
1188 /// durable point (`flush_wal`) and return a retriable `CommitTimeout` for a
1189 /// transaction that is already durable and visible — a retry would then
1190 /// double-apply it. Bounding only the lock wait keeps the "another commit is
1191 /// taking too long" signal without ever cancelling durable work.
1192 ///
1193 /// # Errors
1194 /// Returns [`uni_common::api::error::UniError::CommitTimeout`] (with an empty
1195 /// `tx_id` for the caller to fill in) if `flush_lock` cannot be acquired
1196 /// within `lock_timeout`, plus any error from the commit itself.
1197 pub async fn commit_transaction_l0_with_lock_timeout(
1198 self: &Arc<Self>,
1199 tx_l0_arc: Arc<RwLock<L0Buffer>>,
1200 lock_timeout: Option<std::time::Duration>,
1201 ) -> Result<(u64, bool)> {
1202 // Hold `flush_lock` across WAL append + flush + main-L0 merge.
1203 // Two concurrent commits serialize here; in Phase 3 the outer
1204 // `Arc<RwLock<Writer>>` already provides this exclusion, so the
1205 // acquisition is uncontended. Phase 4 drops the outer lock and
1206 // this becomes the load-bearing serialization point.
1207 let _flush_lock_guard = match lock_timeout {
1208 Some(dur) => match tokio::time::timeout(dur, self.flush_lock.lock()).await {
1209 Ok(guard) => guard,
1210 Err(_) => {
1211 return Err(uni_common::api::error::UniError::CommitTimeout {
1212 tx_id: String::new(),
1213 hint: "Another commit is in progress and taking longer than expected. \
1214 Your transaction is still active \u{2014} you can retry commit().",
1215 }
1216 .into());
1217 }
1218 },
1219 None => self.flush_lock.lock().await,
1220 };
1221
1222 // Crash-recovery seam: simulate process death immediately after winning
1223 // the commit serialization point but before any durable work. No-op
1224 // unless built with `--features failpoints`. (See ssi_resilience tests.)
1225 fail::fail_point!("commit::after-flush-lock");
1226
1227 // SSI: optimistic conflict detection. This MUST run before any WAL
1228 // write — `flush_wal()` below is the durable commit point and the WAL
1229 // has no abort marker, so aborting after it would resurrect this
1230 // transaction on crash recovery. The write-set is reused for
1231 // registration after a successful merge.
1232 // Runtime-gated on `config.ssi_enabled`. When off, no validation runs
1233 // and `occ_write_set` is `None`, so the post-merge registration below
1234 // is skipped — reproducing last-writer-wins exactly.
1235 let occ_write_set: Option<crate::runtime::occ::WriteSet> = if self.config.ssi_enabled {
1236 let tx_l0 = tx_l0_arc.read();
1237 let read_seq = tx_l0.occ_read_seq;
1238 let write_set = crate::runtime::occ::WriteSet::from_l0(&tx_l0);
1239 if !write_set.is_empty() {
1240 // Telemetry: one validation per non-empty (writing) commit. The
1241 // ratio of conflicts to validations is the headline abort rate.
1242 metrics::counter!("uni_ssi_commit_validations_total").increment(1);
1243 // Read-set is consulted only for writing transactions, so a
1244 // read-only commit (empty write-set) runs at snapshot isolation.
1245 let read_guard = tx_l0.occ_read_set.as_ref().map(|rs| rs.lock());
1246 if let Some(conflict) =
1247 self.committed_writes
1248 .lock()
1249 .check(read_seq, &write_set, read_guard.as_deref())
1250 {
1251 use crate::runtime::occ::Conflict;
1252 match &conflict {
1253 Conflict::WriteWrite { .. } => metrics::counter!(
1254 "uni_ssi_serialization_conflicts_total",
1255 "kind" => "write_write",
1256 )
1257 .increment(1),
1258 Conflict::ReadWrite { .. } => metrics::counter!(
1259 "uni_ssi_serialization_conflicts_total",
1260 "kind" => "read_write",
1261 )
1262 .increment(1),
1263 Conflict::HistoryTruncated { .. } => {
1264 metrics::counter!("uni_ssi_history_truncated_total").increment(1)
1265 }
1266 }
1267 return Err(anyhow::Error::new(
1268 uni_common::UniError::SerializationConflict {
1269 message: conflict.to_string(),
1270 },
1271 ));
1272 }
1273 }
1274
1275 // Validate against the committed-but-unflushed overlay under
1276 // `flush_lock`: serializable MERGE uniqueness + CRDT carve-out
1277 // soundness. The current buffer alone does not hold all committed
1278 // state — a flush rotation moves it onto `pending_flush` until the
1279 // Lance write completes (the Bug #9A window, here at the
1280 // commit-time layer) — so every check walks [current, pending…].
1281 {
1282 let pending = self.l0_manager.get_pending_flush();
1283 let main_l0 = self.l0_manager.get_current();
1284 let overlay: Vec<Arc<RwLock<L0Buffer>>> =
1285 std::iter::once(main_l0).chain(pending).collect();
1286
1287 // SSI / serializable MERGE: abort if a concurrent transaction has
1288 // already committed a row with one of this transaction's unique
1289 // keys. Commits serialize here, so this closes the race window
1290 // left by the per-insert check. (Empty index → no iterations.)
1291 for (key, vid) in &tx_l0.constraint_index {
1292 if overlay
1293 .iter()
1294 .any(|b| b.read().has_constraint_key(key, *vid))
1295 {
1296 metrics::counter!("uni_ssi_constraint_conflicts_total").increment(1);
1297 return Err(anyhow::Error::new(
1298 uni_common::UniError::ConstraintConflict {
1299 message: "unique key already committed by a concurrent \
1300 transaction"
1301 .to_string(),
1302 },
1303 ));
1304 }
1305 }
1306
1307 // Same serializable guard for edge unique/nodekey keys: abort if a
1308 // concurrent transaction committed an edge with one of this
1309 // transaction's edge unique keys. (Empty index → no iterations.)
1310 for (key, eid) in &tx_l0.edge_constraint_index {
1311 if overlay
1312 .iter()
1313 .any(|b| b.read().has_edge_constraint_key(key, *eid))
1314 {
1315 metrics::counter!("uni_ssi_constraint_conflicts_total").increment(1);
1316 return Err(anyhow::Error::new(
1317 uni_common::UniError::ConstraintConflict {
1318 message: "unique edge key already committed by a concurrent \
1319 transaction"
1320 .to_string(),
1321 },
1322 ));
1323 }
1324 }
1325
1326 // Implicit MERGE phantom guard: a `MERGE` that *created* a node
1327 // registered its (label, key-props) here even with no declared
1328 // UNIQUE constraint. If a concurrent transaction already committed
1329 // the same MERGE key, abort retriably so the two converge to one
1330 // node on retry (the loser's MATCH then finds the committed row).
1331 // Only MERGE-creates register keys, so a plain CREATE of the same
1332 // properties never lands here. (Empty index → no iterations.)
1333 for (key, vid) in &tx_l0.merge_guard_index {
1334 if overlay
1335 .iter()
1336 .any(|b| b.read().has_merge_guard_key(key, *vid))
1337 {
1338 metrics::counter!("uni_ssi_constraint_conflicts_total").increment(1);
1339 return Err(anyhow::Error::new(
1340 uni_common::UniError::ConstraintConflict {
1341 message: "MERGE key already committed by a concurrent \
1342 transaction"
1343 .to_string(),
1344 },
1345 ));
1346 }
1347 }
1348
1349 // Same race window for global ext_id uniqueness: the per-insert
1350 // check ran against an older main L0; re-probe the committed
1351 // index here, where commits serialize.
1352 for (ext_id, vid) in &tx_l0.extid_index {
1353 let taken = overlay.iter().any(|b| {
1354 matches!(b.read().extid_index.get(ext_id), Some(&owner) if owner != *vid)
1355 });
1356 if taken {
1357 metrics::counter!("uni_ssi_constraint_conflicts_total").increment(1);
1358 return Err(anyhow::Error::new(
1359 uni_common::UniError::ConstraintConflict {
1360 message: format!(
1361 "ext_id '{ext_id}' already committed by a concurrent \
1362 transaction"
1363 ),
1364 },
1365 ));
1366 }
1367 }
1368
1369 // CRDT carve-out soundness: a pure-CRDT write was dropped from the
1370 // write-set assuming its merge commutes. If the overlay holds a
1371 // *different* CRDT variant for the same property, the merge would
1372 // silently overwrite it — abort instead of losing the update.
1373 // (Checked against every overlay buffer: conservative if an old
1374 // generation held a different variant that a newer commit already
1375 // replaced, but an abort+retry is always sound.)
1376 for buf in &overlay {
1377 if let Some(conflict) =
1378 crate::runtime::occ::crdt_carveout_overwrite(&tx_l0, &buf.read())
1379 {
1380 metrics::counter!("uni_ssi_crdt_aborts_total").increment(1);
1381 return Err(anyhow::Error::new(
1382 uni_common::UniError::SerializationConflict {
1383 message: conflict.to_string(),
1384 },
1385 ));
1386 }
1387 }
1388 }
1389 Some(write_set)
1390 } else {
1391 None
1392 };
1393
1394 // Issue #77: an edge whose endpoint is effectively deleted makes the
1395 // merge below bail. That bail MUST happen before the durable WAL flush —
1396 // after it the transaction is committed-but-unmerged (a ghost commit),
1397 // and WAL replay re-hits the same bail, making the database unopenable.
1398 // SSI validation was deliberately placed before the flush for exactly
1399 // this reason; the endpoint check belongs here too. Runs unconditionally
1400 // (issue #77 is not SSI-gated) under `flush_lock`, so the overlay
1401 // tombstone state cannot change between here and the merge. A
1402 // tombstone may live in a flush-rotated pending buffer rather than
1403 // the current buffer, so the check walks the overlay newest-first.
1404 {
1405 let tx_l0 = tx_l0_arc.read();
1406 self.validate_edge_endpoints_overlay(&tx_l0)?;
1407 }
1408
1409 // Crash-recovery seam: SSI validation has passed; the transaction is
1410 // about to become durable. A crash here must leave NO trace (validation
1411 // happens before the WAL is touched). No-op unless `failpoints`.
1412 fail::fail_point!("commit::after-validate");
1413
1414 // 1. Write transaction mutations to WAL BEFORE merging into main L0
1415 // This ensures durability before visibility.
1416 {
1417 let tx_l0 = tx_l0_arc.read();
1418 let main_l0_arc = self.l0_manager.get_current();
1419 let main_l0 = main_l0_arc.read();
1420
1421 // If WAL exists, write mutations to it for durability
1422 if let Some(wal) = main_l0.wal.as_ref() {
1423 // Order: vertices first, then edges (to ensure src/dst exist on replay)
1424
1425 // Vertex insertions
1426 for (vid, properties) in &tx_l0.vertex_properties {
1427 if !tx_l0.vertex_tombstones.contains(vid) {
1428 let labels = tx_l0.vertex_labels.get(vid).cloned().unwrap_or_default();
1429 wal.append(crate::runtime::wal::Mutation::InsertVertex {
1430 vid: *vid,
1431 properties: properties.clone(),
1432 labels,
1433 })?;
1434 }
1435 }
1436
1437 // Vertex deletions
1438 for vid in &tx_l0.vertex_tombstones {
1439 let labels = tx_l0.vertex_labels.get(vid).cloned().unwrap_or_default();
1440 wal.append(crate::runtime::wal::Mutation::DeleteVertex { vid: *vid, labels })?;
1441 }
1442
1443 // Label-only mutations (SET n:Label / REMOVE n:Label). After
1444 // vertex inserts (so the vertex exists on replay), before edges,
1445 // and skipping vertices deleted in this same commit.
1446 for vid in &tx_l0.vertex_label_overwrites {
1447 if tx_l0.vertex_tombstones.contains(vid) {
1448 continue;
1449 }
1450 let labels = tx_l0.vertex_labels.get(vid).cloned().unwrap_or_default();
1451 wal.append(crate::runtime::wal::Mutation::SetVertexLabels {
1452 vid: *vid,
1453 labels,
1454 })?;
1455 }
1456
1457 // Crash-recovery seam: vertices appended, edges not yet. Tests
1458 // assert that a crash here (before `flush_wal`) recovers NOTHING
1459 // — the durable commit point is the flush below, not append.
1460 fail::fail_point!("commit::mid-wal");
1461
1462 // Edge insertions and deletions from edge_endpoints
1463 for (eid, (src_vid, dst_vid, edge_type)) in &tx_l0.edge_endpoints {
1464 if tx_l0.tombstones.contains_key(eid) {
1465 let version = tx_l0.edge_versions.get(eid).copied().unwrap_or(0);
1466 wal.append(crate::runtime::wal::Mutation::DeleteEdge {
1467 eid: *eid,
1468 src_vid: *src_vid,
1469 dst_vid: *dst_vid,
1470 edge_type: *edge_type,
1471 version,
1472 })?;
1473 } else {
1474 let properties =
1475 tx_l0.edge_properties.get(eid).cloned().unwrap_or_default();
1476 let version = tx_l0.edge_versions.get(eid).copied().unwrap_or(0);
1477 let edge_type_name = tx_l0.edge_types.get(eid).cloned();
1478 wal.append(crate::runtime::wal::Mutation::InsertEdge {
1479 src_vid: *src_vid,
1480 dst_vid: *dst_vid,
1481 edge_type: *edge_type,
1482 eid: *eid,
1483 version,
1484 properties,
1485 edge_type_name,
1486 })?;
1487 }
1488 }
1489
1490 // Tombstones for edges that only exist in the global L0 (not in
1491 // this transaction's edge_endpoints). Without this, deletes of
1492 // pre-existing edges would be silently lost.
1493 for (eid, tombstone) in &tx_l0.tombstones {
1494 if !tx_l0.edge_endpoints.contains_key(eid) {
1495 let version = tx_l0.edge_versions.get(eid).copied().unwrap_or(0);
1496 wal.append(crate::runtime::wal::Mutation::DeleteEdge {
1497 eid: *eid,
1498 src_vid: tombstone.src_vid,
1499 dst_vid: tombstone.dst_vid,
1500 edge_type: tombstone.edge_type,
1501 version,
1502 })?;
1503 }
1504 }
1505 }
1506 }
1507
1508 // 2. Flush WAL to durable storage - THIS IS THE COMMIT POINT
1509 let wal_lsn = self.flush_wal().await?;
1510
1511 // Crash-recovery seam: the WAL is durable but main L0 has NOT merged.
1512 // A crash here must RECOVER the transaction on replay (it is committed),
1513 // even though it was never made visible in-process. No-op unless `failpoints`.
1514 fail::fail_point!("commit::after-wal-flush");
1515
1516 // Component C1: if an outstanding snapshot pins the current generation,
1517 // clone it aside (lazy copy-on-write) before merging, so the pinning
1518 // transaction's reads stay isolated from this commit. No-op — and zero
1519 // cost — when nothing is pinned (the common case). We hold `flush_lock`,
1520 // so this cannot race a flush rotate or another commit's merge; the merge
1521 // below re-fetches `get_current()`, landing in the fresh post-freeze buffer.
1522 // Self-gates on the runtime SSI toggle: a snapshot is only ever pinned by
1523 // a transaction begun under `ssi_enabled`, so `is_current_pinned()` is
1524 // always false when SSI is off and this is a zero-cost no-op.
1525 if self.l0_manager.is_current_pinned() {
1526 self.l0_manager.freeze_current_for_snapshot();
1527 metrics::counter!("uni_l0_snapshot_freezes_total").increment(1);
1528 }
1529
1530 // 3. Merge into main L0 and make visible
1531 {
1532 // Write-lock the tx buffer: `merge_take` moves its property maps
1533 // into main L0 instead of cloning them. The commit consumes the
1534 // transaction, so the drained maps are never observed afterwards;
1535 // everything read below (endpoints, versions, tombstones) is left
1536 // intact.
1537 let mut tx_l0 = tx_l0_arc.write();
1538 let main_l0_arc = self.l0_manager.get_current();
1539 let mut main_l0 = main_l0_arc.write();
1540 // A CRDT property's committed state may live only in a
1541 // flush-rotated pending buffer (the post-rotation current is
1542 // empty until the Lance write completes — the Bug #9A window).
1543 // `merge_crdt_properties` merges against the CURRENT buffer's
1544 // value — without seeding, the tx's CRDT state would SHADOW the
1545 // pending buffer's at read time (newest buffer wins per property)
1546 // and concurrent increments would be lost. Seed the newest
1547 // overlay value for each CRDT property the tx writes that current
1548 // lacks, so the merge below merges instead of replaces.
1549 self.seed_crdt_state_from_chain(&tx_l0, &mut main_l0);
1550 main_l0.merge_take(&mut tx_l0)?;
1551
1552 // Replay transaction edges into the AdjacencyManager overlay
1553 for (eid, (src, dst, etype)) in &tx_l0.edge_endpoints {
1554 let edge_version = tx_l0
1555 .edge_versions
1556 .get(eid)
1557 .copied()
1558 .unwrap_or(main_l0.current_version);
1559 if tx_l0.tombstones.contains_key(eid) {
1560 self.adjacency_manager
1561 .add_tombstone(*eid, *src, *dst, *etype, edge_version);
1562 } else {
1563 self.adjacency_manager
1564 .insert_edge(*src, *dst, *eid, *etype, edge_version);
1565 }
1566 }
1567
1568 // Replay tombstones for edges that only exist in the global L0
1569 // (not in this transaction's edge_endpoints).
1570 for (eid, tombstone) in &tx_l0.tombstones {
1571 if !tx_l0.edge_endpoints.contains_key(eid) {
1572 let edge_version = tx_l0
1573 .edge_versions
1574 .get(eid)
1575 .copied()
1576 .unwrap_or(main_l0.current_version);
1577 self.adjacency_manager.add_tombstone(
1578 *eid,
1579 tombstone.src_vid,
1580 tombstone.dst_vid,
1581 tombstone.edge_type,
1582 edge_version,
1583 );
1584 }
1585 }
1586 }
1587
1588 // Crash-recovery seam: durable AND merged, but the in-memory commit
1589 // registry has not recorded this write-set yet. A crash here is
1590 // indistinguishable from one at `after-wal-flush` on reopen (the
1591 // registry is in-memory and rebuilt empty); the tx still recovers.
1592 fail::fail_point!("commit::after-merge");
1593
1594 // SSI: register this commit's write-set under a fresh commit sequence so
1595 // later transactions detect conflicts against it. Still under
1596 // `flush_lock`, before the async-flush branch can drop the guard.
1597 // `occ_write_set` is `Some` only when `config.ssi_enabled`.
1598 if let Some(write_set) = occ_write_set
1599 && !write_set.is_empty()
1600 {
1601 // Bump-then-record via the shared OCC seam (see `CommitRegistry::commit`)
1602 // so production and the loom/shuttle models exercise identical logic.
1603 self.committed_writes
1604 .lock()
1605 .commit(&self.commit_sequence, write_set);
1606 }
1607
1608 self.update_metrics();
1609
1610 // 4. Best-effort post-commit auto-flush.
1611 //
1612 // Two paths:
1613 // - async_flush_enabled = false (default): inline under our
1614 // existing flush_lock guard via flush_inline_under_lock.
1615 // - async_flush_enabled = true: rotate inline, drop flush_lock,
1616 // then submit the stream phase to the coordinator. Gated on
1617 // `pending_flush_count() < max_pending_flushes` so we don't
1618 // stack up rotations beyond the configured pipeline depth.
1619 // `try_acquire_permit` is non-blocking: if we lose the race
1620 // for the last permit, we just skip this trigger (the next
1621 // commit retries).
1622 let mut flush_pending = false;
1623 if self.should_flush() {
1624 if self.config.async_flush_enabled
1625 && let Some(coord) = self.flush_coordinator.as_ref()
1626 {
1627 // Async mode: submit only when a pipeline slot is free, else SKIP
1628 // (a later commit retries). Never fall back to a blocking inline
1629 // flush here — under a stalled flush (issue #132) that inline
1630 // flush would hit the same stall with no timeout and hang holding
1631 // `flush_lock`, cascading into a full runtime park. A stalled
1632 // async flush is instead bounded by `flush_stream_timeout`, which
1633 // frees its permit for the retry.
1634 if coord.pending_flush_count() < self.config.max_pending_flushes {
1635 match coord.try_acquire_permit() {
1636 Some(permit) => {
1637 match self.flush_l0_rotate().await {
1638 Ok(rotate_out) => {
1639 // Allocate the rotate seq and bump pending ONLY
1640 // after the rotate succeeds (Bug #3). A failed
1641 // rotate must consume neither: the finalizer
1642 // advances strictly in consecutive seq order and
1643 // only decrements pending on finalize, so a
1644 // leaked seq/pending from a failed rotate would
1645 // wedge the finalizer forever and climb pending
1646 // toward `max_pending_flushes`. The seq is still
1647 // allocated under `flush_lock` (immediately after
1648 // the rotate, before the guard drops below), so
1649 // concurrent rotates keep seq order == rotation
1650 // order, and the seq is not used until submit.
1651 let seq = coord.next_rotate_seq();
1652 coord.note_pending();
1653 // Release flush_lock BEFORE the spawn so concurrent
1654 // commits can proceed while the stream runs.
1655 drop(_flush_lock_guard);
1656 let parent_manifest = self.cached_manifest.lock().clone();
1657 let rotated = crate::runtime::flush_coordinator::RotatedFlush {
1658 seq,
1659 old_l0_arc: rotate_out.old_l0_arc.clone(),
1660 wal_lsn: rotate_out.wal_lsn,
1661 current_version: rotate_out.current_version,
1662 name: None,
1663 parent_manifest,
1664 permit,
1665 flush_in_progress_guard: rotate_out.flush_in_progress_guard,
1666 };
1667 let writer = self.clone();
1668 let _ticket = coord.submit_for_stream(
1669 rotated,
1670 move |old_l0, wal, ver, n| async move {
1671 let outcome =
1672 writer.flush_stream_l1(old_l0, wal, ver, n).await?;
1673 Ok(crate::runtime::flush_coordinator::FlushOutcome {
1674 new_manifest: outcome.manifest,
1675 snapshot_id: outcome.snapshot_id,
1676 })
1677 },
1678 );
1679 flush_pending = true;
1680 // Early return — flush_lock already dropped.
1681 return Ok((wal_lsn, flush_pending));
1682 }
1683 Err(e) => {
1684 tracing::warn!("Async rotate failed (non-critical): {}", e);
1685 // No seq was allocated and pending was not
1686 // bumped (both moved into the Ok arm for Bug
1687 // #3), so the finalizer is not wedged. The
1688 // permit drops here, freeing the slot.
1689 }
1690 }
1691 }
1692 None => {
1693 // Race: someone else grabbed the last permit. Skip;
1694 // next commit will retry should_flush().
1695 metrics::counter!("uni_flush_trigger_skipped_total").increment(1);
1696 }
1697 }
1698 } else {
1699 // Pipeline full — possibly a stalled flush occupying a slot.
1700 // Skip and retry on a later commit rather than blocking on an
1701 // inline flush that could hit the same stall (issue #132).
1702 metrics::counter!("uni_flush_trigger_skipped_total").increment(1);
1703 }
1704 } else if let Err(e) = self.flush_inline_under_lock(None).await {
1705 tracing::warn!("Post-commit flush check failed (non-critical): {}", e);
1706 }
1707 }
1708
1709 Ok((wal_lsn, flush_pending))
1710 }
1711
1712 /// Flush the WAL buffer to durable storage.
1713 ///
1714 /// Returns the LSN of the flushed segment, or `0` when no WAL is configured.
1715 pub async fn flush_wal(&self) -> Result<u64> {
1716 let l0 = self.l0_manager.get_current();
1717 let wal = l0.read().wal.clone();
1718
1719 match wal {
1720 Some(wal) => Ok(wal.flush().await?),
1721 None => Ok(0),
1722 }
1723 }
1724
1725 /// Record property removals in the active L0 mutation stats.
1726 ///
1727 /// Routes to the transaction L0 if provided, otherwise to the main L0.
1728 pub fn track_properties_removed(&self, count: usize, tx_l0: Option<&Arc<RwLock<L0Buffer>>>) {
1729 if count == 0 {
1730 return;
1731 }
1732 let l0 = self.resolve_l0(tx_l0);
1733 l0.write().mutation_stats.properties_removed += count;
1734 }
1735
1736 /// Validates vertex constraints for the given properties.
1737 /// In the new design, label is passed as a parameter since VID no longer embeds label.
1738 async fn validate_vertex_constraints_for_label(
1739 &self,
1740 vid: Vid,
1741 properties: &Properties,
1742 label: &str,
1743 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
1744 ) -> Result<()> {
1745 self.validate_vertex_constraints_for_label_impl(vid, properties, label, tx_l0, false)
1746 .await
1747 }
1748
1749 /// Partial-update sibling: validates only constraints touching keys
1750 /// present in `properties` (the touched set). NOT NULL is checked
1751 /// only for touched keys; multi-key UNIQUE / CHECK / EXISTS are
1752 /// skipped when any referenced key is absent (the caller is
1753 /// expected to have routed to the full-row path in that case via
1754 /// `touched_needs_full_read`).
1755 async fn validate_vertex_constraints_for_label_partial(
1756 &self,
1757 vid: Vid,
1758 properties: &Properties,
1759 label: &str,
1760 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
1761 ) -> Result<()> {
1762 self.validate_vertex_constraints_for_label_impl(vid, properties, label, tx_l0, true)
1763 .await
1764 }
1765
1766 async fn validate_vertex_constraints_for_label_impl(
1767 &self,
1768 vid: Vid,
1769 properties: &Properties,
1770 label: &str,
1771 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
1772 partial: bool,
1773 ) -> Result<()> {
1774 let schema = self.schema_manager.schema();
1775
1776 {
1777 // 1. Check NOT NULL constraints (from Property definitions).
1778 // Under partial-update mode, skip properties NOT in
1779 // `properties` — they retain their previous (already-
1780 // validated) value.
1781 if let Some(props_meta) = schema.properties.get(label) {
1782 for (prop_name, meta) in props_meta {
1783 let present = properties.get(prop_name);
1784
1785 // Declared vector/multi-vector columns: enforce dimensions here so
1786 // writes that bypass the Cypher coercion guard (python bulk insert,
1787 // auto-embed output) can't smuggle in a wrong-length vector that the
1788 // Arrow converters would otherwise null at flush (issue #137).
1789 if let Some(value) = present
1790 && let Err(e) = meta.r#type.check_vector_dims(value)
1791 {
1792 return Err(anyhow!(
1793 "vector dimension mismatch: property '{}.{}': {}",
1794 label,
1795 prop_name,
1796 e
1797 ));
1798 }
1799
1800 if !meta.nullable {
1801 if partial && present.is_none() {
1802 continue;
1803 }
1804 if present.is_none_or(|v| v.is_null()) {
1805 log::warn!(
1806 "Constraint violation: Property '{}' cannot be null for label '{}'",
1807 prop_name,
1808 label
1809 );
1810 return Err(anyhow!(
1811 "Constraint violation: Property '{}' cannot be null",
1812 prop_name
1813 ));
1814 }
1815 }
1816 }
1817 }
1818
1819 // 2. Check Explicit Constraints (Unique, Check, etc.)
1820 for constraint in &schema.constraints {
1821 if !constraint.enabled {
1822 continue;
1823 }
1824 match &constraint.target {
1825 ConstraintTarget::Label(l) if l == label => {}
1826 _ => continue,
1827 }
1828
1829 match &constraint.constraint_type {
1830 ConstraintType::Unique {
1831 properties: unique_props,
1832 } => {
1833 // Support single and multi-property unique constraints
1834 if !unique_props.is_empty() {
1835 let mut key_values = Vec::new();
1836 let mut missing = false;
1837 for prop in unique_props {
1838 if let Some(val) = properties.get(prop) {
1839 key_values.push((prop.clone(), val.clone()));
1840 } else {
1841 missing = true; // Can't enforce if property missing (partial update?)
1842 // For INSERT, missing means null?
1843 // If property is nullable, unique constraint typically allows multiple nulls or ignores?
1844 // For now, only check if ALL keys are present
1845 }
1846 }
1847
1848 if !missing {
1849 self.check_unique_constraint_multi(label, &key_values, vid, tx_l0)
1850 .await?;
1851 }
1852 }
1853 }
1854 ConstraintType::Exists { property } => {
1855 if properties.get(property).is_none_or(|v| v.is_null()) {
1856 log::warn!(
1857 "Constraint violation: Property '{}' must exist for label '{}'",
1858 property,
1859 label
1860 );
1861 return Err(anyhow!(
1862 "Constraint violation: Property '{}' must exist",
1863 property
1864 ));
1865 }
1866 }
1867 ConstraintType::Check { expression } => {
1868 if !uni_common::core::check_constraint::evaluate(expression, properties)? {
1869 return Err(anyhow!(
1870 "CHECK constraint '{}' violated: expression '{}' evaluated to false",
1871 constraint.name,
1872 expression
1873 ));
1874 }
1875 }
1876 ConstraintType::NodeKey {
1877 properties: key_props,
1878 } => {
1879 // Node key = composite uniqueness + NOT NULL on every key
1880 // property. Unlike `Unique` (which skips enforcement when a
1881 // key property is absent), a missing/null key property is
1882 // itself a violation.
1883 if !key_props.is_empty() {
1884 let mut key_values = Vec::with_capacity(key_props.len());
1885 for prop in key_props {
1886 match properties.get(prop) {
1887 Some(val) if !val.is_null() => {
1888 key_values.push((prop.clone(), val.clone()));
1889 }
1890 _ => {
1891 return Err(anyhow!(
1892 "Node key constraint '{}' violated: property '{}' must exist and be non-null for label '{}'",
1893 constraint.name,
1894 prop,
1895 label
1896 ));
1897 }
1898 }
1899 }
1900 self.check_unique_constraint_multi(label, &key_values, vid, tx_l0)
1901 .await?;
1902 }
1903 }
1904 _ => {
1905 return Err(anyhow!("Unsupported constraint type"));
1906 }
1907 }
1908 }
1909 }
1910 Ok(())
1911 }
1912
1913 /// Validates vertex constraints for a vertex with the given labels.
1914 /// Labels must be passed explicitly since the vertex may not yet be in L0.
1915 /// Unknown labels (not in schema) are skipped.
1916 async fn validate_vertex_constraints(
1917 &self,
1918 vid: Vid,
1919 properties: &Properties,
1920 labels: &[String],
1921 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
1922 ) -> Result<()> {
1923 let schema = self.schema_manager.schema();
1924
1925 // Validate constraints only for known labels
1926 for label in labels {
1927 // Skip unknown labels (schemaless support)
1928 if schema.get_label_case_insensitive(label).is_none() {
1929 continue;
1930 }
1931 self.validate_vertex_constraints_for_label(vid, properties, label, tx_l0)
1932 .await?;
1933 }
1934
1935 // Check global ext_id uniqueness if ext_id is provided
1936 if let Some(ext_id) = properties.get("ext_id").and_then(|v| v.as_str()) {
1937 self.check_extid_globally_unique(ext_id, vid, tx_l0).await?;
1938 }
1939
1940 Ok(())
1941 }
1942
1943 /// Partial sibling of `validate_vertex_constraints` — validates only
1944 /// constraints touching keys present in `properties`. Used by
1945 /// `insert_vertex_partial`'s fast path; the caller pre-screens for
1946 /// multi-key UNIQUE constraints via `touched_needs_full_read`.
1947 async fn validate_vertex_constraints_partial(
1948 &self,
1949 vid: Vid,
1950 touched: &Properties,
1951 labels: &[String],
1952 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
1953 ) -> Result<()> {
1954 let schema = self.schema_manager.schema();
1955 for label in labels {
1956 if schema.get_label_case_insensitive(label).is_none() {
1957 continue;
1958 }
1959 self.validate_vertex_constraints_for_label_partial(vid, touched, label, tx_l0)
1960 .await?;
1961 }
1962 if let Some(ext_id) = touched.get("ext_id").and_then(|v| v.as_str()) {
1963 self.check_extid_globally_unique(ext_id, vid, tx_l0).await?;
1964 }
1965 Ok(())
1966 }
1967
1968 /// Collect ext_ids and unique constraint keys from an iterator of vertex properties.
1969 ///
1970 /// Used to build a constraint key index from L0 buffers for batch validation.
1971 fn collect_constraint_keys_from_properties<'a>(
1972 properties_iter: impl Iterator<Item = &'a Properties>,
1973 label: &str,
1974 constraints: &[uni_common::core::schema::Constraint],
1975 existing_keys: &mut HashMap<String, HashSet<String>>,
1976 existing_extids: &mut HashSet<String>,
1977 ) {
1978 for props in properties_iter {
1979 if let Some(ext_id) = props.get("ext_id").and_then(|v| v.as_str()) {
1980 existing_extids.insert(ext_id.to_string());
1981 }
1982
1983 for constraint in constraints {
1984 if !constraint.enabled {
1985 continue;
1986 }
1987 if let ConstraintTarget::Label(l) = &constraint.target {
1988 if l != label {
1989 continue;
1990 }
1991 } else {
1992 continue;
1993 }
1994
1995 // Collect keys for both Unique and NodeKey (uniqueness half).
1996 if let Some(unique_props) = constraint.constraint_type.unique_properties() {
1997 let mut key_parts = Vec::new();
1998 let mut all_present = true;
1999 for prop in unique_props {
2000 if let Some(val) = props.get(prop) {
2001 key_parts.push(format!("{}:{}", prop, val));
2002 } else {
2003 all_present = false;
2004 break;
2005 }
2006 }
2007 if all_present {
2008 let key = key_parts.join("|");
2009 existing_keys
2010 .entry(constraint.name.clone())
2011 .or_default()
2012 .insert(key);
2013 }
2014 }
2015 }
2016 }
2017 }
2018
2019 /// Validates constraints for a batch of vertices efficiently.
2020 ///
2021 /// This method builds an in-memory index from L0 buffers ONCE instead of scanning
2022 /// per vertex, reducing complexity from O(n²) to O(n) for bulk inserts.
2023 ///
2024 /// # Arguments
2025 /// * `vids` - VIDs of vertices being inserted
2026 /// * `properties_batch` - Properties for each vertex
2027 /// * `label` - Label for all vertices (assumes single label for now)
2028 ///
2029 /// # Performance
2030 /// For N vertices with unique constraints:
2031 /// - Old approach: O(N²) - scan L0 buffer N times
2032 /// - New approach: O(N) - scan L0 buffer once, build HashSet, check each vertex in O(1)
2033 async fn validate_vertex_batch_constraints(
2034 &self,
2035 vids: &[Vid],
2036 properties_batch: &[Properties],
2037 label: &str,
2038 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2039 ) -> Result<()> {
2040 if vids.len() != properties_batch.len() {
2041 return Err(anyhow!("VID/properties length mismatch"));
2042 }
2043
2044 let schema = self.schema_manager.schema();
2045
2046 // 1. Validate NOT NULL constraints for each vertex
2047 if let Some(props_meta) = schema.properties.get(label) {
2048 for (idx, properties) in properties_batch.iter().enumerate() {
2049 for (prop_name, meta) in props_meta {
2050 // Enforce declared vector dimensions on the batch path too — the
2051 // python bulk API reaches here without the Cypher guard (issue #137).
2052 if let Some(value) = properties.get(prop_name)
2053 && let Err(e) = meta.r#type.check_vector_dims(value)
2054 {
2055 return Err(anyhow!(
2056 "vector dimension mismatch at index {}: property '{}.{}': {}",
2057 idx,
2058 label,
2059 prop_name,
2060 e
2061 ));
2062 }
2063 if !meta.nullable && properties.get(prop_name).is_none_or(|v| v.is_null()) {
2064 return Err(anyhow!(
2065 "Constraint violation at index {}: Property '{}' cannot be null",
2066 idx,
2067 prop_name
2068 ));
2069 }
2070 }
2071 }
2072 }
2073
2074 // 2. Build constraint key index from L0 buffers (ONCE for entire batch)
2075 let mut existing_keys: HashMap<String, HashSet<String>> = HashMap::new();
2076 let mut existing_extids: HashSet<String> = HashSet::new();
2077
2078 // Scan current L0 buffer
2079 {
2080 let l0 = self.l0_manager.get_current();
2081 let l0_guard = l0.read();
2082 Self::collect_constraint_keys_from_properties(
2083 l0_guard.vertex_properties.values(),
2084 label,
2085 &schema.constraints,
2086 &mut existing_keys,
2087 &mut existing_extids,
2088 );
2089 }
2090
2091 // Scan pending-flush buffers (rows rotated off `current` but not yet in
2092 // Lance). The single-vertex paths (check_unique_constraint_multi,
2093 // check_extid_globally_unique) consult these; skipping them here left the
2094 // Bug #9A window open — a duplicate key/ext_id whose only prior copy sits
2095 // on pending_flush would pass batch validation.
2096 for pending_l0 in self.l0_manager.get_pending_flush() {
2097 let pending_guard = pending_l0.read();
2098 Self::collect_constraint_keys_from_properties(
2099 pending_guard.vertex_properties.values(),
2100 label,
2101 &schema.constraints,
2102 &mut existing_keys,
2103 &mut existing_extids,
2104 );
2105 }
2106
2107 // Scan transaction L0 if present
2108 if let Some(tx_l0) = tx_l0 {
2109 let tx_l0_guard = tx_l0.read();
2110 Self::collect_constraint_keys_from_properties(
2111 tx_l0_guard.vertex_properties.values(),
2112 label,
2113 &schema.constraints,
2114 &mut existing_keys,
2115 &mut existing_extids,
2116 );
2117 }
2118
2119 // 3. Check batch vertices against index AND check for duplicates within batch
2120 let mut batch_keys: HashMap<String, HashMap<String, usize>> = HashMap::new();
2121 let mut batch_extids: HashMap<String, usize> = HashMap::new();
2122
2123 for (idx, (_vid, properties)) in vids.iter().zip(properties_batch.iter()).enumerate() {
2124 // Check ext_id uniqueness
2125 if let Some(ext_id) = properties.get("ext_id").and_then(|v| v.as_str()) {
2126 if existing_extids.contains(ext_id) {
2127 return Err(anyhow!(
2128 "Constraint violation at index {}: ext_id '{}' already exists",
2129 idx,
2130 ext_id
2131 ));
2132 }
2133 if let Some(first_idx) = batch_extids.get(ext_id) {
2134 return Err(anyhow!(
2135 "Constraint violation: ext_id '{}' duplicated in batch at indices {} and {}",
2136 ext_id,
2137 first_idx,
2138 idx
2139 ));
2140 }
2141 // Also check the main vertices table — the L0 scans above
2142 // miss vertices already flushed to L1, so without this a
2143 // batch insert (e.g. a fork promote onto primary) silently
2144 // twins a duplicate ext_id instead of erroring. Mirrors the
2145 // single-vertex `check_extid_globally_unique`.
2146 if let Ok(Some(found_vid)) =
2147 MainVertexDataset::find_by_ext_id(self.storage.backend(), ext_id, None).await
2148 {
2149 return Err(anyhow!(
2150 "Constraint violation at index {}: ext_id '{}' already exists (vertex {:?})",
2151 idx,
2152 ext_id,
2153 found_vid
2154 ));
2155 }
2156 batch_extids.insert(ext_id.to_string(), idx);
2157 }
2158
2159 // Check unique constraints
2160 for constraint in &schema.constraints {
2161 if !constraint.enabled {
2162 continue;
2163 }
2164 if let ConstraintTarget::Label(l) = &constraint.target {
2165 if l != label {
2166 continue;
2167 }
2168 } else {
2169 continue;
2170 }
2171
2172 match &constraint.constraint_type {
2173 ConstraintType::Unique {
2174 properties: unique_props,
2175 } => {
2176 let mut key_parts = Vec::new();
2177 let mut all_present = true;
2178 for prop in unique_props {
2179 if let Some(val) = properties.get(prop) {
2180 key_parts.push(format!("{}:{}", prop, val));
2181 } else {
2182 all_present = false;
2183 break;
2184 }
2185 }
2186
2187 if all_present {
2188 let key = key_parts.join("|");
2189
2190 // Check against existing L0 keys
2191 if let Some(keys) = existing_keys.get(&constraint.name)
2192 && keys.contains(&key)
2193 {
2194 return Err(anyhow!(
2195 "Constraint violation at index {}: Duplicate composite key for label '{}' (constraint '{}')",
2196 idx,
2197 label,
2198 constraint.name
2199 ));
2200 }
2201
2202 // Check for duplicates within batch
2203 let batch_constraint_keys =
2204 batch_keys.entry(constraint.name.clone()).or_default();
2205 if let Some(first_idx) = batch_constraint_keys.get(&key) {
2206 return Err(anyhow!(
2207 "Constraint violation: Duplicate key '{}' in batch at indices {} and {}",
2208 key,
2209 first_idx,
2210 idx
2211 ));
2212 }
2213 batch_constraint_keys.insert(key, idx);
2214 }
2215 }
2216 ConstraintType::Exists { property }
2217 if properties.get(property).is_none_or(|v| v.is_null()) =>
2218 {
2219 return Err(anyhow!(
2220 "Constraint violation at index {}: Property '{}' must exist",
2221 idx,
2222 property
2223 ));
2224 }
2225 ConstraintType::Check { expression }
2226 if !uni_common::core::check_constraint::evaluate(
2227 expression, properties,
2228 )? =>
2229 {
2230 return Err(anyhow!(
2231 "Constraint violation at index {}: CHECK constraint '{}' violated",
2232 idx,
2233 constraint.name
2234 ));
2235 }
2236 ConstraintType::NodeKey {
2237 properties: key_props,
2238 } => {
2239 // NOT-NULL half: every key property must be present and
2240 // non-null (a missing key is a violation, unlike Unique).
2241 // Uniqueness half: same in-batch + L0 dedup as Unique.
2242 let mut key_parts = Vec::with_capacity(key_props.len());
2243 for prop in key_props {
2244 match properties.get(prop) {
2245 Some(val) if !val.is_null() => {
2246 key_parts.push(format!("{}:{}", prop, val));
2247 }
2248 _ => {
2249 return Err(anyhow!(
2250 "Constraint violation at index {}: node key '{}' requires property '{}' to exist and be non-null",
2251 idx,
2252 constraint.name,
2253 prop
2254 ));
2255 }
2256 }
2257 }
2258 let key = key_parts.join("|");
2259 if let Some(keys) = existing_keys.get(&constraint.name)
2260 && keys.contains(&key)
2261 {
2262 return Err(anyhow!(
2263 "Constraint violation at index {}: Duplicate composite key for label '{}' (constraint '{}')",
2264 idx,
2265 label,
2266 constraint.name
2267 ));
2268 }
2269 let batch_constraint_keys =
2270 batch_keys.entry(constraint.name.clone()).or_default();
2271 if let Some(first_idx) = batch_constraint_keys.get(&key) {
2272 return Err(anyhow!(
2273 "Constraint violation: Duplicate key '{}' in batch at indices {} and {}",
2274 key,
2275 first_idx,
2276 idx
2277 ));
2278 }
2279 batch_constraint_keys.insert(key, idx);
2280 }
2281 _ => {}
2282 }
2283 }
2284 }
2285
2286 // 4. Check storage for unique constraints (can batch this into a single query)
2287 for constraint in &schema.constraints {
2288 if !constraint.enabled {
2289 continue;
2290 }
2291 if let ConstraintTarget::Label(l) = &constraint.target {
2292 if l != label {
2293 continue;
2294 }
2295 } else {
2296 continue;
2297 }
2298
2299 // Probe flushed storage for both Unique and NodeKey (uniqueness half).
2300 if let Some(unique_props) = constraint.constraint_type.unique_properties() {
2301 // Build compound OR filter for all batch vertices
2302 let mut or_filters = Vec::new();
2303 for properties in properties_batch.iter() {
2304 let mut and_parts = Vec::new();
2305 let mut all_present = true;
2306 for prop in unique_props {
2307 if let Some(val) = properties.get(prop) {
2308 let Some(scalar) = constraint_scalar(val) else {
2309 all_present = false;
2310 break;
2311 };
2312 and_parts.push(FilterExpr::equals(prop.as_str(), scalar));
2313 } else {
2314 all_present = false;
2315 break;
2316 }
2317 }
2318 if all_present {
2319 or_filters.push(FilterExpr::all(and_parts));
2320 }
2321 }
2322
2323 #[cfg(feature = "lance-backend")]
2324 if !or_filters.is_empty() {
2325 let filter = FilterExpr::all([
2326 FilterExpr::any_of(or_filters),
2327 FilterExpr::not_deleted(),
2328 FilterExpr::negate(FilterExpr::one_of(
2329 "_vid",
2330 vids.iter().map(|v| Scalar::UInt(v.as_u64())),
2331 )),
2332 ]);
2333
2334 // Count flushed duplicates through the `StorageBackend`
2335 // (branch-aware, correct `.lance` path). A missing table
2336 // means nothing is flushed yet — the L0/pending/tx checks
2337 // above already covered in-memory rows — so skip cleanly;
2338 // any other backend error must abort the write rather than
2339 // silently fail open (the prior `open_raw()` foot-gun).
2340 let backend = self.storage.backend();
2341 let table = table_names::vertex_table_name(label);
2342 if backend.table_exists(&table).await? {
2343 let count = backend.count_rows(&table, Some(&filter)).await?;
2344 if count > 0 {
2345 return Err(anyhow!(
2346 "Constraint violation: Duplicate composite key for label '{}' in storage (constraint '{}')",
2347 label,
2348 constraint.name
2349 ));
2350 }
2351 }
2352 }
2353 }
2354 }
2355
2356 Ok(())
2357 }
2358
2359 /// Checks that ext_id is globally unique across all vertices.
2360 ///
2361 /// Searches L0 buffers (current, transaction, pending) and the main vertices table
2362 /// to ensure no other vertex uses this ext_id.
2363 ///
2364 /// # Errors
2365 ///
2366 /// Returns error if another vertex with the same ext_id exists.
2367 async fn check_extid_globally_unique(
2368 &self,
2369 ext_id: &str,
2370 current_vid: Vid,
2371 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2372 ) -> Result<()> {
2373 // Check L0 buffers: current, transaction, and pending flush
2374 let l0_buffers_to_check: Vec<Arc<RwLock<L0Buffer>>> = {
2375 let mut buffers = vec![self.l0_manager.get_current()];
2376 if let Some(tx_l0) = tx_l0 {
2377 buffers.push(tx_l0.clone());
2378 }
2379 buffers.extend(self.l0_manager.get_pending_flush());
2380 buffers
2381 };
2382
2383 for l0 in &l0_buffers_to_check {
2384 // O(1) per buffer via the maintained `extid_index` (the previous
2385 // full `vertex_properties` scan made constrained ingest O(n²)).
2386 if let Some(&vid) = l0.read().extid_index.get(ext_id)
2387 && vid != current_vid
2388 {
2389 return Err(anyhow!(
2390 "Constraint violation: ext_id '{}' already exists (vertex {:?})",
2391 ext_id,
2392 vid
2393 ));
2394 }
2395 }
2396
2397 // Check main vertices table (if it exists)
2398 // Pass None for global uniqueness check (not snapshot-isolated)
2399 let backend = self.storage.backend();
2400 if let Ok(Some(found_vid)) = MainVertexDataset::find_by_ext_id(backend, ext_id, None).await
2401 && found_vid != current_vid
2402 {
2403 return Err(anyhow!(
2404 "Constraint violation: ext_id '{}' already exists (vertex {:?})",
2405 ext_id,
2406 found_vid
2407 ));
2408 }
2409
2410 Ok(())
2411 }
2412
2413 /// Helper to get vertex labels from L0 buffer.
2414 fn get_vertex_labels_from_l0(&self, vid: Vid) -> Option<Vec<String>> {
2415 let l0 = self.l0_manager.get_current();
2416 let l0_guard = l0.read();
2417 // Check if vertex is tombstoned (deleted) - if so, return None
2418 if l0_guard.vertex_tombstones.contains(&vid) {
2419 return None;
2420 }
2421 l0_guard.get_vertex_labels(vid).map(|l| l.to_vec())
2422 }
2423
2424 /// Get vertex labels from all sources: current L0, pending L0s, and storage.
2425 /// This is the proper way to read vertex labels after a flush, as it checks both
2426 /// in-memory buffers and persisted storage.
2427 pub async fn get_vertex_labels(
2428 &self,
2429 vid: Vid,
2430 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2431 ) -> Option<Vec<String>> {
2432 // 1. Check current L0
2433 if let Some(labels) = self.get_vertex_labels_from_l0(vid) {
2434 return Some(labels);
2435 }
2436
2437 // 2. Check transaction L0 if present
2438 if let Some(tx_l0) = tx_l0 {
2439 let guard = tx_l0.read();
2440 if guard.vertex_tombstones.contains(&vid) {
2441 return None;
2442 }
2443 if let Some(labels) = guard.get_vertex_labels(vid) {
2444 return Some(labels.to_vec());
2445 }
2446 }
2447
2448 // 3. Check pending flush L0s
2449 for pending_l0 in self.l0_manager.get_pending_flush() {
2450 let guard = pending_l0.read();
2451 if guard.vertex_tombstones.contains(&vid) {
2452 return None;
2453 }
2454 if let Some(labels) = guard.get_vertex_labels(vid) {
2455 return Some(labels.to_vec());
2456 }
2457 }
2458
2459 // 4. Check storage
2460 self.find_vertex_labels_in_storage(vid).await.ok().flatten()
2461 }
2462
2463 /// Helper to get edge type from L0 buffer.
2464 fn get_edge_type_from_l0(&self, eid: Eid) -> Option<String> {
2465 let l0 = self.l0_manager.get_current();
2466 let l0_guard = l0.read();
2467 l0_guard.get_edge_type(eid).map(|s| s.to_string())
2468 }
2469
2470 /// Look up the edge type ID (u32) for an EID from the L0 buffer's edge endpoints.
2471 /// Falls back to the transaction L0 if available.
2472 pub fn get_edge_type_id_from_l0(
2473 &self,
2474 eid: Eid,
2475 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2476 ) -> Option<u32> {
2477 // Check transaction L0 first
2478 if let Some(tx_l0) = tx_l0 {
2479 let guard = tx_l0.read();
2480 if let Some((_, _, etype)) = guard.get_edge_endpoint_full(eid) {
2481 return Some(etype);
2482 }
2483 }
2484 // Fall back to main L0
2485 let l0 = self.l0_manager.get_current();
2486 let l0_guard = l0.read();
2487 l0_guard
2488 .get_edge_endpoint_full(eid)
2489 .map(|(_, _, etype)| etype)
2490 }
2491
2492 /// Set the type name for an edge (used for schemaless edge types).
2493 /// This is called during CREATE for edge types not found in the schema.
2494 pub fn set_edge_type(
2495 &self,
2496 eid: Eid,
2497 type_name: String,
2498 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2499 ) {
2500 self.resolve_l0(tx_l0).write().set_edge_type(eid, type_name);
2501 }
2502
2503 async fn check_unique_constraint_multi(
2504 &self,
2505 label: &str,
2506 key_values: &[(String, Value)],
2507 current_vid: Vid,
2508 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2509 ) -> Result<()> {
2510 if self
2511 .unique_key_exists_full_horizon(label, key_values, Some(current_vid), tx_l0)
2512 .await?
2513 {
2514 return Err(anyhow!(
2515 "Constraint violation: Duplicate composite key for label '{}'",
2516 label
2517 ));
2518 }
2519 Ok(())
2520 }
2521
2522 /// Reports whether a UNIQUE composite key already exists across the full write horizon.
2523 ///
2524 /// Probes every layer a duplicate could hide in — the current L0 buffer, any
2525 /// pending-flush buffers, an optional transaction-local L0, and committed
2526 /// storage (L1/L2). `exclude_vid` is the vertex being checked so it never
2527 /// conflicts with itself; pass `None` when the caller is inserting a
2528 /// brand-new vertex that has no VID yet (e.g. the bulk loader), so nothing is
2529 /// excluded. This is the single lookup surface shared by the Writer's own
2530 /// `check_unique_constraint_multi` and the bulk loader's validation, so both
2531 /// write channels observe committed-but-unflushed keys (finding uni-bulk D6).
2532 ///
2533 /// # Errors
2534 /// Returns an error if a storage backend probe fails; the check fails closed
2535 /// rather than silently treating the key as absent.
2536 pub async fn unique_key_exists_full_horizon(
2537 &self,
2538 label: &str,
2539 key_values: &[(String, Value)],
2540 exclude_vid: Option<Vid>,
2541 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2542 ) -> Result<bool> {
2543 // Serialize constraint key once for O(1) lookups
2544 let key = serialize_constraint_key(label, key_values);
2545
2546 // Sentinel for the in-memory `has_constraint_key` comparisons when there
2547 // is no self to exclude: u64::MAX is never an allocated VID, so any real
2548 // key hit compares unequal and counts. (The storage probe below omits
2549 // the `_vid !=` clause entirely rather than emit an out-of-range literal.)
2550 let exclude = exclude_vid.unwrap_or_else(|| Vid::new(u64::MAX));
2551
2552 // 1. Check L0 (in-memory) using O(1) constraint index
2553 {
2554 let l0 = self.l0_manager.get_current();
2555 let l0_guard = l0.read();
2556 if l0_guard.has_constraint_key(&key, exclude) {
2557 return Ok(true);
2558 }
2559 }
2560
2561 // 1b. Check pending-flush buffers (Bug #9A). A flush rotates a key's
2562 // buffer onto `pending_flush` and installs a fresh empty current
2563 // buffer; until the rotated rows reach Lance the key is invisible to
2564 // both the current-buffer check above and the storage check below, so
2565 // a duplicate could slip through that flush window. Mirror the read
2566 // paths (e.g. `check_extid_globally_unique`, `get_vertex_labels`) that
2567 // already consult `pending_flush`.
2568 for pending_l0 in self.l0_manager.get_pending_flush() {
2569 if pending_l0.read().has_constraint_key(&key, exclude) {
2570 return Ok(true);
2571 }
2572 }
2573
2574 // Check Transaction L0
2575 if let Some(tx_l0) = tx_l0 {
2576 let tx_l0_guard = tx_l0.read();
2577 if tx_l0_guard.has_constraint_key(&key, exclude) {
2578 return Ok(true);
2579 }
2580 }
2581
2582 // 2. Check Storage (L1/L2)
2583 let mut parts: Vec<FilterExpr> = key_values
2584 .iter()
2585 .map(|(prop, val)| {
2586 FilterExpr::equals(
2587 prop.as_str(),
2588 constraint_scalar(val).unwrap_or(Scalar::Null),
2589 )
2590 })
2591 .collect();
2592 parts.push(FilterExpr::not_deleted());
2593 // Exclude the vertex's own row only when it has a VID; a brand-new insert
2594 // (bulk) has none, and emitting `_vid != u64::MAX` would overflow the
2595 // filter's i64 literal parsing.
2596 if let Some(vid) = exclude_vid {
2597 parts.push(FilterExpr::compare(
2598 "_vid",
2599 CmpOp::NotEq,
2600 Scalar::UInt(vid.as_u64()),
2601 ));
2602 }
2603 let filter = FilterExpr::all(parts);
2604
2605 // 2. Check Storage (L1/L2) through the `StorageBackend` (branch-aware,
2606 // correct `.lance` path). Skip cleanly when the table is not yet
2607 // flushed; propagate any real backend error instead of failing open.
2608 #[cfg(feature = "lance-backend")]
2609 {
2610 let backend = self.storage.backend();
2611 let table = table_names::vertex_table_name(label);
2612 if backend.table_exists(&table).await? {
2613 let count = backend.count_rows(&table, Some(&filter)).await?;
2614 if count > 0 {
2615 return Ok(true);
2616 }
2617 }
2618 }
2619
2620 Ok(false)
2621 }
2622
2623 /// Edge counterpart of [`validate_vertex_constraints`](Self::validate_vertex_constraints):
2624 /// enforces declared `Unique` / `NodeKey` constraints on an edge type before
2625 /// the write. `NodeKey` additionally requires every key property present and
2626 /// non-null; `Unique` skips enforcement when a key property is absent.
2627 ///
2628 /// # Errors
2629 /// Returns an error if a key property collides with another live edge, or (for
2630 /// `NodeKey`) if a key property is missing/null.
2631 async fn validate_edge_constraints(
2632 &self,
2633 eid: Eid,
2634 edge_type_name: &str,
2635 properties: &Properties,
2636 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2637 ) -> Result<()> {
2638 let schema = self.schema_manager.schema();
2639 for constraint in &schema.constraints {
2640 if !constraint.enabled {
2641 continue;
2642 }
2643 match &constraint.target {
2644 ConstraintTarget::EdgeType(t) if t == edge_type_name => {}
2645 _ => continue,
2646 }
2647 match &constraint.constraint_type {
2648 ConstraintType::Unique {
2649 properties: unique_props,
2650 } => {
2651 if unique_props.is_empty() {
2652 continue;
2653 }
2654 let mut key_values = Vec::with_capacity(unique_props.len());
2655 let mut missing = false;
2656 for prop in unique_props {
2657 match properties.get(prop) {
2658 Some(val) => key_values.push((prop.clone(), val.clone())),
2659 None => {
2660 missing = true; // Unique skips enforcement on a missing key.
2661 break;
2662 }
2663 }
2664 }
2665 if !missing {
2666 self.check_unique_edge_constraint_multi(
2667 edge_type_name,
2668 &key_values,
2669 eid,
2670 tx_l0,
2671 )
2672 .await?;
2673 }
2674 }
2675 ConstraintType::NodeKey {
2676 properties: key_props,
2677 } => {
2678 if key_props.is_empty() {
2679 continue;
2680 }
2681 let mut key_values = Vec::with_capacity(key_props.len());
2682 for prop in key_props {
2683 match properties.get(prop) {
2684 Some(val) if !val.is_null() => {
2685 key_values.push((prop.clone(), val.clone()))
2686 }
2687 _ => {
2688 return Err(anyhow!(
2689 "Relationship key constraint '{}' violated: property '{}' must exist and be non-null for edge type '{}'",
2690 constraint.name,
2691 prop,
2692 edge_type_name
2693 ));
2694 }
2695 }
2696 }
2697 self.check_unique_edge_constraint_multi(
2698 edge_type_name,
2699 &key_values,
2700 eid,
2701 tx_l0,
2702 )
2703 .await?;
2704 }
2705 // Edge `Exists`/`Check` are out of scope for the uniqueness family.
2706 _ => {}
2707 }
2708 }
2709 Ok(())
2710 }
2711
2712 /// Registers an edge's declared `Unique`/`NodeKey` key values into the L0 edge
2713 /// constraint index, the edge analogue of the vertex index-population inside
2714 /// `insert_vertex_with_labels`. A key with a missing/null member is not
2715 /// registered (it cannot participate in a satisfied unique key).
2716 fn populate_edge_constraint_index(
2717 &self,
2718 eid: Eid,
2719 edge_type_name: &str,
2720 properties: &Properties,
2721 l0: &Arc<RwLock<L0Buffer>>,
2722 ) {
2723 let schema = self.schema_manager.schema();
2724 let mut guard = l0.write();
2725 for constraint in &schema.constraints {
2726 if !constraint.enabled {
2727 continue;
2728 }
2729 match &constraint.target {
2730 ConstraintTarget::EdgeType(t) if t == edge_type_name => {}
2731 _ => continue,
2732 }
2733 let Some(key_props) = constraint.constraint_type.unique_properties() else {
2734 continue;
2735 };
2736 let mut key_values = Vec::with_capacity(key_props.len());
2737 let mut all_present = true;
2738 for prop in key_props {
2739 match properties.get(prop) {
2740 Some(val) if !val.is_null() => key_values.push((prop.clone(), val.clone())),
2741 _ => {
2742 all_present = false;
2743 break;
2744 }
2745 }
2746 }
2747 if all_present && !key_values.is_empty() {
2748 let key = serialize_constraint_key(edge_type_name, &key_values);
2749 guard.insert_edge_constraint_key(key, eid);
2750 }
2751 }
2752 }
2753
2754 /// Edge analogue of [`check_unique_constraint_multi`](Self::check_unique_constraint_multi):
2755 /// errors if `key_values` collide with another live edge of `edge_type`.
2756 async fn check_unique_edge_constraint_multi(
2757 &self,
2758 edge_type: &str,
2759 key_values: &[(String, Value)],
2760 current_eid: Eid,
2761 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2762 ) -> Result<()> {
2763 if self
2764 .unique_edge_key_exists_full_horizon(edge_type, key_values, Some(current_eid), tx_l0)
2765 .await?
2766 {
2767 return Err(anyhow!(
2768 "Constraint violation: Duplicate composite key for edge type '{}'",
2769 edge_type
2770 ));
2771 }
2772 Ok(())
2773 }
2774
2775 /// Reports whether a UNIQUE composite key already exists across the full write
2776 /// horizon for an *edge type* — the edge counterpart of
2777 /// [`unique_key_exists_full_horizon`](Self::unique_key_exists_full_horizon).
2778 ///
2779 /// Probes the same layers (current L0, pending-flush buffers, optional
2780 /// transaction L0, then committed storage), keyed to an `Eid`. `exclude_eid`
2781 /// is the edge being checked so it never conflicts with itself; pass `None`
2782 /// for a brand-new edge with no self to exclude. The committed-storage half is
2783 /// delegated to [`PropertyManager::flushed_edge_key_conflict`], which resolves
2784 /// the LSM delta table's latest-version-per-eid liveness correctly.
2785 ///
2786 /// # Errors
2787 /// Returns an error if a storage probe fails — fails closed rather than
2788 /// silently treating the key as absent.
2789 pub async fn unique_edge_key_exists_full_horizon(
2790 &self,
2791 edge_type: &str,
2792 key_values: &[(String, Value)],
2793 exclude_eid: Option<Eid>,
2794 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2795 ) -> Result<bool> {
2796 let key = serialize_constraint_key(edge_type, key_values);
2797 // Sentinel: u64::MAX is never an allocated EID, so with no self to exclude
2798 // any real key hit compares unequal and counts.
2799 let exclude = exclude_eid.unwrap_or_else(|| Eid::new(u64::MAX));
2800
2801 // 1. Current in-memory L0 index.
2802 if self
2803 .l0_manager
2804 .get_current()
2805 .read()
2806 .has_edge_constraint_key(&key, exclude)
2807 {
2808 return Ok(true);
2809 }
2810
2811 // 1b. Pending-flush buffers (closes the flush-window leak, mirroring the
2812 // vertex probe's Bug #9A handling).
2813 for pending_l0 in self.l0_manager.get_pending_flush() {
2814 if pending_l0.read().has_edge_constraint_key(&key, exclude) {
2815 return Ok(true);
2816 }
2817 }
2818
2819 // 1c. Transaction-local L0.
2820 if let Some(tx_l0) = tx_l0
2821 && tx_l0.read().has_edge_constraint_key(&key, exclude)
2822 {
2823 return Ok(true);
2824 }
2825
2826 // 2. Committed storage (LSM delta), resolved for latest-version liveness.
2827 #[cfg(feature = "lance-backend")]
2828 if let Some(pm) = &self.property_manager
2829 && pm
2830 .flushed_edge_key_conflict(edge_type, key_values, exclude_eid)
2831 .await?
2832 {
2833 return Ok(true);
2834 }
2835
2836 Ok(false)
2837 }
2838
2839 async fn check_write_pressure(&self) -> Result<()> {
2840 let status = self
2841 .storage
2842 .compaction_status()
2843 .map_err(|e| anyhow::anyhow!("Failed to get compaction status: {}", e))?;
2844 let l1_runs = status.l1_runs;
2845 let throttle = &self.config.throttle;
2846
2847 if l1_runs >= throttle.hard_limit {
2848 log::warn!("Write stalled: L1 runs ({}) at hard limit", l1_runs);
2849 // Simple polling for now
2850 while self
2851 .storage
2852 .compaction_status()
2853 .map_err(|e| anyhow::anyhow!("Failed to get compaction status: {}", e))?
2854 .l1_runs
2855 >= throttle.hard_limit
2856 {
2857 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2858 }
2859 } else if l1_runs >= throttle.soft_limit {
2860 let excess = l1_runs - throttle.soft_limit;
2861 // Cap multiplier to avoid overflow
2862 let excess = std::cmp::min(excess, 31);
2863 let multiplier = 2_u32.pow(excess as u32);
2864 let delay = throttle.base_delay * multiplier;
2865 tokio::time::sleep(delay).await;
2866 }
2867 Ok(())
2868 }
2869
2870 /// Check transaction memory limit to prevent OOM.
2871 /// No-op when no transaction is active.
2872 fn check_transaction_memory(&self, tx_l0: Option<&Arc<RwLock<L0Buffer>>>) -> Result<()> {
2873 if let Some(tx_l0) = tx_l0 {
2874 let size = tx_l0.read().estimated_size;
2875 if size > self.config.max_transaction_memory {
2876 return Err(anyhow!(
2877 "Transaction memory limit exceeded: {} bytes used, limit is {} bytes. \
2878 Roll back or commit the current transaction.",
2879 size,
2880 self.config.max_transaction_memory
2881 ));
2882 }
2883 }
2884 Ok(())
2885 }
2886
2887 async fn get_query_context(
2888 &self,
2889 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2890 ) -> Option<QueryContext> {
2891 Some(QueryContext::new_with_pending(
2892 self.l0_manager.get_current(),
2893 tx_l0.cloned(),
2894 self.l0_manager.get_pending_flush(),
2895 ))
2896 }
2897
2898 /// Layer-1 CRDT variant enforcement, shared by the single-vertex and batch
2899 /// write paths.
2900 ///
2901 /// Rejects a declared CRDT property written as a parsed CRDT value
2902 /// (`Value::Map`) whose variant differs from the schema's declared variant.
2903 /// A mismatch would make the commit-time merge silently overwrite instead of
2904 /// merge, and the OCC CRDT carve-out (`occ::crdt_carveout_overwrite` /
2905 /// `WriteSet::from_l0`) would hide it as a lost update — so it must be caught
2906 /// at write time, on *every* write path. `try_as_crdt` is `Map`-gated, so the
2907 /// JSON-string (Cypher) form and non-CRDT values pass through untouched: they
2908 /// are never carved out and stay conflictable.
2909 fn enforce_crdt_variants(
2910 props_meta: &std::collections::HashMap<String, uni_common::core::schema::PropertyMeta>,
2911 properties: &Properties,
2912 ) -> Result<()> {
2913 for (key, value) in properties {
2914 let Some(meta) = props_meta.get(key) else {
2915 continue;
2916 };
2917 let uni_common::core::schema::DataType::Crdt(expected) = &meta.r#type else {
2918 continue;
2919 };
2920 if let Some(crdt) = crate::runtime::l0::try_as_crdt(value)
2921 && crdt.type_name() != expected.type_name()
2922 {
2923 return Err(anyhow::Error::new(uni_common::UniError::Constraint {
2924 message: format!(
2925 "CRDT property '{key}' must be written as a {} value",
2926 expected.type_name()
2927 ),
2928 }));
2929 }
2930 }
2931 Ok(())
2932 }
2933
2934 /// Prepare a vertex for upsert by merging CRDT properties with existing values.
2935 ///
2936 /// When `label` is provided, uses it directly to look up property metadata.
2937 /// Otherwise falls back to discovering the label from L0 buffers and storage.
2938 ///
2939 /// # Errors
2940 ///
2941 /// Returns an error if CRDT property merging fails.
2942 async fn prepare_vertex_upsert(
2943 &self,
2944 vid: Vid,
2945 properties: &mut Properties,
2946 label: Option<&str>,
2947 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
2948 ) -> Result<()> {
2949 let Some(pm) = &self.property_manager else {
2950 return Ok(());
2951 };
2952
2953 let schema = self.schema_manager.schema();
2954
2955 // Resolve label: use provided label or discover from L0/storage
2956 let discovered_labels;
2957 let label_name = if let Some(l) = label {
2958 Some(l)
2959 } else {
2960 discovered_labels = self.get_vertex_labels(vid, tx_l0).await;
2961 discovered_labels
2962 .as_ref()
2963 .and_then(|l| l.first().map(|s| s.as_str()))
2964 };
2965
2966 let Some(label_str) = label_name else {
2967 return Ok(());
2968 };
2969 let Some(props_meta) = schema.properties.get(label_str) else {
2970 return Ok(());
2971 };
2972
2973 // Identify CRDT properties in the insert data
2974 let crdt_keys: Vec<String> = properties
2975 .keys()
2976 .filter(|key| {
2977 props_meta.get(*key).is_some_and(|meta| {
2978 matches!(meta.r#type, uni_common::core::schema::DataType::Crdt(_))
2979 })
2980 })
2981 .cloned()
2982 .collect();
2983
2984 if crdt_keys.is_empty() {
2985 return Ok(());
2986 }
2987
2988 // Enforce that each declared CRDT property written as a parsed CRDT value
2989 // (`Value::Map`) carries its declared variant. A mismatched variant makes
2990 // `merge_crdt_properties` overwrite rather than merge at commit, and the
2991 // OCC carve-out (`occ::crdt_carveout_overwrite` / `WriteSet::from_l0`)
2992 // would hide that as a silent lost update — reject it at the source.
2993 //
2994 // Only the `Map` form is checked: it is exactly the form the carve-out
2995 // applies to (`try_as_crdt` is `Map`-gated). A CRDT written as a JSON
2996 // string (the Cypher form) or a non-CRDT value is never carved out — it
2997 // stays conflictable — so it poses no carve-out soundness risk and is left
2998 // to the existing merge/parse path. This is the declared-property half of
2999 // the layered fix; the commit-time check covers undeclared CRDT-shaped values.
3000 Self::enforce_crdt_variants(props_meta, properties)?;
3001
3002 let ctx = self.get_query_context(tx_l0).await;
3003 for key in crdt_keys {
3004 let existing = pm.get_vertex_prop_with_ctx(vid, &key, ctx.as_ref()).await?;
3005 if !existing.is_null()
3006 && let Some(val) = properties.get_mut(&key)
3007 {
3008 *val = pm.merge_crdt_values(&existing, val)?;
3009 }
3010 }
3011
3012 Ok(())
3013 }
3014
3015 async fn prepare_edge_upsert(
3016 &self,
3017 eid: Eid,
3018 properties: &mut Properties,
3019 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3020 ) -> Result<()> {
3021 if let Some(pm) = &self.property_manager {
3022 let schema = self.schema_manager.schema();
3023 // Get edge type from L0 buffer instead of from EID
3024 let type_name = self.get_edge_type_from_l0(eid);
3025
3026 if let Some(ref t_name) = type_name
3027 && let Some(props_meta) = schema.properties.get(t_name)
3028 {
3029 let mut crdt_keys = Vec::new();
3030 for key in properties.keys() {
3031 if let Some(meta) = props_meta.get(key)
3032 && matches!(meta.r#type, uni_common::core::schema::DataType::Crdt(_))
3033 {
3034 crdt_keys.push(key.clone());
3035 }
3036 }
3037
3038 if !crdt_keys.is_empty() {
3039 let ctx = self.get_query_context(tx_l0).await;
3040 for key in crdt_keys {
3041 let existing = pm.get_edge_prop(eid, &key, ctx.as_ref()).await?;
3042
3043 if !existing.is_null()
3044 && let Some(val) = properties.get_mut(&key)
3045 {
3046 *val = pm.merge_crdt_values(&existing, val)?;
3047 }
3048 }
3049 }
3050 }
3051 }
3052 Ok(())
3053 }
3054
3055 #[instrument(skip(self, properties), level = "trace")]
3056 pub async fn insert_vertex(
3057 &self,
3058 vid: Vid,
3059 properties: Properties,
3060 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3061 ) -> Result<()> {
3062 self.insert_vertex_with_labels(vid, properties, &[], tx_l0)
3063 .await?;
3064 Ok(())
3065 }
3066
3067 /// Component C1 (G4): before a non-transactional mutation merges into main
3068 /// L0, if an outstanding snapshot pins the current generation, freeze it
3069 /// aside so snapshots taken *before* this write stay isolated from it.
3070 ///
3071 /// `flush_lock` (acquired and released here) serializes the freeze against
3072 /// concurrent commit-time freezes/merges, matching the atomicity the tx
3073 /// commit path gets. No-op for transactional writes (their freeze happens at
3074 /// commit) and — the common case — when nothing is pinned, where it costs one
3075 /// atomic load. Freezes at most once per pinned generation: the freeze
3076 /// installs a fresh unpinned `current`, so later writes in the same bulk
3077 /// import see no pin and merge in place, and the snapshot keeps reading the
3078 /// frozen pre-import buffer.
3079 async fn freeze_for_non_tx_write_if_pinned(&self, tx_l0: Option<&Arc<RwLock<L0Buffer>>>) {
3080 // Self-gates on the runtime SSI toggle: nothing pins a snapshot unless a
3081 // transaction began under `ssi_enabled`, so `is_current_pinned()` is
3082 // always false (one atomic load) when SSI is off.
3083 if tx_l0.is_none() && self.l0_manager.is_current_pinned() {
3084 let _flush_lock_guard = self.flush_lock.lock().await;
3085 // Re-check under the lock: a concurrent commit may have frozen first.
3086 if self.l0_manager.is_current_pinned() {
3087 self.l0_manager.freeze_current_for_snapshot();
3088 metrics::counter!("uni_l0_snapshot_freezes_total").increment(1);
3089 }
3090 }
3091 }
3092
3093 #[instrument(skip(self, properties, labels), level = "trace")]
3094 pub async fn insert_vertex_with_labels(
3095 &self,
3096 vid: Vid,
3097 mut properties: Properties,
3098 labels: &[String],
3099 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3100 ) -> Result<Properties> {
3101 let start = std::time::Instant::now();
3102 self.check_write_pressure().await?;
3103 self.check_transaction_memory(tx_l0)?;
3104
3105 // Component C1 (G4): a non-transactional write (`tx_l0 == None`, e.g. bulk
3106 // import / LOAD CSV) mutates main L0 directly, outside the commit-time
3107 // snapshot freeze. Freeze the pinned generation aside first so snapshots
3108 // taken before this write stay isolated from it.
3109 self.freeze_for_non_tx_write_if_pinned(tx_l0).await;
3110
3111 if !self.try_defer_embedding(labels, &properties, vid, tx_l0) {
3112 self.process_embeddings_for_labels(labels, &mut properties)
3113 .await?;
3114 }
3115 self.validate_vertex_constraints(vid, &properties, labels, tx_l0)
3116 .await?;
3117 self.prepare_vertex_upsert(
3118 vid,
3119 &mut properties,
3120 labels.first().map(|s| s.as_str()),
3121 tx_l0,
3122 )
3123 .await?;
3124
3125 // Clone properties and labels before moving into L0 to return them and populate constraint index
3126 let properties_copy = properties.clone();
3127 let labels_copy = labels.to_vec();
3128
3129 {
3130 // For a non-tx write, re-resolve the live `current` buffer and hold
3131 // `flush_lock` across the (synchronous) write so a concurrent flush
3132 // rotate (which takes `flush_lock` via `begin_flush`) cannot install
3133 // a fresh `current` between resolve and write, dropping our write
3134 // (Bug #4). For a tx write `resolve_l0` returns the tx-private
3135 // buffer, never the rotating `current`, so no `flush_lock` is needed
3136 // (and taking it would risk re-entrancy with the commit path).
3137 let _flush_lock_guard = if tx_l0.is_none() {
3138 Some(self.flush_lock.lock().await)
3139 } else {
3140 None
3141 };
3142 let l0 = self.resolve_l0(tx_l0);
3143 let mut l0_guard = l0.write();
3144 // Generation chaining: a non-tx CRDT write into the (post-freeze,
3145 // possibly empty) current buffer must merge against the chained
3146 // committed state, not shadow it. No-op when the chain is empty
3147 // or this is a tx-private write.
3148 if tx_l0.is_none() {
3149 let pending = self.l0_manager.get_pending_flush();
3150 if !pending.is_empty() {
3151 Self::seed_crdt_props(&pending, vid, &properties, &mut l0_guard);
3152 }
3153 }
3154 l0_guard.insert_vertex_with_labels(vid, properties, labels);
3155
3156 // Populate constraint index for O(1) duplicate detection
3157 let schema = self.schema_manager.schema();
3158 for label in &labels_copy {
3159 if schema.get_label_case_insensitive(label).is_none() {
3160 if self.config.strict_schema {
3161 return Err(anyhow::anyhow!(
3162 "Label '{}' is not defined in the schema \
3163 (strict_schema is enabled).",
3164 label
3165 ));
3166 }
3167 continue; // Schemaless: skip unknown labels.
3168 }
3169
3170 // For each unique constraint on this label, insert into constraint index
3171 for constraint in &schema.constraints {
3172 if !constraint.enabled {
3173 continue;
3174 }
3175 if let ConstraintTarget::Label(l) = &constraint.target {
3176 if l != label {
3177 continue;
3178 }
3179 } else {
3180 continue;
3181 }
3182
3183 // Index keys for both Unique and NodeKey (uniqueness half), so
3184 // the full-horizon probe sees prior NodeKey rows too.
3185 if let Some(unique_props) = constraint.constraint_type.unique_properties() {
3186 let mut key_values = Vec::new();
3187 let mut all_present = true;
3188 for prop in unique_props {
3189 if let Some(val) = properties_copy.get(prop) {
3190 key_values.push((prop.clone(), val.clone()));
3191 } else {
3192 all_present = false;
3193 break;
3194 }
3195 }
3196
3197 if all_present {
3198 let key = serialize_constraint_key(label, &key_values);
3199 l0_guard.insert_constraint_key(key, vid);
3200 }
3201 }
3202 }
3203 }
3204 }
3205
3206 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3207 self.update_metrics();
3208
3209 if tx_l0.is_none() {
3210 self.check_flush().await?;
3211 }
3212 if start.elapsed().as_millis() > 100 {
3213 log::warn!("Slow insert_vertex: {}ms", start.elapsed().as_millis());
3214 }
3215 Ok(properties_copy)
3216 }
3217
3218 /// True iff routing this partial write through MergeInsert would
3219 /// miss a constraint check. Specifically: a multi-key UNIQUE
3220 /// constraint where the touched-set doesn't cover all member keys
3221 /// requires the unchanged keys from the existing row to compute
3222 /// the composite. Conservative: also returns true if any touched
3223 /// key is `ext_id` (uniqueness checked globally — handled in the
3224 /// full-row path).
3225 fn touched_needs_full_read(&self, touched: &Properties, labels: &[String]) -> bool {
3226 if touched.contains_key("ext_id") {
3227 return true;
3228 }
3229 let schema = self.schema_manager.schema();
3230 for label in labels {
3231 if schema.get_label_case_insensitive(label).is_none() {
3232 continue;
3233 }
3234 for constraint in &schema.constraints {
3235 if !constraint.enabled {
3236 continue;
3237 }
3238 if let ConstraintTarget::Label(l) = &constraint.target {
3239 if !l.eq_ignore_ascii_case(label) {
3240 continue;
3241 }
3242 } else {
3243 continue;
3244 }
3245 if let ConstraintType::Unique {
3246 properties: unique_props,
3247 } = &constraint.constraint_type
3248 {
3249 if unique_props.len() < 2 {
3250 continue; // single-key UNIQUE — partial path sees the key
3251 }
3252 if unique_props.iter().any(|p| touched.contains_key(p)) {
3253 return true;
3254 }
3255 }
3256 }
3257 }
3258 false
3259 }
3260
3261 /// Insert a vertex's FULL property row plus a touched-keys hint so
3262 /// the flush emits ONLY those columns via Lance MergeInsert.
3263 ///
3264 /// Caller must have read the full row (via PropertyManager) and
3265 /// applied SET-touched values on top before calling — same input
3266 /// shape as `insert_vertex_with_labels`. The new arg `touched_keys`
3267 /// is the set of property keys this SET statement actually
3268 /// assigned; L0 records it in `vertex_partial_keys[vid]` and the
3269 /// flush filters the MergeInsert source schema down to those keys.
3270 /// When `UniConfig::partial_lance_writes == false`, falls through
3271 /// to `insert_vertex_with_labels` (Append) — preserving bit-for-bit
3272 /// equivalence with prior releases.
3273 /// Refresh auto-embed targets for a partial / `SET` write whose `touched_keys`
3274 /// include an embed *source* column: drop stale target embeddings from `props`
3275 /// (so they re-embed) and add them to `touched_keys`. Public so the query
3276 /// executor can apply it on the coalesced write **before** the partial-vs-full
3277 /// branch — both branches need it. No-op for non-SET writes / non-embed labels.
3278 pub fn refresh_embed_targets(
3279 &self,
3280 props: &mut Properties,
3281 touched_keys: &mut HashSet<String>,
3282 labels: &[String],
3283 ) {
3284 refresh_touched_embed_targets(&self.schema_manager.schema(), props, touched_keys, labels);
3285 }
3286
3287 #[instrument(skip(self, props, touched_keys, labels), level = "trace")]
3288 pub async fn insert_vertex_partial_full(
3289 &self,
3290 vid: Vid,
3291 mut props: Properties,
3292 touched_keys: HashSet<String>,
3293 labels: &[String],
3294 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3295 ) -> Result<()> {
3296 if !self.config.partial_lance_writes
3297 || self.touched_needs_full_read(&props_subset(&props, &touched_keys), labels)
3298 {
3299 self.insert_vertex_with_labels(vid, props, labels, tx_l0)
3300 .await?;
3301 return Ok(());
3302 }
3303
3304 self.check_write_pressure().await?;
3305 self.check_transaction_memory(tx_l0)?;
3306 if !self.try_defer_embedding(labels, &props, vid, tx_l0) {
3307 self.process_embeddings_for_labels(labels, &mut props)
3308 .await?;
3309 }
3310 // Full-row validation runs because we have the complete map;
3311 // no need for the partial-only validator.
3312 self.validate_vertex_constraints(vid, &props, labels, tx_l0)
3313 .await?;
3314 {
3315 let l0 = self.resolve_l0(tx_l0);
3316 let mut l0_guard = l0.write();
3317 l0_guard.insert_vertex_partial_full(vid, props, touched_keys, labels);
3318 }
3319 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3320 metrics::counter!("uni_partial_writes_total").increment(1);
3321 self.update_metrics();
3322 if tx_l0.is_none() {
3323 self.check_flush().await?;
3324 }
3325 Ok(())
3326 }
3327
3328 /// Insert a vertex's *partial* property set without first reading the
3329 /// full row.
3330 ///
3331 /// When `WriterConfig::partial_lance_writes` is `true`, the touched
3332 /// keys flow into `L0Buffer::vertex_partial_keys` so the next flush
3333 /// emits them via Lance `MergeInsertBuilder` against a subset-of-
3334 /// schema source — preserving untouched columns (e.g., embeddings)
3335 /// byte-equal in Lance with no read at the caller and no write of
3336 /// those columns.
3337 ///
3338 /// When the flag is `false`, this falls back to the existing
3339 /// `insert_vertex_with_labels` path after merging `touched` with
3340 /// the current properties from L0/storage. The caller can therefore
3341 /// use this entry point unconditionally; the optimization activates
3342 /// only when the flag is on.
3343 #[instrument(skip(self, touched, labels), level = "trace")]
3344 pub async fn insert_vertex_partial(
3345 &self,
3346 vid: Vid,
3347 touched: Properties,
3348 labels: &[String],
3349 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3350 ) -> Result<()> {
3351 let needs_full_read =
3352 !self.config.partial_lance_writes || self.touched_needs_full_read(&touched, labels);
3353 if needs_full_read {
3354 // Flag-off fallback (or constraint-driven fallback): merge
3355 // `touched` with the current full property snapshot from
3356 // L0/storage and route through the existing path. Preserves
3357 // bit-for-bit equivalence with the pre-Round-11 release.
3358 let existing = if let Some(pm) = &self.property_manager {
3359 pm.get_all_vertex_props_with_ctx(vid, None)
3360 .await
3361 .unwrap_or_default()
3362 .unwrap_or_default()
3363 } else {
3364 Properties::new()
3365 };
3366 let mut merged = existing;
3367 for (k, v) in touched {
3368 merged.insert(k, v);
3369 }
3370 self.insert_vertex_with_labels(vid, merged, labels, tx_l0)
3371 .await?;
3372 return Ok(());
3373 }
3374
3375 // Flag-on fast path: stage the partial update directly. Pressure
3376 // checks, embedding generation, constraint validation all still
3377 // run — but the validator is the partial-aware variant that
3378 // skips NOT NULL / multi-key UNIQUE / CHECK / EXISTS for
3379 // properties not present in `touched`. Multi-key UNIQUE that
3380 // overlaps the touched set forces a fallback above via
3381 // `touched_needs_full_read`.
3382 let mut touched = touched;
3383 self.check_write_pressure().await?;
3384 self.check_transaction_memory(tx_l0)?;
3385 if !self.try_defer_embedding(labels, &touched, vid, tx_l0) {
3386 self.process_embeddings_for_labels(labels, &mut touched)
3387 .await?;
3388 }
3389 self.validate_vertex_constraints_partial(vid, &touched, labels, tx_l0)
3390 .await?;
3391
3392 {
3393 let l0 = self.resolve_l0(tx_l0);
3394 let mut l0_guard = l0.write();
3395 l0_guard.insert_vertex_partial(vid, touched, labels);
3396 }
3397
3398 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3399 metrics::counter!("uni_partial_writes_total").increment(1);
3400 self.update_metrics();
3401 if tx_l0.is_none() {
3402 self.check_flush().await?;
3403 }
3404 Ok(())
3405 }
3406
3407 /// Insert multiple vertices with batched operations.
3408 ///
3409 /// This method uses batched operations to achieve O(N) complexity instead of O(N²)
3410 /// for bulk inserts with unique constraints.
3411 ///
3412 /// # Performance Improvements
3413 /// - Batch VID allocation: 1 call instead of N calls
3414 /// - Batch constraint validation: O(N) instead of O(N²)
3415 /// - Batch embedding generation: 1 API call per config instead of N calls
3416 /// - Transaction wrapping: Automatic flush deferral, atomicity
3417 ///
3418 /// # Arguments
3419 /// * `vids` - Pre-allocated VIDs for the vertices
3420 /// * `properties_batch` - Properties for each vertex
3421 /// * `labels` - Labels for all vertices (assumes single label for simplicity)
3422 ///
3423 /// # Errors
3424 /// Returns error if:
3425 /// - VID/properties length mismatch
3426 /// - Constraint violation detected
3427 /// - Embedding generation fails
3428 /// - Transaction commit fails
3429 ///
3430 /// # Atomicity
3431 /// If this method fails, all changes are rolled back (if transaction was started here).
3432 pub async fn insert_vertices_batch(
3433 &self,
3434 vids: Vec<Vid>,
3435 mut properties_batch: Vec<Properties>,
3436 labels: Vec<String>,
3437 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3438 ) -> Result<Vec<Properties>> {
3439 let start = std::time::Instant::now();
3440
3441 // Validate inputs
3442 if vids.len() != properties_batch.len() {
3443 return Err(anyhow!(
3444 "VID/properties size mismatch: {} vids, {} properties",
3445 vids.len(),
3446 properties_batch.len()
3447 ));
3448 }
3449
3450 if vids.is_empty() {
3451 return Ok(Vec::new());
3452 }
3453
3454 // Batch operations — writes go directly to the resolved L0.
3455 // Atomicity is guaranteed by the caller holding the writer lock.
3456 let result = async {
3457 self.check_write_pressure().await?;
3458 self.check_transaction_memory(tx_l0)?;
3459
3460 // Component C1 (G4): batch bulk-import is the canonical non-tx write —
3461 // freeze the pinned generation aside before merging so snapshot
3462 // readers stay isolated. No-op when unpinned or transactional.
3463 self.freeze_for_non_tx_write_if_pinned(tx_l0).await;
3464
3465 // Batch embedding generation (1 API call per config)
3466 self.process_embeddings_for_batch(&labels, &mut properties_batch)
3467 .await?;
3468
3469 // Batch constraint validation (O(N) instead of O(N²))
3470 let label = labels
3471 .first()
3472 .ok_or_else(|| anyhow!("No labels provided"))?;
3473 self.validate_vertex_batch_constraints(&vids, &properties_batch, label, tx_l0)
3474 .await?;
3475
3476 // Batch prepare (CRDT merging if needed)
3477 // Check schema once: skip entirely if no CRDT properties for this label.
3478 // For new vertices (freshly allocated VIDs), there are no existing CRDT
3479 // values to merge, so the per-vertex lookup is unnecessary in that case.
3480 let has_crdt_fields = {
3481 let schema = self.schema_manager.schema();
3482 schema
3483 .properties
3484 .get(label.as_str())
3485 .is_some_and(|props_meta| {
3486 props_meta.values().any(|meta| {
3487 matches!(meta.r#type, uni_common::core::schema::DataType::Crdt(_))
3488 })
3489 })
3490 };
3491
3492 if has_crdt_fields {
3493 // Layer-1 variant enforcement (G3): the batch path must reject a
3494 // declared-CRDT variant mismatch exactly as the single-vertex
3495 // `prepare_vertex_upsert` does. Without this, a wrong-variant CRDT
3496 // written via batch import slips past write-time validation and
3497 // the OCC carve-out then masks the overwrite as a lost update.
3498 {
3499 let schema = self.schema_manager.schema();
3500 if let Some(props_meta) = schema.properties.get(label.as_str()) {
3501 for props in &properties_batch {
3502 Self::enforce_crdt_variants(props_meta, props)?;
3503 }
3504 }
3505 }
3506
3507 // Batch fetch existing CRDT values: collect VIDs that need merging,
3508 // then query once via PropertyManager instead of per-vertex lookups.
3509 let schema = self.schema_manager.schema();
3510 let crdt_keys: Vec<String> = schema
3511 .properties
3512 .get(label.as_str())
3513 .map(|props_meta| {
3514 props_meta
3515 .iter()
3516 .filter(|(_, meta)| {
3517 matches!(meta.r#type, uni_common::core::schema::DataType::Crdt(_))
3518 })
3519 .map(|(key, _)| key.clone())
3520 .collect()
3521 })
3522 .unwrap_or_default();
3523
3524 if let Some(pm) = &self.property_manager {
3525 let ctx = self.get_query_context(tx_l0).await;
3526 for (vid, props) in vids.iter().zip(&mut properties_batch) {
3527 for key in &crdt_keys {
3528 if props.contains_key(key) {
3529 let existing =
3530 pm.get_vertex_prop_with_ctx(*vid, key, ctx.as_ref()).await?;
3531 if !existing.is_null()
3532 && let Some(val) = props.get_mut(key)
3533 {
3534 *val = pm.merge_crdt_values(&existing, val)?;
3535 }
3536 }
3537 }
3538 }
3539 }
3540 }
3541
3542 // Batch L0 writes — route to active L0 (transaction L0 if active, else current).
3543 let target_l0 = self.resolve_l0(tx_l0);
3544
3545 let properties_result = properties_batch.clone();
3546 {
3547 let schema = self.schema_manager.schema();
3548 let mut l0_guard = target_l0.write();
3549 for (vid, props) in vids.iter().zip(properties_batch.iter()) {
3550 l0_guard.insert_vertex_with_labels(*vid, props.clone(), &labels);
3551
3552 // Populate the L0 constraint index so a later single insert
3553 // (or commit-time probe) sees this batch row's unique keys via
3554 // has_constraint_key. The single-vertex path does this; the
3555 // batch path formerly did not, silently twinning unique keys.
3556 for label in &labels {
3557 for constraint in &schema.constraints {
3558 if !constraint.enabled {
3559 continue;
3560 }
3561 let ConstraintTarget::Label(l) = &constraint.target else {
3562 continue;
3563 };
3564 if l != label {
3565 continue;
3566 }
3567 // Index keys for both Unique and NodeKey.
3568 if let Some(unique_props) =
3569 constraint.constraint_type.unique_properties()
3570 {
3571 let mut key_values = Vec::new();
3572 let mut all_present = true;
3573 for prop in unique_props {
3574 if let Some(val) = props.get(prop) {
3575 key_values.push((prop.clone(), val.clone()));
3576 } else {
3577 all_present = false;
3578 break;
3579 }
3580 }
3581 if all_present {
3582 let key = serialize_constraint_key(label, &key_values);
3583 l0_guard.insert_constraint_key(key, *vid);
3584 }
3585 }
3586 }
3587 }
3588 }
3589 }
3590
3591 // Update metrics (batch increment)
3592 metrics::counter!("uni_l0_buffer_mutations_total").increment(vids.len() as u64);
3593 self.update_metrics();
3594
3595 Ok::<Vec<Properties>, anyhow::Error>(properties_result)
3596 }
3597 .await;
3598
3599 let props = result?;
3600
3601 if start.elapsed().as_millis() > 100 {
3602 log::warn!(
3603 "Slow insert_vertices_batch ({} vertices): {}ms",
3604 vids.len(),
3605 start.elapsed().as_millis()
3606 );
3607 }
3608
3609 Ok(props)
3610 }
3611
3612 /// Delete a vertex by VID.
3613 ///
3614 /// When `labels` is provided, uses them directly to populate L0 for
3615 /// correct tombstone flushing. Otherwise discovers labels from L0
3616 /// buffers and storage (which can be slow for many vertices).
3617 ///
3618 /// # Errors
3619 ///
3620 /// Returns an error if write pressure stalls, label lookup fails, or
3621 /// the L0 delete operation fails.
3622 #[instrument(skip(self, labels), level = "trace")]
3623 pub async fn delete_vertex(
3624 &self,
3625 vid: Vid,
3626 labels: Option<Vec<String>>,
3627 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3628 ) -> Result<()> {
3629 let start = std::time::Instant::now();
3630 self.check_write_pressure().await?;
3631 self.check_transaction_memory(tx_l0)?;
3632 self.freeze_for_non_tx_write_if_pinned(tx_l0).await; // C1 (G4)
3633
3634 // Before deleting, ensure we have the vertex's labels stored in L0 so
3635 // the tombstone can be flushed to the correct label datasets. Discover
3636 // them up front (this may await storage) WITHOUT pinning the buffer we
3637 // will eventually mutate — for non-tx writes the live `current` buffer
3638 // is re-resolved below under `flush_lock`, so a concurrent rotate can't
3639 // drop our write (Bug #4). `resolve_l0` here is only used for cheap
3640 // reads that tolerate a racing rotate.
3641 let has_labels = {
3642 let l0_guard = self.resolve_l0(tx_l0);
3643 let guard = l0_guard.read();
3644 guard.vertex_labels.contains_key(&vid)
3645 };
3646
3647 let backfill_labels = if has_labels {
3648 None
3649 } else if let Some(provided) = labels {
3650 // Caller provided labels — skip the lookup entirely
3651 Some(provided)
3652 } else {
3653 // Discover labels from pending flush L0s, then storage
3654 let mut found = None;
3655 for pending_l0 in self.l0_manager.get_pending_flush() {
3656 let pending_guard = pending_l0.read();
3657 if let Some(l) = pending_guard.get_vertex_labels(vid) {
3658 found = Some(l.to_vec());
3659 break;
3660 }
3661 }
3662 if found.is_none() {
3663 found = self.find_vertex_labels_in_storage(vid).await?;
3664 }
3665 found
3666 };
3667
3668 // Test-only seam (no-op without the `failpoints` feature): pause a
3669 // non-transactional delete AFTER the awaited label discovery but BEFORE
3670 // it re-resolves the live buffer and writes the tombstone. A concurrent
3671 // flush can rotate+complete a buffer in this window; the fix re-resolves
3672 // `get_current()` and mutates it under `flush_lock`, so the tombstone
3673 // always lands in the live buffer (Bug #4 — silent lost delete across
3674 // L0 rotation).
3675 fail::fail_point!("nontx::after-capture");
3676
3677 // Apply the label backfill and the tombstone together. For a non-tx
3678 // write, hold `flush_lock` across the (synchronous) re-resolve + write
3679 // so a concurrent flush rotate (which takes `flush_lock` via
3680 // `begin_flush`) cannot install a fresh `current` between our resolve
3681 // and our write. For a tx write `resolve_l0` returns the tx-private
3682 // buffer (never the rotating `current`), so no `flush_lock` is needed
3683 // — and taking it there would risk re-entrancy with the commit path.
3684 if tx_l0.is_none() {
3685 let _flush_lock_guard = self.flush_lock.lock().await;
3686 let l0 = self.l0_manager.get_current();
3687 let mut guard = l0.write();
3688 if let Some(found_labels) = backfill_labels {
3689 guard.vertex_labels.insert(vid, found_labels);
3690 }
3691 guard.delete_vertex(vid)?;
3692 } else {
3693 let l0 = self.resolve_l0(tx_l0);
3694 let mut guard = l0.write();
3695 if let Some(found_labels) = backfill_labels {
3696 guard.vertex_labels.insert(vid, found_labels);
3697 }
3698 guard.delete_vertex(vid)?;
3699 }
3700 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3701 self.update_metrics();
3702
3703 if tx_l0.is_none() {
3704 self.check_flush().await?;
3705 }
3706 if start.elapsed().as_millis() > 100 {
3707 log::warn!("Slow delete_vertex: {}ms", start.elapsed().as_millis());
3708 }
3709 Ok(())
3710 }
3711
3712 /// Find vertex labels from storage by querying the main vertices table.
3713 /// Returns the labels from the latest non-deleted version of the vertex.
3714 async fn find_vertex_labels_in_storage(&self, vid: Vid) -> Result<Option<Vec<String>>> {
3715 use crate::backend::types::ScanRequest;
3716 use arrow_array::Array;
3717 use arrow_array::cast::AsArray;
3718
3719 let backend = self.storage.backend();
3720 let table_name = MainVertexDataset::table_name();
3721
3722 // Check if table exists first; if not, vertex hasn't been flushed to storage yet
3723 if !backend.table_exists(table_name).await? {
3724 return Ok(None);
3725 }
3726
3727 // Query for this specific vid (don't filter by _deleted yet - we need to find the latest version first)
3728 let filter = FilterExpr::equals("_vid", Scalar::UInt(vid.as_u64()));
3729 let batches = backend
3730 .scan(
3731 ScanRequest::all(table_name)
3732 .with_filter(filter)
3733 .with_columns(vec![
3734 "_vid".to_string(),
3735 "labels".to_string(),
3736 "_version".to_string(),
3737 "_deleted".to_string(),
3738 ]),
3739 )
3740 .await
3741 .unwrap_or_default();
3742
3743 // Find the row with the highest version number
3744 let mut max_version: Option<u64> = None;
3745 let mut labels: Option<Vec<String>> = None;
3746 let mut is_deleted = false;
3747
3748 for batch in batches {
3749 if batch.num_rows() == 0 {
3750 continue;
3751 }
3752
3753 let version_array = batch
3754 .column_by_name("_version")
3755 .unwrap()
3756 .as_primitive::<arrow_array::types::UInt64Type>();
3757
3758 let deleted_array = batch.column_by_name("_deleted").unwrap().as_boolean();
3759
3760 let labels_array = batch.column_by_name("labels").unwrap().as_list::<i32>();
3761
3762 for row_idx in 0..batch.num_rows() {
3763 let version = version_array.value(row_idx);
3764
3765 if max_version.is_none_or(|mv| version > mv) {
3766 is_deleted = deleted_array.value(row_idx);
3767
3768 let labels_list = labels_array.value(row_idx);
3769 let string_array = labels_list.as_string::<i32>();
3770 let vertex_labels: Vec<String> = (0..string_array.len())
3771 .filter(|&i| !string_array.is_null(i))
3772 .map(|i| string_array.value(i).to_string())
3773 .collect();
3774
3775 max_version = Some(version);
3776 labels = Some(vertex_labels);
3777 }
3778 }
3779 }
3780
3781 // If the latest version is deleted, return None
3782 if is_deleted { Ok(None) } else { Ok(labels) }
3783 }
3784
3785 #[expect(clippy::too_many_arguments)]
3786 #[instrument(skip(self, props, touched_keys), level = "trace")]
3787 pub async fn insert_edge_partial_full(
3788 &self,
3789 src_vid: Vid,
3790 dst_vid: Vid,
3791 edge_type: u32,
3792 eid: Eid,
3793 props: Properties,
3794 edge_type_name: Option<String>,
3795 touched_keys: HashSet<String>,
3796 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3797 ) -> Result<()> {
3798 self.freeze_for_non_tx_write_if_pinned(tx_l0).await; // C1 (G4)
3799 if !self.config.partial_lance_writes {
3800 return self
3801 .insert_edge(
3802 src_vid,
3803 dst_vid,
3804 edge_type,
3805 eid,
3806 props,
3807 edge_type_name,
3808 tx_l0,
3809 )
3810 .await;
3811 }
3812
3813 let start = std::time::Instant::now();
3814 self.check_write_pressure().await?;
3815 self.check_transaction_memory(tx_l0)?;
3816 let mut props = props;
3817 self.prepare_edge_upsert(eid, &mut props, tx_l0).await?;
3818
3819 // Enforce declared edge-type UNIQUE / NODE KEY constraints whose key
3820 // properties are all present in this (possibly partial) write, then
3821 // register them. A partial write touching only part of a composite key
3822 // does not carry the full key, so like the vertex partial path it enforces
3823 // only fully-present keys.
3824 if let Some(type_name) = edge_type_name.as_deref() {
3825 self.validate_edge_constraints(eid, type_name, &props, tx_l0)
3826 .await?;
3827 }
3828
3829 let l0 = self.resolve_l0(tx_l0);
3830 if let Some(type_name) = edge_type_name.as_deref() {
3831 self.populate_edge_constraint_index(eid, type_name, &props, &l0);
3832 }
3833 l0.write().insert_edge_partial_full(
3834 src_vid,
3835 dst_vid,
3836 edge_type,
3837 eid,
3838 props,
3839 edge_type_name,
3840 touched_keys,
3841 )?;
3842
3843 if tx_l0.is_none() {
3844 let version = l0.read().current_version;
3845 self.adjacency_manager
3846 .insert_edge(src_vid, dst_vid, eid, edge_type, version);
3847 }
3848
3849 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3850 metrics::counter!("uni_partial_writes_total").increment(1);
3851 self.update_metrics();
3852 if tx_l0.is_none() {
3853 self.check_flush().await?;
3854 }
3855 if start.elapsed().as_millis() > 100 {
3856 log::warn!(
3857 "Slow insert_edge_partial_full: {}ms",
3858 start.elapsed().as_millis()
3859 );
3860 }
3861 Ok(())
3862 }
3863
3864 #[expect(clippy::too_many_arguments)]
3865 pub async fn insert_edge(
3866 &self,
3867 src_vid: Vid,
3868 dst_vid: Vid,
3869 edge_type: u32,
3870 eid: Eid,
3871 mut properties: Properties,
3872 edge_type_name: Option<String>,
3873 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3874 ) -> Result<()> {
3875 let start = std::time::Instant::now();
3876 self.check_write_pressure().await?;
3877 self.check_transaction_memory(tx_l0)?;
3878 self.freeze_for_non_tx_write_if_pinned(tx_l0).await; // C1 (G4)
3879 self.prepare_edge_upsert(eid, &mut properties, tx_l0)
3880 .await?;
3881
3882 // Enforce declared edge-type UNIQUE / NODE KEY constraints across the full
3883 // write horizon before the edge lands, then register its keys so later
3884 // writes (this tx and concurrent ones) observe it. Schemaless edges (no
3885 // type name) carry no declared constraints and are skipped.
3886 if let Some(type_name) = edge_type_name.as_deref() {
3887 self.validate_edge_constraints(eid, type_name, &properties, tx_l0)
3888 .await?;
3889 }
3890
3891 let l0 = self.resolve_l0(tx_l0);
3892 if let Some(type_name) = edge_type_name.as_deref() {
3893 self.populate_edge_constraint_index(eid, type_name, &properties, &l0);
3894 }
3895 l0.write()
3896 .insert_edge(src_vid, dst_vid, edge_type, eid, properties, edge_type_name)?;
3897
3898 // Dual-write to AdjacencyManager overlay (survives flush).
3899 // Skip for transaction-local L0 -- transaction edges are overlaid separately.
3900 if tx_l0.is_none() {
3901 let version = l0.read().current_version;
3902 self.adjacency_manager
3903 .insert_edge(src_vid, dst_vid, eid, edge_type, version);
3904 }
3905
3906 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3907 self.update_metrics();
3908
3909 if tx_l0.is_none() {
3910 self.check_flush().await?;
3911 }
3912 if start.elapsed().as_millis() > 100 {
3913 log::warn!("Slow insert_edge: {}ms", start.elapsed().as_millis());
3914 }
3915 Ok(())
3916 }
3917
3918 #[instrument(skip(self), level = "trace")]
3919 pub async fn delete_edge(
3920 &self,
3921 eid: Eid,
3922 src_vid: Vid,
3923 dst_vid: Vid,
3924 edge_type: u32,
3925 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
3926 ) -> Result<()> {
3927 let start = std::time::Instant::now();
3928 self.check_write_pressure().await?;
3929 self.check_transaction_memory(tx_l0)?;
3930 self.freeze_for_non_tx_write_if_pinned(tx_l0).await; // C1 (G4)
3931 let l0 = self.resolve_l0(tx_l0);
3932
3933 l0.write().delete_edge(eid, src_vid, dst_vid, edge_type)?;
3934
3935 // Dual-write tombstone to AdjacencyManager overlay.
3936 if tx_l0.is_none() {
3937 let version = l0.read().current_version;
3938 self.adjacency_manager
3939 .add_tombstone(eid, src_vid, dst_vid, edge_type, version);
3940 }
3941 metrics::counter!("uni_l0_buffer_mutations_total").increment(1);
3942 self.update_metrics();
3943
3944 if tx_l0.is_none() {
3945 self.check_flush().await?;
3946 }
3947 if start.elapsed().as_millis() > 100 {
3948 log::warn!("Slow delete_edge: {}ms", start.elapsed().as_millis());
3949 }
3950 Ok(())
3951 }
3952
3953 /// Decide whether a flush should be triggered based on mutation count
3954 /// or elapsed time since the last flush.
3955 ///
3956 /// Extracted from [`Writer::check_flush`] so `commit_transaction_l0` can
3957 /// reuse the decision while bypassing the lock-acquiring entry point
3958 /// (it already holds `flush_lock`).
3959 fn should_flush(&self) -> bool {
3960 let count = self.l0_manager.get_current().read().mutation_count;
3961 if count == 0 {
3962 return false;
3963 }
3964 if count >= self.config.auto_flush_threshold {
3965 return true;
3966 }
3967 if let Some(interval) = self.config.auto_flush_interval
3968 && self.last_flush_time.lock().elapsed() >= interval
3969 && count >= self.config.auto_flush_min_mutations
3970 {
3971 return true;
3972 }
3973 false
3974 }
3975
3976 /// Check if flush should be triggered based on mutation count or time elapsed.
3977 /// This method is called after each write operation and can also be called
3978 /// by a background task for time-based flushing.
3979 pub async fn check_flush(&self) -> Result<()> {
3980 if self.should_flush() {
3981 self.flush_to_l1(None).await?;
3982 }
3983 Ok(())
3984 }
3985
3986 /// Process embeddings for a vertex using labels passed directly.
3987 /// Use this when labels haven't been stored to L0 yet.
3988 async fn process_embeddings_for_labels(
3989 &self,
3990 labels: &[String],
3991 properties: &mut Properties,
3992 ) -> Result<()> {
3993 let label_name = labels.first().map(|s| s.as_str());
3994 self.process_embeddings_impl(label_name, properties).await
3995 }
3996
3997 /// Phase B: if `defer_embeddings` is enabled in `UniConfig` and the
3998 /// vertex has an embedding config that hasn't been satisfied by the
3999 /// caller-provided properties, enqueue the VID in
4000 /// `L0Buffer::pending_embeddings` and return `true`. The caller then
4001 /// skips `process_embeddings_for_labels` and the embedding is computed
4002 /// in a single batched call at flush time via
4003 /// `drain_pending_embeddings`.
4004 ///
4005 /// Returns `false` (caller falls back to today's per-row eager embed)
4006 /// if any of:
4007 /// - the flag is off,
4008 /// - no label has an embedding config,
4009 /// - the user already provided the target property (matches the
4010 /// existing skip-if-present semantics at writer.rs:2727).
4011 ///
4012 /// Trade-off: when deferral is active, in-tx reads of the embedding
4013 /// column return only what was already in storage (or nothing for
4014 /// brand-new vertices). Existing tests that RETURN n.embedding in
4015 /// the same tx as a SET on the source column must run with the flag
4016 /// off; opt in only when no such reads happen between write and
4017 /// commit.
4018 fn try_defer_embedding(
4019 &self,
4020 labels: &[String],
4021 properties: &Properties,
4022 vid: Vid,
4023 tx_l0: Option<&Arc<RwLock<L0Buffer>>>,
4024 ) -> bool {
4025 if !self.config.defer_embeddings {
4026 return false;
4027 }
4028 let Some(label) = labels.first() else {
4029 return false;
4030 };
4031
4032 let schema = self.schema_manager.schema();
4033 let mut has_unsatisfied_cfg = false;
4034 for idx in &schema.indexes {
4035 let unsatisfied = match idx {
4036 IndexDefinition::Vector(v_cfg) => {
4037 v_cfg.label == *label
4038 && v_cfg.embedding_config.is_some()
4039 && !properties.contains_key(&v_cfg.property)
4040 }
4041 IndexDefinition::Sparse(s_cfg) => {
4042 s_cfg.label == *label
4043 && s_cfg.embedding_config.is_some()
4044 && !properties.contains_key(&s_cfg.property)
4045 }
4046 _ => false,
4047 };
4048 if unsatisfied {
4049 has_unsatisfied_cfg = true;
4050 break;
4051 }
4052 }
4053 if !has_unsatisfied_cfg {
4054 return false;
4055 }
4056
4057 let l0 = self.resolve_l0(tx_l0);
4058 let mut guard = l0.write();
4059 guard.pending_embeddings.insert(vid, label.clone());
4060 true
4061 }
4062
4063 /// Drain `pending_embeddings` from the rotated old-L0 right before
4064 /// `flush_stream_l1` reads it. Groups by label, issues one batched
4065 /// `process_embeddings_for_batch` call per label, and writes the
4066 /// resulting embedding vectors into each VID's `vertex_properties`
4067 /// map. After this returns, the flush proceeds against an L0 that
4068 /// looks no different from one whose embeddings were generated
4069 /// per-row at insert.
4070 ///
4071 /// Idempotent: a VID whose embedding was already materialized
4072 /// (e.g., by on-demand read paths in a future Phase B revision) is
4073 /// detected via `properties.contains_key(target_prop)` inside
4074 /// `process_embeddings_for_batch` (writer.rs:~2650), so re-running
4075 /// the drain is safe.
4076 async fn drain_pending_embeddings(&self, old_l0_arc: &Arc<RwLock<L0Buffer>>) -> Result<()> {
4077 let by_label: HashMap<String, Vec<Vid>> = {
4078 let guard = old_l0_arc.read();
4079 if guard.pending_embeddings.is_empty() {
4080 return Ok(());
4081 }
4082 let mut m: HashMap<String, Vec<Vid>> = HashMap::new();
4083 for (vid, label) in &guard.pending_embeddings {
4084 m.entry(label.clone()).or_default().push(*vid);
4085 }
4086 m
4087 };
4088
4089 for (label, vids) in by_label {
4090 let mut properties_batch: Vec<Properties> = {
4091 let guard = old_l0_arc.read();
4092 vids.iter()
4093 .map(|vid| {
4094 guard
4095 .vertex_properties
4096 .get(vid)
4097 .cloned()
4098 .unwrap_or_default()
4099 })
4100 .collect()
4101 };
4102
4103 self.process_embeddings_for_batch(std::slice::from_ref(&label), &mut properties_batch)
4104 .await?;
4105
4106 let mut guard = old_l0_arc.write();
4107 for (vid, props) in vids.iter().zip(properties_batch) {
4108 let target = guard.vertex_properties.entry(*vid).or_default();
4109 for (k, v) in props {
4110 target.insert(k, v);
4111 }
4112 guard.pending_embeddings.remove(vid);
4113 }
4114 }
4115 Ok(())
4116 }
4117
4118 /// Materialise MUVERA FDE columns for the about-to-flush L0. Mirrors
4119 /// [`Self::drain_pending_embeddings`]: for each MUVERA index, compute the derived
4120 /// Fixed-Dimensional Encoding from each row's source multi-vector and inject it into
4121 /// that row's `vertex_properties` (so the normal column builder writes the
4122 /// `__fde_*` column with no hot-path change). For partial-write rows that touched the
4123 /// source column, the derived column is added to `vertex_partial_keys` so the partial
4124 /// MergeInsert batch carries the recomputed FDE (avoids staleness on `SET`).
4125 ///
4126 /// No-op when the schema has no MUVERA index. Unlike auto-embed, the FDE is a pure,
4127 /// deterministic, in-process transform — no runtime/embedding service needed.
4128 fn materialize_fde_columns(&self, old_l0_arc: &Arc<RwLock<L0Buffer>>) -> Result<()> {
4129 let schema = self.schema_manager.schema();
4130 let specs = crate::storage::muvera_index::fde_specs(&schema);
4131 if specs.is_empty() {
4132 return Ok(());
4133 }
4134 let mut guard = old_l0_arc.write();
4135 for spec in &specs {
4136 let encoder = uni_common::muvera::FdeEncoder::new(&spec.params)
4137 .map_err(|e| anyhow!("MUVERA index '{}': {e}", spec.index_name))?;
4138 // VIDs of this label currently in L0 (collect first to avoid a borrow
4139 // conflict with the per-row mutation below).
4140 let vids: Vec<Vid> = guard
4141 .vertex_labels
4142 .iter()
4143 .filter(|(_, labels)| labels.contains(&spec.label))
4144 .map(|(vid, _)| *vid)
4145 .collect();
4146 for vid in vids {
4147 // Decode the source multi-vector tokens (borrow ends before the mutation).
4148 let tokens = match guard
4149 .vertex_properties
4150 .get(&vid)
4151 .and_then(|p| p.get(&spec.source_prop))
4152 {
4153 Some(v) => crate::storage::muvera_index::value_to_multivec(v),
4154 None => continue, // source absent → leave the FDE column NULL
4155 };
4156 // A source token with the wrong dimension makes `encode_doc` error.
4157 // Skipping the row (leaving the FDE column NULL → ranks last under the
4158 // mandatory Dot metric; harmless) keeps one malformed document from
4159 // wedging *every* flush of this label (issue #96). Post-#137 the write
4160 // paths reject wrong-dimension tokens and WAL replay nulls pre-fix
4161 // ones, so this skip is defense-in-depth for values that predate
4162 // dimension enforcement.
4163 let fde = match encoder.encode_doc(&tokens) {
4164 Ok(fde) => fde,
4165 Err(e) => {
4166 tracing::warn!(
4167 index = %spec.index_name,
4168 vid = ?vid,
4169 error = %e,
4170 "muvera.fde.skip_malformed: leaving FDE NULL for a source \
4171 multi-vector that failed encoding"
4172 );
4173 continue;
4174 }
4175 };
4176 if let Some(props) = guard.vertex_properties.get_mut(&vid) {
4177 props.insert(spec.derived_col.clone(), Value::Vector(fde));
4178 }
4179 if let Some(touched) = guard.vertex_partial_keys.get_mut(&vid)
4180 && touched.contains(&spec.source_prop)
4181 {
4182 touched.insert(spec.derived_col.clone());
4183 }
4184 }
4185 }
4186 Ok(())
4187 }
4188
4189 /// Validates auto-embed dense output width against the declared `VECTOR(dim)` targets.
4190 ///
4191 /// A schema/model dimension mismatch fails here with a message naming the embedding
4192 /// alias, instead of surfacing later as a generic dimension error — or, on the
4193 /// deferred path, as a flush failure (issue #137).
4194 fn check_dense_embed_dims(
4195 schema: &uni_common::core::schema::Schema,
4196 label: &str,
4197 alias: &str,
4198 targets: &[String],
4199 actual: usize,
4200 ) -> Result<()> {
4201 use uni_common::core::schema::DataType;
4202 for target in targets {
4203 let declared = schema
4204 .properties
4205 .get(label)
4206 .and_then(|props| props.get(target))
4207 .map(|meta| &meta.r#type);
4208 if let Some(DataType::Vector { dimensions }) = declared
4209 && actual != *dimensions
4210 {
4211 return Err(anyhow!(
4212 "auto-embed: model alias '{alias}' produced a {actual}-dimensional \
4213 embedding but '{label}.{target}' is declared VECTOR({dimensions}); \
4214 fix the schema dimensions or the embedding model"
4215 ));
4216 }
4217 }
4218 Ok(())
4219 }
4220
4221 /// Validates auto-embed multi-vector output against declared `List(Vector(dim))` targets.
4222 ///
4223 /// Multi-vector sibling of [`Self::check_dense_embed_dims`]: every emitted token must
4224 /// match the declared per-token dimensions.
4225 fn check_multi_embed_dims(
4226 schema: &uni_common::core::schema::Schema,
4227 label: &str,
4228 alias: &str,
4229 targets: &[String],
4230 tokens: &[Vec<f32>],
4231 ) -> Result<()> {
4232 use uni_common::core::schema::DataType;
4233 for target in targets {
4234 let declared = schema
4235 .properties
4236 .get(label)
4237 .and_then(|props| props.get(target))
4238 .map(|meta| &meta.r#type);
4239 if let Some(DataType::List(inner)) = declared
4240 && let DataType::Vector { dimensions } = inner.as_ref()
4241 && let Some(bad) = tokens.iter().find(|tok| tok.len() != *dimensions)
4242 {
4243 return Err(anyhow!(
4244 "auto-embed: model alias '{alias}' produced a token with {} dimensions \
4245 but '{label}.{target}' is declared as a multi-vector of \
4246 {dimensions}-dimensional tokens; fix the schema dimensions or the \
4247 embedding model",
4248 bad.len()
4249 ));
4250 }
4251 }
4252 Ok(())
4253 }
4254
4255 /// Process embeddings for a batch of vertices efficiently.
4256 ///
4257 /// Groups vertices by embedding config and makes batched API calls to the
4258 /// embedding service instead of calling once per vertex.
4259 ///
4260 /// # Performance
4261 /// For N vertices with embedding config:
4262 /// - Old approach: N API calls to embedding service
4263 /// - New approach: 1 API call per embedding config (usually 1 total)
4264 async fn process_embeddings_for_batch(
4265 &self,
4266 labels: &[String],
4267 properties_batch: &mut [Properties],
4268 ) -> Result<()> {
4269 let Some(label) = labels.first().map(|s| s.as_str()) else {
4270 return Ok(());
4271 };
4272 let schema = self.schema_manager.schema();
4273
4274 // Group auto-embed targets by (alias, source). A group with both a dense Vector and a
4275 // multi-vector List<Vector> column is a single-pass hybrid source: one inference fills
4276 // both. Non-mixed groups use the dense / multi-vector embedder as before.
4277 let groups = collect_embed_groups(&schema, label);
4278 if groups.is_empty() {
4279 return Ok(());
4280 }
4281
4282 for (key, group) in groups {
4283 let alias = &key.0;
4284 let want_dense = !group.dense.is_empty();
4285 let want_multi = !group.multi.is_empty();
4286 let want_sparse = !group.sparse.is_empty();
4287
4288 // A row needs this group's inference if it has the source text and is still missing
4289 // at least one of the group's target columns (user-supplied values are preserved).
4290 let mut input_texts: Vec<String> = Vec::new();
4291 let mut needs: Vec<usize> = Vec::new();
4292 for (idx, properties) in properties_batch.iter().enumerate() {
4293 let all_present = group
4294 .dense
4295 .iter()
4296 .chain(group.multi.iter())
4297 .chain(group.sparse.iter())
4298 .all(|t| properties.contains_key(t));
4299 if all_present {
4300 continue;
4301 }
4302 let mut inputs = Vec::new();
4303 for src in &group.source_properties {
4304 if let Some(val) = properties.get(src)
4305 && let Some(s) = val.as_str()
4306 {
4307 inputs.push(s.to_string());
4308 }
4309 }
4310 if inputs.is_empty() {
4311 continue;
4312 }
4313 let text = inputs.join(" ");
4314 let text = match &group.document_prefix {
4315 Some(prefix) => format!("{prefix}{text}"),
4316 None => text,
4317 };
4318 input_texts.push(text);
4319 needs.push(idx);
4320 }
4321 if input_texts.is_empty() {
4322 continue;
4323 }
4324
4325 let runtime = self
4326 .xervo_runtime
4327 .get()
4328 .ok_or_else(|| anyhow!("Uni-Xervo runtime not configured for auto-embedding"))?;
4329 let input_refs: Vec<&str> = input_texts.iter().map(|s| s.as_str()).collect();
4330 let (dense, multi, sparse) = embed_group(
4331 runtime,
4332 alias,
4333 &input_refs,
4334 want_dense,
4335 want_multi,
4336 want_sparse,
4337 )
4338 .await?;
4339
4340 // Reject a schema/model width mismatch before inserting anything — on the
4341 // deferred (flush-time) path this is the only guard between the model output
4342 // and the fail-closed column builders (issue #137).
4343 if let Some(d) = dense.as_ref() {
4344 for vec in d {
4345 Self::check_dense_embed_dims(&schema, label, alias, &group.dense, vec.len())?;
4346 }
4347 }
4348 if let Some(m) = multi.as_ref() {
4349 for tokens in m {
4350 Self::check_multi_embed_dims(&schema, label, alias, &group.multi, tokens)?;
4351 }
4352 }
4353
4354 for (i, &row) in needs.iter().enumerate() {
4355 if let Some(vec) = dense.as_ref().and_then(|d| d.get(i)) {
4356 let vals: Vec<Value> = vec.iter().map(|f| Value::Float(*f as f64)).collect();
4357 for t in &group.dense {
4358 if !properties_batch[row].contains_key(t) {
4359 properties_batch[row].insert(t.clone(), Value::List(vals.clone()));
4360 }
4361 }
4362 }
4363 if let Some(tokens) = multi.as_ref().and_then(|m| m.get(i)) {
4364 let mv = multivec_to_value(tokens);
4365 for t in &group.multi {
4366 if !properties_batch[row].contains_key(t) {
4367 properties_batch[row].insert(t.clone(), mv.clone());
4368 }
4369 }
4370 }
4371 if let Some(pairs) = sparse.as_ref().and_then(|s| s.get(i)) {
4372 let sv = sparse_pairs_to_value(pairs);
4373 for t in &group.sparse {
4374 if !properties_batch[row].contains_key(t) {
4375 properties_batch[row].insert(t.clone(), sv.clone());
4376 }
4377 }
4378 }
4379 }
4380 }
4381
4382 Ok(())
4383 }
4384
4385 async fn process_embeddings_impl(
4386 &self,
4387 label_name: Option<&str>,
4388 properties: &mut Properties,
4389 ) -> Result<()> {
4390 let schema = self.schema_manager.schema();
4391
4392 let Some(label) = label_name else {
4393 return Ok(());
4394 };
4395
4396 // Same (alias, source) grouping as the deferred path: a mixed dense + multi-vector
4397 // group is a single-pass hybrid source (one inference fills both columns).
4398 let groups = collect_embed_groups(&schema, label);
4399 if groups.is_empty() {
4400 log::info!("No embedding config found for label {}", label);
4401 return Ok(());
4402 }
4403
4404 for (key, group) in groups {
4405 let alias = &key.0;
4406 // Skip if every target already present (user-supplied values win).
4407 if group
4408 .dense
4409 .iter()
4410 .chain(group.multi.iter())
4411 .chain(group.sparse.iter())
4412 .all(|t| properties.contains_key(t))
4413 {
4414 continue;
4415 }
4416
4417 let mut inputs = Vec::new();
4418 for src in &group.source_properties {
4419 if let Some(val) = properties.get(src)
4420 && let Some(s) = val.as_str()
4421 {
4422 inputs.push(s.to_string());
4423 }
4424 }
4425 if inputs.is_empty() {
4426 continue;
4427 }
4428 let text = inputs.join(" ");
4429 let text = match &group.document_prefix {
4430 Some(prefix) => format!("{prefix}{text}"),
4431 None => text,
4432 };
4433
4434 let runtime = self
4435 .xervo_runtime
4436 .get()
4437 .ok_or_else(|| anyhow!("Uni-Xervo runtime not configured for auto-embedding"))?;
4438 let want_dense = !group.dense.is_empty();
4439 let want_multi = !group.multi.is_empty();
4440 let want_sparse = !group.sparse.is_empty();
4441 let (dense, multi, sparse) = embed_group(
4442 runtime,
4443 alias,
4444 &[text.as_str()],
4445 want_dense,
4446 want_multi,
4447 want_sparse,
4448 )
4449 .await?;
4450
4451 if let Some(vec) = dense.as_ref().and_then(|d| d.first()) {
4452 // Alias-naming guard for the schema/model width mismatch (issue #137).
4453 Self::check_dense_embed_dims(&schema, label, alias, &group.dense, vec.len())?;
4454 let vals: Vec<Value> = vec.iter().map(|f| Value::Float(*f as f64)).collect();
4455 for t in &group.dense {
4456 if !properties.contains_key(t) {
4457 properties.insert(t.clone(), Value::List(vals.clone()));
4458 }
4459 }
4460 }
4461 if let Some(tokens) = multi.as_ref().and_then(|m| m.first()) {
4462 Self::check_multi_embed_dims(&schema, label, alias, &group.multi, tokens)?;
4463 let mv = multivec_to_value(tokens);
4464 for t in &group.multi {
4465 if !properties.contains_key(t) {
4466 properties.insert(t.clone(), mv.clone());
4467 }
4468 }
4469 }
4470 if let Some(pairs) = sparse.as_ref().and_then(|s| s.first()) {
4471 let sv = sparse_pairs_to_value(pairs);
4472 for t in &group.sparse {
4473 if !properties.contains_key(t) {
4474 properties.insert(t.clone(), sv.clone());
4475 }
4476 }
4477 }
4478 }
4479 Ok(())
4480 }
4481
4482 /// Flushes the current in-memory L0 buffer to L1 storage.
4483 ///
4484 /// # Lock Ordering
4485 ///
4486 /// To prevent deadlocks, locks must be acquired in the following order:
4487 /// 1. `Writer` lock (held by caller via outer `Arc<RwLock<Writer>>`; removed in Phase 4)
4488 /// 2. `flush_lock` (acquired by this entry point; held across the whole flush)
4489 /// 3. `L0Manager` lock (via `begin_flush` / `get_current`)
4490 /// 4. `L0Buffer` lock (individual buffer RWLocks)
4491 /// 5. `Index` / `Storage` locks (during actual flush)
4492 ///
4493 /// Callers that already hold `flush_lock` (today only `commit_transaction_l0`)
4494 /// must call `flush_inline_under_lock` (private) directly to avoid a re-entrant
4495 /// `tokio::sync::Mutex` deadlock — see concurrent_writer.md §5.5.
4496 pub async fn flush_to_l1(&self, name: Option<String>) -> Result<String> {
4497 // Drain any in-flight async flushes first. `flush_to_l1` is a
4498 // SYNCHRONIZATION BARRIER — callers (test fixtures, fork
4499 // setup, shutdown paths) rely on it as "all writes are now
4500 // durably in Lance". Without the drain, an async stream from
4501 // a recent commit might still be writing to Lance when
4502 // `flush_to_l1` returns, leaving a window where forks branch
4503 // off pre-write Lance state and lose data.
4504 if let Some(coord) = self.flush_coordinator.as_ref() {
4505 let _ = coord.drain(self.config.drop_fork_drain_timeout).await;
4506 }
4507 let _flush_lock_guard = self.flush_lock.lock().await;
4508 self.flush_inline_under_lock(name).await
4509 }
4510
4511 /// Flush L0→L1 and capture the fork point under one held `flush_lock`.
4512 ///
4513 /// Drains in-flight async flushes, takes `flush_lock`, runs the inline
4514 /// flush, and then — still holding the lock — reads the allocator
4515 /// high-water marks and each existing candidate dataset's Lance
4516 /// version. Capturing under the held lock is what makes the fork point
4517 /// atomic: no concurrent commit can advance the allocator and no
4518 /// concurrent flush can advance a dataset tip between the flush and the
4519 /// reads. See [`ForkPoint`].
4520 ///
4521 /// `candidate_dataset_names` are resolved to `{base_uri}/{name}.lance`;
4522 /// names with no `.lance` directory on disk are skipped (returned map
4523 /// has no entry for them), matching the fork branch loop's existence
4524 /// check.
4525 ///
4526 /// # Errors
4527 /// Propagates flush failures from `flush_inline_under_lock` and any
4528 /// per-dataset version read failure from `lance_branch::current_version`.
4529 ///
4530 /// # Deadlocks
4531 /// Must not be called by a task already holding `flush_lock` (e.g.
4532 /// `commit_transaction_l0`); the `tokio::sync::Mutex` is not reentrant.
4533 /// Fork creation never holds the lock, so the sole call site is safe.
4534 pub async fn flush_and_capture_fork_point(
4535 &self,
4536 candidate_dataset_names: &[String],
4537 parent_branches: &BTreeMap<String, String>,
4538 ) -> Result<ForkPoint> {
4539 if let Some(coord) = self.flush_coordinator.as_ref() {
4540 let _ = coord.drain(self.config.drop_fork_drain_timeout).await;
4541 }
4542 let _flush_lock_guard = self.flush_lock.lock().await;
4543 self.flush_inline_under_lock(None).await?;
4544
4545 // Still under `flush_lock`: capture the allocator HWM, the MVCC
4546 // version HWM, and every existing dataset's Lance version so
4547 // nothing can interleave.
4548 let (vid_hwm, eid_hwm) = self.allocator.current_hwm().await;
4549 // The parent's current L0 version is the largest `_version` any
4550 // inherited row can carry (flushed or in-memory). A fork bootstraps
4551 // its version floor to this so a fork tx read still sees inherited
4552 // rows. Cheap read lock; no buffer clone.
4553 let version_hwm = self.l0_manager.get_current().read().current_version;
4554
4555 let branching = self.storage.backend().branching().ok_or_else(|| {
4556 anyhow::anyhow!(
4557 "storage backend does not support fork branching; \
4558 cannot capture a fork point"
4559 )
4560 })?;
4561 let mut dataset_versions = BTreeMap::new();
4562 let mut parent_branch_versions = BTreeMap::new();
4563 for name in candidate_dataset_names {
4564 if !branching.table_exists(name).await? {
4565 continue;
4566 }
4567 let version = branching.current_version(name).await?;
4568 dataset_versions.insert(name.clone(), version);
4569
4570 // For a nested fork, also capture the parent branch's tip under the
4571 // lock — that is the version the child must branch from, and it
4572 // advances independently of `main`. Reading it here (still holding
4573 // `flush_lock`, after `flush_inline_under_lock`) is what makes the
4574 // nested fork's branch point atomic w.r.t. a concurrent parent
4575 // commit+flush (the D2 fix).
4576 if let Some(parent_branch) = parent_branches.get(name) {
4577 let branch_version = branching
4578 .current_version_on_branch(name, parent_branch)
4579 .await?;
4580 parent_branch_versions.insert(name.clone(), branch_version);
4581 }
4582 }
4583
4584 Ok(ForkPoint {
4585 vid_hwm,
4586 eid_hwm,
4587 dataset_versions,
4588 version_hwm,
4589 parent_branch_versions,
4590 })
4591 }
4592
4593 /// Async-flush entry point: rotate under `flush_lock`, release the
4594 /// lock, then submit the stream phase to the [`FlushCoordinator`].
4595 /// Returns a [`FlushTicket`](crate::runtime::flush_coordinator::FlushTicket)
4596 /// that resolves when finalize completes.
4597 ///
4598 /// Errors if `config.async_flush_enabled = false` (the coordinator
4599 /// is `None` in that case — see `flush_coordinator` field doc).
4600 pub async fn flush_to_l1_async(
4601 self: &Arc<Self>,
4602 name: Option<String>,
4603 ) -> Result<crate::runtime::flush_coordinator::FlushTicket> {
4604 let coord = self
4605 .flush_coordinator
4606 .as_ref()
4607 .ok_or_else(|| anyhow!("async flush not enabled (config.async_flush_enabled=false)"))?
4608 .clone();
4609 // 1. Acquire permit FIRST (outside flush_lock) so we don't
4610 // introduce a permit-while-holding-flush-lock convoy.
4611 let permit = coord.acquire_permit().await?;
4612 // 2. Rotate under flush_lock (µs work), then allocate the rotate seq
4613 // and bump pending ONLY after the rotate succeeds (Bug #3). A failed
4614 // rotate (the `?` below) must consume neither: the finalizer
4615 // advances in strictly consecutive seq order and only decrements
4616 // pending on finalize, so a leaked seq/pending would wedge it
4617 // forever. The seq is allocated under `flush_lock`, immediately
4618 // after the rotate and before the guard drops, so concurrent rotates
4619 // keep seq order == rotation order, and the seq is unused until
4620 // submit. On the `?` error path the permit drops, freeing the slot.
4621 let (
4622 RotateOutput {
4623 old_l0_arc,
4624 wal_lsn,
4625 current_version,
4626 flush_in_progress_guard,
4627 },
4628 seq,
4629 ) = {
4630 let _flush_lock_guard = self.flush_lock.lock().await;
4631 let rotate_out = self.flush_l0_rotate().await?;
4632 let seq = coord.next_rotate_seq();
4633 coord.note_pending();
4634 (rotate_out, seq)
4635 };
4636 // 3. Build the coordinator's RotatedFlush. parent_manifest is the
4637 // cached_manifest snapshot at this moment.
4638 let parent_manifest = self.cached_manifest.lock().clone();
4639 let rotated = RotatedFlush {
4640 seq,
4641 old_l0_arc: old_l0_arc.clone(),
4642 wal_lsn,
4643 current_version,
4644 name: name.clone(),
4645 parent_manifest,
4646 permit,
4647 flush_in_progress_guard,
4648 };
4649 // 4. Spawn the stream phase via the coordinator. The closure
4650 // captures Arc<Writer> transiently — drops when stream
4651 // completes (bounded, ~50-500 ms).
4652 let writer = self.clone();
4653 let ticket = coord.submit_for_stream(rotated, move |old_l0, wal, ver, n| async move {
4654 let outcome = writer.flush_stream_l1(old_l0, wal, ver, n).await?;
4655 Ok(crate::runtime::flush_coordinator::FlushOutcome {
4656 new_manifest: outcome.manifest,
4657 snapshot_id: outcome.snapshot_id,
4658 })
4659 });
4660 Ok(ticket)
4661 }
4662
4663 /// Phase A+B+C of the flush: flush the WAL, rotate L0 (so the
4664 /// to-be-flushed buffer moves to `pending_flush` and a fresh L0 takes
4665 /// its place), and hand off the WAL to the new L0.
4666 ///
4667 /// Runs in microseconds. Must be called under `flush_lock` (the caller
4668 /// is responsible). The returned [`RotateOutput`] carries everything
4669 /// the subsequent stream + finalize phases need; in particular the
4670 /// [`FlushInProgressGuard`] is bound to the return value so it stays
4671 /// alive for the full flush lifetime — including any future async
4672 /// path where stream runs on a spawned task.
4673 async fn flush_l0_rotate(&self) -> Result<RotateOutput> {
4674 // Acquire the in-progress counter BEFORE any heavy work. The
4675 // guard lives on RotateOutput; dropping RotateOutput drops the
4676 // guard, so the counter goes back to zero exactly when the flush
4677 // is fully done.
4678 let flush_in_progress_guard = FlushInProgressGuard::new(&self.storage);
4679
4680 // A. Flush WAL BEFORE rotating L0. If WAL flush fails, the
4681 // current L0 is still active and mutations are retained in
4682 // memory until restart/retry.
4683 let wal_for_truncate = {
4684 let current_l0 = self.l0_manager.get_current();
4685 let l0_guard = current_l0.read();
4686 l0_guard.wal.clone()
4687 };
4688 // Test-only seam (no-op without the `failpoints` feature): inject a
4689 // WAL-flush failure here to drive the "failed async rotate wedges the
4690 // finalizer" regression (Bug #3). When configured to "return" it makes
4691 // `flush_l0_rotate` return Err exactly as a real WAL-flush failure would.
4692 fail::fail_point!("flush::rotate-fail", |_| {
4693 Err(anyhow!("flush::rotate-fail injected WAL-flush failure"))
4694 });
4695 let wal_lsn = if let Some(ref w) = wal_for_truncate {
4696 w.flush().await?
4697 } else {
4698 0
4699 };
4700
4701 // B. Begin flush: rotate L0 and keep old L0 visible to reads via
4702 // pending_flush until complete_flush is called by finalize.
4703 let old_l0_arc = self.l0_manager.begin_flush(0, None);
4704 metrics::counter!("uni_l0_buffer_rotations_total").increment(1);
4705
4706 // C. WAL handoff: record wal_lsn on old L0, transfer WAL handle
4707 // and current_version to the new L0.
4708 let current_version;
4709 {
4710 let mut old_l0_guard = old_l0_arc.write();
4711 current_version = old_l0_guard.current_version;
4712 old_l0_guard.wal_lsn_at_flush = wal_lsn;
4713 let wal = old_l0_guard.wal.take();
4714 let new_l0_arc = self.l0_manager.get_current();
4715 let mut new_l0_guard = new_l0_arc.write();
4716 new_l0_guard.wal = wal;
4717 new_l0_guard.current_version = current_version;
4718 // The new active buffer starts accumulating strictly above the
4719 // rotation point: everything <= `wal_lsn` is now owned by the old
4720 // (being-flushed) buffer or earlier. This start watermark is the
4721 // floor that keeps WAL truncation / checkpoint publication from
4722 // discarding this buffer's data if its eventual flush fails.
4723 new_l0_guard.wal_lsn_at_start = wal_lsn;
4724 }
4725
4726 Ok(RotateOutput {
4727 old_l0_arc,
4728 wal_lsn,
4729 current_version,
4730 flush_in_progress_guard,
4731 })
4732 }
4733
4734 /// Phases D, E, F, G of the flush: L1 collect, orphan resolve,
4735 /// manifest seed, Lance writes. Reads from `old_l0_arc` (kept in
4736 /// pending_flush by Phase B); writes append-only Lance datasets; does
4737 /// NOT call save_snapshot / set_latest_snapshot — those are
4738 /// finalize's job, so the manifest doesn't get published until the
4739 /// next phase.
4740 ///
4741 /// Today takes `&self`; in a follow-up commit this becomes a
4742 /// static `Send + 'static` function over `SharedFlushCtx` so it can
4743 /// run on a spawned task while concurrent commits proceed.
4744 async fn flush_stream_l1(
4745 &self,
4746 old_l0_arc: Arc<RwLock<L0Buffer>>,
4747 wal_lsn: u64,
4748 current_version: u64,
4749 name: Option<String>,
4750 ) -> Result<FlushOutcome> {
4751 // Test-only seam (no-op without the `failpoints` feature): the rotate
4752 // (begin_flush) already moved the to-be-flushed buffer onto
4753 // pending_flush and installed a fresh empty current buffer, but the
4754 // rotated rows are NOT yet durable in Lance. Pausing here holds that
4755 // window open to drive the unique-constraint-hole regression
4756 // (Bug #9 Mechanism A).
4757 fail::fail_point!("flush::after-rotate-before-lance");
4758
4759 // Test-only (issue #132): model a STALLED flush stream. A lost-wakeup in
4760 // the sparse/multivec Lance read-modify-write is an *async* future that
4761 // never resolves while the worker stays idle. The `fail` crate's
4762 // `sleep`/`pause` actions block the worker thread synchronously — which
4763 // both mis-models a lost-wakeup and defeats `tokio::time::timeout` (a
4764 // timeout can't cancel a blocking call, and once the inner future
4765 // completes `timeout` returns `Ok`). So stall at an async `.await`
4766 // instead, letting the flush-stream timeout convert it into a data-safe
4767 // failure. Arm with `flush::stream-async-stall = 1*return` (fires once).
4768 #[cfg(feature = "failpoints")]
4769 if fail::eval("flush::stream-async-stall", |_| ()).is_some() {
4770 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
4771 }
4772
4773 // Phase B: materialize any deferred embeddings before column
4774 // extraction. No-op when `defer_embeddings` is off (the set will
4775 // be empty). On-demand reads of the embedding column are a TODO
4776 // for a future revision (see UniConfig::defer_embeddings docs).
4777 self.drain_pending_embeddings(&old_l0_arc).await?;
4778
4779 // Materialise MUVERA FDE columns from each row's source multi-vector (pure/sync;
4780 // no-op without a MUVERA index). Runs after embeddings so a row can be both
4781 // auto-embedded and FDE-encoded.
4782 self.materialize_fde_columns(&old_l0_arc)?;
4783
4784 let schema = self.schema_manager.schema();
4785 // 2. Acquire Read lock on Old L0 for flushing
4786 let mut entries_by_type: HashMap<u32, Vec<L1Entry>> = HashMap::new();
4787 // (Vid, labels, properties, deleted, version)
4788 type VertexEntry = (Vid, Vec<String>, Properties, bool, u64);
4789 let mut vertices_by_label: HashMap<u16, Vec<VertexEntry>> = HashMap::new();
4790 // Partial-column updates (Lance MergeInsert path). Per-VID tuple:
4791 // (vid, full L0 properties map, version, set of keys to update).
4792 // Only the keys in the HashSet are emitted to the partial source;
4793 // the full props map is retained so the per-row column extractor
4794 // can read each touched key's value.
4795 type PartialEntry = (Vid, Properties, u64, std::collections::HashSet<String>);
4796 let mut partial_by_label: HashMap<u16, Vec<PartialEntry>> = HashMap::new();
4797 // DELETE-via-MergeInsert (Round-12 §B): tombstones flush as a
4798 // partial source with just `_vid`, `_deleted=true`, `_version`,
4799 // `_updated_at`. Skips the wide-row Append payload that adds
4800 // nothing on a soft-delete.
4801 let mut tombstones_by_label: HashMap<u16, Vec<(Vid, u64)>> = HashMap::new();
4802 let mut main_vertex_tombstones: Vec<(Vid, u64)> = Vec::new();
4803 // Collect vertex timestamps from L0 for flushing to storage
4804 let mut vertex_created_at: HashMap<Vid, i64> = HashMap::new();
4805 let mut vertex_updated_at: HashMap<Vid, i64> = HashMap::new();
4806 // Track tombstones missing labels for storage query fallback
4807 let mut orphaned_tombstones: Vec<(Vid, u64)> = Vec::new();
4808
4809 {
4810 let old_l0 = old_l0_arc.read();
4811
4812 // 1. Collect all edges and tombstones from L0
4813 for edge in old_l0.graph.edges() {
4814 let properties = old_l0
4815 .edge_properties
4816 .get(&edge.eid)
4817 .cloned()
4818 .unwrap_or_default();
4819 let version = old_l0.edge_versions.get(&edge.eid).copied().unwrap_or(0);
4820
4821 // Get timestamps from L0 buffer (populated during insert)
4822 let created_at = old_l0.edge_created_at.get(&edge.eid).copied();
4823 let updated_at = old_l0.edge_updated_at.get(&edge.eid).copied();
4824
4825 entries_by_type
4826 .entry(edge.edge_type)
4827 .or_default()
4828 .push(L1Entry {
4829 src_vid: edge.src_vid,
4830 dst_vid: edge.dst_vid,
4831 eid: edge.eid,
4832 op: Op::Insert,
4833 version,
4834 properties,
4835 created_at,
4836 updated_at,
4837 });
4838 }
4839
4840 // From tombstones
4841 for tombstone in old_l0.tombstones.values() {
4842 let version = old_l0
4843 .edge_versions
4844 .get(&tombstone.eid)
4845 .copied()
4846 .unwrap_or(0);
4847 // Get timestamps - for deletes, updated_at reflects deletion time
4848 let created_at = old_l0.edge_created_at.get(&tombstone.eid).copied();
4849 let updated_at = old_l0.edge_updated_at.get(&tombstone.eid).copied();
4850
4851 entries_by_type
4852 .entry(tombstone.edge_type)
4853 .or_default()
4854 .push(L1Entry {
4855 src_vid: tombstone.src_vid,
4856 dst_vid: tombstone.dst_vid,
4857 eid: tombstone.eid,
4858 op: Op::Delete,
4859 version,
4860 properties: HashMap::new(),
4861 created_at,
4862 updated_at,
4863 });
4864 }
4865
4866 // 1b. Collect vertices by label (using vertex_labels from L0)
4867 //
4868 // Helper: fan-out a single vertex entry into per-label buckets.
4869 // Each per-label table row carries the full label set so multi-label
4870 // info is preserved after flush.
4871 let push_vertex_to_labels =
4872 |vid: Vid,
4873 all_labels: &[String],
4874 props: Properties,
4875 deleted: bool,
4876 version: u64,
4877 out: &mut HashMap<u16, Vec<VertexEntry>>| {
4878 for label in all_labels {
4879 if let Some(label_id) = schema.label_id_by_name(label) {
4880 out.entry(label_id).or_default().push((
4881 vid,
4882 all_labels.to_vec(),
4883 props.clone(),
4884 deleted,
4885 version,
4886 ));
4887 }
4888 }
4889 };
4890
4891 for (vid, props) in &old_l0.vertex_properties {
4892 let version = old_l0.vertex_versions.get(vid).copied().unwrap_or(0);
4893 // Collect timestamps for this vertex
4894 if let Some(&ts) = old_l0.vertex_created_at.get(vid) {
4895 vertex_created_at.insert(*vid, ts);
4896 }
4897 if let Some(&ts) = old_l0.vertex_updated_at.get(vid) {
4898 vertex_updated_at.insert(*vid, ts);
4899 }
4900 if let Some(labels) = old_l0.vertex_labels.get(vid) {
4901 // Partial-write routing: when this VID was last
4902 // touched via `insert_vertex_partial` AND the
4903 // partial_lance_writes flag is on, send only the
4904 // touched columns to a MergeInsert batch. Otherwise
4905 // (CREATE, MERGE-ON-CREATE, full-replace SET, DELETE
4906 // — or flag off) use the existing full-row Append.
4907 let is_partial = self.config.partial_lance_writes
4908 && old_l0.vertex_partial_keys.contains_key(vid);
4909 if is_partial {
4910 if let Some(touched) = old_l0.vertex_partial_keys.get(vid) {
4911 for label in labels {
4912 if let Some(label_id) = schema.label_id_by_name(label) {
4913 partial_by_label.entry(label_id).or_default().push((
4914 *vid,
4915 props.clone(),
4916 version,
4917 touched.clone(),
4918 ));
4919 }
4920 }
4921 }
4922 } else {
4923 push_vertex_to_labels(
4924 *vid,
4925 labels,
4926 props.clone(),
4927 false,
4928 version,
4929 &mut vertices_by_label,
4930 );
4931 }
4932 }
4933 }
4934 for &vid in &old_l0.vertex_tombstones {
4935 let version = old_l0.vertex_versions.get(&vid).copied().unwrap_or(0);
4936 if let Some(&ts) = old_l0.vertex_updated_at.get(&vid) {
4937 vertex_updated_at.insert(vid, ts);
4938 }
4939 if let Some(labels) = old_l0.vertex_labels.get(&vid) {
4940 // Round-12 §B: tombstones flush via Lance MergeInsert
4941 // (just `_vid`, `_deleted=true`, `_version`,
4942 // `_updated_at`) — skipping the wide-row Append.
4943 // Unconditional (no `partial_lance_writes` gating);
4944 // tombstone Append carries no useful payload.
4945 for label in labels {
4946 if let Some(label_id) = schema.label_id_by_name(label) {
4947 tombstones_by_label
4948 .entry(label_id)
4949 .or_default()
4950 .push((vid, version));
4951 }
4952 }
4953 } else {
4954 // Tombstone missing labels (old WAL format) - collect for storage query fallback
4955 orphaned_tombstones.push((vid, version));
4956 }
4957 }
4958 } // Drop read lock
4959
4960 // Resolve orphaned tombstones (missing labels) from storage
4961 if !orphaned_tombstones.is_empty() {
4962 tracing::warn!(
4963 count = orphaned_tombstones.len(),
4964 "Tombstones missing labels in L0, querying storage as fallback"
4965 );
4966 for (vid, version) in orphaned_tombstones {
4967 if let Ok(Some(labels)) = self.find_vertex_labels_in_storage(vid).await
4968 && !labels.is_empty()
4969 {
4970 for label in &labels {
4971 if let Some(label_id) = schema.label_id_by_name(label) {
4972 // Round-12 §B: route through partial tombstone too.
4973 tombstones_by_label
4974 .entry(label_id)
4975 .or_default()
4976 .push((vid, version));
4977 }
4978 }
4979 }
4980 }
4981 }
4982
4983 // 1. Load previous snapshot from cache, or fall back to storage.
4984 //
4985 // Use clone() not take(): for the async path, multiple
4986 // concurrent streams may run; if we take() here, a sibling
4987 // stream sees cached_manifest = None and seeds from
4988 // load_latest_snapshot (stale), losing the chain. clone()
4989 // preserves the parent. Finalize writes back the new manifest
4990 // unconditionally.
4991 let mut manifest = if let Some(cached) = self.cached_manifest.lock().clone() {
4992 cached
4993 } else {
4994 self.storage
4995 .snapshot_manager()
4996 .load_latest_snapshot()
4997 .await?
4998 .unwrap_or_else(|| {
4999 SnapshotManifest::new(Uuid::new_v4().to_string(), schema.schema_version)
5000 })
5001 };
5002
5003 // Update snapshot metadata
5004 // Save parent snapshot ID before generating new one (for lineage tracking)
5005 let parent_id = manifest.snapshot_id.clone();
5006 manifest.parent_snapshot = Some(parent_id);
5007 manifest.snapshot_id = Uuid::new_v4().to_string();
5008 manifest.name = name;
5009 manifest.created_at = Utc::now();
5010 manifest.version_high_water_mark = current_version;
5011 // Cap the published WAL checkpoint at the floor of any OTHER pending
5012 // flush. A still-pending flush (notably one that FAILED and left its
5013 // buffer in `pending_flush`) holds committed WAL entries above its start
5014 // that are NOT in this snapshot; recovery replays from this mark, so
5015 // claiming durability past that floor would skip them (lost commit). A
5016 // normal flush with no other pending buffer keeps `wal_lsn` unchanged.
5017 manifest.wal_high_water_mark = self
5018 .l0_manager
5019 .min_pending_wal_lsn_start(&old_l0_arc)
5020 .map_or(wal_lsn, |floor| floor.min(wal_lsn));
5021 let snapshot_id = manifest.snapshot_id.clone();
5022
5023 tracing::Span::current().record("snapshot_id", &snapshot_id);
5024
5025 // 2. Write main unified tables FIRST (before deltas).
5026 // Ensures the dual-write invariant: by the time an EID appears in a
5027 // delta table, it already exists in main_edges. This prevents the
5028 // compaction debug_assert from firing when compaction interleaves
5029 // with flush at async yield points.
5030 //
5031 // 2.1 Main edges table
5032 let (main_edges, edge_created_at_map, edge_updated_at_map) = {
5033 let _old_l0 = old_l0_arc.read();
5034 let mut main_edges: Vec<(
5035 uni_common::core::id::Eid,
5036 Vid,
5037 Vid,
5038 String,
5039 Properties,
5040 bool,
5041 u64,
5042 )> = Vec::new();
5043 let mut edge_created_at_map: HashMap<uni_common::core::id::Eid, i64> = HashMap::new();
5044 let mut edge_updated_at_map: HashMap<uni_common::core::id::Eid, i64> = HashMap::new();
5045
5046 for (&edge_type_id, entries) in entries_by_type.iter() {
5047 for entry in entries {
5048 let edge_type_name = self
5049 .storage
5050 .schema_manager()
5051 .edge_type_name_by_id_unified(edge_type_id)
5052 .unwrap_or_else(|| "unknown".to_string());
5053
5054 let deleted = matches!(entry.op, Op::Delete);
5055 main_edges.push((
5056 entry.eid,
5057 entry.src_vid,
5058 entry.dst_vid,
5059 edge_type_name,
5060 entry.properties.clone(),
5061 deleted,
5062 entry.version,
5063 ));
5064
5065 if let Some(ts) = entry.created_at {
5066 edge_created_at_map.insert(entry.eid, ts);
5067 }
5068 if let Some(ts) = entry.updated_at {
5069 edge_updated_at_map.insert(entry.eid, ts);
5070 }
5071 }
5072 }
5073
5074 (main_edges, edge_created_at_map, edge_updated_at_map)
5075 };
5076
5077 if !main_edges.is_empty() {
5078 let main_edge_batch = MainEdgeDataset::build_record_batch(
5079 &main_edges,
5080 Some(&edge_created_at_map),
5081 Some(&edge_updated_at_map),
5082 )?;
5083 MainEdgeDataset::write_batch(self.storage.backend(), main_edge_batch).await?;
5084 MainEdgeDataset::ensure_default_indexes(self.storage.backend()).await?;
5085 }
5086
5087 // 2.2 Main vertices table
5088 let mut main_vertices: Vec<(Vid, Vec<String>, Properties, bool, u64)> = {
5089 let old_l0 = old_l0_arc.read();
5090 let mut vertices = Vec::new();
5091
5092 // Live vertices: full-row Append on the main table (the
5093 // props_json blob is required for global ID lookups). For
5094 // partial-row VIDs (vertex_partial_keys non-empty), the
5095 // main table still needs the full props for the
5096 // ext_id-uniqueness path; we keep the Append here. The
5097 // per-label Lance write IS partial via MergeInsert.
5098 for (vid, props) in &old_l0.vertex_properties {
5099 let version = old_l0.vertex_versions.get(vid).copied().unwrap_or(0);
5100 let labels = old_l0.vertex_labels.get(vid).cloned().unwrap_or_default();
5101 vertices.push((*vid, labels, props.clone(), false, version));
5102 }
5103
5104 // Tombstones: collected into `main_vertex_tombstones` for
5105 // the MergeInsert path below; skipping the wide-row Append.
5106 for &vid in &old_l0.vertex_tombstones {
5107 let version = old_l0.vertex_versions.get(&vid).copied().unwrap_or(0);
5108 main_vertex_tombstones.push((vid, version));
5109 }
5110
5111 vertices
5112 };
5113
5114 // M8: durable label-only mutations across flush windows.
5115 //
5116 // `SET n:Label` / `REMOVE n:Label` mark the vid in
5117 // `vertex_label_overwrites` and update `vertex_labels`, but for a
5118 // vid flushed in a PRIOR window they never re-add it to
5119 // `vertex_properties`. The loops above key off `vertex_properties`,
5120 // so such a relabel would be silently lost: absent from the main
5121 // table, the per-label datasets, and the VidLabelsIndex (and so
5122 // `rebuild_vid_labels_index` reads stale labels after a restart).
5123 // The same-window create+relabel case already works because the
5124 // create put the vid in `vertex_properties`.
5125 //
5126 // Re-derive each overwrite-only vid by fetching its persisted props
5127 // and labels, then route it into `main_vertices` (main table +
5128 // index), the new per-label datasets, and a tombstone in any
5129 // per-label dataset it left. `MATCH (n:OldLabel)` scans the
5130 // per-label table directly, so the old-label tombstone is required.
5131 let overwrite_only: Vec<(Vid, Vec<String>, u64)> = {
5132 let old_l0 = old_l0_arc.read();
5133 old_l0
5134 .vertex_label_overwrites
5135 .iter()
5136 .filter(|vid| {
5137 !old_l0.vertex_properties.contains_key(*vid)
5138 && !old_l0.vertex_tombstones.contains(*vid)
5139 })
5140 .map(|vid| {
5141 let labels = old_l0.vertex_labels.get(vid).cloned().unwrap_or_default();
5142 let version = old_l0.vertex_versions.get(vid).copied().unwrap_or(0);
5143 (*vid, labels, version)
5144 })
5145 .collect()
5146 };
5147 for (vid, new_labels, version) in overwrite_only {
5148 // Persisted props of the prior-window row — required so the
5149 // re-Appended main row does not blank the vertex's properties.
5150 let Some(props) = MainVertexDataset::find_props_by_vid(
5151 self.storage.backend(),
5152 vid,
5153 self.storage.version_high_water_mark(),
5154 )
5155 .await?
5156 else {
5157 tracing::warn!(
5158 vid = vid.as_u64(),
5159 "label-only mutation for a vid with no persisted main row; skipping flush \
5160 of its relabel"
5161 );
5162 continue;
5163 };
5164 // Labels the vid carried BEFORE this relabel; the storage read
5165 // reflects pre-flush state. Any label no longer present must be
5166 // tombstoned in its per-label dataset.
5167 let old_labels = self
5168 .find_vertex_labels_in_storage(vid)
5169 .await?
5170 .unwrap_or_default();
5171
5172 main_vertices.push((vid, new_labels.clone(), props.clone(), false, version));
5173 for label in &new_labels {
5174 if let Some(label_id) = schema.label_id_by_name(label) {
5175 vertices_by_label.entry(label_id).or_default().push((
5176 vid,
5177 new_labels.clone(),
5178 props.clone(),
5179 false,
5180 version,
5181 ));
5182 }
5183 }
5184 for label in &old_labels {
5185 if !new_labels.contains(label)
5186 && let Some(label_id) = schema.label_id_by_name(label)
5187 {
5188 tombstones_by_label
5189 .entry(label_id)
5190 .or_default()
5191 .push((vid, version));
5192 }
5193 }
5194 }
5195
5196 if !main_vertices.is_empty() {
5197 let main_vertex_batch = MainVertexDataset::build_record_batch(
5198 &main_vertices,
5199 Some(&vertex_created_at),
5200 Some(&vertex_updated_at),
5201 )?;
5202 MainVertexDataset::write_batch(self.storage.backend(), main_vertex_batch).await?;
5203 }
5204 // Round-12 §B: tombstones via MergeInsert on the main vertices
5205 // table. Independent of `vertex_properties` length.
5206 if !main_vertex_tombstones.is_empty() {
5207 let tomb_batch = MainVertexDataset::build_tombstone_partial_batch(
5208 &main_vertex_tombstones,
5209 Some(&vertex_updated_at),
5210 )?;
5211 MainVertexDataset::merge_insert_tombstone_batch(self.storage.backend(), tomb_batch)
5212 .await?;
5213 }
5214 if !main_vertices.is_empty() || !main_vertex_tombstones.is_empty() {
5215 MainVertexDataset::ensure_default_indexes(self.storage.backend()).await?;
5216 }
5217
5218 // Keep the VidLabelsIndex current for every flushed vertex. This is the
5219 // single place that sees all vertices: the per-label fan-out below skips
5220 // undeclared (schemaless) labels, so updating the index there would miss
5221 // them. Traversal-time label predicates read this index to resolve
5222 // labels for vertices that live only in Lance — notably on a fork, whose
5223 // data is flushed to Lance before branching. (GitHub #99)
5224 for (vid, labels, _props, _deleted, _version) in &main_vertices {
5225 self.storage.update_vid_labels_index(*vid, labels.clone());
5226 }
5227 for (vid, _version) in &main_vertex_tombstones {
5228 self.storage.remove_from_vid_labels_index(*vid);
5229 }
5230
5231 // 3. For each edge type, write FWD and BWD delta runs
5232 for (&edge_type_id, entries) in entries_by_type.iter() {
5233 // Get edge type name from unified lookup (handles both schema'd and schemaless)
5234 let edge_type_name = self
5235 .storage
5236 .schema_manager()
5237 .edge_type_name_by_id_unified(edge_type_id)
5238 .ok_or_else(|| anyhow!("Edge type ID {} not found", edge_type_id))?;
5239
5240 // FWD Run (sorted by src_vid)
5241 // Round-12 §A: split entries into full-row Append and
5242 // partial MergeInsert routes based on `edge_partial_keys`.
5243 // Edges in `edge_partial_keys` were last written via
5244 // `insert_edge_partial_full`; the per-edge-type delta
5245 // tables receive only the touched schema columns plus
5246 // (when any overflow key was touched) the regenerated
5247 // `overflow_json` blob. Untouched columns retain their
5248 // previous-version value via Lance MergeInsert.
5249 let partial_eids: std::collections::HashSet<Eid> = {
5250 let old_l0 = old_l0_arc.read();
5251 entries
5252 .iter()
5253 .filter(|e| {
5254 self.config.partial_lance_writes
5255 && old_l0.edge_partial_keys.contains_key(&e.eid)
5256 })
5257 .map(|e| e.eid)
5258 .collect()
5259 };
5260 let touched_union_by_eid: HashMap<Eid, std::collections::HashSet<String>> = {
5261 let old_l0 = old_l0_arc.read();
5262 partial_eids
5263 .iter()
5264 .filter_map(|eid| old_l0.edge_partial_keys.get(eid).map(|s| (*eid, s.clone())))
5265 .collect()
5266 };
5267 let (full_entries, partial_entries): (Vec<L1Entry>, Vec<L1Entry>) = entries
5268 .clone()
5269 .into_iter()
5270 .partition(|e| !partial_eids.contains(&e.eid));
5271
5272 let backend = self.storage.backend();
5273
5274 // FWD run (sorted by src_vid)
5275 let mut fwd_full = full_entries.clone();
5276 fwd_full.sort_by_key(|e| e.src_vid);
5277 let mut fwd_partial = partial_entries.clone();
5278 fwd_partial.sort_by_key(|e| e.src_vid);
5279 let fwd_ds = self.storage.delta_dataset(&edge_type_name, "fwd")?;
5280 if !fwd_full.is_empty() {
5281 let fwd_batch = fwd_ds.build_record_batch(&fwd_full, &schema)?;
5282 fwd_ds.write_run(backend, fwd_batch).await?;
5283 }
5284 if !fwd_partial.is_empty() {
5285 let touched_union: std::collections::HashSet<String> = fwd_partial
5286 .iter()
5287 .flat_map(|e| {
5288 touched_union_by_eid
5289 .get(&e.eid)
5290 .cloned()
5291 .unwrap_or_default()
5292 .into_iter()
5293 })
5294 .collect();
5295 let fwd_partial_batch =
5296 fwd_ds.build_partial_record_batch(&fwd_partial, &touched_union, &schema)?;
5297 fwd_ds
5298 .merge_insert_partial_run(backend, fwd_partial_batch)
5299 .await?;
5300 }
5301 fwd_ds.ensure_eid_index(backend).await?;
5302
5303 // BWD Run (sorted by dst_vid)
5304 let mut bwd_full = full_entries.clone();
5305 bwd_full.sort_by_key(|e| e.dst_vid);
5306 let mut bwd_partial = partial_entries.clone();
5307 bwd_partial.sort_by_key(|e| e.dst_vid);
5308 let bwd_ds = self.storage.delta_dataset(&edge_type_name, "bwd")?;
5309 if !bwd_full.is_empty() {
5310 let bwd_batch = bwd_ds.build_record_batch(&bwd_full, &schema)?;
5311 bwd_ds.write_run(backend, bwd_batch).await?;
5312 }
5313 if !bwd_partial.is_empty() {
5314 let touched_union: std::collections::HashSet<String> = bwd_partial
5315 .iter()
5316 .flat_map(|e| {
5317 touched_union_by_eid
5318 .get(&e.eid)
5319 .cloned()
5320 .unwrap_or_default()
5321 .into_iter()
5322 })
5323 .collect();
5324 let bwd_partial_batch =
5325 bwd_ds.build_partial_record_batch(&bwd_partial, &touched_union, &schema)?;
5326 bwd_ds
5327 .merge_insert_partial_run(backend, bwd_partial_batch)
5328 .await?;
5329 }
5330 bwd_ds.ensure_eid_index(backend).await?;
5331
5332 // Update Manifest
5333 let current_snap =
5334 manifest
5335 .edges
5336 .entry(edge_type_name.to_string())
5337 .or_insert(EdgeSnapshot {
5338 version: 0,
5339 count: 0,
5340 lance_version: 0,
5341 });
5342 current_snap.version += 1;
5343 current_snap.count += entries.len() as u64;
5344 // LanceDB tables don't expose Lance version directly
5345 current_snap.lance_version = 0;
5346
5347 // Note: No CSR invalidation needed. AdjacencyManager's overlay
5348 // already has these edges via dual-write in insert_edge/delete_edge.
5349 }
5350
5351 // 4. Per-label vertex table writes
5352 // Iterate all labels that have either full-row OR partial-write
5353 // data pending. A label may appear in only one of the two maps
5354 // (e.g., all updates on this label were partial-only).
5355 let all_label_ids: std::collections::HashSet<u16> = vertices_by_label
5356 .keys()
5357 .chain(partial_by_label.keys())
5358 .chain(tombstones_by_label.keys())
5359 .copied()
5360 .collect();
5361 for label_id in all_label_ids {
5362 let vertices = vertices_by_label.remove(&label_id).unwrap_or_default();
5363 let label_name = schema
5364 .label_name_by_id(label_id)
5365 .ok_or_else(|| anyhow!("Label ID {} not found", label_id))?;
5366
5367 let ds = self.storage.vertex_dataset(label_name)?;
5368
5369 // Collect inverted index updates before consuming vertices
5370 // Maps: cfg.property -> (added, removed)
5371 type InvertedUpdateMap = HashMap<String, (HashMap<Vid, Vec<String>>, HashSet<Vid>)>;
5372 let mut inverted_updates: InvertedUpdateMap = HashMap::new();
5373
5374 for idx in &schema.indexes {
5375 if let IndexDefinition::Inverted(cfg) = idx
5376 && cfg.label == label_name
5377 {
5378 let mut added: HashMap<Vid, Vec<String>> = HashMap::new();
5379 let mut removed: HashSet<Vid> = HashSet::new();
5380
5381 for (vid, _labels, props, deleted, _version) in &vertices {
5382 if *deleted {
5383 removed.insert(*vid);
5384 } else if let Some(prop_value) = props.get(&cfg.property) {
5385 // Extract terms from the property value (List<String>)
5386 if let Some(arr) = prop_value.as_array() {
5387 let terms: Vec<String> = arr
5388 .iter()
5389 .filter_map(|v| v.as_str().map(ToString::to_string))
5390 .collect();
5391 if !terms.is_empty() {
5392 added.insert(*vid, terms);
5393 }
5394 }
5395 }
5396 }
5397 // Round-12 §B: tombstones no longer in `vertices`;
5398 // pull them from `tombstones_by_label` for inverted
5399 // index removal.
5400 if let Some(tomb_rows) = tombstones_by_label.get(&label_id) {
5401 for (vid, _) in tomb_rows {
5402 removed.insert(*vid);
5403 }
5404 }
5405
5406 if !added.is_empty() || !removed.is_empty() {
5407 inverted_updates.insert(cfg.property.clone(), (added, removed));
5408 }
5409 }
5410 }
5411
5412 // Collect sparse-vector index updates before consuming vertices.
5413 // Maps: cfg.property -> (added [(term_id, weight)] per vid, removed).
5414 type SparseUpdateMap = HashMap<String, (HashMap<Vid, Vec<(u32, f32)>>, HashSet<Vid>)>;
5415 let mut sparse_updates: SparseUpdateMap = HashMap::new();
5416
5417 for idx in &schema.indexes {
5418 if let IndexDefinition::Sparse(cfg) = idx
5419 && cfg.label == label_name
5420 {
5421 let mut added: HashMap<Vid, Vec<(u32, f32)>> = HashMap::new();
5422 let mut removed: HashSet<Vid> = HashSet::new();
5423
5424 for (vid, _labels, props, deleted, _version) in &vertices {
5425 if *deleted {
5426 removed.insert(*vid);
5427 } else if let Some(uni_common::Value::SparseVector { indices, values }) =
5428 props.get(&cfg.property)
5429 {
5430 let pairs: Vec<(u32, f32)> = indices
5431 .iter()
5432 .copied()
5433 .zip(values.iter().copied())
5434 .collect();
5435 added.insert(*vid, pairs);
5436 // An in-place SET re-flushes an already-indexed vid. The
5437 // sparse postings are a `Vec` with no per-vid dedup, so unless
5438 // the vid is also marked removed, `apply_incremental_updates`
5439 // appends the new postings *alongside* the stale ones — leaking
5440 // duplicates that grow unboundedly on hot-updated docs and
5441 // double-count the advisory `query_topk` score (issue #95).
5442 // Mark every updated vid removed so its prior postings are
5443 // purged before the new ones are appended (remove-then-add).
5444 removed.insert(*vid);
5445 }
5446 }
5447 // Tombstones are not in `vertices`; pull from tombstones_by_label.
5448 if let Some(tomb_rows) = tombstones_by_label.get(&label_id) {
5449 for (vid, _) in tomb_rows {
5450 removed.insert(*vid);
5451 }
5452 }
5453
5454 if !added.is_empty() || !removed.is_empty() {
5455 sparse_updates.insert(cfg.property.clone(), (added, removed));
5456 }
5457 }
5458 }
5459
5460 let mut v_data = Vec::new();
5461 let mut d_data = Vec::new();
5462 let mut ver_data = Vec::new();
5463 for (vid, labels, props, deleted, version) in vertices {
5464 v_data.push((vid, labels, props));
5465 d_data.push(deleted);
5466 ver_data.push(version);
5467 }
5468
5469 let backend = self.storage.backend();
5470
5471 // Skip the full-row Append entirely if this label only has
5472 // partial-write rows pending.
5473 if !v_data.is_empty() {
5474 let batch = ds.build_record_batch_with_timestamps(
5475 &v_data,
5476 &d_data,
5477 &ver_data,
5478 &schema,
5479 Some(&vertex_created_at),
5480 Some(&vertex_updated_at),
5481 )?;
5482 ds.write_batch(backend, batch, &schema).await?;
5483 }
5484
5485 // Partial-column batch (Lance MergeInsert path). The flag
5486 // gates whether the routing classified any VIDs as partial;
5487 // outside the flag this collection is always empty so the
5488 // call below is a cheap no-op.
5489 if let Some(partial_rows) = partial_by_label.remove(&label_id)
5490 && !partial_rows.is_empty()
5491 {
5492 let touched_union: std::collections::HashSet<String> = partial_rows
5493 .iter()
5494 .flat_map(|(_, _, _, keys)| keys.iter().cloned())
5495 .collect();
5496 let pairs: Vec<(Vid, Properties)> = partial_rows
5497 .iter()
5498 .map(|(vid, props, _, _)| (*vid, props.clone()))
5499 .collect();
5500 let versions: Vec<u64> = partial_rows.iter().map(|(_, _, v, _)| *v).collect();
5501 let partial_batch = ds.build_partial_record_batch(
5502 &pairs,
5503 &versions,
5504 &touched_union,
5505 &schema,
5506 Some(&vertex_updated_at),
5507 )?;
5508 if partial_batch.num_rows() > 0 {
5509 ds.merge_insert_batch(backend, partial_batch).await?;
5510 }
5511 }
5512
5513 // Tombstone batch (Round-12 §B): always MergeInsert with
5514 // just `_vid`, `_deleted=true`, `_version`, `_updated_at`.
5515 // No partial_lance_writes gating — tombstones never carry
5516 // useful property payload to write. Captured tombstone vids
5517 // also drive `remove_from_vid_labels_index` below.
5518 let tombstone_rows = tombstones_by_label.remove(&label_id).unwrap_or_default();
5519 if !tombstone_rows.is_empty() {
5520 let tomb_batch =
5521 ds.build_tombstone_partial_batch(&tombstone_rows, Some(&vertex_updated_at))?;
5522 if tomb_batch.num_rows() > 0 {
5523 ds.merge_insert_batch(backend, tomb_batch).await?;
5524 }
5525 }
5526
5527 ds.ensure_default_indexes(backend).await?;
5528
5529 // VidLabelsIndex maintenance is centralized at the main-vertex
5530 // flush above (it sees both schema'd and schemaless vertices).
5531
5532 // Update Manifest
5533 let current_snap =
5534 manifest
5535 .vertices
5536 .entry(label_name.to_string())
5537 .or_insert(LabelSnapshot {
5538 version: 0,
5539 count: 0,
5540 lance_version: 0,
5541 });
5542 current_snap.version += 1;
5543 current_snap.count += v_data.len() as u64;
5544 // LanceDB tables don't expose Lance version directly
5545 current_snap.lance_version = 0;
5546
5547 // Invalidate table cache to ensure next read picks up new version
5548 self.storage.invalidate_table_cache(label_name);
5549
5550 // Apply inverted index updates incrementally
5551 #[cfg(feature = "lance-backend")]
5552 for idx in &schema.indexes {
5553 if let IndexDefinition::Inverted(cfg) = idx
5554 && cfg.label == label_name
5555 && let Some((added, removed)) = inverted_updates.get(&cfg.property)
5556 {
5557 self.storage
5558 .index_manager()
5559 .update_inverted_index_incremental(cfg, added, removed)
5560 .await?;
5561 }
5562 }
5563
5564 // Apply sparse-vector index updates incrementally
5565 #[cfg(feature = "lance-backend")]
5566 for idx in &schema.indexes {
5567 if let IndexDefinition::Sparse(cfg) = idx
5568 && cfg.label == label_name
5569 && let Some((added, removed)) = sparse_updates.get(&cfg.property)
5570 {
5571 self.storage
5572 .index_manager()
5573 .update_sparse_vector_index_incremental(cfg, added, removed)
5574 .await?;
5575 }
5576 }
5577
5578 // Update UID index with new vertex mappings
5579 // Collect (UniId, Vid) mappings from non-deleted vertices
5580 #[cfg(feature = "lance-backend")]
5581 {
5582 let mut uid_mappings: Vec<(uni_common::core::id::UniId, Vid)> = Vec::new();
5583 for (vid, _labels, props) in &v_data {
5584 let ext_id = props.get("ext_id").and_then(|v| v.as_str());
5585 let uid = crate::storage::vertex::VertexDataset::compute_vertex_uid(
5586 label_name, ext_id, props,
5587 );
5588 uid_mappings.push((uid, *vid));
5589 }
5590
5591 if !uid_mappings.is_empty()
5592 && let Ok(uid_index) = self.storage.uid_index(label_name)
5593 {
5594 // Stamp mappings with this flush's MVCC version so a later
5595 // re-create of the same UID deterministically outranks the
5596 // stale mapping (review C3).
5597 uid_index
5598 .write_mapping_versioned(&uid_mappings, current_version)
5599 .await?;
5600 }
5601 }
5602 }
5603 Ok(FlushOutcome {
5604 manifest,
5605 snapshot_id,
5606 })
5607 }
5608
5609 /// Composition entry that assumes the caller already holds `flush_lock`.
5610 /// Runs rotate + stream + finalize_locked in sequence. Used by
5611 /// [`Writer::flush_to_l1`] (acquires the lock first) and by
5612 /// `commit_transaction_l0`'s post-merge auto-flush branch (which already
5613 /// holds the lock from the commit critical section).
5614 #[instrument(
5615 skip(self),
5616 fields(snapshot_id, mutations_count, size_bytes),
5617 level = "info"
5618 )]
5619 async fn flush_inline_under_lock(&self, name: Option<String>) -> Result<String> {
5620 let start = std::time::Instant::now();
5621
5622 let (initial_size, initial_count) = {
5623 let l0_arc = self.l0_manager.get_current();
5624 let l0 = l0_arc.read();
5625 (l0.estimated_size, l0.mutation_count)
5626 };
5627 tracing::Span::current().record("size_bytes", initial_size);
5628 tracing::Span::current().record("mutations_count", initial_count);
5629
5630 debug!("Starting L0 flush to L1");
5631
5632 // Phases A (WAL pre-flush), B (rotate), C (WAL handoff).
5633 // FlushInProgressGuard lives on RotateOutput and stays alive for
5634 // the full flush — including the finalize_locked call below.
5635 let RotateOutput {
5636 old_l0_arc,
5637 wal_lsn,
5638 current_version,
5639 flush_in_progress_guard: _flush_guard,
5640 } = self.flush_l0_rotate().await?;
5641
5642 // Phases D (L1 collect), E (orphan resolve), F (manifest seed),
5643 // G (Lance writes). Builds the manifest but does NOT publish it.
5644 let FlushOutcome {
5645 manifest,
5646 snapshot_id,
5647 } = self
5648 .flush_stream_l1(old_l0_arc.clone(), wal_lsn, current_version, name)
5649 .await?;
5650
5651 // Phases H..S: publish manifest, complete_flush, WAL truncate,
5652 // property cache clear, last_flush_time, metrics, l1_runs++,
5653 // compaction trigger, index-rebuild scheduling, fork tick.
5654 self.flush_finalize_locked(
5655 old_l0_arc,
5656 wal_lsn,
5657 manifest,
5658 snapshot_id,
5659 initial_size,
5660 initial_count,
5661 start,
5662 )
5663 .await
5664 }
5665
5666 /// Phases H..S of the flush: publish the manifest and run all
5667 /// post-publish bookkeeping. Assumes the caller already holds
5668 /// `flush_lock` — see [`Writer::flush_finalize_now`] for the
5669 /// lock-acquiring variant used by the async finalize path.
5670 #[allow(clippy::too_many_arguments)]
5671 async fn flush_finalize_locked(
5672 &self,
5673 old_l0_arc: Arc<RwLock<L0Buffer>>,
5674 wal_lsn: u64,
5675 manifest: SnapshotManifest,
5676 snapshot_id: String,
5677 initial_size: usize,
5678 initial_count: usize,
5679 start: std::time::Instant,
5680 ) -> Result<String> {
5681 Self::flush_finalize_body(
5682 &self.shared_ctx(),
5683 old_l0_arc,
5684 wal_lsn,
5685 manifest,
5686 snapshot_id,
5687 initial_size,
5688 initial_count,
5689 start,
5690 )
5691 .await
5692 }
5693
5694 /// Phases H..S of the flush, lock-acquiring variant. Used by the
5695 /// async-flush finalizer task (running on a spawned tokio task),
5696 /// which holds neither `&self` nor `flush_lock`. Briefly re-acquires
5697 /// `flush_lock` to serialize the publish boundary, then runs the
5698 /// same body as `flush_finalize_locked` but over a SharedFlushCtx.
5699 #[allow(clippy::too_many_arguments)]
5700 pub(crate) async fn flush_finalize_now(
5701 shared: SharedFlushCtx,
5702 old_l0_arc: Arc<RwLock<L0Buffer>>,
5703 wal_lsn: u64,
5704 manifest: SnapshotManifest,
5705 snapshot_id: String,
5706 initial_size: usize,
5707 initial_count: usize,
5708 start: std::time::Instant,
5709 ) -> Result<String> {
5710 let _flush_lock_guard = shared.flush_lock.clone().lock_owned().await;
5711 Self::flush_finalize_body(
5712 &shared,
5713 old_l0_arc,
5714 wal_lsn,
5715 manifest,
5716 snapshot_id,
5717 initial_size,
5718 initial_count,
5719 start,
5720 )
5721 .await
5722 }
5723
5724 /// Shared body of `flush_finalize_locked` and `flush_finalize_now`.
5725 /// Static over `SharedFlushCtx`; the caller is responsible for
5726 /// holding `flush_lock`.
5727 #[allow(clippy::too_many_arguments)]
5728 async fn flush_finalize_body(
5729 shared: &SharedFlushCtx,
5730 old_l0_arc: Arc<RwLock<L0Buffer>>,
5731 wal_lsn: u64,
5732 mut manifest: SnapshotManifest,
5733 snapshot_id: String,
5734 initial_size: usize,
5735 initial_count: usize,
5736 start: std::time::Instant,
5737 ) -> Result<String> {
5738 // Parent-snapshot fixup. The stream phase built `manifest` with
5739 // parent_snapshot set from cached_manifest at stream time. If
5740 // OTHER flushes (sync or async) have finalized since then,
5741 // cached_manifest has advanced. Re-link this manifest to the
5742 // current cached chain so we don't orphan their data when we
5743 // overwrite cached_manifest below.
5744 let current_parent_id = shared
5745 .cached_manifest
5746 .lock()
5747 .as_ref()
5748 .map(|m| m.snapshot_id.clone());
5749 if current_parent_id.is_some() && manifest.parent_snapshot != current_parent_id {
5750 manifest.parent_snapshot = current_parent_id;
5751 metrics::counter!("uni_flush_parent_chain_fixups_total").increment(1);
5752 }
5753
5754 // H. Publish manifest (body first, then pointer — recovery is
5755 // idempotent if we crash between the two).
5756 // A fork writer must publish to a fork-scoped namespace, never the
5757 // global `catalog/latest` that the primary reopen reads (review C1).
5758 debug_assert_eq!(
5759 shared.fork_id.is_some(),
5760 shared.storage.snapshot_manager().is_fork_scoped(),
5761 "fork writer must publish to a fork-scoped snapshot namespace (review C1)"
5762 );
5763 shared
5764 .storage
5765 .snapshot_manager()
5766 .save_snapshot(&manifest)
5767 .await?;
5768 shared
5769 .storage
5770 .snapshot_manager()
5771 .set_latest_snapshot(&manifest.snapshot_id)
5772 .await?;
5773
5774 // H2. Durability barrier (review C4). `save_snapshot` / `set_latest_snapshot`
5775 // wrote the manifest body and the `catalog/latest` pointer through the
5776 // object store, which does NOT fsync. WAL truncation (K, below) removes
5777 // the only other durable copy of this flush's data, so a crash after K
5778 // but before the OS flushed those writes would lose the snapshot —
5779 // recovery could not resolve `latest`. Make them durable now (local-fs
5780 // only; remote stores provide their own durability on `put`).
5781 crate::snapshot::manager::fsync_snapshot_pointer(
5782 shared.storage.local_fs_root().as_deref(),
5783 shared.fork_id.as_ref(),
5784 &manifest.snapshot_id,
5785 )
5786 .map_err(|e| {
5787 anyhow!(
5788 "fsync snapshot {} before WAL truncate: {}",
5789 manifest.snapshot_id,
5790 e
5791 )
5792 })?;
5793
5794 // I. Cache manifest for next flush to avoid re-reading from object store.
5795 *shared.cached_manifest.lock() = Some(manifest.clone());
5796
5797 // L. Invalidate the property cache BEFORE removing the flushed buffer
5798 // from the L0 chain (Bug #10). `clear_cache` has no dependency on the
5799 // complete_flush (J) / WAL-truncate (K) steps below, so clearing it
5800 // first closes the non-monotonic-read window: once the buffer leaves
5801 // the L0 chain at J a freshly-written value would otherwise miss the
5802 // chain and fall through to a stale cache entry. By the time finalize
5803 // runs the streamed rows are already durable in L1, so a post-clear
5804 // read falls through to fresh storage instead. The finalizer holds
5805 // `flush_lock` throughout, so reordering L ahead of J is safe.
5806 if let Some(ref pm) = shared.property_manager {
5807 pm.clear_cache().await;
5808 }
5809
5810 // J. Complete flush: remove old L0 from pending_flush. MUST happen
5811 // BEFORE WAL truncation so min_pending_wal_lsn is accurate.
5812 shared.l0_manager.complete_flush(&old_l0_arc);
5813
5814 // Test-only seam (no-op without the `failpoints` feature): pause AFTER
5815 // complete_flush removed the buffer from the L0 chain (J) but BEFORE
5816 // WAL truncation (K). The property cache is already cleared (L moved
5817 // ahead of J above), so a read in this window falls through to fresh
5818 // L1 storage rather than a stale cache entry (Bug #10 — non-monotonic
5819 // read after flush finalize).
5820 fail::fail_point!("flush::after-complete-before-cache-clear");
5821
5822 // K. Truncate WAL up to the safe LSN. The floor is the START watermark
5823 // of any OTHER pending flush (this flush's own buffer was removed from
5824 // pending by `complete_flush` in J, so it is excluded): a pending — e.g.
5825 // failed — flush's committed entries live above its start and are not yet
5826 // in L1, so truncating to its high watermark would delete its own data
5827 // (the lost-commit-on-graceful-close bug).
5828 let wal_handle = shared.l0_manager.get_current().read().wal.clone();
5829 if let Some(w) = wal_handle {
5830 let safe_lsn = shared
5831 .l0_manager
5832 .min_pending_wal_lsn_start(&old_l0_arc)
5833 .map_or(wal_lsn, |floor| floor.min(wal_lsn));
5834 w.truncate_before(safe_lsn).await?;
5835 }
5836
5837 // M. Reset last flush time for time-based auto-flush.
5838 *shared.last_flush_time.lock() = std::time::Instant::now();
5839
5840 info!(
5841 snapshot_id,
5842 mutations_count = initial_count,
5843 size_bytes = initial_size,
5844 "L0 flush to L1 completed successfully"
5845 );
5846 metrics::histogram!("uni_flush_duration_seconds").record(start.elapsed().as_secs_f64());
5847 metrics::counter!("uni_flush_bytes_total").increment(initial_size as u64);
5848 metrics::counter!("uni_flush_rows_total").increment(initial_count as u64);
5849
5850 // P. Increment flush generation counter for write throttling.
5851 {
5852 let mut status = uni_common::sync::acquire_mutex(
5853 &shared.storage.compaction_status,
5854 "compaction_status",
5855 )?;
5856 status.l1_runs += 1;
5857 }
5858
5859 // Q. Trigger CSR compaction if enough frozen segments have accumulated.
5860 let am = shared.adjacency_manager.clone();
5861 if am.should_compact(shared.compaction_config.frozen_segments_compact_threshold) {
5862 let previous_still_running = {
5863 let guard = shared.compaction_handle.read();
5864 guard.as_ref().is_some_and(|h| !h.is_finished())
5865 };
5866 if previous_still_running {
5867 info!("Skipping compaction: previous compaction still in progress");
5868 } else {
5869 // Reclaim shadow-CSR entries no in-flight reader can reach.
5870 // The floor is the minimum version pinned by a live
5871 // `pinned_at_version` view, or the current version when none is
5872 // pinned — a reader starting now pins at the current version,
5873 // so anything deleted at or below it is already unreachable.
5874 let gc_version = shared
5875 .storage
5876 .version_high_water_mark()
5877 .unwrap_or_else(|| shared.l0_manager.get_current().read().current_version);
5878 let handle = tokio::spawn(async move {
5879 am.compact();
5880 am.gc_shadow(gc_version);
5881 });
5882 *shared.compaction_handle.write() = Some(handle);
5883 }
5884 }
5885
5886 // R. Post-flush: check if any indexes need rebuilding based on thresholds.
5887 if shared.auto_rebuild_enabled
5888 && let Some(rebuild_mgr) = shared.index_rebuild_manager.get()
5889 {
5890 Self::schedule_index_rebuilds_if_needed_static(
5891 &manifest,
5892 rebuild_mgr.clone(),
5893 shared.schema_manager.clone(),
5894 shared.index_rebuild_config.clone(),
5895 );
5896 }
5897
5898 // S. Emit fork-fragment observability after a successful forked flush.
5899 Self::tick_fork_fragment_observability_static(
5900 shared.fork_id,
5901 shared.fork_flush_count.clone(),
5902 shared.fork_fragment_warn_fired.clone(),
5903 shared.fork_fragment_warn_threshold,
5904 );
5905
5906 Ok(snapshot_id)
5907 }
5908
5909 /// Increment fork-flush bookkeeping and fire the fragment warn
5910 /// once if the threshold is crossed.
5911 ///
5912 /// Each flush typically appends ~1 fragment per touched dataset on
5913 /// the fork's branches; without compaction (deferred to Phase 5)
5914 /// long-lived heavy-write forks degrade. The flush count is a
5915 /// proxy for actual fragment growth — reading
5916 /// `Dataset::manifest().fragments.len()` per dataset would add a
5917 /// per-flush object-store roundtrip on the hot commit path, which
5918 /// is too costly for a purely observational guard rail.
5919 ///
5920 /// No-op for primary writers (`fork_id == None`).
5921 #[allow(dead_code)] // called by tests; production path uses _static
5922 pub(crate) fn tick_fork_fragment_observability(&self) {
5923 Self::tick_fork_fragment_observability_static(
5924 self.fork_id,
5925 self.fork_flush_count.clone(),
5926 self.fork_fragment_warn_fired.clone(),
5927 self.config.fork_fragment_warn_threshold,
5928 );
5929 }
5930
5931 /// Static variant of [`Writer::tick_fork_fragment_observability`].
5932 /// Used by the async-flush finalize path, where we hold a
5933 /// [`SharedFlushCtx`] bundle of Arcs rather than `&Writer`.
5934 pub(crate) fn tick_fork_fragment_observability_static(
5935 fork_id: Option<ForkId>,
5936 fork_flush_count: Arc<AtomicU64>,
5937 fork_fragment_warn_fired: Arc<AtomicBool>,
5938 warn_threshold: usize,
5939 ) {
5940 let Some(fork_id) = fork_id else { return };
5941 // `Relaxed` is sufficient: observational counter, no synchronizes-with.
5942 let new_count = fork_flush_count.fetch_add(1, Ordering::Relaxed) + 1;
5943 let fork_label = fork_id.to_string();
5944 metrics::gauge!(
5945 "uni_fork_l1_flushes",
5946 "fork" => fork_label.clone(),
5947 )
5948 .set(new_count as f64);
5949 let threshold = warn_threshold as u64;
5950 if !fork_fragment_warn_fired.load(Ordering::Relaxed)
5951 && threshold > 0
5952 && new_count >= threshold
5953 {
5954 fork_fragment_warn_fired.store(true, Ordering::Relaxed);
5955 tracing::warn!(
5956 fork = %fork_label,
5957 flush_count = new_count,
5958 threshold,
5959 "fork has exceeded the L1 flush-count threshold; \
5960 fork compaction is deferred to Phase 5 — consider \
5961 drop+recreate or promotion to bound fragment growth"
5962 );
5963 }
5964 }
5965
5966 /// Check rebuild thresholds and schedule background index rebuilds for
5967 /// labels that exceed growth or age limits. Marks affected indexes as
5968 /// `Stale` and spawns an async task to schedule the rebuild.
5969 #[allow(dead_code)] // production path uses _static; kept as the
5970 // documented instance entry point.
5971 fn schedule_index_rebuilds_if_needed(
5972 &self,
5973 manifest: &SnapshotManifest,
5974 rebuild_mgr: Arc<crate::storage::index_rebuild::IndexRebuildManager>,
5975 ) {
5976 Self::schedule_index_rebuilds_if_needed_static(
5977 manifest,
5978 rebuild_mgr,
5979 self.schema_manager.clone(),
5980 self.config.index_rebuild.clone(),
5981 );
5982 }
5983
5984 /// Static variant of [`Writer::schedule_index_rebuilds_if_needed`].
5985 /// Used by the async-flush finalize path, where we hold the
5986 /// [`SchemaManager`] via `SharedFlushCtx` rather than `&Writer`.
5987 pub(crate) fn schedule_index_rebuilds_if_needed_static(
5988 manifest: &SnapshotManifest,
5989 rebuild_mgr: Arc<crate::storage::index_rebuild::IndexRebuildManager>,
5990 schema_manager: Arc<uni_common::core::schema::SchemaManager>,
5991 index_rebuild_config: uni_common::config::IndexRebuildConfig,
5992 ) {
5993 let checker =
5994 crate::storage::index_rebuild::RebuildTriggerChecker::new(index_rebuild_config);
5995 let schema = schema_manager.schema();
5996 let labels = checker.labels_needing_rebuild(manifest, &schema.indexes);
5997
5998 if labels.is_empty() {
5999 return;
6000 }
6001
6002 // Mark affected indexes as Stale
6003 for label in &labels {
6004 for idx in &schema.indexes {
6005 if idx.label() == label {
6006 let _ = schema_manager.update_index_metadata(idx.name(), |m| {
6007 m.status = uni_common::core::schema::IndexStatus::Stale;
6008 });
6009 }
6010 }
6011 }
6012
6013 tokio::spawn(async move {
6014 if let Err(e) = rebuild_mgr.schedule(labels).await {
6015 tracing::warn!("Failed to schedule index rebuild: {e}");
6016 }
6017 });
6018 }
6019}
6020
6021/// `FinalizeFn` implementation that the `FlushCoordinator` invokes from
6022/// its single-task finalizer loop. Unit struct on purpose: it must NOT
6023/// hold `Arc<Writer>` (that would create a reference cycle Writer ->
6024/// FlushCoordinator -> Arc<dyn FinalizeFn> -> Writer). All state needed
6025/// for finalize travels in via `SharedFlushCtx`.
6026pub(crate) struct WriterFinalizer;
6027
6028impl FinalizeFn for WriterFinalizer {
6029 fn finalize<'a>(
6030 &'a self,
6031 rotated: RotatedFlush,
6032 outcome: AsyncFlushOutcome,
6033 shared: SharedFlushCtx,
6034 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
6035 Box::pin(async move {
6036 // Read initial_size / initial_count from the rotated L0 so
6037 // we don't have to plumb them through the coordinator
6038 // submission. The buffer is still alive in pending_flush
6039 // until `complete_flush` (J) below pops it.
6040 let (initial_size, initial_count) = {
6041 let l0 = rotated.old_l0_arc.read();
6042 (l0.estimated_size, l0.mutation_count)
6043 };
6044 let result = Writer::flush_finalize_now(
6045 shared,
6046 rotated.old_l0_arc.clone(),
6047 rotated.wal_lsn,
6048 outcome.new_manifest,
6049 outcome.snapshot_id,
6050 initial_size,
6051 initial_count,
6052 std::time::Instant::now(),
6053 )
6054 .await;
6055 // `rotated` (permit + flush_in_progress_guard) drops here.
6056 drop(rotated.permit);
6057 result
6058 })
6059 }
6060
6061 fn finalize_failure<'a>(
6062 &'a self,
6063 rotated: RotatedFlush,
6064 err: anyhow::Error,
6065 _shared: SharedFlushCtx,
6066 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Error> + Send + 'a>> {
6067 Box::pin(async move {
6068 tracing::warn!(
6069 error = %err,
6070 seq = rotated.seq,
6071 "async flush stream failed; old L0 remains in pending_flush, \
6072 WAL retains its data, recovery via WAL replay on restart"
6073 );
6074 metrics::counter!("uni_flush_failures_total").increment(1);
6075 // Permit + guard drop here so back-pressure releases even on
6076 // failure.
6077 drop(rotated.permit);
6078 err
6079 })
6080 }
6081}
6082
6083/// Map a constraint key's [`Value`] onto a [`Scalar`] literal.
6084///
6085/// `None` for anything with no scalar equivalent (list, map, vector, null).
6086/// The two constraint probes differ in what they do with that: the batch check
6087/// abandons the row entirely, while the full-horizon check probes with SQL
6088/// NULL — preserving each site's prior behavior exactly.
6089fn constraint_scalar(value: &Value) -> Option<Scalar> {
6090 match value {
6091 Value::String(s) => Some(Scalar::Str(s.clone())),
6092 Value::Int(n) => Some(Scalar::Int(*n)),
6093 Value::Float(f) => Some(Scalar::Float(*f)),
6094 Value::Bool(b) => Some(Scalar::Bool(*b)),
6095 _ => None,
6096 }
6097}
6098
6099#[cfg(test)]
6100mod tests {
6101 use super::*;
6102 use tempfile::tempdir;
6103
6104 /// Test that commit_transaction writes mutations to WAL before merging to main L0.
6105 /// This verifies fix for issue #137 (transaction commit atomicity).
6106 #[tokio::test]
6107 async fn test_commit_transaction_wal_before_merge() -> Result<()> {
6108 use crate::runtime::wal::WriteAheadLog;
6109 use crate::storage::manager::StorageManager;
6110 use object_store::local::LocalFileSystem;
6111 use object_store::path::Path as ObjectStorePath;
6112 use uni_common::core::schema::SchemaManager;
6113
6114 let dir = tempdir()?;
6115 let path = dir.path().to_str().unwrap();
6116 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6117 let schema_path = ObjectStorePath::from("schema.json");
6118
6119 let schema_manager =
6120 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6121 let _label_id = schema_manager.add_label("Test")?;
6122 schema_manager.save().await?;
6123
6124 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6125
6126 // Create WAL for main L0
6127 let wal_path = ObjectStorePath::from("wal");
6128 let wal = Arc::new(WriteAheadLog::new(store.clone(), wal_path));
6129
6130 let writer = Writer::new_with_config(
6131 storage.clone(),
6132 schema_manager.clone(),
6133 1,
6134 UniConfig::default(),
6135 Some(wal),
6136 None,
6137 )
6138 .await?;
6139
6140 // Begin transaction — create a transaction L0
6141 let tx_l0 = writer.create_transaction_l0();
6142
6143 // Insert data in transaction
6144 let vid_a = writer.next_vid().await?;
6145 let vid_b = writer.next_vid().await?;
6146
6147 let mut props = std::collections::HashMap::new();
6148 props.insert("test".to_string(), Value::String("data".to_string()));
6149
6150 writer
6151 .insert_vertex_with_labels(vid_a, props.clone(), &["Test".to_string()], Some(&tx_l0))
6152 .await?;
6153 writer
6154 .insert_vertex_with_labels(
6155 vid_b,
6156 std::collections::HashMap::new(),
6157 &["Test".to_string()],
6158 Some(&tx_l0),
6159 )
6160 .await?;
6161
6162 let eid = writer.next_eid(1).await?;
6163 writer
6164 .insert_edge(
6165 vid_a,
6166 vid_b,
6167 1,
6168 eid,
6169 std::collections::HashMap::new(),
6170 None,
6171 Some(&tx_l0),
6172 )
6173 .await?;
6174
6175 // Get WAL before commit
6176 let l0 = writer.l0_manager.get_current();
6177 let wal = l0.read().wal.clone().expect("Main L0 should have WAL");
6178 let mutations_before = wal.replay().await?;
6179 let count_before = mutations_before.len();
6180
6181 // Commit transaction - this should write to WAL first
6182 let writer = Arc::new(writer);
6183 writer.commit_transaction_l0(tx_l0).await?;
6184
6185 // Verify WAL has the new mutations
6186 let mutations_after = wal.replay().await?;
6187 assert!(
6188 mutations_after.len() > count_before,
6189 "WAL should contain transaction mutations after commit"
6190 );
6191
6192 // Verify mutations are in correct order: vertices first, then edges
6193 let new_mutations: Vec<_> = mutations_after.into_iter().skip(count_before).collect();
6194
6195 let mut saw_vertex_a = false;
6196 let mut saw_vertex_b = false;
6197 let mut saw_edge = false;
6198
6199 for mutation in &new_mutations {
6200 match mutation {
6201 crate::runtime::wal::Mutation::InsertVertex { vid, .. } => {
6202 if *vid == vid_a {
6203 saw_vertex_a = true;
6204 }
6205 if *vid == vid_b {
6206 saw_vertex_b = true;
6207 }
6208 // Vertices should come before edges
6209 assert!(!saw_edge, "Vertices should be logged to WAL before edges");
6210 }
6211 crate::runtime::wal::Mutation::InsertEdge { eid: e, .. } => {
6212 if *e == eid {
6213 saw_edge = true;
6214 }
6215 // Edges should come after vertices
6216 assert!(
6217 saw_vertex_a && saw_vertex_b,
6218 "Edge should be logged after both vertices"
6219 );
6220 }
6221 _ => {}
6222 }
6223 }
6224
6225 assert!(saw_vertex_a, "Vertex A should be in WAL");
6226 assert!(saw_vertex_b, "Vertex B should be in WAL");
6227 assert!(saw_edge, "Edge should be in WAL");
6228
6229 // Verify data is also in main L0
6230 let l0_read = l0.read();
6231 assert!(
6232 l0_read.vertex_properties.contains_key(&vid_a),
6233 "Vertex A should be in main L0"
6234 );
6235 assert!(
6236 l0_read.vertex_properties.contains_key(&vid_b),
6237 "Vertex B should be in main L0"
6238 );
6239 assert!(
6240 l0_read.edge_endpoints.contains_key(&eid),
6241 "Edge should be in main L0"
6242 );
6243
6244 Ok(())
6245 }
6246
6247 /// Test that failed WAL flush leaves transaction intact for retry or rollback.
6248 #[tokio::test]
6249 async fn test_commit_transaction_wal_failure_rollback() -> Result<()> {
6250 use crate::runtime::wal::WriteAheadLog;
6251 use crate::storage::manager::StorageManager;
6252 use object_store::local::LocalFileSystem;
6253 use object_store::path::Path as ObjectStorePath;
6254 use uni_common::core::schema::SchemaManager;
6255
6256 let dir = tempdir()?;
6257 let path = dir.path().to_str().unwrap();
6258 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6259 let schema_path = ObjectStorePath::from("schema.json");
6260
6261 let schema_manager =
6262 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6263 let _label_id = schema_manager.add_label("Test")?;
6264 let _baseline_label_id = schema_manager.add_label("Baseline")?;
6265 let _txdata_label_id = schema_manager.add_label("TxData")?;
6266 schema_manager.save().await?;
6267
6268 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6269
6270 // Create WAL for main L0
6271 let wal_path = ObjectStorePath::from("wal");
6272 let wal = Arc::new(WriteAheadLog::new(store.clone(), wal_path));
6273
6274 let writer = Writer::new_with_config(
6275 storage.clone(),
6276 schema_manager.clone(),
6277 1,
6278 UniConfig::default(),
6279 Some(wal),
6280 None,
6281 )
6282 .await?;
6283
6284 // Insert baseline data (outside transaction)
6285 let baseline_vid = writer.next_vid().await?;
6286 writer
6287 .insert_vertex_with_labels(
6288 baseline_vid,
6289 [("baseline".to_string(), Value::Bool(true))]
6290 .into_iter()
6291 .collect(),
6292 &["Baseline".to_string()],
6293 None,
6294 )
6295 .await?;
6296
6297 // Begin transaction — create a transaction L0
6298 let tx_l0 = writer.create_transaction_l0();
6299
6300 // Insert data in transaction
6301 let tx_vid = writer.next_vid().await?;
6302 writer
6303 .insert_vertex_with_labels(
6304 tx_vid,
6305 [("tx_data".to_string(), Value::Bool(true))]
6306 .into_iter()
6307 .collect(),
6308 &["TxData".to_string()],
6309 Some(&tx_l0),
6310 )
6311 .await?;
6312
6313 // Capture main L0 state before rollback
6314 let l0 = writer.l0_manager.get_current();
6315 let vertex_count_before = l0.read().vertex_properties.len();
6316
6317 // Rollback transaction (simulating what would happen after WAL flush failure)
6318 drop(tx_l0);
6319
6320 // Verify main L0 is unchanged
6321 let vertex_count_after = l0.read().vertex_properties.len();
6322 assert_eq!(
6323 vertex_count_before, vertex_count_after,
6324 "Main L0 should not change after rollback"
6325 );
6326
6327 // Baseline should still be present
6328 assert!(
6329 l0.read().vertex_properties.contains_key(&baseline_vid),
6330 "Baseline data should remain"
6331 );
6332
6333 // Transaction data should NOT be in main L0
6334 assert!(
6335 !l0.read().vertex_properties.contains_key(&tx_vid),
6336 "Transaction data should not be in main L0 after rollback"
6337 );
6338
6339 Ok(())
6340 }
6341
6342 /// Test that batch insert with shared labels does not clone labels per vertex.
6343 /// This verifies fix for issue #161 (redundant label cloning).
6344 #[tokio::test]
6345 async fn test_batch_insert_shared_labels() -> Result<()> {
6346 use crate::storage::manager::StorageManager;
6347 use object_store::local::LocalFileSystem;
6348 use object_store::path::Path as ObjectStorePath;
6349 use uni_common::core::schema::SchemaManager;
6350
6351 let dir = tempdir()?;
6352 let path = dir.path().to_str().unwrap();
6353 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6354 let schema_path = ObjectStorePath::from("schema.json");
6355
6356 let schema_manager =
6357 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6358 let _label_id = schema_manager.add_label("Person")?;
6359 schema_manager.save().await?;
6360
6361 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6362
6363 let writer = Writer::new(storage.clone(), schema_manager.clone(), 1).await?;
6364
6365 // Shared labels - should not be cloned per vertex
6366 let labels = &["Person".to_string()];
6367
6368 // Insert batch of vertices with same labels
6369 let mut vids = Vec::new();
6370 for i in 0..100 {
6371 let vid = writer.next_vid().await?;
6372 let mut props = std::collections::HashMap::new();
6373 props.insert("id".to_string(), Value::Int(i));
6374 writer
6375 .insert_vertex_with_labels(vid, props, labels, None)
6376 .await?;
6377 vids.push(vid);
6378 }
6379
6380 // Verify all vertices have the correct labels
6381 let l0 = writer.l0_manager.get_current();
6382 for vid in vids {
6383 let l0_guard = l0.read();
6384 let vertex_labels = l0_guard.vertex_labels.get(&vid);
6385 assert!(vertex_labels.is_some(), "Vertex should have labels");
6386 assert_eq!(
6387 vertex_labels.unwrap(),
6388 &vec!["Person".to_string()],
6389 "Labels should match"
6390 );
6391 }
6392
6393 Ok(())
6394 }
6395
6396 /// Test that estimated_size tracks mutations correctly and approximates size_bytes().
6397 /// This verifies fix for issue #147 (O(V+E) size_bytes() in metrics).
6398 #[tokio::test]
6399 async fn test_estimated_size_tracks_mutations() -> Result<()> {
6400 use crate::storage::manager::StorageManager;
6401 use object_store::local::LocalFileSystem;
6402 use object_store::path::Path as ObjectStorePath;
6403 use uni_common::core::schema::SchemaManager;
6404
6405 let dir = tempdir()?;
6406 let path = dir.path().to_str().unwrap();
6407 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6408 let schema_path = ObjectStorePath::from("schema.json");
6409
6410 let schema_manager =
6411 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6412 let _label_id = schema_manager.add_label("Test")?;
6413 schema_manager.save().await?;
6414
6415 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6416
6417 let writer = Writer::new(storage.clone(), schema_manager.clone(), 1).await?;
6418
6419 let l0 = writer.l0_manager.get_current();
6420
6421 // Initial state should be empty
6422 let initial_estimated = l0.read().estimated_size;
6423 let initial_actual = l0.read().size_bytes();
6424 assert_eq!(initial_estimated, 0, "Initial estimated_size should be 0");
6425 assert_eq!(initial_actual, 0, "Initial size_bytes should be 0");
6426
6427 // Insert vertices with properties
6428 let mut vids = Vec::new();
6429 for i in 0..10 {
6430 let vid = writer.next_vid().await?;
6431 let mut props = std::collections::HashMap::new();
6432 props.insert("name".to_string(), Value::String(format!("vertex_{}", i)));
6433 props.insert("index".to_string(), Value::Int(i));
6434 writer
6435 .insert_vertex_with_labels(vid, props, &[], None)
6436 .await?;
6437 vids.push(vid);
6438 }
6439
6440 // Verify estimated_size grew
6441 let after_vertices_estimated = l0.read().estimated_size;
6442 let after_vertices_actual = l0.read().size_bytes();
6443 assert!(
6444 after_vertices_estimated > 0,
6445 "estimated_size should grow after insertions"
6446 );
6447
6448 // Verify estimated_size is within reasonable bounds of actual size (within 2x)
6449 let ratio = after_vertices_estimated as f64 / after_vertices_actual as f64;
6450 assert!(
6451 (0.5..=2.0).contains(&ratio),
6452 "estimated_size ({}) should be within 2x of size_bytes ({}), ratio: {}",
6453 after_vertices_estimated,
6454 after_vertices_actual,
6455 ratio
6456 );
6457
6458 // Insert edges with a simple edge type
6459 let edge_type = 1u32;
6460 for i in 0..9 {
6461 let eid = writer.next_eid(edge_type).await?;
6462 writer
6463 .insert_edge(
6464 vids[i],
6465 vids[i + 1],
6466 edge_type,
6467 eid,
6468 std::collections::HashMap::new(),
6469 Some("NEXT".to_string()),
6470 None,
6471 )
6472 .await?;
6473 }
6474
6475 // Verify estimated_size grew further
6476 let after_edges_estimated = l0.read().estimated_size;
6477 let after_edges_actual = l0.read().size_bytes();
6478 assert!(
6479 after_edges_estimated > after_vertices_estimated,
6480 "estimated_size should grow after edge insertions"
6481 );
6482
6483 // Verify still within reasonable bounds
6484 let ratio = after_edges_estimated as f64 / after_edges_actual as f64;
6485 assert!(
6486 (0.5..=2.0).contains(&ratio),
6487 "estimated_size ({}) should be within 2x of size_bytes ({}), ratio: {}",
6488 after_edges_estimated,
6489 after_edges_actual,
6490 ratio
6491 );
6492
6493 Ok(())
6494 }
6495
6496 /// Test that flushing WAL on a writer with no mutations succeeds cleanly.
6497 #[tokio::test]
6498 async fn test_flush_wal_empty_l0_is_noop() -> Result<()> {
6499 use crate::runtime::wal::WriteAheadLog;
6500 use crate::storage::manager::StorageManager;
6501 use object_store::local::LocalFileSystem;
6502 use object_store::path::Path as ObjectStorePath;
6503 use uni_common::core::schema::SchemaManager;
6504
6505 let dir = tempdir()?;
6506 let path = dir.path().to_str().unwrap();
6507 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6508 let schema_path = ObjectStorePath::from("schema.json");
6509
6510 let schema_manager =
6511 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6512 schema_manager.save().await?;
6513
6514 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6515
6516 let wal_path = ObjectStorePath::from("wal");
6517 let wal = Arc::new(WriteAheadLog::new(store.clone(), wal_path));
6518
6519 let writer = Writer::new_with_config(
6520 storage.clone(),
6521 schema_manager.clone(),
6522 1,
6523 UniConfig::default(),
6524 Some(wal.clone()),
6525 None,
6526 )
6527 .await?;
6528
6529 // Flush with no mutations — should succeed cleanly
6530 let lsn = writer.flush_wal().await?;
6531 // LSN should be 0 or 1 (no real mutations flushed)
6532 assert!(lsn <= 1, "Empty flush should produce low LSN, got {}", lsn);
6533
6534 Ok(())
6535 }
6536
6537 /// Test that transaction data does not leak into main L0 without commit.
6538 #[tokio::test]
6539 async fn test_transaction_isolation_without_commit() -> Result<()> {
6540 use crate::runtime::wal::WriteAheadLog;
6541 use crate::storage::manager::StorageManager;
6542 use object_store::local::LocalFileSystem;
6543 use object_store::path::Path as ObjectStorePath;
6544 use uni_common::core::schema::SchemaManager;
6545
6546 let dir = tempdir()?;
6547 let path = dir.path().to_str().unwrap();
6548 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6549 let schema_path = ObjectStorePath::from("schema.json");
6550
6551 let schema_manager =
6552 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6553 let _label_id = schema_manager.add_label("Person")?;
6554 schema_manager.save().await?;
6555
6556 let storage = Arc::new(StorageManager::new(path, schema_manager.clone()).await?);
6557
6558 let wal_path = ObjectStorePath::from("wal");
6559 let wal = Arc::new(WriteAheadLog::new(store.clone(), wal_path));
6560
6561 let writer = Writer::new_with_config(
6562 storage.clone(),
6563 schema_manager.clone(),
6564 1,
6565 UniConfig::default(),
6566 Some(wal),
6567 None,
6568 )
6569 .await?;
6570
6571 // Create transaction L0
6572 let tx_l0 = writer.create_transaction_l0();
6573
6574 // Insert vertex into transaction L0
6575 let vid = writer.next_vid().await?;
6576 writer
6577 .insert_vertex_with_labels(
6578 vid,
6579 [("name".to_string(), Value::String("Ghost".to_string()))]
6580 .into_iter()
6581 .collect(),
6582 &["Person".to_string()],
6583 Some(&tx_l0),
6584 )
6585 .await?;
6586
6587 // Verify data is in transaction L0
6588 assert!(
6589 tx_l0.read().vertex_properties.contains_key(&vid),
6590 "Transaction L0 should contain the vertex"
6591 );
6592
6593 // Verify data is NOT in main L0
6594 let main_l0 = writer.l0_manager.get_current();
6595 assert!(
6596 !main_l0.read().vertex_properties.contains_key(&vid),
6597 "Main L0 should NOT contain uncommitted transaction data"
6598 );
6599
6600 // Drop transaction without committing — data should be lost
6601 drop(tx_l0);
6602
6603 // Main L0 still should not have it
6604 assert!(
6605 !main_l0.read().vertex_properties.contains_key(&vid),
6606 "Main L0 should remain clean after dropped transaction"
6607 );
6608
6609 Ok(())
6610 }
6611
6612 /// Phase 2 Day 12: the fork-fragment warn fires exactly once when
6613 /// the flush count crosses the configured threshold and stays
6614 /// silent on subsequent flushes for the lifetime of the writer.
6615 /// Primary writers (`fork_id == None`) never fire it.
6616 ///
6617 /// Tested directly against `tick_fork_fragment_observability` so
6618 /// the contract is locked in independently of the broader
6619 /// `flush_to_l1` path (the end-to-end fork-flush path is blocked
6620 /// on Day 10's on-the-fly schema overlay growth).
6621 #[tokio::test]
6622 async fn fork_fragment_warn_fires_once_then_silences() -> Result<()> {
6623 use crate::storage::manager::StorageManager;
6624 use object_store::local::LocalFileSystem;
6625 use object_store::path::Path as ObjectStorePath;
6626 use uni_common::core::fork::ForkId;
6627 use uni_common::core::schema::SchemaManager;
6628
6629 let dir = tempdir()?;
6630 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6631 let schema_path = ObjectStorePath::from("schema.json");
6632 let schema_manager =
6633 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6634 let storage = Arc::new(
6635 StorageManager::new(dir.path().to_str().unwrap(), schema_manager.clone()).await?,
6636 );
6637
6638 let config = UniConfig {
6639 fork_fragment_warn_threshold: 3,
6640 ..Default::default()
6641 };
6642 let mut writer =
6643 Writer::new_with_config(storage, schema_manager, 1, config, None, None).await?;
6644
6645 // Primary path: never fires.
6646 for _ in 0..10 {
6647 writer.tick_fork_fragment_observability();
6648 }
6649 assert!(!writer.fork_fragment_warn_fired.load(Ordering::Relaxed));
6650 assert_eq!(writer.fork_flush_count.load(Ordering::Relaxed), 0);
6651
6652 // Fork path: tag and tick. Below threshold → no fire.
6653 writer.fork_id = Some(ForkId::new());
6654 writer.tick_fork_fragment_observability();
6655 writer.tick_fork_fragment_observability();
6656 assert!(!writer.fork_fragment_warn_fired.load(Ordering::Relaxed));
6657 assert_eq!(writer.fork_flush_count.load(Ordering::Relaxed), 2);
6658
6659 // Crossing threshold → fires once.
6660 writer.tick_fork_fragment_observability();
6661 assert!(writer.fork_fragment_warn_fired.load(Ordering::Relaxed));
6662 assert_eq!(writer.fork_flush_count.load(Ordering::Relaxed), 3);
6663
6664 // Subsequent ticks bump the gauge but do not re-fire.
6665 let fired_after = writer.fork_fragment_warn_fired.load(Ordering::Relaxed);
6666 for _ in 0..5 {
6667 writer.tick_fork_fragment_observability();
6668 }
6669 assert_eq!(writer.fork_flush_count.load(Ordering::Relaxed), 8);
6670 assert_eq!(
6671 writer.fork_fragment_warn_fired.load(Ordering::Relaxed),
6672 fired_after
6673 );
6674
6675 Ok(())
6676 }
6677
6678 /// The hot-path mutators must not write to any `Writer` struct field.
6679 /// Phase 2 of the refactor
6680 /// gave them `&self` receivers, which the compiler enforces against
6681 /// direct `self.x = y` assignment — but interior-mutable writes
6682 /// (Mutex/Atomic/OnceLock) still compile. This regression test snapshots
6683 /// every potentially-writable field, calls each hot-path mutator, and
6684 /// asserts no field changed.
6685 ///
6686 /// Cold-path methods (`flush_to_l1`, `commit_transaction_l0`,
6687 /// `tick_fork_fragment_observability`) DO mutate fields by design and
6688 /// are intentionally out of scope here.
6689 #[tokio::test]
6690 async fn hot_path_mutators_do_not_change_writer_fields() -> Result<()> {
6691 use crate::storage::manager::StorageManager;
6692 use object_store::local::LocalFileSystem;
6693 use object_store::path::Path as ObjectStorePath;
6694 use uni_common::core::schema::SchemaManager;
6695
6696 let dir = tempdir()?;
6697 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
6698 let schema_path = ObjectStorePath::from("schema.json");
6699 let schema_manager =
6700 Arc::new(SchemaManager::load_from_store(store.clone(), &schema_path).await?);
6701 schema_manager.add_label("Person")?;
6702 schema_manager.save().await?;
6703 let storage = Arc::new(
6704 StorageManager::new(dir.path().to_str().unwrap(), schema_manager.clone()).await?,
6705 );
6706
6707 let writer =
6708 Writer::new_with_config(storage, schema_manager, 1, UniConfig::default(), None, None)
6709 .await?;
6710
6711 /// Captures every `Writer` field that *could* be written by a
6712 /// hot-path mutator (i.e., every non-Arc, non-immutable-after-
6713 /// construction field). Arc'd substructures (`l0_manager`,
6714 /// `storage`, etc.) are intentionally not checked — they are
6715 /// re-pointed only at construction.
6716 #[derive(Debug, PartialEq)]
6717 struct Snapshot {
6718 last_flush_time: std::time::Instant,
6719 cached_manifest_some: bool,
6720 fork_flush_count: u64,
6721 fork_fragment_warn_fired: bool,
6722 xervo_runtime_some: bool,
6723 index_rebuild_manager_some: bool,
6724 fork_id: Option<ForkId>,
6725 }
6726
6727 fn snap(w: &Writer) -> Snapshot {
6728 Snapshot {
6729 last_flush_time: *w.last_flush_time.lock(),
6730 cached_manifest_some: w.cached_manifest.lock().is_some(),
6731 fork_flush_count: w.fork_flush_count.load(Ordering::Relaxed),
6732 fork_fragment_warn_fired: w.fork_fragment_warn_fired.load(Ordering::Relaxed),
6733 xervo_runtime_some: w.xervo_runtime.get().is_some(),
6734 index_rebuild_manager_some: w.index_rebuild_manager.get().is_some(),
6735 fork_id: w.fork_id,
6736 }
6737 }
6738
6739 // 1. insert_vertex_with_labels
6740 let before = snap(&writer);
6741 let vid = writer.next_vid().await?;
6742 writer
6743 .insert_vertex_with_labels(vid, Properties::new(), &["Person".to_string()], None)
6744 .await?;
6745 assert_eq!(
6746 snap(&writer),
6747 before,
6748 "insert_vertex_with_labels mutated a Writer field"
6749 );
6750
6751 // 2. insert_vertices_batch
6752 let before = snap(&writer);
6753 let vids = writer.allocate_vids(2).await?;
6754 writer
6755 .insert_vertices_batch(
6756 vids,
6757 vec![Properties::new(), Properties::new()],
6758 vec!["Person".into()],
6759 None,
6760 )
6761 .await?;
6762 assert_eq!(
6763 snap(&writer),
6764 before,
6765 "insert_vertices_batch mutated a Writer field"
6766 );
6767
6768 // 3. delete_vertex
6769 let before = snap(&writer);
6770 writer.delete_vertex(vid, None, None).await?;
6771 assert_eq!(
6772 snap(&writer),
6773 before,
6774 "delete_vertex mutated a Writer field"
6775 );
6776
6777 // (insert_edge / delete_edge are skipped here: their fixture cost is
6778 // disproportionate to the audit's marginal value, and the same
6779 // structural argument plus the compiler-enforced `&self` covers them.)
6780
6781 Ok(())
6782 }
6783}