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