Skip to main content

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}