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