Skip to main content

uni_store/runtime/
l0.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::runtime::wal::{Mutation, WriteAheadLog};
5use anyhow::Result;
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8use std::time::{SystemTime, UNIX_EPOCH};
9use tracing::{instrument, trace};
10use uni_common::core::id::{Eid, Vid};
11use uni_common::graph::simple_graph::{Direction, SimpleGraph};
12use uni_common::{Properties, Value};
13use uni_crdt::Crdt;
14
15/// Items a read-write transaction observed during execution, used for SSI
16/// read-write antidependency detection at commit.
17///
18/// Shared (via `Arc<Mutex<_>>`) between the read path — which records reads
19/// through the transaction's `QueryContext` — and the commit path, which checks
20/// it against concurrently-committed write-sets. Item-level granularity;
21/// phantoms are out of scope (handled by the `FOR UPDATE` escape hatch).
22#[derive(Debug, Default)]
23pub struct OccReadSet {
24    /// Vertices the transaction read.
25    pub vertices: HashSet<Vid>,
26    /// Edges the transaction read.
27    pub edges: HashSet<Eid>,
28}
29
30impl OccReadSet {
31    /// `true` when nothing has been read yet. Used to decide whether a `FOR
32    /// UPDATE` acquisition may safely re-pin a still-fresh transaction.
33    pub fn is_empty(&self) -> bool {
34        self.vertices.is_empty() && self.edges.is_empty()
35    }
36}
37
38/// Returns the current timestamp in nanoseconds since Unix epoch.
39fn now_nanos() -> i64 {
40    SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .map(|d| d.as_nanos() as i64)
43        .unwrap_or(0)
44}
45
46/// Returns the [`Crdt`] a property value encodes, or `None` if it is not one.
47///
48/// `Crdt` is `#[serde(tag = "t", content = "d")]`, so it deserializes only from a
49/// JSON object, and only `Value::Map(_)` produces one — gating on `Map` avoids
50/// allocating a JSON tree for large non-map values (e.g. embedding columns).
51///
52/// This is the single source of truth for "is this value CRDT-mergeable": both
53/// the commit-time merge ([`L0Buffer::merge_crdt_properties`]) and the OCC
54/// write-set carve-out ([`crate::runtime::occ::WriteSet::from_l0`]) consult it,
55/// so the carve-out can never exclude an item the merge would actually overwrite
56/// (which would silently lose an update).
57pub(crate) fn try_as_crdt(v: &Value) -> Option<Crdt> {
58    if !matches!(v, Value::Map(_)) {
59        return None;
60    }
61    serde_json::from_value::<Crdt>(v.clone().into()).ok()
62}
63
64/// Serialize a constraint key for O(1) uniqueness checks.
65/// Format: label + separator + sorted (prop_name, value) pairs.
66pub fn serialize_constraint_key(label: &str, key_values: &[(String, Value)]) -> Vec<u8> {
67    let mut buf = label.as_bytes().to_vec();
68    buf.push(0); // separator
69    let mut sorted = key_values.to_vec();
70    sorted.sort_by(|a, b| a.0.cmp(&b.0));
71    for (k, v) in &sorted {
72        buf.extend(k.as_bytes());
73        buf.push(0);
74        // Use serde_json serialization for deterministic value encoding
75        buf.extend(serde_json::to_vec(v).unwrap_or_default());
76        buf.push(0);
77    }
78    buf
79}
80
81/// Per-type mutation counters accumulated by the L0 buffer.
82///
83/// Used to provide detailed mutation statistics (e.g., `nodes_created`,
84/// `relationships_deleted`) on `ExecuteResult`. Callers snapshot before/after
85/// execution and call [`diff()`](MutationStats::diff) to get the delta.
86#[derive(Debug, Clone, Default)]
87pub struct MutationStats {
88    pub nodes_created: usize,
89    pub nodes_deleted: usize,
90    pub relationships_created: usize,
91    pub relationships_deleted: usize,
92    pub properties_set: usize,
93    pub properties_removed: usize,
94    pub labels_added: usize,
95    pub labels_removed: usize,
96}
97
98impl MutationStats {
99    /// Compute the field-wise difference `self - before`.
100    pub fn diff(&self, before: &Self) -> Self {
101        Self {
102            nodes_created: self.nodes_created.saturating_sub(before.nodes_created),
103            nodes_deleted: self.nodes_deleted.saturating_sub(before.nodes_deleted),
104            relationships_created: self
105                .relationships_created
106                .saturating_sub(before.relationships_created),
107            relationships_deleted: self
108                .relationships_deleted
109                .saturating_sub(before.relationships_deleted),
110            properties_set: self.properties_set.saturating_sub(before.properties_set),
111            properties_removed: self
112                .properties_removed
113                .saturating_sub(before.properties_removed),
114            labels_added: self.labels_added.saturating_sub(before.labels_added),
115            labels_removed: self.labels_removed.saturating_sub(before.labels_removed),
116        }
117    }
118}
119
120#[derive(Clone, Debug)]
121pub struct TombstoneEntry {
122    pub eid: Eid,
123    pub src_vid: Vid,
124    pub dst_vid: Vid,
125    pub edge_type: u32,
126}
127
128pub struct L0Buffer {
129    /// Graph topology using simple adjacency lists
130    pub graph: SimpleGraph,
131    /// Soft-deleted edges (tombstones for LSM-style merging)
132    pub tombstones: HashMap<Eid, TombstoneEntry>,
133    /// Soft-deleted vertices
134    pub vertex_tombstones: HashSet<Vid>,
135    /// Edge version tracking for MVCC
136    pub edge_versions: HashMap<Eid, u64>,
137    /// Vertex version tracking for MVCC
138    pub vertex_versions: HashMap<Vid, u64>,
139    /// Edge properties (stored separately from topology)
140    pub edge_properties: HashMap<Eid, Properties>,
141    /// Vertex properties (stored separately from topology)
142    pub vertex_properties: HashMap<Vid, Properties>,
143    /// Edge endpoint lookup: eid -> (src, dst, type)
144    pub edge_endpoints: HashMap<Eid, (Vid, Vid, u32)>,
145    /// Vertex labels (VID -> list of label names)
146    /// New in storage design: vertices can have multiple labels
147    pub vertex_labels: HashMap<Vid, Vec<String>>,
148    /// Reverse index: label name → set of VIDs with that label. Maintained
149    /// alongside `vertex_labels` for O(1) label-based vertex lookups.
150    pub label_to_vids: HashMap<String, HashSet<Vid>>,
151    /// Vids whose FULL label set was explicitly replaced by a label mutation
152    /// (`SET n:Label` / `REMOVE n:Label`) in this buffer, via
153    /// [`L0Buffer::set_vertex_labels`]. Distinguishes a deliberate label
154    /// replacement from the empty `vertex_labels` entry a property-only write
155    /// incidentally creates (`entry().or_default()`), so `merge` knows to REPLACE
156    /// (not append) these vids' labels and `WriteSet::from_l0` knows they are
157    /// conflictable writes. A transaction-buffer concept; empty on main L0.
158    pub vertex_label_overwrites: HashSet<Vid>,
159    /// Edge types (EID -> type name)
160    pub edge_types: HashMap<Eid, String>,
161    /// Current version counter
162    pub current_version: u64,
163    /// Mutation count for flush decisions
164    pub mutation_count: usize,
165    /// Per-type mutation counters for detailed statistics.
166    pub mutation_stats: MutationStats,
167    /// Write-ahead log for durability
168    pub wal: Option<Arc<WriteAheadLog>>,
169    /// WAL LSN at the time this L0 was rotated for flush.
170    /// Used to ensure WAL truncation doesn't remove entries needed by pending flushes.
171    pub wal_lsn_at_flush: u64,
172    /// WAL LSN at the time this L0 became active (the previous rotation point).
173    ///
174    /// Everything at or below this LSN is durable in L1 before this buffer's own
175    /// data begins; while the buffer is pending flush its committed WAL entries
176    /// live strictly ABOVE it. It is therefore the floor below which WAL
177    /// truncation and a published `wal_high_water_mark` may safely advance —
178    /// using `wal_lsn_at_flush` (the high watermark) there would discard a
179    /// pending buffer's own not-yet-flushed entries (lost-commit on a graceful
180    /// close after a failed flush).
181    pub wal_lsn_at_start: u64,
182    /// Vertex creation timestamps (nanoseconds since epoch)
183    pub vertex_created_at: HashMap<Vid, i64>,
184    /// Vertex update timestamps (nanoseconds since epoch)
185    pub vertex_updated_at: HashMap<Vid, i64>,
186    /// Edge creation timestamps (nanoseconds since epoch)
187    pub edge_created_at: HashMap<Eid, i64>,
188    /// Edge update timestamps (nanoseconds since epoch)
189    pub edge_updated_at: HashMap<Eid, i64>,
190    /// Estimated size in bytes for memory limit enforcement.
191    /// Incremented O(1) on each mutation to avoid O(V+E) size_bytes() calls.
192    pub estimated_size: usize,
193    /// Per-constraint index for O(1) unique key checks.
194    /// Key: constraint composite key (label + sorted property values serialized).
195    /// Value: Vid that owns this key.
196    pub constraint_index: HashMap<Vec<u8>, Vid>,
197    /// Implicit MERGE-key guard for phantom-free `MERGE` *without* a declared
198    /// `UNIQUE` constraint. Same key format as `constraint_index` (built by
199    /// [`serialize_constraint_key`]), but populated only by a `MERGE` that
200    /// *creates* a node, and re-probed at commit only against other concurrent
201    /// `MERGE`-creates — so two concurrent `MERGE`s of the same key converge to
202    /// one node (the loser aborts retriably) instead of silently duplicating,
203    /// while a plain `CREATE` of the same properties is unaffected (it never
204    /// registers a key). Tombstoned with the owning vid; transient (not rebuilt
205    /// on recovery).
206    pub merge_guard_index: HashMap<Vec<u8>, Vid>,
207    /// Per-edge-constraint index for O(1) unique edge-key checks. Mirrors
208    /// [`constraint_index`](Self::constraint_index) but keyed to a live edge's
209    /// `Eid` (built by [`serialize_constraint_key`] with the edge-type name as the
210    /// discriminator). Populated by the edge-insert path for declared edge-type
211    /// `Unique`/`NodeKey` constraints, tombstoned in `apply_edge_deletion`, and
212    /// rebuilt from live edge properties on recovery.
213    pub edge_constraint_index: HashMap<Vec<u8>, Eid>,
214    /// Reverse index `ext_id` → owning vid for O(1) global ext_id uniqueness
215    /// checks (`Writer::check_extid_globally_unique` previously scanned every
216    /// `vertex_properties` map per insert — O(n²) ingest). Maintained by the
217    /// vertex insert impls (synced to the post-CRDT-merge value) and by
218    /// `apply_vertex_deletion`, so merge and WAL replay keep it consistent
219    /// for free.
220    pub extid_index: HashMap<String, Vid>,
221    /// Per-VID set of property keys that should land via Lance MergeInsert
222    /// (partial-column update) at flush time. Populated by
223    /// `insert_vertex_partial`; cleared by full-row inserts and deletes.
224    /// A VID present here at flush time is emitted to the partial batch;
225    /// absent VIDs flush via the existing full-row Append.
226    pub vertex_partial_keys: HashMap<Vid, HashSet<String>>,
227    /// Edge analog of `vertex_partial_keys` (Round 12 §A). Populated by
228    /// `insert_edge_partial_full`; cleared by full-row inserts and edge
229    /// deletes. Per-edge-type delta-table flush honors these by emitting
230    /// a `MergeInsertBuilder` source with only the touched schema
231    /// columns plus `eid`, `op`, `_version`, `_updated_at`, and
232    /// `overflow_json` (when an overflow prop was touched).
233    pub edge_partial_keys: HashMap<Eid, HashSet<String>>,
234    /// Phase B (UniConfig::defer_embeddings): VIDs whose auto-embedding
235    /// was skipped at insert time and is owed at flush. Value = primary
236    /// label name (the rest of the embedding config is looked up from the
237    /// schema at flush time). Drained by `flush_stream_l1` before column
238    /// extraction; entries are removed when the embedding lands in the
239    /// vertex's L0 property map.
240    pub pending_embeddings: HashMap<Vid, String>,
241    /// Optimistic-concurrency read sequence (SSI). Stamped on a transaction's
242    /// private L0 at creation with the Writer's commit-sequence at that moment,
243    /// and consulted at commit to detect intervening conflicting commits. `0`
244    /// for the main L0 and when SSI is disabled.
245    pub occ_read_seq: u64,
246    /// Optimistic-concurrency read-set (SSI). `Some` on a read-write
247    /// transaction's private L0 when SSI tracking is active; the read path
248    /// records observed ids here and commit checks them for antidependencies.
249    /// `None` for the main L0 and read-only / SSI-disabled paths.
250    pub occ_read_set: Option<Arc<parking_lot::Mutex<OccReadSet>>>,
251    /// Optional plugin registry for registry-dispatched CRDT merges at
252    /// commit-time property merge (`merge_crdt_properties`).
253    ///
254    /// Behavior-preserving when absent: falls back to native
255    /// [`uni_crdt::Crdt::try_merge`] bit-for-bit when no provider is
256    /// registered. Stamped onto every live buffer by the owning `L0Manager`.
257    /// Not part of buffer identity — cloned buffers (forked ASSUME/ABDUCE L0s)
258    /// inherit it, but it is never serialized or flushed.
259    pub plugin_registry: Option<Arc<uni_plugin::PluginRegistry>>,
260}
261
262impl std::fmt::Debug for L0Buffer {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("L0Buffer")
265            .field("vertex_count", &self.graph.vertex_count())
266            .field("edge_count", &self.graph.edge_count())
267            .field("tombstones", &self.tombstones.len())
268            .field("vertex_tombstones", &self.vertex_tombstones.len())
269            .field("current_version", &self.current_version)
270            .field("mutation_count", &self.mutation_count)
271            .finish()
272    }
273}
274
275impl Clone for L0Buffer {
276    /// Clone the L0 buffer for fork/restore (ASSUME/ABDUCE).
277    ///
278    /// The cloned buffer does NOT share the WAL reference — forked L0s are
279    /// ephemeral and should not write to the WAL.
280    fn clone(&self) -> Self {
281        Self {
282            graph: self.graph.clone(),
283            tombstones: self.tombstones.clone(),
284            vertex_tombstones: self.vertex_tombstones.clone(),
285            edge_versions: self.edge_versions.clone(),
286            vertex_versions: self.vertex_versions.clone(),
287            edge_properties: self.edge_properties.clone(),
288            vertex_properties: self.vertex_properties.clone(),
289            edge_endpoints: self.edge_endpoints.clone(),
290            vertex_labels: self.vertex_labels.clone(),
291            label_to_vids: self.label_to_vids.clone(),
292            vertex_label_overwrites: self.vertex_label_overwrites.clone(),
293            edge_types: self.edge_types.clone(),
294            current_version: self.current_version,
295            mutation_count: self.mutation_count,
296            mutation_stats: self.mutation_stats.clone(),
297            wal: None, // Forked L0s don't share the WAL
298            wal_lsn_at_flush: self.wal_lsn_at_flush,
299            wal_lsn_at_start: self.wal_lsn_at_start,
300            vertex_created_at: self.vertex_created_at.clone(),
301            vertex_updated_at: self.vertex_updated_at.clone(),
302            edge_created_at: self.edge_created_at.clone(),
303            edge_updated_at: self.edge_updated_at.clone(),
304            estimated_size: self.estimated_size,
305            constraint_index: self.constraint_index.clone(),
306            edge_constraint_index: self.edge_constraint_index.clone(),
307            merge_guard_index: self.merge_guard_index.clone(),
308            extid_index: self.extid_index.clone(),
309            vertex_partial_keys: self.vertex_partial_keys.clone(),
310            edge_partial_keys: self.edge_partial_keys.clone(),
311            pending_embeddings: self.pending_embeddings.clone(),
312            occ_read_seq: self.occ_read_seq,
313            // Forked L0s (ASSUME/ABDUCE) do not participate in OCC tracking.
314            occ_read_set: None,
315            // Inherit the registry so forked buffers merge custom CRDTs the
316            // same way the parent does.
317            plugin_registry: self.plugin_registry.clone(),
318        }
319    }
320}
321
322impl L0Buffer {
323    /// Append labels to a vec, skipping duplicates.
324    fn append_unique_labels(existing: &mut Vec<String>, labels: &[String]) {
325        for label in labels {
326            if !existing.contains(label) {
327                existing.push(label.clone());
328            }
329        }
330    }
331
332    /// Add a VID to the reverse label index for each of the given labels.
333    fn index_labels_for_vid(&mut self, vid: Vid, labels: &[String]) {
334        for label in labels {
335            self.label_to_vids
336                .entry(label.clone())
337                .or_default()
338                .insert(vid);
339        }
340    }
341
342    /// Read a vertex's current string `ext_id` from a property map.
343    fn extid_of(props: &Properties) -> Option<String> {
344        props
345            .get("ext_id")
346            .and_then(|v| v.as_str())
347            .map(str::to_owned)
348    }
349
350    /// Sync `extid_index` for `vid` around a property write.
351    ///
352    /// `old` / `new` are the vertex's ext_id before / after the CRDT merge —
353    /// the index always reflects the post-merge value, matching what the old
354    /// full scan in `Writer::check_extid_globally_unique` observed. Only
355    /// called when the incoming properties contain an `ext_id` key (the merge
356    /// cannot change the value otherwise).
357    fn sync_extid_index(&mut self, vid: Vid, old: Option<String>, new: Option<String>) {
358        if old == new {
359            return;
360        }
361        if let Some(old) = old
362            && self.extid_index.get(&old) == Some(&vid)
363        {
364            self.extid_index.remove(&old);
365        }
366        if let Some(new) = new {
367            self.extid_index.insert(new, vid);
368        }
369    }
370
371    /// Remove a VID from all label entries in the reverse index.
372    fn remove_vid_from_label_index(&mut self, vid: Vid) {
373        if let Some(labels) = self.vertex_labels.get(&vid) {
374            for label in labels {
375                if let Some(set) = self.label_to_vids.get_mut(label) {
376                    set.remove(&vid);
377                }
378            }
379        }
380    }
381
382    /// Replaces a vertex's FULL label set — the semantics of `SET n:Label` /
383    /// `REMOVE n:Label`, which resolve the new complete set before writing.
384    ///
385    /// Unlike [`add_vertex_labels`](Self::add_vertex_labels) (append), this clears
386    /// the vid's existing labels from the reverse index, sets the new set, and
387    /// re-indexes — so a removal actually removes. It marks the vid in
388    /// `vertex_label_overwrites` so `merge` REPLACES (not appends) these labels at
389    /// commit and `WriteSet::from_l0` treats the change as a conflictable write.
390    /// Increments `mutation_count` (a label change is a real mutation; its sibling
391    /// `remove_vertex_label` already does so).
392    pub fn set_vertex_labels(&mut self, vid: Vid, labels: &[String]) {
393        self.remove_vid_from_label_index(vid);
394        self.vertex_labels.insert(vid, labels.to_vec());
395        self.index_labels_for_vid(vid, labels);
396        self.vertex_label_overwrites.insert(vid);
397        self.current_version += 1;
398        self.mutation_count += 1;
399    }
400
401    /// Merge CRDT properties into an existing property map.
402    /// Attempts CRDT merge if both values are valid CRDTs, falls back to overwrite.
403    ///
404    /// When the entry is empty (new vertex insert), skips the expensive JSON
405    /// round-trip and directly assigns the properties.
406    ///
407    /// Logs a warning when a CRDT value is overwritten by a non-CRDT scalar
408    /// (limitation R1).
409    fn merge_crdt_properties(
410        entry: &mut Properties,
411        properties: Properties,
412        registry: Option<&Arc<uni_plugin::PluginRegistry>>,
413    ) {
414        // Fast path: new vertex with no existing properties — skip JSON round-trip
415        if entry.is_empty() {
416            *entry = properties;
417            return;
418        }
419
420        for (k, v) in properties {
421            // `try_as_crdt` performs the Map-gated CRDT probe (see its docs for the
422            // wide-row perf rationale). Sharing it with `WriteSet::from_l0` keeps the
423            // OCC carve-out consistent with this merge-versus-overwrite decision.
424            if let Some(mut new_crdt) = try_as_crdt(&v)
425                && let Some(existing_v) = entry.get(&k)
426                && let Ok(existing_crdt) = serde_json::from_value::<Crdt>(existing_v.clone().into())
427            {
428                // Use a fallible merge to avoid panic on type mismatch.
429                // Operand order: self=new (new_crdt), other=existing
430                // (existing_crdt) — existing-into-new. Preserve — a custom
431                // provider's merge may be non-commutative.
432                let merged = match registry {
433                    Some(reg) => new_crdt.merge_via_registry(&existing_crdt, reg).is_ok(),
434                    None => new_crdt.try_merge(&existing_crdt).is_ok(),
435                };
436                if merged && let Ok(merged_json) = serde_json::to_value(new_crdt) {
437                    entry.insert(k, uni_common::Value::from(merged_json));
438                    continue;
439                }
440                // CRDT variant mismatch (or a failed re-serialize): fall through to
441                // a last-writer-wins overwrite, discarding the existing CRDT's
442                // merged state. The OCC commit-time carve-out check
443                // (`occ::crdt_carveout_overwrite`) aborts a *concurrent* writer
444                // before reaching here, and write-time schema enforcement rejects a
445                // wrong declared variant; this warns on any residual (e.g. a
446                // single-writer variant change) so the discarded state is visible.
447                tracing::warn!(
448                    property = %k,
449                    existing_variant = existing_crdt.type_name(),
450                    "overwriting CRDT property with a different CRDT variant \
451                     (last-writer-wins); merged CRDT state is discarded"
452                );
453            } else if try_as_crdt(&v).is_none()
454                && entry.get(&k).is_some_and(|e| try_as_crdt(e).is_some())
455            {
456                // R1: an existing CRDT value is overwritten by a non-CRDT scalar
457                // (last-writer-wins), silently discarding the CRDT's merged state
458                // — a property written as BOTH a CRDT and last-writer-wins. The OCC
459                // write-set carve-out lets CRDT-only writers commit without
460                // conflicting, so a concurrent LWW write on the same property
461                // cannot be flagged as a conflict; surface it here instead.
462                tracing::warn!(
463                    property = %k,
464                    "overwriting CRDT property with non-CRDT value (last-writer-wins); \
465                     merged CRDT state is discarded"
466                );
467            }
468            // Fallback: Overwrite (last-writer-wins).
469            entry.insert(k, v);
470        }
471    }
472
473    /// Helper function to estimate property map size in bytes.
474    fn estimate_properties_size(props: &Properties) -> usize {
475        props.keys().map(|k| k.len() + 32).sum()
476    }
477
478    /// Returns an estimate of the buffer size in bytes.
479    /// Includes all fields for accurate memory accounting.
480    pub fn size_bytes(&self) -> usize {
481        let mut total = 0;
482
483        // Topology
484        total += self.graph.vertex_count() * 8;
485        total += self.graph.edge_count() * 24;
486
487        // Properties (rough estimate: key string + 32 bytes for value)
488        for props in self.vertex_properties.values() {
489            total += Self::estimate_properties_size(props);
490        }
491        for props in self.edge_properties.values() {
492            total += Self::estimate_properties_size(props);
493        }
494
495        // Metadata
496        total += self.tombstones.len() * 64;
497        total += self.vertex_tombstones.len() * 8;
498        total += self.edge_versions.len() * 16;
499        total += self.vertex_versions.len() * 16;
500        total += self.edge_endpoints.len() * 28; // (Vid, Vid, u32) = 8+8+4 + overhead
501
502        // Vertex labels
503        for labels in self.vertex_labels.values() {
504            total += labels.iter().map(|l| l.len() + 24).sum::<usize>();
505        }
506
507        // Reverse label index (label_to_vids)
508        for (label, vids) in &self.label_to_vids {
509            total += label.len() + 24 + vids.len() * 8 + 48; // string + HashSet overhead
510        }
511
512        // Edge types
513        for type_name in self.edge_types.values() {
514            total += type_name.len() + 24;
515        }
516
517        // Timestamps (4 maps, each entry is 16 bytes: 8-byte key + 8-byte i64 value)
518        total += self.vertex_created_at.len() * 16;
519        total += self.vertex_updated_at.len() * 16;
520        total += self.edge_created_at.len() * 16;
521        total += self.edge_updated_at.len() * 16;
522
523        total
524    }
525
526    pub fn new(start_version: u64, wal: Option<Arc<WriteAheadLog>>) -> Self {
527        Self {
528            graph: SimpleGraph::new(),
529            tombstones: HashMap::new(),
530            vertex_tombstones: HashSet::new(),
531            edge_versions: HashMap::new(),
532            vertex_versions: HashMap::new(),
533            edge_properties: HashMap::new(),
534            vertex_properties: HashMap::new(),
535            edge_endpoints: HashMap::new(),
536            vertex_labels: HashMap::new(),
537            label_to_vids: HashMap::new(),
538            vertex_label_overwrites: HashSet::new(),
539            edge_types: HashMap::new(),
540            current_version: start_version,
541            mutation_count: 0,
542            mutation_stats: MutationStats::default(),
543            wal,
544            wal_lsn_at_flush: 0,
545            wal_lsn_at_start: 0,
546            vertex_created_at: HashMap::new(),
547            vertex_updated_at: HashMap::new(),
548            edge_created_at: HashMap::new(),
549            edge_updated_at: HashMap::new(),
550            estimated_size: 0,
551            constraint_index: HashMap::new(),
552            edge_constraint_index: HashMap::new(),
553            merge_guard_index: HashMap::new(),
554            extid_index: HashMap::new(),
555            vertex_partial_keys: HashMap::new(),
556            edge_partial_keys: HashMap::new(),
557            pending_embeddings: HashMap::new(),
558            occ_read_seq: 0,
559            occ_read_set: None,
560            plugin_registry: None,
561        }
562    }
563
564    /// Install the plugin registry used for registry-dispatched CRDT merges.
565    ///
566    /// Stamped by the owning `L0Manager` onto every buffer it mints so the
567    /// commit-time merge (`merge_crdt_properties`) can route custom
568    /// CRDT kinds through a registered provider. Absent registry preserves
569    /// native [`uni_crdt::Crdt::try_merge`] behavior.
570    pub fn set_plugin_registry(&mut self, registry: Arc<uni_plugin::PluginRegistry>) {
571        self.plugin_registry = Some(registry);
572    }
573
574    pub fn insert_vertex(&mut self, vid: Vid, properties: Properties) {
575        self.insert_vertex_with_labels(vid, properties, &[]);
576    }
577
578    /// Insert a vertex with associated labels.
579    pub fn insert_vertex_with_labels(
580        &mut self,
581        vid: Vid,
582        properties: Properties,
583        labels: &[String],
584    ) {
585        self.insert_vertex_with_labels_impl(vid, properties, labels, false);
586    }
587
588    /// Core vertex insertion. When `skip_wal` is true, skips WAL append
589    /// (used during merge where the caller already wrote to WAL).
590    fn insert_vertex_with_labels_impl(
591        &mut self,
592        vid: Vid,
593        properties: Properties,
594        labels: &[String],
595        skip_wal: bool,
596    ) {
597        self.current_version += 1;
598        let version = self.current_version;
599        let now = now_nanos();
600
601        if !skip_wal && let Some(wal) = &self.wal {
602            let _ = wal.append(Mutation::InsertVertex {
603                vid,
604                properties: properties.clone(),
605                labels: labels.to_vec(),
606            });
607        }
608
609        self.vertex_tombstones.remove(&vid);
610
611        // Full-row insert supersedes any pending partial-update state for
612        // this VID.
613        self.vertex_partial_keys.remove(&vid);
614
615        // Size/count computed up front so `properties` can be moved into the
616        // CRDT merge below instead of deep-cloned.
617        let props_size = Self::estimate_properties_size(&properties);
618        let props_count = properties.len();
619        let tracks_extid = properties.contains_key("ext_id");
620
621        let entry = self.vertex_properties.entry(vid).or_default();
622        let old_extid = if tracks_extid {
623            Self::extid_of(entry)
624        } else {
625            None
626        };
627        Self::merge_crdt_properties(entry, properties, self.plugin_registry.as_ref());
628        if tracks_extid {
629            let new_extid =
630                Self::extid_of(self.vertex_properties.get(&vid).expect("just inserted"));
631            self.sync_extid_index(vid, old_extid, new_extid);
632        }
633        self.vertex_versions.insert(vid, version);
634
635        // Set timestamps - created_at only set if this is a new vertex
636        self.vertex_created_at.entry(vid).or_insert(now);
637        self.vertex_updated_at.insert(vid, now);
638
639        // Track labels — always create an entry so unlabeled vertices are
640        // distinguishable from "not in L0" when queried via get_vertex_labels.
641        let labels_size: usize = labels.iter().map(|l| l.len() + 24).sum();
642        let existing = self.vertex_labels.entry(vid).or_default();
643        Self::append_unique_labels(existing, labels);
644        self.index_labels_for_vid(vid, labels);
645
646        self.graph.add_vertex(vid);
647        self.mutation_count += 1;
648        self.mutation_stats.nodes_created += 1;
649        self.mutation_stats.properties_set += props_count;
650        self.mutation_stats.labels_added += labels.len();
651
652        self.estimated_size += 8 + props_size + 16 + labels_size + 32;
653    }
654
655    /// Insert a vertex's FULL property row, tagging `touched_keys` so the
656    /// flush emits exactly those columns via Lance `MergeInsertBuilder`
657    /// instead of a full-row Append.
658    ///
659    /// `props` MUST be the fully-merged property map (storage union
660    /// in-flight L0 union the new touched values, per
661    /// `PropertyManager::get_all_vertex_props_with_ctx`). The caller is
662    /// responsible for the union; L0 here just stores it so scans see
663    /// the complete row without per-key reconciliation.
664    ///
665    /// `touched_keys` lists the property keys this SET statement
666    /// actually assigned — the union of those across all coalesced
667    /// SetItems on this VID. Lance MergeInsert sends a source batch
668    /// with `_vid`, `_deleted`, `_version`, `_updated_at`, and those
669    /// touched columns; non-touched columns retain their pre-merge
670    /// values on the Lance side, skipping the wide-row write.
671    ///
672    /// A subsequent full-row `insert_vertex_with_labels` or
673    /// `delete_vertex` on the same VID clears the partial-keys entry
674    /// so partial state never outlives a stronger write.
675    pub fn insert_vertex_partial_full(
676        &mut self,
677        vid: Vid,
678        props: Properties,
679        touched_keys: HashSet<String>,
680        labels: &[String],
681    ) {
682        // Stage the full row through the existing partial-impl (same
683        // CRDT merge / version bump / timestamps), preserving the
684        // partial-keys entry so we can extend it below.
685        self.insert_vertex_with_labels_partial_impl(vid, props, labels, false);
686        self.vertex_partial_keys
687            .entry(vid)
688            .or_default()
689            .extend(touched_keys);
690    }
691
692    /// Legacy partial-only variant used by some uni-store paths. Kept
693    /// for source-compatibility but new uni-query callers should use
694    /// `insert_vertex_partial_full` to preserve scan-side L0 visibility.
695    pub fn insert_vertex_partial(&mut self, vid: Vid, touched: Properties, labels: &[String]) {
696        // Record dirty keys BEFORE the full-row impl runs (which would
697        // clear them). The keys come from the touched set; the values
698        // are merged into L0 by the shared CRDT path below.
699        let touched_keys: Vec<String> = touched.keys().cloned().collect();
700
701        // If the VID already has a full-row pending insert (e.g., CREATE
702        // earlier in the same tx), we must NOT downgrade it to partial.
703        // Detected by: VID is in vertex_properties WITH a version stamp
704        // AND not currently in vertex_partial_keys → it was written as
705        // a full row recently. The conservative rule: only enable the
706        // partial path when there's no full-row pending insert. We
707        // approximate "no full-row pending" by checking that the VID's
708        // current entry in vertex_partial_keys is non-empty OR the VID
709        // is not in vertex_properties (fresh row, but caller asked
710        // partial — let it through and the post-flush union covers it).
711        let already_full = self.vertex_properties.contains_key(&vid)
712            && !self.vertex_partial_keys.contains_key(&vid);
713
714        // Stage the CRDT merge through the existing path. We bypass the
715        // full-row `insert_vertex_with_labels_impl` clearing of
716        // partial_keys by inlining the work, then restoring/extending
717        // the partial-key set.
718        self.insert_vertex_with_labels_partial_impl(vid, touched, labels, false);
719
720        if !already_full {
721            self.vertex_partial_keys
722                .entry(vid)
723                .or_default()
724                .extend(touched_keys);
725        }
726    }
727
728    /// Core partial-insert: same as `insert_vertex_with_labels_impl` but
729    /// preserves any existing `vertex_partial_keys[vid]` entry so the
730    /// caller can extend it after the merge.
731    fn insert_vertex_with_labels_partial_impl(
732        &mut self,
733        vid: Vid,
734        properties: Properties,
735        labels: &[String],
736        skip_wal: bool,
737    ) {
738        self.current_version += 1;
739        let version = self.current_version;
740        let now = now_nanos();
741
742        if !skip_wal && let Some(wal) = &self.wal {
743            // WAL records the partial as a full-row InsertVertex; on replay
744            // the full-row path runs (which clears partial_keys). This is
745            // semantically correct — L0 in memory always holds the union of
746            // partial deltas via merge_crdt_properties; recovery doesn't
747            // need to preserve partial-vs-full distinction.
748            let _ = wal.append(Mutation::InsertVertex {
749                vid,
750                properties: properties.clone(),
751                labels: labels.to_vec(),
752            });
753        }
754
755        self.vertex_tombstones.remove(&vid);
756        // NOTE: deliberately DOES NOT remove from vertex_partial_keys.
757        // The caller (`insert_vertex_partial`) extends that set after.
758
759        // Size/count computed up front so `properties` can be moved into the
760        // CRDT merge below instead of deep-cloned.
761        let props_size = Self::estimate_properties_size(&properties);
762        let props_count = properties.len();
763        let tracks_extid = properties.contains_key("ext_id");
764
765        let entry = self.vertex_properties.entry(vid).or_default();
766        let old_extid = if tracks_extid {
767            Self::extid_of(entry)
768        } else {
769            None
770        };
771        Self::merge_crdt_properties(entry, properties, self.plugin_registry.as_ref());
772        if tracks_extid {
773            let new_extid =
774                Self::extid_of(self.vertex_properties.get(&vid).expect("just inserted"));
775            self.sync_extid_index(vid, old_extid, new_extid);
776        }
777        self.vertex_versions.insert(vid, version);
778
779        self.vertex_created_at.entry(vid).or_insert(now);
780        self.vertex_updated_at.insert(vid, now);
781
782        let labels_size: usize = labels.iter().map(|l| l.len() + 24).sum();
783        let existing = self.vertex_labels.entry(vid).or_default();
784        Self::append_unique_labels(existing, labels);
785        self.index_labels_for_vid(vid, labels);
786
787        self.graph.add_vertex(vid);
788        self.mutation_count += 1;
789        // Partial writes don't create new nodes — they update existing ones.
790        // But counting under properties_set is correct.
791        self.mutation_stats.properties_set += props_count;
792        self.mutation_stats.labels_added += labels.len();
793
794        self.estimated_size += 8 + props_size + 16 + labels_size + 32;
795    }
796
797    /// Add labels to an existing vertex.
798    pub fn add_vertex_labels(&mut self, vid: Vid, labels: &[String]) {
799        let existing = self.vertex_labels.entry(vid).or_default();
800        Self::append_unique_labels(existing, labels);
801        self.index_labels_for_vid(vid, labels);
802    }
803
804    /// Remove a label from an existing vertex.
805    /// Returns true if the label was found and removed, false otherwise.
806    pub fn remove_vertex_label(&mut self, vid: Vid, label: &str) -> bool {
807        if let Some(labels) = self.vertex_labels.get_mut(&vid)
808            && let Some(pos) = labels.iter().position(|l| l == label)
809        {
810            labels.remove(pos);
811            if let Some(set) = self.label_to_vids.get_mut(label) {
812                set.remove(&vid);
813            }
814            self.current_version += 1;
815            self.mutation_count += 1;
816            self.mutation_stats.labels_removed += 1;
817            // Note: WAL logging for label mutations not yet implemented
818            // Currently consistent with add_vertex_labels behavior
819            return true;
820        }
821        false
822    }
823
824    /// Set the type for an edge.
825    pub fn set_edge_type(&mut self, eid: Eid, edge_type: String) {
826        self.edge_types.insert(eid, edge_type);
827    }
828
829    pub fn delete_vertex(&mut self, vid: Vid) -> Result<()> {
830        self.delete_vertex_impl(vid, false)
831    }
832
833    /// Core vertex deletion. When `skip_wal` is true, skips WAL append
834    /// (used during merge where the caller already wrote to WAL).
835    fn delete_vertex_impl(&mut self, vid: Vid, skip_wal: bool) -> Result<()> {
836        self.current_version += 1;
837
838        if !skip_wal && let Some(wal) = &mut self.wal {
839            let labels = self.vertex_labels.get(&vid).cloned().unwrap_or_default();
840            wal.append(Mutation::DeleteVertex { vid, labels })?;
841        }
842
843        self.apply_vertex_deletion(vid);
844        Ok(())
845    }
846
847    /// Cascade-delete a vertex: tombstone all connected edges and remove the vertex.
848    ///
849    /// Shared between `delete_vertex` (live mutations) and `replay_mutations` (WAL recovery).
850    fn apply_vertex_deletion(&mut self, vid: Vid) {
851        let version = self.current_version;
852
853        // Collect edges to delete using O(degree) neighbors() instead of O(E) scan
854        let mut edges_to_remove = HashSet::new();
855
856        // Collect outgoing edges
857        for entry in self.graph.neighbors(vid, Direction::Outgoing) {
858            edges_to_remove.insert(entry.eid);
859        }
860
861        // Collect incoming edges
862        for entry in self.graph.neighbors(vid, Direction::Incoming) {
863            edges_to_remove.insert(entry.eid); // HashSet handles self-loop deduplication
864        }
865
866        let cascaded_edges_count = edges_to_remove.len();
867
868        // Tombstone and remove all collected edges
869        for eid in edges_to_remove {
870            // Retrieve edge endpoints from the map to create tombstone
871            if let Some((src, dst, etype)) = self.edge_endpoints.get(&eid) {
872                self.tombstones.insert(
873                    eid,
874                    TombstoneEntry {
875                        eid,
876                        src_vid: *src,
877                        dst_vid: *dst,
878                        edge_type: *etype,
879                    },
880                );
881                self.edge_versions.insert(eid, version);
882                self.edge_endpoints.remove(&eid);
883                self.edge_properties.remove(&eid);
884                self.graph.remove_edge(eid);
885                self.mutation_count += 1;
886                self.mutation_stats.relationships_deleted += 1;
887            }
888        }
889
890        self.remove_vid_from_label_index(vid);
891        self.vertex_tombstones.insert(vid);
892        // Drop the vid's ext_id index entry (O(1) via its current property
893        // value, read before the property map entry is removed below).
894        if let Some(props) = self.vertex_properties.get(&vid)
895            && let Some(ext) = Self::extid_of(props)
896            && self.extid_index.get(&ext) == Some(&vid)
897        {
898            self.extid_index.remove(&ext);
899        }
900        self.vertex_properties.remove(&vid);
901        // Deletion supersedes any pending partial-update state.
902        self.vertex_partial_keys.remove(&vid);
903        self.vertex_versions.insert(vid, version);
904        self.graph.remove_vertex(vid);
905        self.mutation_count += 1;
906        self.mutation_stats.nodes_deleted += 1;
907
908        // Remove constraint index entries for this vertex
909        self.constraint_index.retain(|_, v| *v != vid);
910        // Same for the implicit MERGE guard, so a later re-MERGE of a deleted
911        // node's key does not false-conflict with the stale entry.
912        self.merge_guard_index.retain(|_, v| *v != vid);
913
914        // 64 bytes per edge tombstone + 8 for vertex tombstone
915        self.estimated_size += cascaded_edges_count * 72 + 8;
916    }
917
918    pub fn insert_edge(
919        &mut self,
920        src_vid: Vid,
921        dst_vid: Vid,
922        edge_type: u32,
923        eid: Eid,
924        properties: Properties,
925        edge_type_name: Option<String>,
926    ) -> Result<()> {
927        self.insert_edge_impl(
928            src_vid,
929            dst_vid,
930            edge_type,
931            eid,
932            properties,
933            edge_type_name,
934            false,
935        )
936    }
937
938    /// Core edge insertion. When `skip_wal` is true, skips WAL append
939    /// (used during merge where the caller already wrote to WAL).
940    #[allow(clippy::too_many_arguments)]
941    fn insert_edge_impl(
942        &mut self,
943        src_vid: Vid,
944        dst_vid: Vid,
945        edge_type: u32,
946        eid: Eid,
947        properties: Properties,
948        edge_type_name: Option<String>,
949        skip_wal: bool,
950    ) -> Result<()> {
951        self.current_version += 1;
952        let now = now_nanos();
953
954        if !skip_wal && let Some(wal) = &mut self.wal {
955            wal.append(Mutation::InsertEdge {
956                src_vid,
957                dst_vid,
958                edge_type,
959                eid,
960                version: self.current_version,
961                properties: properties.clone(),
962                edge_type_name: edge_type_name.clone(),
963            })?;
964        }
965
966        self.apply_edge_insertion(src_vid, dst_vid, edge_type, eid, properties)?;
967
968        // Store edge type name in metadata if provided
969        let type_name_size = if let Some(ref name) = edge_type_name {
970            let size = name.len() + 24;
971            self.edge_types.insert(eid, name.clone());
972            size
973        } else {
974            0
975        };
976
977        // Set timestamps - created_at only set if this is a new edge
978        self.edge_created_at.entry(eid).or_insert(now);
979        self.edge_updated_at.insert(eid, now);
980
981        // A full-row insert supersedes any pending partial-update state
982        // for this EID (Round 12 §A).
983        self.edge_partial_keys.remove(&eid);
984
985        self.estimated_size += type_name_size;
986
987        Ok(())
988    }
989
990    /// Insert an edge's FULL property row plus a touched-keys hint so the
991    /// flush emits only those schema columns via Lance `MergeInsert` on
992    /// the per-edge-type delta tables. Edge analog of
993    /// `insert_vertex_partial_full` (Round 12 §A).
994    #[allow(clippy::too_many_arguments)]
995    pub fn insert_edge_partial_full(
996        &mut self,
997        src_vid: Vid,
998        dst_vid: Vid,
999        edge_type: u32,
1000        eid: Eid,
1001        properties: Properties,
1002        edge_type_name: Option<String>,
1003        touched_keys: HashSet<String>,
1004    ) -> Result<()> {
1005        self.current_version += 1;
1006        let now = now_nanos();
1007
1008        if let Some(wal) = &mut self.wal {
1009            wal.append(Mutation::InsertEdge {
1010                src_vid,
1011                dst_vid,
1012                edge_type,
1013                eid,
1014                version: self.current_version,
1015                properties: properties.clone(),
1016                edge_type_name: edge_type_name.clone(),
1017            })?;
1018        }
1019
1020        self.apply_edge_insertion(src_vid, dst_vid, edge_type, eid, properties)?;
1021
1022        // `apply_edge_insertion` cleared the partial-keys entry as a
1023        // safety measure (full-row insert supersedes partial). Re-insert
1024        // with the touched-keys hint so the flush emits a partial source.
1025        self.edge_partial_keys
1026            .entry(eid)
1027            .or_default()
1028            .extend(touched_keys);
1029
1030        let type_name_size = if let Some(ref name) = edge_type_name {
1031            let size = name.len() + 24;
1032            self.edge_types.insert(eid, name.clone());
1033            size
1034        } else {
1035            0
1036        };
1037
1038        self.edge_created_at.entry(eid).or_insert(now);
1039        self.edge_updated_at.insert(eid, now);
1040
1041        self.estimated_size += type_name_size;
1042
1043        Ok(())
1044    }
1045
1046    /// Core edge insertion logic: add vertices, add edge, merge properties, update metadata.
1047    ///
1048    /// Shared between `insert_edge` (live mutations) and `replay_mutations` (WAL recovery).
1049    ///
1050    /// # Errors
1051    ///
1052    /// Returns error if either endpoint vertex has been deleted (exists in vertex_tombstones).
1053    /// This prevents "ghost vertex" resurrection via edge insertion. See issue #77.
1054    fn apply_edge_insertion(
1055        &mut self,
1056        src_vid: Vid,
1057        dst_vid: Vid,
1058        edge_type: u32,
1059        eid: Eid,
1060        properties: Properties,
1061    ) -> Result<()> {
1062        let version = self.current_version;
1063
1064        // Check if either endpoint has been deleted. Inserting an edge to a deleted
1065        // vertex would resurrect it as a "ghost vertex" with no properties. See issue #77.
1066        if self.vertex_tombstones.contains(&src_vid) {
1067            anyhow::bail!(
1068                "Cannot insert edge: source vertex {} has been deleted (issue #77)",
1069                src_vid
1070            );
1071        }
1072        if self.vertex_tombstones.contains(&dst_vid) {
1073            anyhow::bail!(
1074                "Cannot insert edge: destination vertex {} has been deleted (issue #77)",
1075                dst_vid
1076            );
1077        }
1078
1079        // Add vertices to graph topology if they don't exist.
1080        // IMPORTANT: Only add to graph structure, do NOT call insert_vertex.
1081        // insert_vertex creates a new version with empty properties, which would
1082        // cause MVCC to pick the empty version as "latest", losing original properties.
1083        if !self.graph.contains_vertex(src_vid) {
1084            self.graph.add_vertex(src_vid);
1085        }
1086        if !self.graph.contains_vertex(dst_vid) {
1087            self.graph.add_vertex(dst_vid);
1088        }
1089
1090        self.graph.add_edge(src_vid, dst_vid, eid, edge_type);
1091
1092        // Store metadata with CRDT merge logic
1093        let props_size = Self::estimate_properties_size(&properties);
1094        let props_count = properties.len();
1095        if !properties.is_empty() {
1096            let entry = self.edge_properties.entry(eid).or_default();
1097            Self::merge_crdt_properties(entry, properties, self.plugin_registry.as_ref());
1098        }
1099
1100        self.edge_versions.insert(eid, version);
1101        self.edge_endpoints
1102            .insert(eid, (src_vid, dst_vid, edge_type));
1103        self.tombstones.remove(&eid);
1104        self.mutation_count += 1;
1105        self.mutation_stats.relationships_created += 1;
1106        self.mutation_stats.properties_set += props_count;
1107
1108        // 24 edge + props + 16 version + 28 endpoints + 32 timestamps
1109        self.estimated_size += 24 + props_size + 16 + 28 + 32;
1110
1111        Ok(())
1112    }
1113
1114    pub fn delete_edge(
1115        &mut self,
1116        eid: Eid,
1117        src_vid: Vid,
1118        dst_vid: Vid,
1119        edge_type: u32,
1120    ) -> Result<()> {
1121        self.delete_edge_impl(eid, src_vid, dst_vid, edge_type, false)
1122    }
1123
1124    /// Core edge deletion. When `skip_wal` is true, skips WAL append
1125    /// (used during merge where the caller already wrote to WAL).
1126    fn delete_edge_impl(
1127        &mut self,
1128        eid: Eid,
1129        src_vid: Vid,
1130        dst_vid: Vid,
1131        edge_type: u32,
1132        skip_wal: bool,
1133    ) -> Result<()> {
1134        self.current_version += 1;
1135        let now = now_nanos();
1136
1137        if !skip_wal && let Some(wal) = &mut self.wal {
1138            wal.append(Mutation::DeleteEdge {
1139                eid,
1140                src_vid,
1141                dst_vid,
1142                edge_type,
1143                version: self.current_version,
1144            })?;
1145        }
1146
1147        self.apply_edge_deletion(eid, src_vid, dst_vid, edge_type);
1148
1149        // Update timestamp - deletion is an update
1150        self.edge_updated_at.insert(eid, now);
1151
1152        Ok(())
1153    }
1154
1155    /// Core edge deletion logic: tombstone the edge, update version, remove from graph.
1156    ///
1157    /// Shared between `delete_edge` (live mutations) and `replay_mutations` (WAL recovery).
1158    fn apply_edge_deletion(&mut self, eid: Eid, src_vid: Vid, dst_vid: Vid, edge_type: u32) {
1159        let version = self.current_version;
1160
1161        self.tombstones.insert(
1162            eid,
1163            TombstoneEntry {
1164                eid,
1165                src_vid,
1166                dst_vid,
1167                edge_type,
1168            },
1169        );
1170        self.edge_versions.insert(eid, version);
1171        // Deletion supersedes any pending partial-update state for this
1172        // EID (Round 12 §A).
1173        self.edge_partial_keys.remove(&eid);
1174        // Drop any unique-constraint keys this edge owned so a later edge may
1175        // reuse the value (mirrors `apply_vertex_deletion`).
1176        self.edge_constraint_index.retain(|_, e| *e != eid);
1177        self.graph.remove_edge(eid);
1178        self.mutation_count += 1;
1179        self.mutation_stats.relationships_deleted += 1;
1180
1181        // 64 bytes tombstone + 16 bytes version
1182        self.estimated_size += 80;
1183    }
1184
1185    /// Returns neighbors in the specified direction.
1186    /// O(degree) complexity - iterates only edges connected to the vertex.
1187    pub fn get_neighbors(
1188        &self,
1189        vid: Vid,
1190        edge_type: u32,
1191        direction: Direction,
1192    ) -> Vec<(Vid, Eid, u64)> {
1193        let edges = self.graph.neighbors(vid, direction);
1194
1195        edges
1196            .iter()
1197            .filter(|e| e.edge_type == edge_type && !self.is_tombstoned(e.eid))
1198            .map(|e| {
1199                let neighbor = match direction {
1200                    Direction::Outgoing => e.dst_vid,
1201                    Direction::Incoming => e.src_vid,
1202                };
1203                let version = self.edge_versions.get(&e.eid).copied().unwrap_or(0);
1204                (neighbor, e.eid, version)
1205            })
1206            .collect()
1207    }
1208
1209    pub fn is_tombstoned(&self, eid: Eid) -> bool {
1210        self.tombstones.contains_key(&eid)
1211    }
1212
1213    /// Returns all VIDs in vertex_labels that match the given label name.
1214    /// O(1) lookup via the reverse label index.
1215    pub fn vids_for_label(&self, label_name: &str) -> Vec<Vid> {
1216        self.label_to_vids
1217            .get(label_name)
1218            .map(|set| set.iter().copied().collect())
1219            .unwrap_or_default()
1220    }
1221
1222    /// Returns all vertex VIDs in the L0 buffer.
1223    ///
1224    /// Used for schemaless scanning (MATCH (n) without label).
1225    pub fn all_vertex_vids(&self) -> Vec<Vid> {
1226        self.vertex_properties.keys().copied().collect()
1227    }
1228
1229    /// Returns all VIDs in vertex_labels that match any of the given label names.
1230    /// Uses the reverse label index — O(sum of matching set sizes).
1231    pub fn vids_for_labels(&self, label_names: &[&str]) -> Vec<Vid> {
1232        let mut result = HashSet::new();
1233        for label_name in label_names {
1234            if let Some(set) = self.label_to_vids.get(*label_name) {
1235                result.extend(set.iter().copied());
1236            }
1237        }
1238        result.into_iter().collect()
1239    }
1240
1241    /// Returns all VIDs that have ALL specified labels.
1242    /// Uses the reverse label index — intersects the per-label sets.
1243    pub fn vids_with_all_labels(&self, label_names: &[&str]) -> Vec<Vid> {
1244        if label_names.is_empty() {
1245            return Vec::new();
1246        }
1247        // Collect the per-label sets; if any label is missing from the index,
1248        // the intersection is empty.
1249        let sets: Vec<&HashSet<Vid>> = match label_names
1250            .iter()
1251            .map(|ln| self.label_to_vids.get(*ln))
1252            .collect::<Option<Vec<_>>>()
1253        {
1254            Some(s) => s,
1255            None => return Vec::new(),
1256        };
1257        // Start from the smallest set for efficiency.
1258        let smallest = sets.iter().min_by_key(|s| s.len()).unwrap();
1259        smallest
1260            .iter()
1261            .copied()
1262            .filter(|vid| sets.iter().all(|s| s.contains(vid)))
1263            .collect()
1264    }
1265
1266    /// Gets the labels for a VID.
1267    pub fn get_vertex_labels(&self, vid: Vid) -> Option<&[String]> {
1268        self.vertex_labels.get(&vid).map(|v| v.as_slice())
1269    }
1270
1271    /// Gets the edge type for an EID.
1272    pub fn get_edge_type(&self, eid: Eid) -> Option<&str> {
1273        self.edge_types.get(&eid).map(|s| s.as_str())
1274    }
1275
1276    /// Returns all EIDs in edge_types that match the given type name.
1277    /// Used for L0 overlay during schemaless edge scanning.
1278    pub fn eids_for_type(&self, type_name: &str) -> Vec<Eid> {
1279        self.edge_types
1280            .iter()
1281            .filter(|(eid, etype)| *etype == type_name && !self.tombstones.contains_key(eid))
1282            .map(|(eid, _)| *eid)
1283            .collect()
1284    }
1285
1286    /// Returns all edge EIDs in the L0 buffer (non-tombstoned).
1287    ///
1288    /// Used for schemaless scanning (`MATCH ()-[r]->()`) without type.
1289    pub fn all_edge_eids(&self) -> Vec<Eid> {
1290        self.edge_endpoints
1291            .keys()
1292            .filter(|eid| !self.tombstones.contains_key(eid))
1293            .copied()
1294            .collect()
1295    }
1296
1297    /// Returns edge endpoint data (src_vid, dst_vid) for an EID.
1298    pub fn get_edge_endpoints(&self, eid: Eid) -> Option<(Vid, Vid)> {
1299        self.edge_endpoints
1300            .get(&eid)
1301            .map(|(src, dst, _)| (*src, *dst))
1302    }
1303
1304    /// Returns full edge endpoint data (src_vid, dst_vid, edge_type_id) for an EID.
1305    pub fn get_edge_endpoint_full(&self, eid: Eid) -> Option<(Vid, Vid, u32)> {
1306        self.edge_endpoints.get(&eid).copied()
1307    }
1308
1309    /// Insert a constraint key into the index for O(1) duplicate detection.
1310    pub fn insert_constraint_key(&mut self, key: Vec<u8>, vid: Vid) {
1311        self.constraint_index.insert(key, vid);
1312    }
1313
1314    /// Check if a constraint key exists in the index, excluding a specific VID.
1315    /// Returns true if the key exists and is owned by a different vertex.
1316    pub fn has_constraint_key(&self, key: &[u8], exclude_vid: Vid) -> bool {
1317        self.constraint_index
1318            .get(key)
1319            .is_some_and(|&v| v != exclude_vid)
1320    }
1321
1322    /// Insert an edge unique-constraint key into the index (edge analogue of
1323    /// [`insert_constraint_key`](Self::insert_constraint_key)).
1324    pub fn insert_edge_constraint_key(&mut self, key: Vec<u8>, eid: Eid) {
1325        self.edge_constraint_index.insert(key, eid);
1326    }
1327
1328    /// Check if an edge unique-constraint key exists, owned by an edge other than
1329    /// `exclude_eid`. Edge analogue of
1330    /// [`has_constraint_key`](Self::has_constraint_key).
1331    pub fn has_edge_constraint_key(&self, key: &[u8], exclude_eid: Eid) -> bool {
1332        self.edge_constraint_index
1333            .get(key)
1334            .is_some_and(|&e| e != exclude_eid)
1335    }
1336
1337    /// Register a MERGE-create's key into the implicit phantom guard.
1338    pub fn insert_merge_guard_key(&mut self, key: Vec<u8>, vid: Vid) {
1339        self.merge_guard_index.insert(key, vid);
1340    }
1341
1342    /// Check if a MERGE-guard key exists, owned by a different vertex than
1343    /// `exclude_vid` — i.e. a concurrent MERGE already created this key.
1344    pub fn has_merge_guard_key(&self, key: &[u8], exclude_vid: Vid) -> bool {
1345        self.merge_guard_index
1346            .get(key)
1347            .is_some_and(|&v| v != exclude_vid)
1348    }
1349
1350    #[instrument(skip(self, other), level = "trace")]
1351    /// Validate that merging `other` into `self` will not bail on a tombstoned
1352    /// edge endpoint (issue #77), **without mutating** either buffer.
1353    ///
1354    /// Mirrors the endpoint-liveness guard in `apply_edge_insertion`
1355    /// against the tombstone state [`Self::merge`] produces: `other`'s vertex
1356    /// deletions are applied and its vertex inserts clear their own tombstone,
1357    /// so an inserted edge bails iff an endpoint is tombstoned in `self` or
1358    /// `other` and is not (re-)inserted by `other`.
1359    ///
1360    /// Run this under `flush_lock` *before* the durable WAL flush so an
1361    /// offending commit is rejected up front. After the flush the transaction
1362    /// is durable, and a `merge` bail would leave a ghost/partial commit whose
1363    /// WAL replay re-bails — rendering the database unopenable.
1364    ///
1365    /// # Errors
1366    ///
1367    /// Returns an error naming the offending edge and endpoint when the merge
1368    /// would bail.
1369    pub fn validate_merge_edge_endpoints(&self, other: &L0Buffer) -> Result<()> {
1370        // An endpoint is effectively deleted after the merge's vertex phase if
1371        // it is tombstoned in either buffer and `other` does not re-insert it
1372        // (an insert clears the tombstone).
1373        let is_deleted = |vid: &Vid| {
1374            (self.vertex_tombstones.contains(vid) || other.vertex_tombstones.contains(vid))
1375                && !other.vertex_properties.contains_key(vid)
1376        };
1377        for (eid, (src_vid, dst_vid, _etype)) in &other.edge_endpoints {
1378            if other.tombstones.contains_key(eid) {
1379                continue; // a deletion, not an insertion — never resurrects a vertex
1380            }
1381            if is_deleted(src_vid) {
1382                anyhow::bail!(
1383                    "Cannot insert edge {}: source vertex {} has been deleted (issue #77)",
1384                    eid,
1385                    src_vid
1386                );
1387            }
1388            if is_deleted(dst_vid) {
1389                anyhow::bail!(
1390                    "Cannot insert edge {}: destination vertex {} has been deleted (issue #77)",
1391                    eid,
1392                    dst_vid
1393                );
1394            }
1395        }
1396        Ok(())
1397    }
1398
1399    pub fn merge(&mut self, other: &L0Buffer) -> Result<()> {
1400        // Validate-then-apply: reject a merge that would bail on a tombstoned
1401        // edge endpoint before mutating anything, so a failed merge can never
1402        // leave a partially-applied (non-atomic) commit.
1403        self.validate_merge_edge_endpoints(other)?;
1404        self.merge_validated(
1405            other,
1406            other.vertex_properties.clone(),
1407            other.edge_properties.clone(),
1408        )
1409    }
1410
1411    /// Commit-path variant of [`merge`](Self::merge) that consumes `other`'s
1412    /// vertex/edge property maps instead of deep-cloning every row.
1413    ///
1414    /// Everything else in `other` (endpoints, tombstones, versions, labels)
1415    /// is left intact — `commit_transaction_l0` still reads those after the
1416    /// merge. The caller must not rely on `other.vertex_properties` /
1417    /// `other.edge_properties` afterwards, which is safe on the commit path
1418    /// because committing consumes the transaction.
1419    pub fn merge_take(&mut self, other: &mut L0Buffer) -> Result<()> {
1420        // Validate BEFORE draining: the endpoint check consults
1421        // `other.vertex_properties` (the "re-inserted by other" exemption).
1422        self.validate_merge_edge_endpoints(other)?;
1423        let vertex_props = std::mem::take(&mut other.vertex_properties);
1424        let edge_props = std::mem::take(&mut other.edge_properties);
1425        self.merge_validated(other, vertex_props, edge_props)
1426    }
1427
1428    /// Shared merge body. `vertex_props` / `edge_props` are `other`'s property
1429    /// maps, passed by value so rows move instead of clone; the caller has
1430    /// already run `validate_merge_edge_endpoints`.
1431    fn merge_validated(
1432        &mut self,
1433        other: &L0Buffer,
1434        vertex_props: HashMap<Vid, Properties>,
1435        mut edge_props: HashMap<Eid, Properties>,
1436    ) -> Result<()> {
1437        trace!(
1438            other_mutation_count = other.mutation_count,
1439            "Merging L0 buffer"
1440        );
1441        // skip_wal=true throughout: the caller (commit_transaction_l0) already
1442        // wrote every one of these mutations to WAL before invoking merge —
1443        // re-appending here would double the WAL volume per commit.
1444        // Merge Vertices
1445        for &vid in &other.vertex_tombstones {
1446            self.delete_vertex_impl(vid, true)?;
1447        }
1448
1449        for (vid, props) in vertex_props {
1450            let labels = other.vertex_labels.get(&vid).cloned().unwrap_or_default();
1451            self.insert_vertex_with_labels_impl(vid, props, &labels, true);
1452        }
1453
1454        // Merge vertex labels that might not have properties
1455        for (vid, labels) in &other.vertex_labels {
1456            if !self.vertex_labels.contains_key(vid) {
1457                self.vertex_labels.insert(*vid, labels.clone());
1458                for label in labels {
1459                    self.label_to_vids
1460                        .entry(label.clone())
1461                        .or_default()
1462                        .insert(*vid);
1463                }
1464            }
1465        }
1466
1467        // Label-overwrite pass: a `SET n:Label` / `REMOVE n:Label` resolved the
1468        // FULL new label set into `other.vertex_labels[vid]` and flagged the vid.
1469        // REPLACE (not append) so removals actually remove and an existing
1470        // vertex's label change lands — overriding any append from the property
1471        // loop above. Skip vids deleted in the same commit. (The append loops
1472        // stay correct for property-path label unions, which are NOT flagged.)
1473        for vid in &other.vertex_label_overwrites {
1474            if other.vertex_tombstones.contains(vid) {
1475                continue;
1476            }
1477            let labels = other.vertex_labels.get(vid).cloned().unwrap_or_default();
1478            self.remove_vid_from_label_index(*vid);
1479            self.vertex_labels.insert(*vid, labels.clone());
1480            self.index_labels_for_vid(*vid, &labels);
1481            // Carry the overwrite flag into the persistent (main) buffer so a
1482            // pure relabel of a prior-window vid — which is absent from
1483            // `vertex_properties` — is still re-derived at flush (M8). The
1484            // flag is cleared when this buffer is rotated out by the flush.
1485            self.vertex_label_overwrites.insert(*vid);
1486        }
1487
1488        // Merge Edges - insert all edges from edge_endpoints, using empty props if none exist
1489        for (eid, (src, dst, etype)) in &other.edge_endpoints {
1490            if other.tombstones.contains_key(eid) {
1491                self.delete_edge_impl(*eid, *src, *dst, *etype, true)?;
1492            } else {
1493                let props = edge_props.remove(eid).unwrap_or_default();
1494                let etype_name = other.edge_types.get(eid).cloned();
1495                self.insert_edge_impl(*src, *dst, *etype, *eid, props, etype_name, true)?;
1496            }
1497        }
1498
1499        // Merge tombstones for edges that only exist in the target buffer (self),
1500        // not in the source buffer's edge_endpoints.  Without this, transaction
1501        // DELETEs of pre-existing edges are silently lost on commit.
1502        for (eid, tombstone) in &other.tombstones {
1503            if !other.edge_endpoints.contains_key(eid) {
1504                self.delete_edge_impl(
1505                    *eid,
1506                    tombstone.src_vid,
1507                    tombstone.dst_vid,
1508                    tombstone.edge_type,
1509                    true,
1510                )?;
1511            }
1512        }
1513
1514        // Edge types are now merged inside insert_edge, so no separate loop needed
1515
1516        // Merge timestamps - preserve semantics of or_insert (keep oldest created_at)
1517        // and insert (use latest updated_at)
1518        for (vid, ts) in &other.vertex_created_at {
1519            self.vertex_created_at.entry(*vid).or_insert(*ts); // keep oldest
1520        }
1521        for (vid, ts) in &other.vertex_updated_at {
1522            self.vertex_updated_at.insert(*vid, *ts); // use latest (tx wins)
1523        }
1524
1525        for (eid, ts) in &other.edge_created_at {
1526            self.edge_created_at.entry(*eid).or_insert(*ts); // keep oldest
1527        }
1528        for (eid, ts) in &other.edge_updated_at {
1529            self.edge_updated_at.insert(*eid, *ts); // use latest (tx wins)
1530        }
1531
1532        // Conservatively add other's estimated size (may overcount due to
1533        // deduplication, but that's safe for a memory limit).
1534        self.estimated_size += other.estimated_size;
1535
1536        // Merge constraint index
1537        for (key, vid) in &other.constraint_index {
1538            self.constraint_index.insert(key.clone(), *vid);
1539        }
1540
1541        // Merge the implicit MERGE-key guard so a committed MERGE-create is
1542        // visible to a concurrent transaction's commit-time re-probe.
1543        for (key, vid) in &other.merge_guard_index {
1544            self.merge_guard_index.insert(key.clone(), *vid);
1545        }
1546
1547        // Merge the edge unique-constraint index (parallel to `constraint_index`).
1548        for (key, eid) in &other.edge_constraint_index {
1549            self.edge_constraint_index.insert(key.clone(), *eid);
1550        }
1551
1552        // Carry deferred-embedding markers from the tx L0 into the main L0 so the
1553        // flush-time `drain_pending_embeddings` sees them (the marked vids' properties were
1554        // just merged above). Without this, `defer_embeddings` auto-embed silently no-ops for
1555        // any transactional write — a pre-existing gap that also affects single-vector
1556        // deferral, surfaced while wiring multi-vector auto-embed (issue #104).
1557        for (vid, label) in &other.pending_embeddings {
1558            self.pending_embeddings.insert(*vid, label.clone());
1559        }
1560
1561        Ok(())
1562    }
1563
1564    /// Replay mutations from WAL without re-logging them.
1565    /// Used during startup recovery to restore L0 state from persisted WAL.
1566    /// Uses CRDT merge semantics to ensure recovered state matches pre-crash state.
1567    #[instrument(skip(self, mutations), level = "debug")]
1568    pub fn replay_mutations(&mut self, mutations: Vec<Mutation>) -> Result<()> {
1569        trace!(count = mutations.len(), "Replaying mutations");
1570        for mutation in mutations {
1571            match mutation {
1572                Mutation::InsertVertex {
1573                    vid,
1574                    properties,
1575                    labels,
1576                } => {
1577                    // Apply without WAL logging, with CRDT merge semantics
1578                    self.current_version += 1;
1579                    let version = self.current_version;
1580
1581                    self.vertex_tombstones.remove(&vid);
1582                    let tracks_extid = properties.contains_key("ext_id");
1583                    let entry = self.vertex_properties.entry(vid).or_default();
1584                    let old_extid = if tracks_extid {
1585                        Self::extid_of(entry)
1586                    } else {
1587                        None
1588                    };
1589                    Self::merge_crdt_properties(entry, properties, self.plugin_registry.as_ref());
1590                    if tracks_extid {
1591                        let new_extid = Self::extid_of(
1592                            self.vertex_properties.get(&vid).expect("just inserted"),
1593                        );
1594                        self.sync_extid_index(vid, old_extid, new_extid);
1595                    }
1596                    self.vertex_versions.insert(vid, version);
1597                    self.graph.add_vertex(vid);
1598                    self.mutation_count += 1;
1599
1600                    // Restore vertex labels from WAL
1601                    let existing = self.vertex_labels.entry(vid).or_default();
1602                    Self::append_unique_labels(existing, &labels);
1603                    for label in &labels {
1604                        self.label_to_vids
1605                            .entry(label.clone())
1606                            .or_default()
1607                            .insert(vid);
1608                    }
1609                }
1610                Mutation::DeleteVertex { vid, labels } => {
1611                    self.current_version += 1;
1612                    // Restore labels BEFORE apply_vertex_deletion
1613                    if !labels.is_empty() {
1614                        let existing = self.vertex_labels.entry(vid).or_default();
1615                        Self::append_unique_labels(existing, &labels);
1616                        for label in &labels {
1617                            self.label_to_vids
1618                                .entry(label.clone())
1619                                .or_default()
1620                                .insert(vid);
1621                        }
1622                    }
1623                    self.apply_vertex_deletion(vid);
1624                }
1625                Mutation::SetVertexLabels { vid, labels } => {
1626                    // REPLACE the vid's full label set (a label-only mutation
1627                    // resolved the complete set). Replace, not append, so a
1628                    // replayed removal removes; clears the old reverse-index
1629                    // entries first.
1630                    self.current_version += 1;
1631                    self.remove_vid_from_label_index(vid);
1632                    self.vertex_labels.insert(vid, labels.clone());
1633                    self.index_labels_for_vid(vid, &labels);
1634                    // Mark this vid as a label overwrite, exactly like the live
1635                    // `set_vertex_labels` path. Without this marker the M8
1636                    // flush/merge overwrite pass skips the vid (a label-only
1637                    // mutation leaves no `vertex_properties` entry), so a
1638                    // WAL-durable SET/REMOVE label on a prior-window vertex
1639                    // would be silently lost at the first post-recovery flush.
1640                    self.vertex_label_overwrites.insert(vid);
1641                    self.mutation_count += 1;
1642                }
1643                Mutation::InsertEdge {
1644                    src_vid,
1645                    dst_vid,
1646                    edge_type,
1647                    eid,
1648                    version: _,
1649                    properties,
1650                    edge_type_name,
1651                } => {
1652                    self.current_version += 1;
1653                    // Skip-and-warn on the issue-#77 endpoint bail: a pre-fix
1654                    // durable WAL may hold a ghost edge whose endpoint was
1655                    // tombstoned. Recovery must still open the database rather
1656                    // than abort, so drop the offending edge and continue.
1657                    match self.apply_edge_insertion(src_vid, dst_vid, edge_type, eid, properties) {
1658                        Ok(()) => {
1659                            // Restore edge type name metadata if present
1660                            if let Some(name) = edge_type_name {
1661                                self.edge_types.insert(eid, name);
1662                            }
1663                        }
1664                        Err(e) => {
1665                            tracing::warn!(
1666                                ?eid,
1667                                ?src_vid,
1668                                ?dst_vid,
1669                                error = %e,
1670                                "WAL replay: skipping edge insertion to a deleted endpoint (issue #77)"
1671                            );
1672                        }
1673                    }
1674                }
1675                Mutation::DeleteEdge {
1676                    eid,
1677                    src_vid,
1678                    dst_vid,
1679                    edge_type,
1680                    version: _,
1681                } => {
1682                    self.current_version += 1;
1683                    self.apply_edge_deletion(eid, src_vid, dst_vid, edge_type);
1684                }
1685            }
1686        }
1687        Ok(())
1688    }
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693    use super::*;
1694
1695    #[test]
1696    fn test_l0_buffer_ops() -> Result<()> {
1697        let mut l0 = L0Buffer::new(0, None);
1698        let vid_a = Vid::new(1);
1699        let vid_b = Vid::new(2);
1700        let eid_ab = Eid::new(101);
1701
1702        l0.insert_edge(vid_a, vid_b, 1, eid_ab, HashMap::new(), None)?;
1703
1704        let neighbors = l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1705        assert_eq!(neighbors.len(), 1);
1706        assert_eq!(neighbors[0].0, vid_b);
1707        assert_eq!(neighbors[0].1, eid_ab);
1708
1709        l0.delete_edge(eid_ab, vid_a, vid_b, 1)?;
1710        assert!(l0.is_tombstoned(eid_ab));
1711
1712        // Verify neighbors are empty after deletion
1713        let neighbors_after = l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1714        assert_eq!(neighbors_after.len(), 0);
1715
1716        Ok(())
1717    }
1718
1719    /// Regression for review #5: merging an edge whose endpoint is tombstoned
1720    /// in the target buffer must be rejected up front (`validate_merge_edge_endpoints`)
1721    /// and `merge` must be atomic — never partially applied. Before the fix this
1722    /// bailed only *inside* `merge`, after the durable WAL flush, leaving a ghost
1723    /// commit that made the database unopenable on replay.
1724    #[test]
1725    fn validate_merge_rejects_edge_to_tombstoned_endpoint() {
1726        let mut main = L0Buffer::new(0, None);
1727        let vid_a = Vid::new(1);
1728        let vid_b = Vid::new(2);
1729        main.insert_vertex(vid_a, HashMap::new());
1730        main.insert_vertex(vid_b, HashMap::new());
1731        main.delete_vertex(vid_b).unwrap(); // B is now tombstoned in main
1732
1733        // A transaction that inserts an edge A -> B (B tombstoned in main).
1734        let mut tx = L0Buffer::new(0, None);
1735        let eid = Eid::new(101);
1736        tx.insert_edge(vid_a, vid_b, 1, eid, HashMap::new(), None)
1737            .unwrap();
1738
1739        assert!(
1740            main.validate_merge_edge_endpoints(&tx).is_err(),
1741            "edge to a tombstoned endpoint must be rejected before merge"
1742        );
1743        // merge validates first, so it errors and leaves main untouched (atomic).
1744        assert!(
1745            main.merge(&tx).is_err(),
1746            "merge must reject, not bail mid-apply"
1747        );
1748        assert!(
1749            !main.edge_endpoints.contains_key(&eid),
1750            "a rejected merge must not have partially applied the edge"
1751        );
1752    }
1753
1754    /// When the transaction re-inserts the endpoint vertex, the edge is valid
1755    /// (the insert clears the tombstone) and the merge succeeds.
1756    #[test]
1757    fn validate_merge_allows_edge_when_endpoint_reinserted() {
1758        let mut main = L0Buffer::new(0, None);
1759        let vid_a = Vid::new(1);
1760        let vid_b = Vid::new(2);
1761        main.insert_vertex(vid_a, HashMap::new());
1762        main.insert_vertex(vid_b, HashMap::new());
1763        main.delete_vertex(vid_b).unwrap();
1764
1765        let mut tx = L0Buffer::new(0, None);
1766        tx.insert_vertex(vid_b, HashMap::new()); // re-insert B
1767        let eid = Eid::new(101);
1768        tx.insert_edge(vid_a, vid_b, 1, eid, HashMap::new(), None)
1769            .unwrap();
1770
1771        assert!(main.validate_merge_edge_endpoints(&tx).is_ok());
1772        assert!(main.merge(&tx).is_ok());
1773        assert!(main.edge_endpoints.contains_key(&eid));
1774    }
1775
1776    /// Edges between live endpoints merge as before — no false positives.
1777    #[test]
1778    fn validate_merge_allows_edge_to_live_endpoints() {
1779        let mut main = L0Buffer::new(0, None);
1780        let vid_a = Vid::new(1);
1781        let vid_b = Vid::new(2);
1782        main.insert_vertex(vid_a, HashMap::new());
1783        main.insert_vertex(vid_b, HashMap::new());
1784
1785        let mut tx = L0Buffer::new(0, None);
1786        let eid = Eid::new(101);
1787        tx.insert_edge(vid_a, vid_b, 1, eid, HashMap::new(), None)
1788            .unwrap();
1789
1790        assert!(main.validate_merge_edge_endpoints(&tx).is_ok());
1791        assert!(main.merge(&tx).is_ok());
1792        assert!(main.edge_endpoints.contains_key(&eid));
1793    }
1794
1795    #[test]
1796    fn test_l0_buffer_multiple_edges() -> Result<()> {
1797        let mut l0 = L0Buffer::new(0, None);
1798        let vid_a = Vid::new(1);
1799        let vid_b = Vid::new(2);
1800        let vid_c = Vid::new(3);
1801        let eid_ab = Eid::new(101);
1802        let eid_ac = Eid::new(102);
1803
1804        l0.insert_edge(vid_a, vid_b, 1, eid_ab, HashMap::new(), None)?;
1805        l0.insert_edge(vid_a, vid_c, 1, eid_ac, HashMap::new(), None)?;
1806
1807        let neighbors = l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1808        assert_eq!(neighbors.len(), 2);
1809
1810        // Delete one edge
1811        l0.delete_edge(eid_ab, vid_a, vid_b, 1)?;
1812
1813        // Should still have one neighbor
1814        let neighbors_after = l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1815        assert_eq!(neighbors_after.len(), 1);
1816        assert_eq!(neighbors_after[0].0, vid_c);
1817
1818        Ok(())
1819    }
1820
1821    #[test]
1822    fn test_l0_buffer_edge_type_filter() -> Result<()> {
1823        let mut l0 = L0Buffer::new(0, None);
1824        let vid_a = Vid::new(1);
1825        let vid_b = Vid::new(2);
1826        let vid_c = Vid::new(3);
1827        let eid_ab = Eid::new(101);
1828        let eid_ac = Eid::new(201); // Different edge type
1829
1830        l0.insert_edge(vid_a, vid_b, 1, eid_ab, HashMap::new(), None)?;
1831        l0.insert_edge(vid_a, vid_c, 2, eid_ac, HashMap::new(), None)?;
1832
1833        // Filter by edge type 1
1834        let type1_neighbors = l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1835        assert_eq!(type1_neighbors.len(), 1);
1836        assert_eq!(type1_neighbors[0].0, vid_b);
1837
1838        // Filter by edge type 2
1839        let type2_neighbors = l0.get_neighbors(vid_a, 2, Direction::Outgoing);
1840        assert_eq!(type2_neighbors.len(), 1);
1841        assert_eq!(type2_neighbors[0].0, vid_c);
1842
1843        Ok(())
1844    }
1845
1846    #[test]
1847    fn test_l0_buffer_incoming_edges() -> Result<()> {
1848        let mut l0 = L0Buffer::new(0, None);
1849        let vid_a = Vid::new(1);
1850        let vid_b = Vid::new(2);
1851        let vid_c = Vid::new(3);
1852        let eid_ab = Eid::new(101);
1853        let eid_cb = Eid::new(102);
1854
1855        // a -> b and c -> b
1856        l0.insert_edge(vid_a, vid_b, 1, eid_ab, HashMap::new(), None)?;
1857        l0.insert_edge(vid_c, vid_b, 1, eid_cb, HashMap::new(), None)?;
1858
1859        // Check incoming edges to b
1860        let incoming = l0.get_neighbors(vid_b, 1, Direction::Incoming);
1861        assert_eq!(incoming.len(), 2);
1862
1863        Ok(())
1864    }
1865
1866    /// Regression test: merge should preserve edges without properties
1867    #[test]
1868    fn test_merge_empty_props_edge() -> Result<()> {
1869        let mut main_l0 = L0Buffer::new(0, None);
1870        let mut tx_l0 = L0Buffer::new(0, None);
1871
1872        let vid_a = Vid::new(1);
1873        let vid_b = Vid::new(2);
1874        let eid_ab = Eid::new(101);
1875
1876        // Insert edge with empty properties in transaction L0
1877        tx_l0.insert_edge(vid_a, vid_b, 1, eid_ab, HashMap::new(), None)?;
1878
1879        // Verify edge exists in tx_l0
1880        assert!(tx_l0.edge_endpoints.contains_key(&eid_ab));
1881        assert!(!tx_l0.edge_properties.contains_key(&eid_ab)); // No properties entry
1882
1883        // Merge into main L0
1884        main_l0.merge(&tx_l0)?;
1885
1886        // Edge should exist in main L0 after merge
1887        assert!(main_l0.edge_endpoints.contains_key(&eid_ab));
1888        let neighbors = main_l0.get_neighbors(vid_a, 1, Direction::Outgoing);
1889        assert_eq!(neighbors.len(), 1);
1890        assert_eq!(neighbors[0].0, vid_b);
1891
1892        Ok(())
1893    }
1894
1895    /// Regression test: WAL replay should use CRDT merge semantics
1896    #[test]
1897    fn test_replay_crdt_merge() -> Result<()> {
1898        use crate::runtime::wal::Mutation;
1899        use serde_json::json;
1900        use uni_common::Value;
1901
1902        let mut l0 = L0Buffer::new(0, None);
1903        let vid = Vid::new(1);
1904
1905        // Create GCounter CRDT values using correct serde format:
1906        // {"t": "gc", "d": {"counts": {...}}}
1907        let counter1: Value = json!({
1908            "t": "gc",
1909            "d": {"counts": {"node1": 5}}
1910        })
1911        .into();
1912        let counter2: Value = json!({
1913            "t": "gc",
1914            "d": {"counts": {"node2": 3}}
1915        })
1916        .into();
1917
1918        // First mutation: insert vertex with counter1
1919        let mut props1 = HashMap::new();
1920        props1.insert("counter".to_string(), counter1.clone());
1921        l0.replay_mutations(vec![Mutation::InsertVertex {
1922            vid,
1923            properties: props1,
1924            labels: vec![],
1925        }])?;
1926
1927        // Second mutation: insert same vertex with counter2 (should merge)
1928        let mut props2 = HashMap::new();
1929        props2.insert("counter".to_string(), counter2.clone());
1930        l0.replay_mutations(vec![Mutation::InsertVertex {
1931            vid,
1932            properties: props2,
1933            labels: vec![],
1934        }])?;
1935
1936        // Verify CRDT was merged (both node1 and node2 counts present)
1937        let stored_props = l0.vertex_properties.get(&vid).unwrap();
1938        let stored_counter = stored_props.get("counter").unwrap();
1939
1940        // Convert back to serde_json::Value for nested access
1941        let stored_json: serde_json::Value = stored_counter.clone().into();
1942        // The merged counter should have both node1: 5 and node2: 3
1943        let data = stored_json.get("d").unwrap();
1944        let counts = data.get("counts").unwrap();
1945        assert_eq!(counts.get("node1"), Some(&json!(5)));
1946        assert_eq!(counts.get("node2"), Some(&json!(3)));
1947
1948        Ok(())
1949    }
1950
1951    #[test]
1952    fn test_merge_preserves_vertex_timestamps() -> Result<()> {
1953        let mut l0_main = L0Buffer::new(0, None);
1954        let mut l0_tx = L0Buffer::new(0, None);
1955        let vid = Vid::new(1);
1956
1957        // Main buffer: insert vertex with timestamp T1
1958        let ts_main_created = 1000;
1959        let ts_main_updated = 1100;
1960        l0_main.insert_vertex(vid, HashMap::new());
1961        l0_main.vertex_created_at.insert(vid, ts_main_created);
1962        l0_main.vertex_updated_at.insert(vid, ts_main_updated);
1963
1964        // Transaction buffer: update same vertex with timestamp T2 (later)
1965        let ts_tx_created = 2000; // should be ignored (main has older created_at)
1966        let ts_tx_updated = 2100; // should win (tx has newer updated_at)
1967        l0_tx.insert_vertex(vid, HashMap::new());
1968        l0_tx.vertex_created_at.insert(vid, ts_tx_created);
1969        l0_tx.vertex_updated_at.insert(vid, ts_tx_updated);
1970
1971        // Merge transaction into main
1972        l0_main.merge(&l0_tx)?;
1973
1974        // Verify created_at is oldest (from main)
1975        assert_eq!(
1976            *l0_main.vertex_created_at.get(&vid).unwrap(),
1977            ts_main_created,
1978            "created_at should preserve oldest timestamp"
1979        );
1980
1981        // Verify updated_at is latest (from tx)
1982        assert_eq!(
1983            *l0_main.vertex_updated_at.get(&vid).unwrap(),
1984            ts_tx_updated,
1985            "updated_at should use latest timestamp"
1986        );
1987
1988        Ok(())
1989    }
1990
1991    #[test]
1992    fn test_merge_preserves_edge_timestamps() -> Result<()> {
1993        let mut l0_main = L0Buffer::new(0, None);
1994        let mut l0_tx = L0Buffer::new(0, None);
1995        let vid_a = Vid::new(1);
1996        let vid_b = Vid::new(2);
1997        let eid = Eid::new(100);
1998
1999        // Main buffer: insert edge with timestamp T1
2000        let ts_main_created = 1000;
2001        let ts_main_updated = 1100;
2002        l0_main.insert_edge(vid_a, vid_b, 1, eid, HashMap::new(), None)?;
2003        l0_main.edge_created_at.insert(eid, ts_main_created);
2004        l0_main.edge_updated_at.insert(eid, ts_main_updated);
2005
2006        // Transaction buffer: update same edge with timestamp T2 (later)
2007        let ts_tx_created = 2000; // should be ignored
2008        let ts_tx_updated = 2100; // should win
2009        l0_tx.insert_edge(vid_a, vid_b, 1, eid, HashMap::new(), None)?;
2010        l0_tx.edge_created_at.insert(eid, ts_tx_created);
2011        l0_tx.edge_updated_at.insert(eid, ts_tx_updated);
2012
2013        // Merge transaction into main
2014        l0_main.merge(&l0_tx)?;
2015
2016        // Verify created_at is oldest (from main)
2017        assert_eq!(
2018            *l0_main.edge_created_at.get(&eid).unwrap(),
2019            ts_main_created,
2020            "edge created_at should preserve oldest timestamp"
2021        );
2022
2023        // Verify updated_at is latest (from tx)
2024        assert_eq!(
2025            *l0_main.edge_updated_at.get(&eid).unwrap(),
2026            ts_tx_updated,
2027            "edge updated_at should use latest timestamp"
2028        );
2029
2030        Ok(())
2031    }
2032
2033    #[test]
2034    fn test_merge_created_at_not_overwritten_for_existing_vertex() -> Result<()> {
2035        use uni_common::Value;
2036
2037        let mut l0_main = L0Buffer::new(0, None);
2038        let mut l0_tx = L0Buffer::new(0, None);
2039        let vid = Vid::new(1);
2040
2041        // Main buffer: vertex created at T1
2042        let ts_original = 1000;
2043        l0_main.insert_vertex(vid, HashMap::new());
2044        l0_main.vertex_created_at.insert(vid, ts_original);
2045        l0_main.vertex_updated_at.insert(vid, ts_original);
2046
2047        // Transaction buffer: update vertex (created_at would be T2 if set)
2048        let ts_tx = 2000;
2049        let mut props = HashMap::new();
2050        props.insert("updated".to_string(), Value::String("yes".to_string()));
2051        l0_tx.insert_vertex(vid, props);
2052        l0_tx.vertex_created_at.insert(vid, ts_tx);
2053        l0_tx.vertex_updated_at.insert(vid, ts_tx);
2054
2055        // Merge transaction into main
2056        l0_main.merge(&l0_tx)?;
2057
2058        // Verify created_at was NOT overwritten (still T1, not T2)
2059        assert_eq!(
2060            *l0_main.vertex_created_at.get(&vid).unwrap(),
2061            ts_original,
2062            "created_at must not be overwritten for existing vertex"
2063        );
2064
2065        // Verify updated_at WAS updated (now T2)
2066        assert_eq!(
2067            *l0_main.vertex_updated_at.get(&vid).unwrap(),
2068            ts_tx,
2069            "updated_at should reflect transaction timestamp"
2070        );
2071
2072        // Verify properties were merged
2073        assert!(
2074            l0_main
2075                .vertex_properties
2076                .get(&vid)
2077                .unwrap()
2078                .contains_key("updated")
2079        );
2080
2081        Ok(())
2082    }
2083
2084    /// Test for Issue #23: Vertex labels preserved through replay_mutations
2085    #[test]
2086    fn test_replay_mutations_preserves_vertex_labels() -> Result<()> {
2087        use crate::runtime::wal::Mutation;
2088
2089        let mut l0 = L0Buffer::new(0, None);
2090        let vid = Vid::new(42);
2091
2092        // Create InsertVertex mutation with labels
2093        let mutations = vec![Mutation::InsertVertex {
2094            vid,
2095            properties: {
2096                let mut props = HashMap::new();
2097                props.insert(
2098                    "name".to_string(),
2099                    uni_common::Value::String("Alice".to_string()),
2100                );
2101                props
2102            },
2103            labels: vec!["Person".to_string(), "User".to_string()],
2104        }];
2105
2106        // Replay mutations
2107        l0.replay_mutations(mutations)?;
2108
2109        // Verify vertex exists in L0
2110        assert!(l0.vertex_properties.contains_key(&vid));
2111
2112        // Verify labels are preserved
2113        let labels = l0.get_vertex_labels(vid).expect("Labels should exist");
2114        assert_eq!(labels.len(), 2);
2115        assert!(labels.contains(&"Person".to_string()));
2116        assert!(labels.contains(&"User".to_string()));
2117
2118        // Verify vertex is findable by label
2119        let person_vids = l0.vids_for_label("Person");
2120        assert_eq!(person_vids.len(), 1);
2121        assert_eq!(person_vids[0], vid);
2122
2123        let user_vids = l0.vids_for_label("User");
2124        assert_eq!(user_vids.len(), 1);
2125        assert_eq!(user_vids[0], vid);
2126
2127        Ok(())
2128    }
2129
2130    /// Test for Issue #23: DeleteVertex labels preserved for tombstone flushing
2131    #[test]
2132    fn test_replay_mutations_preserves_delete_vertex_labels() -> Result<()> {
2133        use crate::runtime::wal::Mutation;
2134
2135        let mut l0 = L0Buffer::new(0, None);
2136        let vid = Vid::new(99);
2137
2138        // First insert vertex with labels
2139        l0.insert_vertex_with_labels(
2140            vid,
2141            HashMap::new(),
2142            &["Person".to_string(), "Admin".to_string()],
2143        );
2144
2145        // Verify vertex and labels exist
2146        assert!(l0.vertex_properties.contains_key(&vid));
2147        let labels = l0.get_vertex_labels(vid).expect("Labels should exist");
2148        assert_eq!(labels.len(), 2);
2149
2150        // Create DeleteVertex mutation with labels
2151        let mutations = vec![Mutation::DeleteVertex {
2152            vid,
2153            labels: vec!["Person".to_string(), "Admin".to_string()],
2154        }];
2155
2156        // Replay deletion
2157        l0.replay_mutations(mutations)?;
2158
2159        // Verify vertex is tombstoned
2160        assert!(l0.vertex_tombstones.contains(&vid));
2161
2162        // Verify labels are preserved in L0 (needed for Issue #76 tombstone flushing)
2163        // The labels should still be accessible for the flush logic to know which tables to update
2164        let labels = l0.get_vertex_labels(vid);
2165        assert!(
2166            labels.is_some(),
2167            "Labels should be preserved even after deletion for tombstone flushing"
2168        );
2169
2170        Ok(())
2171    }
2172
2173    /// Test for Issue #28: Edge type name preserved through replay_mutations
2174    #[test]
2175    fn test_replay_mutations_preserves_edge_type_name() -> Result<()> {
2176        use crate::runtime::wal::Mutation;
2177
2178        let mut l0 = L0Buffer::new(0, None);
2179        let src = Vid::new(1);
2180        let dst = Vid::new(2);
2181        let eid = Eid::new(500);
2182        let edge_type = 100;
2183
2184        // Create InsertEdge mutation with edge_type_name
2185        let mutations = vec![Mutation::InsertEdge {
2186            src_vid: src,
2187            dst_vid: dst,
2188            edge_type,
2189            eid,
2190            version: 1,
2191            properties: {
2192                let mut props = HashMap::new();
2193                props.insert("since".to_string(), uni_common::Value::Int(2020));
2194                props
2195            },
2196            edge_type_name: Some("KNOWS".to_string()),
2197        }];
2198
2199        // Replay mutations
2200        l0.replay_mutations(mutations)?;
2201
2202        // Verify edge exists in L0
2203        assert!(l0.edge_endpoints.contains_key(&eid));
2204
2205        // Verify edge type name is preserved
2206        let type_name = l0.get_edge_type(eid).expect("Edge type name should exist");
2207        assert_eq!(type_name, "KNOWS");
2208
2209        // Verify edge is findable by type name
2210        let knows_eids = l0.eids_for_type("KNOWS");
2211        assert_eq!(knows_eids.len(), 1);
2212        assert_eq!(knows_eids[0], eid);
2213
2214        Ok(())
2215    }
2216
2217    /// Test for Issue #28: Edge type mapping survives multiple replay cycles
2218    #[test]
2219    fn test_edge_type_mapping_survives_multiple_replays() -> Result<()> {
2220        use crate::runtime::wal::Mutation;
2221
2222        let mut l0 = L0Buffer::new(0, None);
2223
2224        // Replay multiple edge insertions with different types
2225        let mutations = vec![
2226            Mutation::InsertEdge {
2227                src_vid: Vid::new(1),
2228                dst_vid: Vid::new(2),
2229                edge_type: 100,
2230                eid: Eid::new(1000),
2231                version: 1,
2232                properties: HashMap::new(),
2233                edge_type_name: Some("KNOWS".to_string()),
2234            },
2235            Mutation::InsertEdge {
2236                src_vid: Vid::new(2),
2237                dst_vid: Vid::new(3),
2238                edge_type: 101,
2239                eid: Eid::new(1001),
2240                version: 2,
2241                properties: HashMap::new(),
2242                edge_type_name: Some("LIKES".to_string()),
2243            },
2244            Mutation::InsertEdge {
2245                src_vid: Vid::new(3),
2246                dst_vid: Vid::new(1),
2247                edge_type: 100,
2248                eid: Eid::new(1002),
2249                version: 3,
2250                properties: HashMap::new(),
2251                edge_type_name: Some("KNOWS".to_string()),
2252            },
2253        ];
2254
2255        l0.replay_mutations(mutations)?;
2256
2257        // Verify all edge type mappings are preserved
2258        assert_eq!(l0.get_edge_type(Eid::new(1000)), Some("KNOWS"));
2259        assert_eq!(l0.get_edge_type(Eid::new(1001)), Some("LIKES"));
2260        assert_eq!(l0.get_edge_type(Eid::new(1002)), Some("KNOWS"));
2261
2262        // Verify edges can be queried by type
2263        let knows_edges = l0.eids_for_type("KNOWS");
2264        assert_eq!(knows_edges.len(), 2);
2265        assert!(knows_edges.contains(&Eid::new(1000)));
2266        assert!(knows_edges.contains(&Eid::new(1002)));
2267
2268        let likes_edges = l0.eids_for_type("LIKES");
2269        assert_eq!(likes_edges.len(), 1);
2270        assert_eq!(likes_edges[0], Eid::new(1001));
2271
2272        Ok(())
2273    }
2274
2275    /// Test for Issue #23 + #28: Combined vertex labels and edge types in replay
2276    #[test]
2277    fn test_replay_mutations_combined_labels_and_edge_types() -> Result<()> {
2278        use crate::runtime::wal::Mutation;
2279
2280        let mut l0 = L0Buffer::new(0, None);
2281        let alice = Vid::new(1);
2282        let bob = Vid::new(2);
2283        let eid = Eid::new(100);
2284
2285        // Simulate crash recovery scenario: replay full transaction log
2286        let mutations = vec![
2287            // Insert Alice with Person label
2288            Mutation::InsertVertex {
2289                vid: alice,
2290                properties: {
2291                    let mut props = HashMap::new();
2292                    props.insert(
2293                        "name".to_string(),
2294                        uni_common::Value::String("Alice".to_string()),
2295                    );
2296                    props
2297                },
2298                labels: vec!["Person".to_string()],
2299            },
2300            // Insert Bob with Person label
2301            Mutation::InsertVertex {
2302                vid: bob,
2303                properties: {
2304                    let mut props = HashMap::new();
2305                    props.insert(
2306                        "name".to_string(),
2307                        uni_common::Value::String("Bob".to_string()),
2308                    );
2309                    props
2310                },
2311                labels: vec!["Person".to_string()],
2312            },
2313            // Create KNOWS edge between them
2314            Mutation::InsertEdge {
2315                src_vid: alice,
2316                dst_vid: bob,
2317                edge_type: 1,
2318                eid,
2319                version: 3,
2320                properties: HashMap::new(),
2321                edge_type_name: Some("KNOWS".to_string()),
2322            },
2323        ];
2324
2325        // Replay all mutations
2326        l0.replay_mutations(mutations)?;
2327
2328        // Verify vertex labels preserved
2329        assert_eq!(l0.get_vertex_labels(alice).unwrap().len(), 1);
2330        assert_eq!(l0.get_vertex_labels(bob).unwrap().len(), 1);
2331        assert_eq!(l0.vids_for_label("Person").len(), 2);
2332
2333        // Verify edge type name preserved
2334        assert_eq!(l0.get_edge_type(eid).unwrap(), "KNOWS");
2335        assert_eq!(l0.eids_for_type("KNOWS").len(), 1);
2336
2337        // Verify graph structure
2338        let alice_neighbors = l0.get_neighbors(alice, 1, Direction::Outgoing);
2339        assert_eq!(alice_neighbors.len(), 1);
2340        assert_eq!(alice_neighbors[0].0, bob);
2341
2342        Ok(())
2343    }
2344
2345    /// Test for Issue #23: Empty labels should deserialize correctly (backward compat)
2346    #[test]
2347    fn test_replay_mutations_backward_compat_empty_labels() -> Result<()> {
2348        use crate::runtime::wal::Mutation;
2349
2350        let mut l0 = L0Buffer::new(0, None);
2351        let vid = Vid::new(1);
2352
2353        // Simulate old WAL format: InsertVertex with empty labels
2354        // (This tests #[serde(default)] behavior)
2355        let mutations = vec![Mutation::InsertVertex {
2356            vid,
2357            properties: HashMap::new(),
2358            labels: vec![], // Empty labels (old format compatibility)
2359        }];
2360
2361        l0.replay_mutations(mutations)?;
2362
2363        // Vertex should exist
2364        assert!(l0.vertex_properties.contains_key(&vid));
2365
2366        // Labels should be empty but entry should exist in vertex_labels
2367        let labels = l0.get_vertex_labels(vid);
2368        assert!(labels.is_some(), "Labels entry should exist even if empty");
2369        assert_eq!(labels.unwrap().len(), 0);
2370
2371        Ok(())
2372    }
2373
2374    #[test]
2375    fn test_now_nanos_returns_nanosecond_range() {
2376        // Test that now_nanos() returns a value in nanosecond range
2377        // As of 2025, Unix timestamp in nanoseconds should be > 1.7e18
2378        // (2025-01-01 is approximately 1,735,689,600 seconds = 1.735e18 nanoseconds)
2379        let now = now_nanos();
2380
2381        // Verify it's in nanosecond range (not microseconds which would be 1000x smaller)
2382        assert!(
2383            now > 1_700_000_000_000_000_000,
2384            "now_nanos() returned {}, expected > 1.7e18 for nanoseconds",
2385            now
2386        );
2387
2388        // Sanity check: should also be less than year 2100 in nanoseconds (4.1e18)
2389        assert!(
2390            now < 4_100_000_000_000_000_000,
2391            "now_nanos() returned {}, expected < 4.1e18",
2392            now
2393        );
2394    }
2395}