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