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