Skip to main content

uni_store/runtime/
property_manager.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::backend::types::{FilterExpr, Scalar};
5use crate::runtime::context::QueryContext;
6use crate::runtime::l0::L0Buffer;
7use crate::runtime::l0_visibility;
8use crate::storage::main_vertex::MainVertexDataset;
9use crate::storage::manager::StorageManager;
10use crate::storage::value_codec::CrdtDecodeMode;
11use anyhow::{Result, anyhow};
12use arrow_array::{Array, BooleanArray, RecordBatch, UInt64Array};
13use lru::LruCache;
14use metrics;
15use std::collections::HashMap;
16use std::num::NonZeroUsize;
17use std::sync::Arc;
18use tokio::sync::Mutex;
19use tracing::{debug, instrument, warn};
20use uni_common::Properties;
21use uni_common::Value;
22use uni_common::core::id::{Eid, Vid};
23use uni_common::core::schema::{DataType, SchemaManager};
24use uni_crdt::Crdt;
25
26pub struct PropertyManager {
27    storage: Arc<StorageManager>,
28    schema_manager: Arc<SchemaManager>,
29    /// Plugin registry consulted by CRDT merges via
30    /// [`uni_crdt::Crdt::merge_via_registry`]. The legacy 3-arg
31    /// [`Self::new`] passes an empty registry so callers that don't
32    /// wire plugin dispatch keep getting bit-identical native
33    /// behavior (empty registry → `merge_via_registry`'s native
34    /// fallback). Production paths in `UniInner` use
35    /// [`Self::with_plugin_registry`] to share the host's registry.
36    plugin_registry: Arc<uni_plugin::PluginRegistry>,
37    /// Cache is None when capacity=0 (caching disabled)
38    vertex_cache: Option<Mutex<LruCache<(Vid, String), Value>>>,
39    edge_cache: Option<Mutex<LruCache<(uni_common::core::id::Eid, String), Value>>>,
40    cache_capacity: usize,
41}
42
43impl PropertyManager {
44    /// Construct a `PropertyManager` with an empty plugin registry.
45    ///
46    /// Back-compat shim for the ~17 algorithm and test call sites that
47    /// don't need registry-dispatched CRDT merges. Equivalent to
48    /// [`Self::with_plugin_registry`] with `Arc::new(PluginRegistry::new())`.
49    pub fn new(
50        storage: Arc<StorageManager>,
51        schema_manager: Arc<SchemaManager>,
52        capacity: usize,
53    ) -> Self {
54        Self::with_plugin_registry(
55            storage,
56            schema_manager,
57            capacity,
58            Arc::new(uni_plugin::PluginRegistry::new()),
59        )
60    }
61
62    /// Construct a `PropertyManager` wired to a shared `PluginRegistry`.
63    ///
64    /// CRDT merges in this `PropertyManager` consult `plugin_registry`
65    /// for `CrdtKindProvider`s matching each `Crdt::kind()`; matched
66    /// kinds dispatch through the provider (so hot-reloaded plugins
67    /// take effect immediately), unmatched kinds fall back to the
68    /// native `Crdt::try_merge`.
69    pub fn with_plugin_registry(
70        storage: Arc<StorageManager>,
71        schema_manager: Arc<SchemaManager>,
72        capacity: usize,
73        plugin_registry: Arc<uni_plugin::PluginRegistry>,
74    ) -> Self {
75        // Capacity of 0 disables caching
76        let (vertex_cache, edge_cache) = if capacity == 0 {
77            (None, None)
78        } else {
79            let cap = NonZeroUsize::new(capacity).unwrap();
80            (
81                Some(Mutex::new(LruCache::new(cap))),
82                Some(Mutex::new(LruCache::new(cap))),
83            )
84        };
85
86        Self {
87            storage,
88            schema_manager,
89            plugin_registry,
90            vertex_cache,
91            edge_cache,
92            cache_capacity: capacity,
93        }
94    }
95
96    pub fn cache_size(&self) -> usize {
97        self.cache_capacity
98    }
99
100    /// Check if caching is enabled
101    pub fn caching_enabled(&self) -> bool {
102        self.cache_capacity > 0
103    }
104
105    /// Clear all caches.
106    /// Call this when L0 is rotated, flushed, or compaction occurs to prevent stale reads.
107    pub async fn clear_cache(&self) {
108        if let Some(ref cache) = self.vertex_cache {
109            cache.lock().await.clear();
110        }
111        if let Some(ref cache) = self.edge_cache {
112            cache.lock().await.clear();
113        }
114    }
115
116    /// Invalidate a specific vertex's cached properties.
117    pub async fn invalidate_vertex(&self, _vid: Vid) {
118        if let Some(ref cache) = self.vertex_cache {
119            let mut cache = cache.lock().await;
120            // LruCache doesn't have a way to iterate and remove, so we pop entries
121            // that match the vid. This is O(n) but necessary for targeted invalidation.
122            // For simplicity, clear the entire cache - LRU will repopulate as needed.
123            cache.clear();
124        }
125    }
126
127    /// Invalidate a specific edge's cached properties.
128    pub async fn invalidate_edge(&self, _eid: uni_common::core::id::Eid) {
129        if let Some(ref cache) = self.edge_cache {
130            let mut cache = cache.lock().await;
131            // Same approach as invalidate_vertex
132            cache.clear();
133        }
134    }
135
136    #[instrument(skip(self, ctx), level = "trace")]
137    pub async fn get_edge_prop(
138        &self,
139        eid: uni_common::core::id::Eid,
140        prop: &str,
141        ctx: Option<&QueryContext>,
142    ) -> Result<Value> {
143        // 1. Check if deleted in any L0 layer
144        if l0_visibility::is_edge_deleted(eid, ctx) {
145            return Ok(Value::Null);
146        }
147
148        // 2. Check L0 chain for property (transaction -> main -> pending)
149        if let Some(val) = l0_visibility::lookup_edge_prop(eid, prop, ctx) {
150            return Ok(val);
151        }
152
153        // 3. Check Cache (if enabled)
154        if let Some(ref cache) = self.edge_cache {
155            let mut cache = cache.lock().await;
156            if let Some(val) = cache.get(&(eid, prop.to_string())) {
157                debug!(eid = ?eid, prop, "Cache HIT");
158                metrics::counter!("uni_property_cache_hits_total", "type" => "edge").increment(1);
159                return Ok(val.clone());
160            } else {
161                debug!(eid = ?eid, prop, "Cache MISS");
162                metrics::counter!("uni_property_cache_misses_total", "type" => "edge").increment(1);
163            }
164        }
165
166        // 4. Fetch from Storage
167        let all = self.get_all_edge_props_with_ctx(eid, ctx).await?;
168        let val = all
169            .as_ref()
170            .and_then(|props| props.get(prop).cloned())
171            .unwrap_or(Value::Null);
172
173        // 5. Update Cache (if enabled) - Cache ALL fetched properties, not just requested one
174        if let Some(ref cache) = self.edge_cache {
175            let mut cache = cache.lock().await;
176            if let Some(ref props) = all {
177                for (prop_name, prop_val) in props {
178                    cache.put((eid, prop_name.clone()), prop_val.clone());
179                }
180            } else {
181                // No properties found, cache the null result for this property
182                cache.put((eid, prop.to_string()), Value::Null);
183            }
184        }
185
186        Ok(val)
187    }
188
189    pub async fn get_all_edge_props_with_ctx(
190        &self,
191        eid: uni_common::core::id::Eid,
192        ctx: Option<&QueryContext>,
193    ) -> Result<Option<Properties>> {
194        // 1. Check if deleted in any L0 layer
195        if l0_visibility::is_edge_deleted(eid, ctx) {
196            return Ok(None);
197        }
198
199        // 2. Accumulate properties from L0 layers (oldest to newest)
200        let mut final_props = l0_visibility::accumulate_edge_props(eid, ctx).unwrap_or_default();
201
202        // 3. Fetch from storage runs
203        let storage_props = self.fetch_all_edge_props_from_storage(eid).await?;
204
205        // 4. Handle case where edge exists but has no properties
206        if final_props.is_empty() && storage_props.is_none() {
207            if l0_visibility::edge_exists_in_l0(eid, ctx) {
208                return Ok(Some(Properties::new()));
209            }
210            return Ok(None);
211        }
212
213        // 5. Merge storage properties (L0 takes precedence)
214        if let Some(sp) = storage_props {
215            for (k, v) in sp {
216                final_props.entry(k).or_insert(v);
217            }
218        }
219
220        Ok(Some(final_props))
221    }
222
223    async fn fetch_all_edge_props_from_storage(&self, eid: Eid) -> Result<Option<Properties>> {
224        // In the new design, we scan all edge types since EID doesn't embed type info
225        self.fetch_all_edge_props_from_storage_with_hint(eid, None)
226            .await
227    }
228
229    async fn fetch_all_edge_props_from_storage_with_hint(
230        &self,
231        eid: Eid,
232        type_name_hint: Option<&str>,
233    ) -> Result<Option<Properties>> {
234        let schema = self.schema_manager.schema();
235        let backend = self.storage.backend();
236
237        // If hint provided, use it directly
238        let type_names: Vec<&str> = if let Some(hint) = type_name_hint {
239            vec![hint]
240        } else {
241            // Scan all edge types
242            schema.edge_types.keys().map(|s| s.as_str()).collect()
243        };
244
245        for type_name in type_names {
246            let type_props = schema.properties.get(type_name);
247
248            // For now, edges are primarily in Delta runs before compaction to L2 CSR.
249            // We check FWD delta runs.
250            if self.storage.delta_dataset(type_name, "fwd").is_err() {
251                continue; // Edge type doesn't exist, try next
252            }
253
254            // Use backend for edge property lookup
255            use crate::backend::table_names;
256            use crate::backend::types::ScanRequest;
257
258            let table_name = table_names::delta_table_name(type_name, "fwd");
259            if !backend.table_exists(&table_name).await.unwrap_or(false) {
260                continue; // No data for this type, try next
261            }
262
263            let base_filter = FilterExpr::equals("eid", Scalar::UInt(eid.as_u64()));
264            let filter_expr = self.storage.apply_version_filter(base_filter);
265
266            let batches = match backend
267                .scan(ScanRequest::all(&table_name).with_filter(filter_expr))
268                .await
269            {
270                Ok(b) => b,
271                Err(_) => continue,
272            };
273
274            // Collect all rows for this edge, sorted by version
275            let mut rows: Vec<(u64, u8, Properties)> = Vec::new();
276
277            for batch in batches {
278                let op_col = match batch.column_by_name("op") {
279                    Some(c) => c
280                        .as_any()
281                        .downcast_ref::<arrow_array::UInt8Array>()
282                        .unwrap(),
283                    None => continue,
284                };
285                let ver_col = match batch.column_by_name("_version") {
286                    Some(c) => c.as_any().downcast_ref::<UInt64Array>().unwrap(),
287                    None => continue,
288                };
289
290                for row in 0..batch.num_rows() {
291                    let ver = ver_col.value(row);
292                    let op = op_col.value(row);
293                    let mut props = Properties::new();
294
295                    if op != 1 {
296                        // Not a delete - extract properties
297                        if let Some(tp) = type_props {
298                            for (p_name, p_meta) in tp {
299                                if let Some(col) = batch.column_by_name(p_name)
300                                    && !col.is_null(row)
301                                {
302                                    let val =
303                                        Self::value_from_column(col.as_ref(), &p_meta.r#type, row)?;
304                                    props.insert(p_name.clone(), val);
305                                }
306                            }
307                        }
308                    }
309                    rows.push((ver, op, props));
310                }
311            }
312
313            if rows.is_empty() {
314                continue;
315            }
316
317            // Sort by version (ascending) so we merge in order
318            rows.sort_by_key(|(ver, _, _)| *ver);
319
320            // Merge properties across all versions
321            // For CRDT properties: merge values
322            // For non-CRDT properties: later versions overwrite earlier ones
323            let mut merged_props: Properties = Properties::new();
324            let mut is_deleted = false;
325
326            for (_, op, props) in rows {
327                if op == 1 {
328                    // Delete operation - mark as deleted
329                    is_deleted = true;
330                    merged_props.clear();
331                } else {
332                    is_deleted = false;
333                    for (p_name, p_val) in props {
334                        // Check if this is a CRDT property
335                        let is_crdt = type_props
336                            .and_then(|tp| tp.get(&p_name))
337                            .map(|pm| matches!(pm.r#type, DataType::Crdt(_)))
338                            .unwrap_or(false);
339
340                        if is_crdt {
341                            // Merge CRDT values
342                            if let Some(existing) = merged_props.get(&p_name) {
343                                if let Ok(merged) = self.merge_crdt_values(existing, &p_val) {
344                                    merged_props.insert(p_name, merged);
345                                }
346                            } else {
347                                merged_props.insert(p_name, p_val);
348                            }
349                        } else {
350                            // Non-CRDT: later version overwrites
351                            merged_props.insert(p_name, p_val);
352                        }
353                    }
354                }
355            }
356
357            if is_deleted {
358                return Ok(None);
359            }
360
361            if !merged_props.is_empty() {
362                return Ok(Some(merged_props));
363            }
364        }
365
366        // Fallback to main edges table props_json for unknown/schemaless types.
367        // Bounded by the same high water mark the delta tier is filtered by
368        // above — otherwise this tier alone reads at HEAD while the others
369        // honour the snapshot. The hwm is `None` unless this `PropertyManager`
370        // was built over pinned storage (today: `UniInner::at_snapshot`), so
371        // for a read-write transaction both tiers read at HEAD by design.
372        use crate::storage::main_edge::MainEdgeDataset;
373        if let Some(props) = MainEdgeDataset::find_props_by_eid(
374            self.storage.backend(),
375            eid,
376            self.storage.version_high_water_mark(),
377        )
378        .await?
379        {
380            return Ok(Some(props));
381        }
382
383        Ok(None)
384    }
385
386    /// Reports whether a *live* flushed edge of `edge_type` already carries the
387    /// given unique-key values, excluding `exclude_eid`.
388    ///
389    /// The committed-storage half of the edge-uniqueness full-horizon probe (the
390    /// in-memory L0 layers are checked separately via `has_edge_constraint_key`).
391    /// The per-type delta table is an LSM log — multiple versions per eid, later
392    /// `op = 0` writes overwrite properties, `op = 1` deletes — so a naive
393    /// `prop = val AND op = 0` count would wrongly flag an edge that was later
394    /// deleted or updated away from `val`. Instead this narrows to candidate eids
395    /// on ONE key property (guaranteed present in some `op = 0` row iff the edge
396    /// currently holds that value), then resolves each candidate's *current
397    /// merged* properties via `fetch_all_edge_props_from_storage_with_hint`
398    /// and confirms the full key still matches on a live edge. Correct — never
399    /// leaks a duplicate — without an O(edges) full scan.
400    ///
401    /// # Errors
402    /// Propagates backend scan errors — fails closed rather than treating a
403    /// conflict as absent.
404    pub async fn flushed_edge_key_conflict(
405        &self,
406        edge_type: &str,
407        key_values: &[(String, Value)],
408        exclude_eid: Option<Eid>,
409    ) -> Result<bool> {
410        if key_values.is_empty() {
411            return Ok(false);
412        }
413        use crate::backend::table_names;
414        use crate::backend::types::ScanRequest;
415
416        let table_name = table_names::delta_table_name(edge_type, "fwd");
417        let backend = self.storage.backend();
418        if !backend.table_exists(&table_name).await.unwrap_or(false) {
419            return Ok(false);
420        }
421
422        // Narrow to candidate eids via the first key property. Any live edge whose
423        // current value of this property equals `probe_val` must have set it in an
424        // `op = 0` row, so this filter catches every genuine conflict; a candidate
425        // that was since deleted or updated is discarded by the per-eid resolution
426        // below.
427        let (probe_prop, probe_val) = &key_values[0];
428        let probe_scalar = match probe_val {
429            Value::String(s) => Scalar::Str(s.clone()),
430            Value::Int(n) => Scalar::Int(*n),
431            Value::Float(f) => Scalar::Float(*f),
432            Value::Bool(b) => Scalar::Bool(*b),
433            // A NULL/unsupported key value can't satisfy a UNIQUE key — nothing to
434            // probe (NodeKey's NOT-NULL half is enforced separately at the call site).
435            _ => return Ok(false),
436        };
437        let base_filter = FilterExpr::all([
438            FilterExpr::equals(probe_prop.as_str(), probe_scalar),
439            FilterExpr::equals("op", Scalar::Int(0)),
440        ]);
441        let filter_expr = self.storage.apply_version_filter(base_filter);
442
443        let batches = backend
444            .scan(ScanRequest::all(&table_name).with_filter(filter_expr))
445            .await?;
446
447        // Distinct candidate eids from the narrowed scan.
448        let mut candidates: std::collections::HashSet<u64> = std::collections::HashSet::new();
449        for batch in &batches {
450            let Some(eid_col) = batch
451                .column_by_name("eid")
452                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
453            else {
454                continue;
455            };
456            for row in 0..batch.num_rows() {
457                if !eid_col.is_null(row) {
458                    candidates.insert(eid_col.value(row));
459                }
460            }
461        }
462
463        let exclude = exclude_eid.map(|e| e.as_u64());
464        for raw in candidates {
465            if Some(raw) == exclude {
466                continue;
467            }
468            let Some(props) = self
469                .fetch_all_edge_props_from_storage_with_hint(Eid::new(raw), Some(edge_type))
470                .await?
471            else {
472                continue; // deleted / not live
473            };
474            // The candidate's *current* value of every key property must match.
475            if key_values.iter().all(|(p, v)| props.get(p) == Some(v)) {
476                return Ok(true);
477            }
478        }
479        Ok(false)
480    }
481
482    /// Batch load properties for multiple vertices
483    pub async fn get_batch_vertex_props(
484        &self,
485        vids: &[Vid],
486        properties: &[&str],
487        ctx: Option<&QueryContext>,
488    ) -> Result<HashMap<Vid, Properties>> {
489        let schema = self.schema_manager.schema();
490        let mut result = HashMap::new();
491        // Tracks vids seen as a per-label deletion tombstone, so the schemaless
492        // main-table fallback below never resurrects a deleted vertex.
493        let mut tombstoned: std::collections::HashSet<Vid> = std::collections::HashSet::new();
494        // MVCC: highest `_version` seen per vid across the storage scan. Rows are
495        // NOT guaranteed in version order, so we must rank — a stale older row
496        // arriving after a newer one must not overwrite it (finding [7], the
497        // batch analogue of the single-vid version-max fix).
498        let mut best_version: HashMap<Vid, u64> = HashMap::new();
499        // `_all_props` is a wildcard sentinel meaning "every property" — used by
500        // schemaless projections that cannot enumerate names. It widens the
501        // per-label column set, overflow merge, and L0 overlay below.
502        let wants_all = properties.contains(&"_all_props");
503        if vids.is_empty() {
504            return Ok(result);
505        }
506
507        // In the new storage model, VIDs are pure auto-increment and don't embed label info.
508        // We need to scan all label datasets to find the vertices.
509
510        // Try VidLabelsIndex for O(1) label resolution
511        let labels_to_scan: Vec<String> = {
512            let mut needed: std::collections::HashSet<String> = std::collections::HashSet::new();
513            let mut all_resolved = true;
514            for &vid in vids {
515                if let Some(labels) = self.storage.get_labels_from_index(vid) {
516                    needed.extend(labels);
517                } else {
518                    all_resolved = false;
519                    break;
520                }
521            }
522            if all_resolved {
523                needed.into_iter().collect()
524            } else {
525                schema.labels.keys().cloned().collect() // Fallback to full scan
526            }
527        };
528
529        // 2. Fetch from storage - scan relevant label datasets
530        for label_name in &labels_to_scan {
531            // Filter to properties that exist in this label's schema. Under the
532            // `_all_props` wildcard, request every declared column for the label.
533            let label_schema_props = schema.properties.get(label_name);
534            let valid_props: Vec<&str> = if wants_all {
535                label_schema_props
536                    .map(|props| props.keys().map(String::as_str).collect())
537                    .unwrap_or_default()
538            } else {
539                properties
540                    .iter()
541                    .cloned()
542                    .filter(|p| label_schema_props.is_some_and(|props| props.contains_key(*p)))
543                    .collect()
544            };
545            // Note: don't skip when valid_props is empty; overflow_json may have the properties
546
547            // A label resolved from the VidLabelsIndex (or the schema fallback)
548            // may not have a per-label typed dataset — a schemaless label, or a
549            // label whose typed table isn't visible in this (e.g. fork-scoped)
550            // storage schema. Skip it gracefully rather than failing the whole
551            // batch fetch, mirroring the `table_exists` skip just below. Its
552            // properties, if any, come from the L0 overlay / main table instead.
553            let ds = match self.storage.vertex_dataset(label_name) {
554                Ok(ds) => ds,
555                Err(_) => continue,
556            };
557            let backend = self.storage.backend();
558            let vtable_name = ds.table_name();
559
560            if !backend.table_exists(&vtable_name).await.unwrap_or(false) {
561                continue; // Table doesn't exist yet — skip this label
562            }
563
564            let base_filter =
565                FilterExpr::one_of("_vid", vids.iter().map(|v| Scalar::UInt(v.as_u64())));
566
567            let final_filter = self.storage.apply_version_filter(base_filter);
568
569            // Build column list for projection
570            let mut columns: Vec<String> = Vec::with_capacity(valid_props.len() + 4);
571            columns.push("_vid".to_string());
572            columns.push("_version".to_string());
573            columns.push("_deleted".to_string());
574            columns.extend(valid_props.iter().map(|s| s.to_string()));
575            // Add overflow_json to fetch non-schema properties
576            columns.push("overflow_json".to_string());
577
578            use crate::backend::types::ScanRequest;
579            let request = ScanRequest::all(&vtable_name)
580                .with_filter(final_filter)
581                .with_columns(columns);
582
583            let batches: Vec<RecordBatch> = match backend.scan(request).await {
584                Ok(b) => b,
585                Err(e) => {
586                    warn!(
587                        label = %label_name,
588                        error = %e,
589                        "failed to scan label table, skipping"
590                    );
591                    continue;
592                }
593            };
594            for batch in batches {
595                let vid_col = match batch
596                    .column_by_name("_vid")
597                    .and_then(|col| col.as_any().downcast_ref::<UInt64Array>())
598                {
599                    Some(c) => c,
600                    None => continue,
601                };
602                let del_col = match batch
603                    .column_by_name("_deleted")
604                    .and_then(|col| col.as_any().downcast_ref::<BooleanArray>())
605                {
606                    Some(c) => c,
607                    None => continue,
608                };
609                let ver_col = batch
610                    .column_by_name("_version")
611                    .and_then(|col| col.as_any().downcast_ref::<UInt64Array>());
612
613                for row in 0..batch.num_rows() {
614                    let vid = Vid::from(vid_col.value(row));
615                    let version = ver_col
616                        .map(|c| if c.is_null(row) { 0 } else { c.value(row) })
617                        .unwrap_or(0);
618
619                    // Skip rows older than the newest already applied for this vid.
620                    if best_version.get(&vid).is_some_and(|bv| version < *bv) {
621                        continue;
622                    }
623                    best_version.insert(vid, version);
624
625                    if del_col.value(row) {
626                        result.remove(&vid);
627                        tombstoned.insert(vid);
628                        continue;
629                    }
630
631                    // A newer live row un-tombstones the vid.
632                    tombstoned.remove(&vid);
633                    let label_props = schema.properties.get(label_name);
634                    let mut props =
635                        Self::extract_row_properties(&batch, row, &valid_props, label_props)?;
636                    Self::merge_overflow_into_props(&batch, row, properties, &mut props)?;
637                    result.insert(vid, props);
638                }
639            }
640        }
641
642        // 2b. Schemaless main-table fallback: any requested vid with no per-label
643        // row and no tombstone may store its properties in the main table's
644        // `props_json`. Insert before the L0 overlay below so uncommitted edits
645        // still take precedence.
646        let missing: Vec<Vid> = vids
647            .iter()
648            .copied()
649            .filter(|vid| !result.contains_key(vid) && !tombstoned.contains(vid))
650            .collect();
651        self.main_table_fallback(&missing, &mut result).await?;
652
653        // 3. Overlay L0 buffers in age order: pending (oldest to newest) -> current -> transaction
654        if let Some(ctx) = ctx {
655            // First, overlay pending flush L0s in order (oldest first, so iterate forward)
656            for pending_l0_arc in &ctx.pending_flush_l0s {
657                let pending_l0 = pending_l0_arc.read();
658                self.overlay_l0_batch(vids, &pending_l0, properties, &mut result);
659            }
660
661            // Then overlay current L0 (newer than pending)
662            let l0 = ctx.l0.read();
663            self.overlay_l0_batch(vids, &l0, properties, &mut result);
664
665            // Finally overlay transaction L0 (newest)
666            // Skip transaction L0 if querying a snapshot
667            // (Transaction changes are at current version, not in snapshot)
668            if self.storage.version_high_water_mark().is_none()
669                && let Some(tx_l0_arc) = &ctx.transaction_l0
670            {
671                let tx_l0 = tx_l0_arc.read();
672                self.overlay_l0_batch(vids, &tx_l0, properties, &mut result);
673            }
674        }
675
676        Ok(result)
677    }
678
679    fn overlay_l0_batch(
680        &self,
681        vids: &[Vid],
682        l0: &L0Buffer,
683        properties: &[&str],
684        result: &mut HashMap<Vid, Properties>,
685    ) {
686        let schema = self.schema_manager.schema();
687        // `_all_props` is a wildcard sentinel: overlay every L0 property, not just
688        // the named ones. Schemaless projections (`RETURN n`) request it because
689        // they cannot enumerate property names up front.
690        let wants_all = properties.contains(&"_all_props");
691        for &vid in vids {
692            // If deleted in L0, remove from result.
693            if l0.vertex_tombstones.contains(&vid) {
694                // Version-gate the tombstone exactly like the property branch
695                // below: a deletion committed *after* the pinned snapshot (its
696                // version is beyond the high-water mark) must not remove a
697                // vertex that is still visible at the pinned version. Without
698                // this gate a beyond-pin tombstone wrongly deletes a live row
699                // under a version-pinned / time-travel read.
700                let tombstone_version = l0.vertex_versions.get(&vid).copied().unwrap_or(0);
701                if self
702                    .storage
703                    .version_high_water_mark()
704                    .is_some_and(|hwm| tombstone_version > hwm)
705                {
706                    continue;
707                }
708                result.remove(&vid);
709                continue;
710            }
711            // If in L0, check version before merging
712            if let Some(l0_props) = l0.vertex_properties.get(&vid) {
713                // Skip entries beyond snapshot boundary
714                let entry_version = l0.vertex_versions.get(&vid).copied().unwrap_or(0);
715                if self
716                    .storage
717                    .version_high_water_mark()
718                    .is_some_and(|hwm| entry_version > hwm)
719                {
720                    continue;
721                }
722
723                let entry = result.entry(vid).or_default();
724                // In new storage model, get labels from L0Buffer
725                let labels = l0.get_vertex_labels(vid);
726
727                for (k, v) in l0_props {
728                    if wants_all || properties.contains(&k.as_str()) {
729                        // Check if property is CRDT by looking up in any of the vertex's labels
730                        let is_crdt = labels
731                            .and_then(|label_list| {
732                                label_list.iter().find_map(|ln| {
733                                    schema
734                                        .properties
735                                        .get(ln)
736                                        .and_then(|lp| lp.get(k))
737                                        .filter(|pm| matches!(pm.r#type, DataType::Crdt(_)))
738                                })
739                            })
740                            .is_some();
741
742                        if is_crdt {
743                            let existing = entry.entry(k.clone()).or_insert(Value::Null);
744                            *existing = self.merge_crdt_values(existing, v).unwrap_or(v.clone());
745                        } else {
746                            entry.insert(k.clone(), v.clone());
747                        }
748                    }
749                }
750            }
751        }
752    }
753
754    /// Load properties as Arrow columns for vectorized processing
755    /// Batch load properties for multiple edges
756    pub async fn get_batch_edge_props(
757        &self,
758        eids: &[uni_common::core::id::Eid],
759        properties: &[&str],
760        ctx: Option<&QueryContext>,
761    ) -> Result<HashMap<Vid, Properties>> {
762        let schema = self.schema_manager.schema();
763        let mut result = HashMap::new();
764        if eids.is_empty() {
765            return Ok(result);
766        }
767        // MVCC: highest `_version` seen per eid across the delta scan. Rows are
768        // not version-ordered, so a stale older row must not overwrite a newer
769        // one (finding [3]); without this, an out-of-order DELETE/live pair
770        // resurrected a deleted edge's props depending on scan order.
771        let mut best_version: HashMap<uni_common::core::id::Eid, u64> = HashMap::new();
772
773        // In the new storage model, EIDs are pure auto-increment and don't embed type info.
774        // We need to scan all edge type datasets to find the edges.
775
776        // Try to resolve edge types from L0 context for O(1) lookup
777        let types_to_scan: Vec<String> = {
778            if let Some(ctx) = ctx {
779                let mut needed: std::collections::HashSet<String> =
780                    std::collections::HashSet::new();
781                let mut all_resolved = true;
782                for &eid in eids {
783                    if let Some(etype) = ctx.l0.read().get_edge_type(eid) {
784                        needed.insert(etype.to_string());
785                    } else {
786                        all_resolved = false;
787                        break;
788                    }
789                }
790                if all_resolved {
791                    needed.into_iter().collect()
792                } else {
793                    schema.edge_types.keys().cloned().collect() // Fallback to full scan
794                }
795            } else {
796                schema.edge_types.keys().cloned().collect() // No context, full scan
797            }
798        };
799
800        // 2. Fetch from storage (Delta runs) - scan relevant edge types
801        for type_name in &types_to_scan {
802            let type_props = schema.properties.get(type_name);
803            let valid_props: Vec<&str> = properties
804                .iter()
805                .cloned()
806                .filter(|p| type_props.is_some_and(|props| props.contains_key(*p)))
807                .collect();
808            // Note: don't skip when valid_props is empty; overflow_json may have the properties
809
810            let delta_ds = match self.storage.delta_dataset(type_name, "fwd") {
811                Ok(ds) => ds,
812                Err(_) => continue,
813            };
814            let backend = self.storage.backend();
815            let dtable_name = delta_ds.table_name();
816
817            if !backend.table_exists(&dtable_name).await.unwrap_or(false) {
818                continue; // Table doesn't exist yet — skip this edge type
819            }
820
821            let base_filter =
822                FilterExpr::one_of("eid", eids.iter().map(|e| Scalar::UInt(e.as_u64())));
823
824            let final_filter = self.storage.apply_version_filter(base_filter);
825
826            // Build column list for projection
827            let mut columns: Vec<String> = Vec::with_capacity(valid_props.len() + 4);
828            columns.push("eid".to_string());
829            columns.push("_version".to_string());
830            columns.push("op".to_string());
831            columns.extend(valid_props.iter().map(|s| s.to_string()));
832            // Add overflow_json to fetch non-schema properties
833            columns.push("overflow_json".to_string());
834
835            use crate::backend::types::ScanRequest;
836            let request = ScanRequest::all(&dtable_name)
837                .with_filter(final_filter)
838                .with_columns(columns);
839
840            let batches: Vec<RecordBatch> = match backend.scan(request).await {
841                Ok(b) => b,
842                Err(e) => {
843                    warn!(
844                        edge_type = %type_name,
845                        error = %e,
846                        "failed to scan edge delta table, skipping"
847                    );
848                    continue;
849                }
850            };
851            for batch in batches {
852                let eid_col = match batch
853                    .column_by_name("eid")
854                    .and_then(|col| col.as_any().downcast_ref::<UInt64Array>())
855                {
856                    Some(c) => c,
857                    None => continue,
858                };
859                let op_col = match batch
860                    .column_by_name("op")
861                    .and_then(|col| col.as_any().downcast_ref::<arrow_array::UInt8Array>())
862                {
863                    Some(c) => c,
864                    None => continue,
865                };
866                let ver_col = batch
867                    .column_by_name("_version")
868                    .and_then(|col| col.as_any().downcast_ref::<UInt64Array>());
869
870                for row in 0..batch.num_rows() {
871                    let eid = uni_common::core::id::Eid::from(eid_col.value(row));
872                    let version = ver_col
873                        .map(|c| if c.is_null(row) { 0 } else { c.value(row) })
874                        .unwrap_or(0);
875
876                    // Skip rows older than the newest already applied for this eid.
877                    if best_version.get(&eid).is_some_and(|bv| version < *bv) {
878                        continue;
879                    }
880                    best_version.insert(eid, version);
881
882                    // op=1 is Delete
883                    if op_col.value(row) == 1 {
884                        result.remove(&Vid::from(eid.as_u64()));
885                        continue;
886                    }
887
888                    let mut props =
889                        Self::extract_row_properties(&batch, row, &valid_props, type_props)?;
890                    Self::merge_overflow_into_props(&batch, row, properties, &mut props)?;
891                    // Reuse Vid as key for compatibility with materialized_property
892                    result.insert(Vid::from(eid.as_u64()), props);
893                }
894            }
895        }
896
897        // 3. Overlay L0 buffers in age order: pending (oldest to newest) -> current -> transaction
898        if let Some(ctx) = ctx {
899            // First, overlay pending flush L0s in order (oldest first, so iterate forward)
900            for pending_l0_arc in &ctx.pending_flush_l0s {
901                let pending_l0 = pending_l0_arc.read();
902                self.overlay_l0_edge_batch(eids, &pending_l0, properties, &mut result);
903            }
904
905            // Then overlay current L0 (newer than pending)
906            let l0 = ctx.l0.read();
907            self.overlay_l0_edge_batch(eids, &l0, properties, &mut result);
908
909            // Finally overlay transaction L0 (newest)
910            // Skip transaction L0 if querying a snapshot
911            // (Transaction changes are at current version, not in snapshot)
912            if self.storage.version_high_water_mark().is_none()
913                && let Some(tx_l0_arc) = &ctx.transaction_l0
914            {
915                let tx_l0 = tx_l0_arc.read();
916                self.overlay_l0_edge_batch(eids, &tx_l0, properties, &mut result);
917            }
918        }
919
920        // 4. Main-edges fallback — the delta tables are NOT the durable home of
921        // edge properties.
922        //
923        // Adjacency compaction folds topology into the L2 table (which carries
924        // only `src_vid`/`neighbors`/`edge_ids`) and then physically deletes the
925        // delta rows it incorporated, on the stated invariant that "edge
926        // properties survive in main_edges (dual-written during flush)". Both
927        // sibling readers honour that invariant — the single-EID path and the
928        // per-type batch path each fall back here — but this one did not, so
929        // from the first compaction onward it returned nothing for every EID and
930        // never recovered.
931        //
932        // The projection is the caller that made it visible: a missing property
933        // becomes `NaN`, `edge_mask_window` compares `v >= lo && v <= hi` which
934        // NaN fails under *any* window, so every masked traversal silently read
935        // zero after a few hundred write transactions. Weighted algorithms
936        // degraded at the same instant, defaulting to unit weights.
937        //
938        // Only unresolved EIDs pay the per-EID lookup, so the batch fast path is
939        // unchanged for edges whose properties are still in the delta runs.
940        {
941            use crate::storage::main_edge::MainEdgeDataset;
942            for &eid in eids {
943                if l0_visibility::is_edge_deleted(eid, ctx) {
944                    continue;
945                }
946                // This map is keyed by Vid-from-Eid, matching the delta scan above.
947                let key = uni_common::core::id::Vid::from(eid.as_u64());
948                let missing_any = match result.get(&key) {
949                    None => true,
950                    Some(found) => properties.iter().any(|p| !found.contains_key(*p)),
951                };
952                if !missing_any {
953                    continue;
954                }
955                if let Some(props) = MainEdgeDataset::find_props_by_eid(
956                    self.storage.backend(),
957                    eid,
958                    self.storage.version_high_water_mark(),
959                )
960                .await?
961                {
962                    let entry = result.entry(key).or_default();
963                    for (k, v) in props {
964                        entry.entry(k).or_insert(v);
965                    }
966                }
967            }
968        }
969
970        Ok(result)
971    }
972
973    fn overlay_l0_edge_batch(
974        &self,
975        eids: &[uni_common::core::id::Eid],
976        l0: &L0Buffer,
977        properties: &[&str],
978        result: &mut HashMap<Vid, Properties>,
979    ) {
980        let schema = self.schema_manager.schema();
981        for &eid in eids {
982            let vid_key = Vid::from(eid.as_u64());
983            if l0.tombstones.contains_key(&eid) {
984                result.remove(&vid_key);
985                continue;
986            }
987            if let Some(l0_props) = l0.edge_properties.get(&eid) {
988                // Skip entries beyond snapshot boundary
989                let entry_version = l0.edge_versions.get(&eid).copied().unwrap_or(0);
990                if self
991                    .storage
992                    .version_high_water_mark()
993                    .is_some_and(|hwm| entry_version > hwm)
994                {
995                    continue;
996                }
997
998                let entry = result.entry(vid_key).or_default();
999                // In new storage model, get edge type from L0Buffer
1000                let type_name = l0.get_edge_type(eid);
1001
1002                let include_all = properties.contains(&"_all_props");
1003                for (k, v) in l0_props {
1004                    if include_all || properties.contains(&k.as_str()) {
1005                        // Check if property is CRDT
1006                        let is_crdt = type_name
1007                            .and_then(|tn| schema.properties.get(tn))
1008                            .and_then(|tp| tp.get(k))
1009                            .map(|pm| matches!(pm.r#type, DataType::Crdt(_)))
1010                            .unwrap_or(false);
1011
1012                        if is_crdt {
1013                            let existing = entry.entry(k.clone()).or_insert(Value::Null);
1014                            *existing = self.merge_crdt_values(existing, v).unwrap_or(v.clone());
1015                        } else {
1016                            entry.insert(k.clone(), v.clone());
1017                        }
1018                    }
1019                }
1020            }
1021        }
1022    }
1023
1024    /// Batch load labels for multiple vertices.
1025    pub async fn get_batch_labels(
1026        &self,
1027        vids: &[Vid],
1028        ctx: Option<&QueryContext>,
1029    ) -> Result<HashMap<Vid, Vec<String>>> {
1030        let mut result = HashMap::new();
1031        if vids.is_empty() {
1032            return Ok(result);
1033        }
1034
1035        // Phase 1: Get from L0 layers (oldest to newest)
1036        if let Some(ctx) = ctx {
1037            let mut collect_labels = |l0: &L0Buffer| {
1038                for &vid in vids {
1039                    if let Some(labels) = l0.get_vertex_labels(vid) {
1040                        result
1041                            .entry(vid)
1042                            .or_default()
1043                            .extend(labels.iter().cloned());
1044                    }
1045                }
1046            };
1047
1048            for l0_arc in &ctx.pending_flush_l0s {
1049                collect_labels(&l0_arc.read());
1050            }
1051            collect_labels(&ctx.l0.read());
1052            if let Some(tx_l0_arc) = &ctx.transaction_l0 {
1053                collect_labels(&tx_l0_arc.read());
1054            }
1055        }
1056
1057        // Phase 2: Get from storage (try VidLabelsIndex first, then LanceDB fallback)
1058        let mut vids_needing_lancedb = Vec::new();
1059
1060        /// Merge new labels into an existing label list, skipping duplicates.
1061        fn merge_labels(existing: &mut Vec<String>, new_labels: Vec<String>) {
1062            for l in new_labels {
1063                if !existing.contains(&l) {
1064                    existing.push(l);
1065                }
1066            }
1067        }
1068
1069        for &vid in vids {
1070            if result.contains_key(&vid) {
1071                continue; // Already have labels from L0
1072            }
1073
1074            if let Some(labels) = self.storage.get_labels_from_index(vid) {
1075                merge_labels(result.entry(vid).or_default(), labels);
1076            } else {
1077                vids_needing_lancedb.push(vid);
1078            }
1079        }
1080
1081        // Fallback to storage backend for VIDs not in the index
1082        if !vids_needing_lancedb.is_empty() {
1083            let backend = self.storage.backend();
1084            let version = self.storage.version_high_water_mark();
1085            let storage_labels = MainVertexDataset::find_batch_labels_by_vids(
1086                backend,
1087                &vids_needing_lancedb,
1088                version,
1089            )
1090            .await?;
1091
1092            for (vid, labels) in storage_labels {
1093                merge_labels(result.entry(vid).or_default(), labels);
1094            }
1095        }
1096
1097        // Deduplicate and sort labels
1098        for labels in result.values_mut() {
1099            labels.sort();
1100            labels.dedup();
1101        }
1102
1103        Ok(result)
1104    }
1105
1106    pub async fn get_all_vertex_props(&self, vid: Vid) -> Result<Properties> {
1107        Ok(self
1108            .get_all_vertex_props_with_ctx(vid, None)
1109            .await?
1110            .unwrap_or_default())
1111    }
1112
1113    pub async fn get_all_vertex_props_with_ctx(
1114        &self,
1115        vid: Vid,
1116        ctx: Option<&QueryContext>,
1117    ) -> Result<Option<Properties>> {
1118        // 1. Check if deleted in any L0 layer
1119        if l0_visibility::is_vertex_deleted(vid, ctx) {
1120            return Ok(None);
1121        }
1122
1123        // 2. Accumulate properties from L0 layers (oldest to newest)
1124        let l0_props = l0_visibility::accumulate_vertex_props(vid, ctx);
1125
1126        // 3. Fetch from storage
1127        let storage_props_opt = self.fetch_all_props_from_storage(vid).await?;
1128
1129        // 4. Handle case where vertex doesn't exist in either layer
1130        if l0_props.is_none() && storage_props_opt.is_none() {
1131            return Ok(None);
1132        }
1133
1134        let mut final_props = l0_props.unwrap_or_default();
1135
1136        // 5. Merge storage properties (L0 takes precedence)
1137        if let Some(storage_props) = storage_props_opt {
1138            for (k, v) in storage_props {
1139                final_props.entry(k).or_insert(v);
1140            }
1141        }
1142
1143        // 6. Normalize CRDT properties - convert JSON strings to JSON objects
1144        // In the new storage model, we need to get labels from context/L0
1145        if let Some(ctx) = ctx {
1146            // Try to get labels from L0 layers
1147            let labels = l0_visibility::get_vertex_labels(vid, ctx);
1148            for label in &labels {
1149                self.normalize_crdt_properties(&mut final_props, label)?;
1150            }
1151        }
1152
1153        Ok(Some(final_props))
1154    }
1155
1156    /// Batch-fetch properties for multiple vertices of a known label.
1157    ///
1158    /// Queries L0 layers in-memory, then fetches remaining VIDs from LanceDB in
1159    /// a single `_vid IN (...)` query on the label table. Much faster than
1160    /// per-vertex `get_all_vertex_props_with_ctx` when many vertices need loading.
1161    ///
1162    /// Fetches every columnar property. Callers that only need a subset (e.g. a
1163    /// vector/search procedure materializing `RETURN node.<prop>`) should prefer
1164    /// [`Self::get_batch_vertex_props_for_label_projected`] to avoid decoding
1165    /// unread heavy columns such as `List(Vector)` (issue #134).
1166    pub async fn get_batch_vertex_props_for_label(
1167        &self,
1168        vids: &[Vid],
1169        label: &str,
1170        ctx: Option<&QueryContext>,
1171    ) -> Result<HashMap<Vid, Properties>> {
1172        self.get_batch_vertex_props_for_label_projected(vids, label, ctx, None)
1173            .await
1174    }
1175
1176    /// Like [`Self::get_batch_vertex_props_for_label`], but restricts the LanceDB
1177    /// fetch to `requested_props` (plus the id/version/overflow bookkeeping
1178    /// columns) when `Some`. Unread columnar properties — notably heavy
1179    /// `List(Vector)` columns — are then never decoded (issue #134). `None`
1180    /// fetches all columnar properties, identical to the unprojected method.
1181    ///
1182    /// Requested names that are not declared columnar properties are ignored
1183    /// here; they are served from `overflow_json`, which is always fetched.
1184    pub async fn get_batch_vertex_props_for_label_projected(
1185        &self,
1186        vids: &[Vid],
1187        label: &str,
1188        ctx: Option<&QueryContext>,
1189        requested_props: Option<&[String]>,
1190    ) -> Result<HashMap<Vid, Properties>> {
1191        let mut result: HashMap<Vid, Properties> = HashMap::new();
1192        let mut need_storage: Vec<Vid> = Vec::new();
1193
1194        // Phase 1: Check L0 layers for each VID (fast, in-memory).
1195        for &vid in vids {
1196            if l0_visibility::is_vertex_deleted(vid, ctx) {
1197                continue;
1198            }
1199            let l0_props = l0_visibility::accumulate_vertex_props(vid, ctx);
1200            if let Some(props) = l0_props {
1201                result.insert(vid, props);
1202            } else {
1203                need_storage.push(vid);
1204            }
1205        }
1206
1207        // If everything was resolved from L0, skip storage entirely.
1208        if need_storage.is_empty() {
1209            // Normalize CRDT properties for L0-resolved vertices.
1210            if ctx.is_some() {
1211                for props in result.values_mut() {
1212                    self.normalize_crdt_properties(props, label)?;
1213                }
1214            }
1215            return Ok(result);
1216        }
1217
1218        // Phase 2: Batch-fetch from LanceDB for remaining VIDs.
1219        let schema = self.schema_manager.schema();
1220        let label_props = schema.properties.get(label);
1221
1222        let mut prop_names: Vec<String> = Vec::new();
1223        if let Some(props) = label_props {
1224            prop_names = match requested_props {
1225                // Prune to the requested columnar props; any requested name that
1226                // is not a declared column is served from overflow_json below, so
1227                // dropping it here is safe and avoids decoding unread columns.
1228                Some(reqs) => reqs
1229                    .iter()
1230                    .filter(|r| props.contains_key(r.as_str()))
1231                    .cloned()
1232                    .collect(),
1233                None => props.keys().cloned().collect(),
1234            };
1235        }
1236
1237        let mut columns: Vec<String> = vec![
1238            "_vid".to_string(),
1239            "_deleted".to_string(),
1240            "_version".to_string(),
1241        ];
1242        columns.extend(prop_names.iter().cloned());
1243        columns.push("overflow_json".to_string());
1244
1245        // Build IN filter for all VIDs at once.
1246        let base_filter = FilterExpr::one_of(
1247            "_vid",
1248            need_storage.iter().map(|v| Scalar::UInt(v.as_u64())),
1249        );
1250
1251        let filter_expr = self.storage.apply_version_filter(base_filter);
1252
1253        let table_name = crate::backend::table_names::vertex_table_name(label);
1254        let batches: Vec<RecordBatch> = self
1255            .storage
1256            .backend()
1257            .scan(
1258                crate::backend::types::ScanRequest::all(&table_name)
1259                    .with_filter(filter_expr.clone())
1260                    .with_columns(columns.clone()),
1261            )
1262            .await?;
1263
1264        let prop_name_refs: Vec<&str> = prop_names.iter().map(|s| s.as_str()).collect();
1265
1266        // Track best version per VID for proper version-based merging.
1267        let mut per_vid_best_version: HashMap<Vid, u64> = HashMap::new();
1268        let mut per_vid_props: HashMap<Vid, Properties> = HashMap::new();
1269
1270        for batch in batches {
1271            let vid_col = match batch
1272                .column_by_name("_vid")
1273                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1274            {
1275                Some(c) => c,
1276                None => continue,
1277            };
1278            let deleted_col = match batch
1279                .column_by_name("_deleted")
1280                .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
1281            {
1282                Some(c) => c,
1283                None => continue,
1284            };
1285            let version_col = match batch
1286                .column_by_name("_version")
1287                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1288            {
1289                Some(c) => c,
1290                None => continue,
1291            };
1292
1293            for row in 0..batch.num_rows() {
1294                let vid = Vid::from(vid_col.value(row));
1295                let version = version_col.value(row);
1296
1297                if deleted_col.value(row) {
1298                    if per_vid_best_version
1299                        .get(&vid)
1300                        .is_none_or(|&best| version >= best)
1301                    {
1302                        per_vid_best_version.insert(vid, version);
1303                        per_vid_props.remove(&vid);
1304                    }
1305                    continue;
1306                }
1307
1308                let mut current_props =
1309                    Self::extract_row_properties(&batch, row, &prop_name_refs, label_props)?;
1310
1311                if let Some(overflow_props) = Self::extract_overflow_properties(&batch, row)? {
1312                    for (k, v) in overflow_props {
1313                        current_props.entry(k).or_insert(v);
1314                    }
1315                }
1316
1317                let best = per_vid_best_version.get(&vid).copied();
1318                let mut best_opt = best;
1319                let mut merged = per_vid_props.remove(&vid);
1320                self.merge_versioned_props(
1321                    current_props,
1322                    version,
1323                    &mut best_opt,
1324                    &mut merged,
1325                    label_props,
1326                )?;
1327                if let Some(v) = best_opt {
1328                    per_vid_best_version.insert(vid, v);
1329                }
1330                if let Some(p) = merged {
1331                    per_vid_props.insert(vid, p);
1332                }
1333            }
1334        }
1335
1336        // Merge storage results with any L0 partial props already in result.
1337        for (vid, storage_props) in per_vid_props {
1338            let entry = result.entry(vid).or_default();
1339            for (k, v) in storage_props {
1340                entry.entry(k).or_insert(v);
1341            }
1342        }
1343
1344        // Phase 2b: schemaless main-table fallback. A `need_storage` vid that
1345        // produced no per-label verdict — neither a live row (present in `result`)
1346        // nor a tombstone (present in `per_vid_best_version`) — may be a schemaless
1347        // vertex whose properties live in the main table's `props_json` rather than
1348        // this label's Lance table. Hydrate those here, mirroring the single-VID
1349        // `MainVertexDataset::find_props_by_vid` fallback.
1350        let missing: Vec<Vid> = need_storage
1351            .iter()
1352            .copied()
1353            .filter(|vid| !result.contains_key(vid) && !per_vid_best_version.contains_key(vid))
1354            .collect();
1355        self.main_table_fallback(&missing, &mut result).await?;
1356
1357        // Phase 3: Normalize CRDT properties.
1358        if ctx.is_some() {
1359            for props in result.values_mut() {
1360                self.normalize_crdt_properties(props, label)?;
1361            }
1362        }
1363
1364        Ok(result)
1365    }
1366
1367    /// Hydrate `missing` vids from the main (schemaless) vertex table into `out`.
1368    ///
1369    /// Batch counterpart of the single-VID `MainVertexDataset::find_props_by_vid`
1370    /// fallback used by `get_vertex_props`: vertices whose properties live in the
1371    /// main table's `props_json` (a schemaless label, or a label with no visible
1372    /// per-label typed dataset) have no row in any `vertices_<label>` table, so a
1373    /// per-label scan returns nothing. Callers pass only vids with no per-label
1374    /// verdict — neither a live row nor a tombstone — so a per-label deletion is
1375    /// never resurrected by an older main-table row. Existing entries in `out` are
1376    /// preserved (`or_insert`); the caller applies any L0 overlay afterwards.
1377    ///
1378    /// # Errors
1379    ///
1380    /// Returns an error if the main-table scan or its `props_json` decode fails.
1381    async fn main_table_fallback(
1382        &self,
1383        missing: &[Vid],
1384        out: &mut HashMap<Vid, Properties>,
1385    ) -> Result<()> {
1386        if missing.is_empty() {
1387            return Ok(());
1388        }
1389        let main_props = MainVertexDataset::find_batch_props_by_vids(
1390            self.storage.backend(),
1391            missing,
1392            self.storage.version_high_water_mark(),
1393        )
1394        .await?;
1395        for (vid, props) in main_props {
1396            out.entry(vid).or_insert(props);
1397        }
1398        Ok(())
1399    }
1400
1401    /// Batch-fetch properties for multiple edges of a known type.
1402    ///
1403    /// Mirrors `get_batch_vertex_props_for_label` (above) for the edge path.
1404    /// Issues one `eid IN (...)` scan against the delta table for the edge
1405    /// type, replaying per-EID version history (op-replay + CRDT merge) the
1406    /// same way `fetch_all_edge_props_from_storage_with_hint` does. Far
1407    /// faster than per-edge `get_all_edge_props_with_ctx` when many edges
1408    /// of the same type need loading (e.g., batched SET/REMOVE on edges
1409    /// matched by a MATCH).
1410    ///
1411    /// EIDs of deleted edges or those with no rows in delta storage are
1412    /// omitted from the returned map; callers can fall back to the per-EID
1413    /// path for misses.
1414    pub async fn get_batch_edge_props_for_type(
1415        &self,
1416        eids: &[Eid],
1417        type_name: &str,
1418        ctx: Option<&QueryContext>,
1419    ) -> Result<HashMap<Eid, Properties>> {
1420        use crate::backend::table_names;
1421        use crate::backend::types::ScanRequest;
1422
1423        let mut result: HashMap<Eid, Properties> = HashMap::new();
1424        if eids.is_empty() {
1425            return Ok(result);
1426        }
1427
1428        // Phase 1: L0 check per EID. Skip deleted; serve from L0 if it has
1429        // an accumulated property set; otherwise note for storage scan.
1430        let mut need_storage: Vec<Eid> = Vec::new();
1431        for &eid in eids {
1432            if l0_visibility::is_edge_deleted(eid, ctx) {
1433                continue;
1434            }
1435            let l0_props = l0_visibility::accumulate_edge_props(eid, ctx);
1436            // Edge L0 semantics: even an empty accumulator means "edge exists
1437            // in L0 with no user props yet" — we still go to storage to pick
1438            // up the persisted full row (mirrors get_all_edge_props_with_ctx).
1439            if let Some(props) = l0_props {
1440                result.insert(eid, props);
1441            }
1442            need_storage.push(eid);
1443        }
1444
1445        if need_storage.is_empty() {
1446            return Ok(result);
1447        }
1448
1449        // Phase 2: One scan with `eid IN (...)` on the delta table.
1450        let schema = self.schema_manager.schema();
1451        let type_props = schema.properties.get(type_name);
1452
1453        if self.storage.delta_dataset(type_name, "fwd").is_err() {
1454            return Ok(result);
1455        }
1456
1457        let table_name = table_names::delta_table_name(type_name, "fwd");
1458        let backend = self.storage.backend();
1459        if !backend.table_exists(&table_name).await.unwrap_or(false) {
1460            return Ok(result);
1461        }
1462
1463        let base_filter =
1464            FilterExpr::one_of("eid", need_storage.iter().map(|e| Scalar::UInt(e.as_u64())));
1465        let filter_expr = self.storage.apply_version_filter(base_filter);
1466
1467        let batches = match backend
1468            .scan(ScanRequest::all(&table_name).with_filter(filter_expr))
1469            .await
1470        {
1471            Ok(b) => b,
1472            Err(_) => return Ok(result), // Storage error: per-row fallback handles correctness
1473        };
1474
1475        // Collect (eid, version, op, props) tuples, then group + replay per EID.
1476        let mut per_eid_rows: HashMap<Eid, Vec<(u64, u8, Properties)>> = HashMap::new();
1477        for batch in batches {
1478            let eid_col = match batch
1479                .column_by_name("eid")
1480                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1481            {
1482                Some(c) => c,
1483                None => continue,
1484            };
1485            let op_col = match batch
1486                .column_by_name("op")
1487                .and_then(|c| c.as_any().downcast_ref::<arrow_array::UInt8Array>())
1488            {
1489                Some(c) => c,
1490                None => continue,
1491            };
1492            let ver_col = match batch
1493                .column_by_name("_version")
1494                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1495            {
1496                Some(c) => c,
1497                None => continue,
1498            };
1499
1500            for row in 0..batch.num_rows() {
1501                let eid = Eid::from(eid_col.value(row));
1502                let ver = ver_col.value(row);
1503                let op = op_col.value(row);
1504                let mut props = Properties::new();
1505
1506                if op != 1
1507                    && let Some(tp) = type_props
1508                {
1509                    for (p_name, p_meta) in tp {
1510                        if let Some(col) = batch.column_by_name(p_name)
1511                            && !col.is_null(row)
1512                        {
1513                            let val = Self::value_from_column(col.as_ref(), &p_meta.r#type, row)?;
1514                            props.insert(p_name.clone(), val);
1515                        }
1516                    }
1517                }
1518                per_eid_rows.entry(eid).or_default().push((ver, op, props));
1519            }
1520        }
1521
1522        for (eid, mut rows) in per_eid_rows {
1523            rows.sort_by_key(|(ver, _, _)| *ver);
1524
1525            let mut merged_props: Properties = Properties::new();
1526            let mut is_deleted = false;
1527
1528            for (_, op, props) in rows {
1529                if op == 1 {
1530                    is_deleted = true;
1531                    merged_props.clear();
1532                } else {
1533                    is_deleted = false;
1534                    for (p_name, p_val) in props {
1535                        let is_crdt = type_props
1536                            .and_then(|tp| tp.get(&p_name))
1537                            .map(|pm| matches!(pm.r#type, DataType::Crdt(_)))
1538                            .unwrap_or(false);
1539                        if is_crdt {
1540                            if let Some(existing) = merged_props.get(&p_name) {
1541                                if let Ok(merged) = self.merge_crdt_values(existing, &p_val) {
1542                                    merged_props.insert(p_name, merged);
1543                                }
1544                            } else {
1545                                merged_props.insert(p_name, p_val);
1546                            }
1547                        } else {
1548                            merged_props.insert(p_name, p_val);
1549                        }
1550                    }
1551                }
1552            }
1553
1554            if is_deleted {
1555                // Deleted in storage; remove any L0 accumulation that may
1556                // have been recorded under this EID by Phase 1 (matches
1557                // is_edge_deleted single-EID semantics).
1558                result.remove(&eid);
1559                continue;
1560            }
1561
1562            // L0 takes precedence over storage for shared keys; insert
1563            // storage values only where L0 did not already provide them.
1564            let entry = result.entry(eid).or_default();
1565            for (k, v) in merged_props {
1566                entry.entry(k).or_insert(v);
1567            }
1568        }
1569
1570        // Schemaless / overflow edge props live in the main edges table's
1571        // `props_json`, NOT in the per-type delta columns — so the delta replay
1572        // above recovers only typed columns and leaves a schemaless edge (or any
1573        // prop absent from the type schema) empty here. Mirror the single-EID
1574        // `fetch_all_edge_props_from_storage` fallback: for any requested EID
1575        // still unresolved (no entry, or an empty one) fall back to the main
1576        // edges table. Without this, a fork SET/REMOVE on an inherited schemaless
1577        // relationship read an empty prefetch and wiped the edge's untouched
1578        // properties (#102). Only misses pay the per-EID lookup, so the batch
1579        // fast-path is preserved for fully-typed edges.
1580        use crate::storage::main_edge::MainEdgeDataset;
1581        for &eid in eids {
1582            if l0_visibility::is_edge_deleted(eid, ctx) {
1583                continue;
1584            }
1585            let needs_fallback = result.get(&eid).is_none_or(|p| p.is_empty());
1586            if !needs_fallback {
1587                continue;
1588            }
1589            if let Some(props) = MainEdgeDataset::find_props_by_eid(
1590                self.storage.backend(),
1591                eid,
1592                self.storage.version_high_water_mark(),
1593            )
1594            .await?
1595            {
1596                let entry = result.entry(eid).or_default();
1597                for (k, v) in props {
1598                    entry.entry(k).or_insert(v);
1599                }
1600            }
1601        }
1602
1603        Ok(result)
1604    }
1605
1606    /// Normalize CRDT properties by converting JSON strings to JSON objects.
1607    /// This handles the case where CRDT values come from Cypher CREATE statements
1608    /// as `Value::String("{\"t\": \"gc\", ...}")` and need to be parsed into objects.
1609    fn normalize_crdt_properties(&self, props: &mut Properties, label: &str) -> Result<()> {
1610        let schema = self.schema_manager.schema();
1611        let label_props = match schema.properties.get(label) {
1612            Some(p) => p,
1613            None => return Ok(()),
1614        };
1615
1616        for (prop_name, prop_meta) in label_props {
1617            if let DataType::Crdt(_) = prop_meta.r#type
1618                && let Some(val) = props.get_mut(prop_name)
1619            {
1620                *val = Value::from(Self::parse_crdt_value(val)?);
1621            }
1622        }
1623
1624        Ok(())
1625    }
1626
1627    /// Extract properties from a single batch row.
1628    fn extract_row_properties(
1629        batch: &RecordBatch,
1630        row: usize,
1631        prop_names: &[&str],
1632        label_props: Option<&HashMap<String, uni_common::core::schema::PropertyMeta>>,
1633    ) -> Result<Properties> {
1634        let mut props = Properties::new();
1635        for name in prop_names {
1636            let col = match batch.column_by_name(name) {
1637                Some(col) => col,
1638                None => continue,
1639            };
1640            if col.is_null(row) {
1641                continue;
1642            }
1643            if let Some(prop_meta) = label_props.and_then(|p| p.get(*name)) {
1644                let val = Self::value_from_column(col.as_ref(), &prop_meta.r#type, row)?;
1645                props.insert((*name).to_string(), val);
1646            }
1647        }
1648        Ok(props)
1649    }
1650
1651    /// Extract overflow properties from the overflow_json column.
1652    ///
1653    /// Returns None if the column doesn't exist or the value is null,
1654    /// otherwise parses the JSON blob and returns the properties.
1655    fn extract_overflow_properties(batch: &RecordBatch, row: usize) -> Result<Option<Properties>> {
1656        use arrow_array::LargeBinaryArray;
1657
1658        let overflow_col = match batch.column_by_name("overflow_json") {
1659            Some(col) => col,
1660            None => return Ok(None), // Column doesn't exist (old schema)
1661        };
1662
1663        if overflow_col.is_null(row) {
1664            return Ok(None);
1665        }
1666
1667        let binary_array = overflow_col
1668            .as_any()
1669            .downcast_ref::<LargeBinaryArray>()
1670            .ok_or_else(|| anyhow!("overflow_json is not LargeBinaryArray"))?;
1671
1672        let jsonb_bytes = binary_array.value(row);
1673
1674        // Decode the CypherValue blob directly to `Value`. Routing through
1675        // `serde_json` would stringify temporal values (and is unnecessary —
1676        // the blob already decodes to a `Value::Map`).
1677        match uni_common::cypher_value_codec::decode(jsonb_bytes)
1678            .map_err(|e| anyhow!("Failed to decode CypherValue: {}", e))?
1679        {
1680            Value::Map(map) => Ok(Some(map)),
1681            Value::Null => Ok(None),
1682            other => Err(anyhow!(
1683                "overflow_json decoded to a non-map value: {other:?}"
1684            )),
1685        }
1686    }
1687
1688    /// Merge overflow properties from the overflow_json column into an existing props map.
1689    ///
1690    /// Handles two concerns:
1691    /// 1. If `overflow_json` is explicitly requested in `properties`, stores the raw JSONB
1692    ///    bytes as a JSON array of u8 values.
1693    /// 2. Extracts individual overflow properties and merges those that are in `properties`.
1694    fn merge_overflow_into_props(
1695        batch: &RecordBatch,
1696        row: usize,
1697        properties: &[&str],
1698        props: &mut Properties,
1699    ) -> Result<()> {
1700        use arrow_array::LargeBinaryArray;
1701
1702        let overflow_col = match batch.column_by_name("overflow_json") {
1703            Some(col) if !col.is_null(row) => col,
1704            _ => return Ok(()),
1705        };
1706
1707        // Store raw JSONB bytes if explicitly requested
1708        if properties.contains(&"overflow_json")
1709            && let Some(binary_array) = overflow_col.as_any().downcast_ref::<LargeBinaryArray>()
1710        {
1711            let jsonb_bytes = binary_array.value(row);
1712            let bytes_list: Vec<Value> =
1713                jsonb_bytes.iter().map(|&b| Value::Int(b as i64)).collect();
1714            props.insert("overflow_json".to_string(), Value::List(bytes_list));
1715        }
1716
1717        // Extract and merge individual overflow properties. `_all_props` is a
1718        // wildcard sentinel: merge every overflow property, not just named ones.
1719        let wants_all = properties.contains(&"_all_props");
1720        if let Some(overflow_props) = Self::extract_overflow_properties(batch, row)? {
1721            for (k, v) in overflow_props {
1722                if wants_all || properties.contains(&k.as_str()) {
1723                    props.entry(k).or_insert(v);
1724                }
1725            }
1726        }
1727
1728        Ok(())
1729    }
1730
1731    /// Merge CRDT properties from source into target.
1732    fn merge_crdt_into(
1733        &self,
1734        target: &mut Properties,
1735        source: Properties,
1736        label_props: Option<&HashMap<String, uni_common::core::schema::PropertyMeta>>,
1737        crdt_only: bool,
1738    ) -> Result<()> {
1739        for (k, v) in source {
1740            if let Some(prop_meta) = label_props.and_then(|p| p.get(&k)) {
1741                if let DataType::Crdt(_) = prop_meta.r#type {
1742                    let existing_v = target.entry(k).or_insert(Value::Null);
1743                    *existing_v = self.merge_crdt_values(existing_v, &v)?;
1744                } else if !crdt_only {
1745                    target.insert(k, v);
1746                }
1747            }
1748        }
1749        Ok(())
1750    }
1751
1752    /// Handle version-based property merging for storage fetch.
1753    fn merge_versioned_props(
1754        &self,
1755        current_props: Properties,
1756        version: u64,
1757        best_version: &mut Option<u64>,
1758        best_props: &mut Option<Properties>,
1759        label_props: Option<&HashMap<String, uni_common::core::schema::PropertyMeta>>,
1760    ) -> Result<()> {
1761        if best_version.is_none_or(|best| version > best) {
1762            // Newest version: strictly newer
1763            if let Some(mut existing_props) = best_props.take() {
1764                // Merge CRDTs from existing into current
1765                let mut merged = current_props;
1766                for (k, v) in merged.iter_mut() {
1767                    if let Some(prop_meta) = label_props.and_then(|p| p.get(k))
1768                        && let DataType::Crdt(_) = prop_meta.r#type
1769                        && let Some(existing_val) = existing_props.remove(k)
1770                    {
1771                        *v = self.merge_crdt_values(v, &existing_val)?;
1772                    }
1773                }
1774                *best_props = Some(merged);
1775            } else {
1776                *best_props = Some(current_props);
1777            }
1778            *best_version = Some(version);
1779        } else if Some(version) == *best_version {
1780            // Same version: merge all properties
1781            if let Some(existing_props) = best_props.as_mut() {
1782                self.merge_crdt_into(existing_props, current_props, label_props, false)?;
1783            } else {
1784                *best_props = Some(current_props);
1785            }
1786        } else {
1787            // Older version: only merge CRDTs
1788            if let Some(existing_props) = best_props.as_mut() {
1789                self.merge_crdt_into(existing_props, current_props, label_props, true)?;
1790            }
1791        }
1792        Ok(())
1793    }
1794
1795    async fn fetch_all_props_from_storage(&self, vid: Vid) -> Result<Option<Properties>> {
1796        // In the new storage model, VID doesn't embed label info.
1797        // We need to scan all label datasets to find the vertex's properties.
1798        let schema = self.schema_manager.schema();
1799        let mut merged_props: Option<Properties> = None;
1800        let mut global_best_version: Option<u64> = None;
1801
1802        // Try VidLabelsIndex for O(1) label resolution
1803        let label_names: Vec<String> = if let Some(labels) = self.storage.get_labels_from_index(vid)
1804        {
1805            labels
1806        } else {
1807            schema.labels.keys().cloned().collect() // Fallback to full scan
1808        };
1809
1810        for label_name in &label_names {
1811            let label_props = schema.properties.get(label_name);
1812
1813            // Get property names from schema
1814            let mut prop_names: Vec<String> = Vec::new();
1815            if let Some(props) = label_props {
1816                prop_names = props.keys().cloned().collect();
1817            }
1818
1819            // Build column selection
1820            let mut columns: Vec<String> = vec!["_deleted".to_string(), "_version".to_string()];
1821            columns.extend(prop_names.iter().cloned());
1822            // Add overflow_json column to fetch non-schema properties
1823            columns.push("overflow_json".to_string());
1824
1825            // Query using backend scan API
1826            let base_filter = FilterExpr::equals("_vid", Scalar::UInt(vid.as_u64()));
1827
1828            let filter_expr = self.storage.apply_version_filter(base_filter);
1829
1830            let table_name = crate::backend::table_names::vertex_table_name(label_name);
1831            let batches: Vec<RecordBatch> = match self
1832                .storage
1833                .backend()
1834                .scan(
1835                    crate::backend::types::ScanRequest::all(&table_name)
1836                        .with_filter(filter_expr.clone())
1837                        .with_columns(columns.clone()),
1838                )
1839                .await
1840            {
1841                Ok(b) => b,
1842                Err(_) => continue,
1843            };
1844
1845            // Convert Vec<String> to Vec<&str> for downstream use
1846            let prop_name_refs: Vec<&str> = prop_names.iter().map(|s| s.as_str()).collect();
1847
1848            for batch in batches {
1849                let deleted_col = match batch
1850                    .column_by_name("_deleted")
1851                    .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
1852                {
1853                    Some(c) => c,
1854                    None => continue,
1855                };
1856                let version_col = match batch
1857                    .column_by_name("_version")
1858                    .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1859                {
1860                    Some(c) => c,
1861                    None => continue,
1862                };
1863
1864                for row in 0..batch.num_rows() {
1865                    let version = version_col.value(row);
1866
1867                    if deleted_col.value(row) {
1868                        if global_best_version.is_none_or(|best| version >= best) {
1869                            global_best_version = Some(version);
1870                            merged_props = None;
1871                        }
1872                        continue;
1873                    }
1874
1875                    let mut current_props =
1876                        Self::extract_row_properties(&batch, row, &prop_name_refs, label_props)?;
1877
1878                    // Also extract overflow properties from overflow_json column
1879                    if let Some(overflow_props) = Self::extract_overflow_properties(&batch, row)? {
1880                        // Merge overflow properties into current_props
1881                        for (k, v) in overflow_props {
1882                            current_props.entry(k).or_insert(v);
1883                        }
1884                    }
1885
1886                    self.merge_versioned_props(
1887                        current_props,
1888                        version,
1889                        &mut global_best_version,
1890                        &mut merged_props,
1891                        label_props,
1892                    )?;
1893                }
1894            }
1895        }
1896
1897        // Fallback to main table props_json for unknown/schemaless labels.
1898        // Gated on "no per-label verdict" (neither a live row nor a tombstone
1899        // was seen), so a per-label deletion tombstone is never overridden by
1900        // an older main-table row.
1901        if merged_props.is_none()
1902            && global_best_version.is_none()
1903            && let Some(main_props) = MainVertexDataset::find_props_by_vid(
1904                self.storage.backend(),
1905                vid,
1906                self.storage.version_high_water_mark(),
1907            )
1908            .await?
1909        {
1910            return Ok(Some(main_props));
1911        }
1912
1913        Ok(merged_props)
1914    }
1915
1916    pub async fn get_vertex_prop(&self, vid: Vid, prop: &str) -> Result<Value> {
1917        self.get_vertex_prop_with_ctx(vid, prop, None).await
1918    }
1919
1920    #[instrument(skip(self, ctx), level = "trace")]
1921    pub async fn get_vertex_prop_with_ctx(
1922        &self,
1923        vid: Vid,
1924        prop: &str,
1925        ctx: Option<&QueryContext>,
1926    ) -> Result<Value> {
1927        // 1. Check if deleted in any L0 layer
1928        if l0_visibility::is_vertex_deleted(vid, ctx) {
1929            return Ok(Value::Null);
1930        }
1931
1932        // 2. Determine if property is CRDT type
1933        // First check labels from context/L0, then fall back to scanning all labels in schema
1934        let schema = self.schema_manager.schema();
1935        let labels = ctx
1936            .map(|c| l0_visibility::get_vertex_labels(vid, c))
1937            .unwrap_or_default();
1938
1939        let is_crdt = if !labels.is_empty() {
1940            // Check labels from context
1941            labels.iter().any(|ln| {
1942                schema
1943                    .properties
1944                    .get(ln)
1945                    .and_then(|lp| lp.get(prop))
1946                    .map(|pm| matches!(pm.r#type, DataType::Crdt(_)))
1947                    .unwrap_or(false)
1948            })
1949        } else {
1950            // No labels from context - check if property is CRDT in ANY label
1951            schema.properties.values().any(|label_props| {
1952                label_props
1953                    .get(prop)
1954                    .map(|pm| matches!(pm.r#type, DataType::Crdt(_)))
1955                    .unwrap_or(false)
1956            })
1957        };
1958
1959        // 3. Check L0 chain for property
1960        if is_crdt {
1961            // For CRDT, accumulate and merge values from all L0 layers
1962            let l0_val = self.accumulate_crdt_from_l0(vid, prop, ctx)?;
1963            return self.finalize_crdt_lookup(vid, prop, l0_val).await;
1964        }
1965
1966        // 4. Non-CRDT: Check L0 chain for property (returns first found)
1967        if let Some(val) = l0_visibility::lookup_vertex_prop(vid, prop, ctx) {
1968            return Ok(val);
1969        }
1970
1971        // 5. Check Cache (if enabled)
1972        if let Some(ref cache) = self.vertex_cache {
1973            let mut cache = cache.lock().await;
1974            if let Some(val) = cache.get(&(vid, prop.to_string())) {
1975                debug!(vid = ?vid, prop, "Cache HIT");
1976                metrics::counter!("uni_property_cache_hits_total", "type" => "vertex").increment(1);
1977                return Ok(val.clone());
1978            } else {
1979                debug!(vid = ?vid, prop, "Cache MISS");
1980                metrics::counter!("uni_property_cache_misses_total", "type" => "vertex")
1981                    .increment(1);
1982            }
1983        }
1984
1985        // 6. Fetch from Storage
1986        let storage_val = self.fetch_prop_from_storage(vid, prop).await?;
1987
1988        // 7. Update Cache (if enabled)
1989        if let Some(ref cache) = self.vertex_cache {
1990            let mut cache = cache.lock().await;
1991            cache.put((vid, prop.to_string()), storage_val.clone());
1992        }
1993
1994        Ok(storage_val)
1995    }
1996
1997    /// Accumulate CRDT values from all L0 layers by merging them together.
1998    fn accumulate_crdt_from_l0(
1999        &self,
2000        vid: Vid,
2001        prop: &str,
2002        ctx: Option<&QueryContext>,
2003    ) -> Result<Value> {
2004        let mut merged = Value::Null;
2005        l0_visibility::visit_l0_buffers(ctx, |l0| {
2006            if let Some(props) = l0.vertex_properties.get(&vid)
2007                && let Some(val) = props.get(prop)
2008            {
2009                // Note: merge_crdt_values can't fail in practice for valid CRDTs
2010                if let Ok(new_merged) = self.merge_crdt_values(&merged, val) {
2011                    merged = new_merged;
2012                }
2013            }
2014            false // Continue visiting all layers
2015        });
2016        Ok(merged)
2017    }
2018
2019    /// Finalize CRDT lookup by merging with cache/storage.
2020    async fn finalize_crdt_lookup(&self, vid: Vid, prop: &str, l0_val: Value) -> Result<Value> {
2021        // Check Cache (if enabled)
2022        let cached_val = if let Some(ref cache) = self.vertex_cache {
2023            let mut cache = cache.lock().await;
2024            cache.get(&(vid, prop.to_string())).cloned()
2025        } else {
2026            None
2027        };
2028
2029        if let Some(val) = cached_val {
2030            let merged = self.merge_crdt_values(&val, &l0_val)?;
2031            return Ok(merged);
2032        }
2033
2034        // Fetch from Storage
2035        let storage_val = self.fetch_prop_from_storage(vid, prop).await?;
2036
2037        // Update Cache (if enabled)
2038        if let Some(ref cache) = self.vertex_cache {
2039            let mut cache = cache.lock().await;
2040            cache.put((vid, prop.to_string()), storage_val.clone());
2041        }
2042
2043        // Merge L0 + Storage
2044        self.merge_crdt_values(&storage_val, &l0_val)
2045    }
2046
2047    async fn fetch_prop_from_storage(&self, vid: Vid, prop: &str) -> Result<Value> {
2048        // In the new storage model, VID doesn't embed label info.
2049        // We need to scan all label datasets to find the property.
2050        let schema = self.schema_manager.schema();
2051        let mut best_version: Option<u64> = None;
2052        let mut best_value: Option<Value> = None;
2053
2054        // Try VidLabelsIndex for O(1) label resolution
2055        let label_names: Vec<String> = if let Some(labels) = self.storage.get_labels_from_index(vid)
2056        {
2057            labels
2058        } else {
2059            schema.labels.keys().cloned().collect() // Fallback to full scan
2060        };
2061
2062        for label_name in &label_names {
2063            // Check if property is defined in schema for this label
2064            let prop_meta = schema
2065                .properties
2066                .get(label_name)
2067                .and_then(|props| props.get(prop));
2068
2069            // Even if property is not in schema, we still check overflow_json
2070
2071            // Query using backend scan API
2072            let base_filter = FilterExpr::equals("_vid", Scalar::UInt(vid.as_u64()));
2073
2074            let filter_expr = self.storage.apply_version_filter(base_filter);
2075
2076            // Always request metadata columns and overflow_json
2077            let mut columns = vec![
2078                "_deleted".to_string(),
2079                "_version".to_string(),
2080                "overflow_json".to_string(),
2081            ];
2082
2083            // Only request the property column if it's defined in schema
2084            if prop_meta.is_some() {
2085                columns.push(prop.to_string());
2086            }
2087
2088            let table_name = crate::backend::table_names::vertex_table_name(label_name);
2089            let batches: Vec<RecordBatch> = match self
2090                .storage
2091                .backend()
2092                .scan(
2093                    crate::backend::types::ScanRequest::all(&table_name)
2094                        .with_filter(filter_expr.clone())
2095                        .with_columns(columns),
2096                )
2097                .await
2098            {
2099                Ok(b) => b,
2100                Err(_) => continue,
2101            };
2102
2103            for batch in batches {
2104                let deleted_col = match batch
2105                    .column_by_name("_deleted")
2106                    .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
2107                {
2108                    Some(c) => c,
2109                    None => continue,
2110                };
2111                let version_col = match batch
2112                    .column_by_name("_version")
2113                    .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
2114                {
2115                    Some(c) => c,
2116                    None => continue,
2117                };
2118                for row in 0..batch.num_rows() {
2119                    let version = version_col.value(row);
2120
2121                    if deleted_col.value(row) {
2122                        if best_version.is_none_or(|best| version >= best) {
2123                            best_version = Some(version);
2124                            best_value = None;
2125                        }
2126                        continue;
2127                    }
2128
2129                    // First try schema column if property is in schema
2130                    let mut val = None;
2131                    if let Some(meta) = prop_meta
2132                        && let Some(col) = batch.column_by_name(prop)
2133                    {
2134                        val = Some(if col.is_null(row) {
2135                            Value::Null
2136                        } else {
2137                            Self::value_from_column(col, &meta.r#type, row)?
2138                        });
2139                    }
2140
2141                    // If not in schema column, check overflow_json
2142                    if val.is_none()
2143                        && let Some(overflow_props) =
2144                            Self::extract_overflow_properties(&batch, row)?
2145                        && let Some(overflow_val) = overflow_props.get(prop)
2146                    {
2147                        val = Some(overflow_val.clone());
2148                    }
2149
2150                    // If we found a value (from schema or overflow), merge it
2151                    if let Some(v) = val {
2152                        if let Some(meta) = prop_meta {
2153                            // Use schema type for merging (handles CRDT)
2154                            self.merge_prop_value(
2155                                v,
2156                                version,
2157                                &meta.r#type,
2158                                &mut best_version,
2159                                &mut best_value,
2160                            )?;
2161                        } else {
2162                            // Overflow property: use simple LWW merging
2163                            if best_version.is_none_or(|best| version >= best) {
2164                                best_version = Some(version);
2165                                best_value = Some(v);
2166                            }
2167                        }
2168                    }
2169                }
2170            }
2171        }
2172
2173        // Fallback to main-table props_json for unknown/schemaless labels —
2174        // their rows have no per-label table at all (mirrors
2175        // `fetch_all_props_from_storage`). Gated on "no per-label verdict"
2176        // (neither a live value nor a tombstone was seen), so a per-label
2177        // tombstone is never overridden by an older main-table row.
2178        if best_value.is_none()
2179            && best_version.is_none()
2180            && let Some(main_props) = MainVertexDataset::find_props_by_vid(
2181                self.storage.backend(),
2182                vid,
2183                self.storage.version_high_water_mark(),
2184            )
2185            .await?
2186        {
2187            return Ok(main_props.get(prop).cloned().unwrap_or(Value::Null));
2188        }
2189
2190        Ok(best_value.unwrap_or(Value::Null))
2191    }
2192
2193    /// Decode an Arrow column value with strict CRDT error handling.
2194    pub fn value_from_column(col: &dyn Array, data_type: &DataType, row: usize) -> Result<Value> {
2195        crate::storage::value_codec::decode_column_value(
2196            col,
2197            data_type,
2198            row,
2199            CrdtDecodeMode::Strict,
2200        )
2201    }
2202
2203    /// Merge two `Value`-wrapped CRDT operands.
2204    ///
2205    /// Routes through [`uni_crdt::Crdt::merge_via_registry`] using the
2206    /// `PropertyManager`'s `plugin_registry`. With an empty registry
2207    /// (the legacy 3-arg [`Self::new`] default) `merge_via_registry`
2208    /// falls back to `Crdt::try_merge`, preserving native semantics.
2209    ///
2210    /// # Errors
2211    ///
2212    /// Returns an `anyhow::Error` when either operand is malformed
2213    /// CRDT JSON, the variants disagree, or the registry-dispatched
2214    /// merge surfaces a `CrdtError`.
2215    pub fn merge_crdt_values(&self, a: &Value, b: &Value) -> Result<Value> {
2216        // Handle the case where values are JSON strings containing CRDT JSON
2217        // (this happens when values come from Cypher CREATE statements)
2218        // Parse before checking for null to ensure proper format conversion
2219        if a.is_null() {
2220            return Self::parse_crdt_value(b).map(Value::from);
2221        }
2222        if b.is_null() {
2223            return Self::parse_crdt_value(a).map(Value::from);
2224        }
2225
2226        let a_parsed = Self::parse_crdt_value(a)?;
2227        let b_parsed = Self::parse_crdt_value(b)?;
2228
2229        let mut crdt_a: Crdt = serde_json::from_value(a_parsed)?;
2230        let crdt_b: Crdt = serde_json::from_value(b_parsed)?;
2231        // M10 follow-up: route through `merge_via_registry` so a
2232        // hot-reloaded `CrdtKindProvider` plugin can intercept the
2233        // merge. With an empty registry (the 3-arg `new()` default)
2234        // this falls back to `Crdt::try_merge`, preserving prior
2235        // behavior bit-for-bit.
2236        crdt_a
2237            .merge_via_registry(&crdt_b, &self.plugin_registry)
2238            .map_err(|e| anyhow::anyhow!("{e}"))?;
2239        Ok(Value::from(serde_json::to_value(crdt_a)?))
2240    }
2241
2242    /// Parse a CRDT value that may be either a JSON object or a JSON string containing JSON.
2243    /// Returns `serde_json::Value` for internal CRDT processing.
2244    fn parse_crdt_value(val: &Value) -> Result<serde_json::Value> {
2245        if let Value::String(s) = val {
2246            // Value is a JSON string - parse the string content as JSON
2247            serde_json::from_str(s).map_err(|e| anyhow!("Failed to parse CRDT JSON string: {}", e))
2248        } else {
2249            // Convert uni_common::Value to serde_json::Value for CRDT processing
2250            Ok(serde_json::Value::from(val.clone()))
2251        }
2252    }
2253
2254    /// Merge a property value based on version, handling CRDT vs LWW semantics.
2255    fn merge_prop_value(
2256        &self,
2257        val: Value,
2258        version: u64,
2259        data_type: &DataType,
2260        best_version: &mut Option<u64>,
2261        best_value: &mut Option<Value>,
2262    ) -> Result<()> {
2263        if let DataType::Crdt(_) = data_type {
2264            self.merge_crdt_prop_value(val, version, best_version, best_value)
2265        } else {
2266            // Standard LWW
2267            if best_version.is_none_or(|best| version >= best) {
2268                *best_version = Some(version);
2269                *best_value = Some(val);
2270            }
2271            Ok(())
2272        }
2273    }
2274
2275    /// Merge CRDT property values across versions (CRDTs merge regardless of version).
2276    fn merge_crdt_prop_value(
2277        &self,
2278        val: Value,
2279        version: u64,
2280        best_version: &mut Option<u64>,
2281        best_value: &mut Option<Value>,
2282    ) -> Result<()> {
2283        if best_version.is_none_or(|best| version > best) {
2284            // Newer version: merge with existing if present
2285            if let Some(existing) = best_value.take() {
2286                *best_value = Some(self.merge_crdt_values(&val, &existing)?);
2287            } else {
2288                *best_value = Some(val);
2289            }
2290            *best_version = Some(version);
2291        } else if Some(version) == *best_version {
2292            // Same version: merge
2293            let existing = best_value.get_or_insert(Value::Null);
2294            *existing = self.merge_crdt_values(existing, &val)?;
2295        } else {
2296            // Older version: still merge for CRDTs
2297            if let Some(existing) = best_value.as_mut() {
2298                *existing = self.merge_crdt_values(existing, &val)?;
2299            }
2300        }
2301        Ok(())
2302    }
2303}