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