Skip to main content

linkrs_storage/edge/csr_with_properties/
read.rs

1use super::CsrWithProperties;
2use crate::cursor::{PredicateRange, ScanPredicate};
3use crate::vertex::column::zone_map::ZONE_MAP_CHUNK_ROWS;
4use linkrs_core::types::{EdgeId, Timestamp};
5use linkrs_core::{StorageError, StorageResult, Value};
6use std::collections::HashSet;
7use std::sync::Arc;
8
9type ProjectedProps = Vec<(Arc<str>, Option<Value>)>;
10type ProjectedBatch = Vec<Option<ProjectedProps>>;
11
12impl CsrWithProperties {
13    /// Read the property row for `edge_id` at `query_ts`, decoding only the
14    /// projected columns.
15    ///
16    /// `projection` selects which columns to decode: `None` decodes every
17    /// column, `Some(&[])` decodes none (topology-only read). Unknown names
18    /// are skipped. Visibility is still enforced: an invisible edge yields
19    /// `None`, a visible one yields `Some` (possibly empty).
20    /// Test-only row-stamp filtered read; production uses physical read plus authority gate.
21    #[cfg(test)]
22    pub fn get_projected_by_edge_id(
23        &self,
24        edge_id: EdgeId,
25        query_ts: Timestamp,
26        projection: Option<&[std::sync::Arc<str>]>,
27    ) -> Option<Vec<(Arc<str>, Option<Value>)>> {
28        let pos = self.mapped_row(edge_id)?;
29        let vis = self.visibility.get(pos)?;
30        if !vis.is_visible_at(query_ts) {
31            return None;
32        }
33        Some(
34            self.resolve_projection(projection)
35                .into_iter()
36                .map(|(i, name)| {
37                    let v = self.property_columns[i].get_at_ts(pos, query_ts);
38                    (name.clone(), v)
39                })
40                .collect(),
41        )
42    }
43
44    /// Test-only row-stamp filtered read; production uses physical read plus authority gate.
45    #[cfg(test)]
46    pub fn get_by_edge_id(
47        &self,
48        edge_id: EdgeId,
49        query_ts: Timestamp,
50    ) -> Option<Vec<(Arc<str>, Option<Value>)>> {
51        self.get_projected_by_edge_id(edge_id, query_ts, None)
52    }
53
54    /// Resolve a projection to `(column position, name)` pairs in schema order.
55    ///
56    /// One hash lookup per requested name instead of one string scan per
57    /// schema column, so wide schemas never pay a quadratic match and the
58    /// hot batch path performs no string comparison at all. Unknown names
59    /// are skipped and an empty list resolves to no columns, matching the
60    /// historical filter semantics exactly. The pairs borrow the schema, so
61    /// resolution itself allocates nothing; materialized output cells share
62    /// the schema name handle through an `Arc` reference-count increment.
63    fn resolve_projection<'s>(
64        &'s self,
65        projection: Option<&[std::sync::Arc<str>]>,
66    ) -> Vec<(usize, &'s Arc<str>)> {
67        match projection {
68            None => self
69                .property_schema
70                .iter()
71                .enumerate()
72                .map(|(i, s)| (i, &s.name))
73                .collect(),
74            Some(names) => {
75                if names.is_empty() {
76                    return Vec::new();
77                }
78                let wanted: HashSet<&str> = names.iter().map(|n| n.as_ref()).collect();
79                self.property_schema
80                    .iter()
81                    .enumerate()
82                    .filter(|(_, s)| wanted.contains(&*s.name))
83                    .map(|(i, s)| (i, &s.name))
84                    .collect()
85            }
86        }
87    }
88
89    /// Physical property projection without row visibility filtering.
90    ///
91    /// Callers must decide visibility through the version authority first;
92    /// row stamps exist only for collection. Returns `None` only when the
93    /// edge has no row mapping. Lenient on history: version chains do not
94    /// survive checkpoints, so pre-floor reads may return the current value;
95    /// time-travel callers use the strict entry instead.
96    pub fn get_projected_physical_by_edge_id(
97        &self,
98        edge_id: EdgeId,
99        query_ts: Timestamp,
100        projection: Option<&[std::sync::Arc<str>]>,
101    ) -> Option<Vec<(Arc<str>, Option<Value>)>> {
102        if self.inline {
103            return None;
104        }
105        let pos = self.mapped_row(edge_id)?;
106        if pos >= self.visibility.len() {
107            return None;
108        }
109        Some(
110            self.resolve_projection(projection)
111                .into_iter()
112                .map(|(i, name)| {
113                    let v = self.property_columns[i].get_at_ts(pos, query_ts);
114                    (name.clone(), v)
115                })
116                .collect(),
117        )
118    }
119
120    /// Physical property projection for many edges without visibility filtering.
121    ///
122    /// Batch form of [`Self::get_projected_physical_by_edge_id`]: the
123    /// projection resolves to column indices once and every edge reuses the
124    /// mapping, so a projected adjacency pays one hash-based schema pass
125    /// instead of one string scan per edge. Output order follows the input;
126    /// each entry carries the same contract as the single-edge call (`None`
127    /// for inline tables, unmapped edges and out-of-range rows).
128    pub fn get_projected_physical_batch_by_edge_ids(
129        &self,
130        edge_ids: &[EdgeId],
131        query_ts: Timestamp,
132        projection: Option<&[std::sync::Arc<str>]>,
133    ) -> ProjectedBatch {
134        if self.inline {
135            return edge_ids.iter().map(|_| None).collect();
136        }
137        let columns = self.resolve_projection(projection);
138        edge_ids
139            .iter()
140            .map(|edge_id| {
141                let pos = self.mapped_row(*edge_id)?;
142                if pos >= self.visibility.len() {
143                    return None;
144                }
145                Some(
146                    columns
147                        .iter()
148                        .map(|(i, name)| {
149                            let v = self.property_columns[*i].get_at_ts(pos, query_ts);
150                            ((*name).clone(), v)
151                        })
152                        .collect(),
153                )
154            })
155            .collect()
156    }
157
158    /// Epoch fence for attribute time travel.
159    ///
160    /// Version chains do not survive checkpoints, so a strict read below
161    /// the load floor for a row created at or below the query timestamp
162    /// may need dropped before-images and fails instead of returning the
163    /// current value. Rows created after the query timestamp read as
164    /// missing through the normal path.
165    fn check_history_floor(
166        &self,
167        pos: usize,
168        edge_id: EdgeId,
169        query_ts: Timestamp,
170    ) -> StorageResult<()> {
171        if query_ts < self.history_floor {
172            let created = self
173                .visibility
174                .get(pos)
175                .map(|vis| vis.create_ts)
176                .unwrap_or(0);
177            if created <= query_ts {
178                return Err(StorageError::history_before_floor_edge(
179                    format!("{:?}", edge_id),
180                    query_ts,
181                    self.history_floor,
182                ));
183            }
184        }
185        Ok(())
186    }
187
188    /// Strict physical projection with explicit decode errors.
189    ///
190    /// Query entry for explicit failure semantics. Visibility stays with the
191    /// caller through the version authority; row stamps never decide it
192    /// here. Never-written windows yield `None` cells; corrupt snapshots,
193    /// side-store payloads and raw length or dimension mismatches yield
194    /// `Err` carrying the column and edge so scans never observe silent
195    /// nulls. Reads below the history epoch floor fail instead of serving
196    /// the current value as history. Unmapped edges still yield `Ok(None)`.
197    pub fn try_get_projected_physical_by_edge_id(
198        &self,
199        edge_id: EdgeId,
200        query_ts: Timestamp,
201        projection: Option<&[std::sync::Arc<str>]>,
202    ) -> StorageResult<Option<ProjectedProps>> {
203        if self.inline {
204            return Ok(None);
205        }
206        let Some(pos) = self.mapped_row(edge_id) else {
207            return Ok(None);
208        };
209        if pos >= self.visibility.len() {
210            return Ok(None);
211        }
212        self.check_history_floor(pos, edge_id, query_ts)?;
213        let mut out = Vec::with_capacity(self.property_schema.len());
214        for (i, name) in self.resolve_projection(projection) {
215            let value = self.property_columns[i]
216                .try_get_at_ts(pos, query_ts)
217                .map_err(|e| {
218                    StorageError::deserialize_error(format!(
219                        "edge {:?} column {} decode failed: {}",
220                        edge_id, name, e
221                    ))
222                })?;
223            out.push((name.clone(), value));
224        }
225        Ok(Some(out))
226    }
227
228    /// Strict batch physical projection with explicit decode errors.
229    ///
230    /// Batch form of
231    /// [`Self::try_get_projected_physical_by_edge_id`]: the projection
232    /// resolves once and every edge reuses it, with the same epoch fence
233    /// and decode contract per edge. Output order follows the input.
234    pub fn try_get_projected_physical_batch_by_edge_ids(
235        &self,
236        edge_ids: &[EdgeId],
237        query_ts: Timestamp,
238        projection: Option<&[std::sync::Arc<str>]>,
239    ) -> StorageResult<ProjectedBatch> {
240        if self.inline {
241            return Ok(edge_ids.iter().map(|_| None).collect());
242        }
243        let columns = self.resolve_projection(projection);
244        let mut out = Vec::with_capacity(edge_ids.len());
245        for edge_id in edge_ids {
246            let Some(pos) = self.mapped_row(*edge_id) else {
247                out.push(None);
248                continue;
249            };
250            if pos >= self.visibility.len() {
251                out.push(None);
252                continue;
253            }
254            self.check_history_floor(pos, *edge_id, query_ts)?;
255            let mut row = Vec::with_capacity(columns.len());
256            for (i, name) in &columns {
257                let value = self.property_columns[*i]
258                    .try_get_at_ts(pos, query_ts)
259                    .map_err(|e| {
260                        StorageError::deserialize_error(format!(
261                            "edge {:?} column {} decode failed: {}",
262                            edge_id, name, e
263                        ))
264                    })?;
265                row.push(((*name).clone(), value));
266            }
267            out.push(Some(row));
268        }
269        Ok(out)
270    }
271    /// Newest stored value of every non-null column for one edge.
272    ///
273    /// Snapshot-independent by design: callers need the word currently
274    /// stored (index maintenance, rollback bookkeeping), which must not
275    /// depend on a reader timestamp and must not pay a version-chain walk.
276    /// Nulls are dropped because an absent cell has no index entry to
277    /// reconcile; inline tables have no rows to read and yield `None`.
278    pub fn latest_properties_by_edge_id(&self, edge_id: EdgeId) -> Option<Vec<(Arc<str>, Value)>> {
279        if self.inline {
280            return None;
281        }
282        let pos = self.mapped_row(edge_id)?;
283        let result: Vec<(Arc<str>, Value)> = self
284            .property_schema
285            .iter()
286            .enumerate()
287            .filter_map(|(i, s)| {
288                let v = self.property_columns[i].get(pos)?;
289                Some((s.name.clone(), v))
290            })
291            .collect();
292        if result.is_empty() {
293            None
294        } else {
295            Some(result)
296        }
297    }
298
299    /// Column index for one property name.
300    fn column_index(&self, name: &str) -> Option<usize> {
301        self.column_index.get(name).copied()
302    }
303
304    /// Snapshot value of one column cell for pushdown filtering.
305    ///
306    /// Reads through the version chain at `query_ts`; a `None` return means
307    /// null at that snapshot, expressed through the column null bitmap rather
308    /// than a materialized record.
309    fn pushdown_cell(&self, row: usize, column: usize, query_ts: Timestamp) -> Option<Value> {
310        self.property_columns.get(column)?.get_at_ts(row, query_ts)
311    }
312
313    /// Whether one edge matches every pushed predicate at `query_ts`.
314    ///
315    /// Column-scan layer: only predicate columns are read, each through its
316    /// null bitmap, and no intermediate record is materialized. A missing
317    /// column, a missing row mapping or a null cell never matches, mirroring
318    /// the query NULL semantics where comparisons against NULL are false.
319    pub fn matches_predicates_for_edge(
320        &self,
321        edge_id: EdgeId,
322        query_ts: Timestamp,
323        predicates: &[crate::cursor::ScanPredicate],
324    ) -> bool {
325        if predicates.is_empty() {
326            return true;
327        }
328        let Some(resolved) = self.resolve_predicate_columns(predicates) else {
329            return false;
330        };
331        let Some(row) = self.mapped_row(edge_id) else {
332            return false;
333        };
334        resolved.iter().all(|(column, predicate)| {
335            self.pushdown_cell(row, *column, query_ts)
336                .is_some_and(|value| predicate.matches_value(&value))
337        })
338    }
339
340    /// Resolve predicate columns to positions once.
341    ///
342    /// Shared by the single-edge and batch matchers so a multi-edge filter
343    /// pays one schema lookup per predicate instead of one per edge.
344    /// `None` when any predicate references a missing column: the
345    /// conjunction can never match, and callers report no hit.
346    fn resolve_predicate_columns<'a>(
347        &self,
348        predicates: &'a [ScanPredicate],
349    ) -> Option<Vec<(usize, &'a ScanPredicate)>> {
350        predicates
351            .iter()
352            .map(|predicate| {
353                self.column_index(predicate.column())
354                    .map(|col| (col, predicate))
355            })
356            .collect()
357    }
358
359    /// Row-chunk liveness for one merged range set.
360    ///
361    /// One entry per zone-map chunk (`ZONE_MAP_CHUNK_ROWS` rows): a chunk is
362    /// dead only when some range provably excludes its recorded bounds.
363    /// Chunks without recorded bounds (all-null or never-written) stay live,
364    /// and rows past the computed chunks read as live, so the pre-filter
365    /// only ever skips rows that cannot match at any snapshot timestamp:
366    /// zone bounds widen monotonically and contain every non-null value any
367    /// snapshot can still observe through a version chain.
368    fn live_row_chunks(&self, ranges: &[(usize, PredicateRange)]) -> Vec<bool> {
369        let chunks = ranges
370            .iter()
371            .filter_map(|(col, _)| self.property_columns.get(*col))
372            .map(|col| col.zone_maps().len())
373            .max()
374            .unwrap_or(0);
375        let mut live = vec![true; chunks];
376        for (chunk, slot) in live.iter_mut().enumerate() {
377            for (col, range) in ranges {
378                let Some(bounds) = self
379                    .property_columns
380                    .get(*col)
381                    .and_then(|c| c.zone_for_chunk(chunk))
382                else {
383                    continue;
384                };
385                let (Some(min), Some(max)) = (&bounds.min, &bounds.max) else {
386                    continue;
387                };
388                if !range.overlaps(min, max) {
389                    *slot = false;
390                    break;
391                }
392            }
393        }
394        live
395    }
396
397    /// Whether one row chunk may still match: chunks past the computed
398    /// liveness map hold no recorded bounds and always scan.
399    fn chunk_is_live(live: &[bool], row: usize) -> bool {
400        live.get(row / ZONE_MAP_CHUNK_ROWS).copied().unwrap_or(true)
401    }
402
403    /// Filter edge ids by pushed predicates at the column-scan layer.
404    ///
405    /// Attribute equality and range predicates filter row numbers first;
406    /// callers look up topology only for the returned hits. Nulls use bitmap
407    /// semantics throughout and no intermediate records are materialized.
408    /// `candidates` bounds the scan when the caller already holds row
409    /// numbers; `None` scans every mapped edge.
410    ///
411    /// Two pre-filters run before the per-row version-chain reads: the
412    /// merged column bounds short-circuit the whole scan, then the per-chunk
413    /// zone bounds skip dead 1024-row chunks, so a selective predicate over
414    /// a clustered column only decodes its surviving chunks. Output order
415    /// follows the input in both arms: skipping never reorders survivors.
416    pub fn filter_edge_ids_by_predicates(
417        &self,
418        predicates: &[crate::cursor::ScanPredicate],
419        query_ts: Timestamp,
420        candidates: Option<&[EdgeId]>,
421    ) -> Vec<EdgeId> {
422        if predicates.is_empty() {
423            return candidates.map_or_else(|| self.edge_ids().collect(), <[EdgeId]>::to_vec);
424        }
425        let Some(resolved) = self.resolve_predicate_columns(predicates) else {
426            return Vec::new();
427        };
428        // Zone-map short-circuit: every merged range must overlap its
429        // column bounds, else no row can match. Bounds only widen and row
430        // matching uses the same ordering, so a disjoint range provably
431        // matches nothing at any snapshot timestamp. Columns without
432        // recorded bounds (all-null columns included) are skipped: their
433        // rows still go through the cell filter below.
434        let merged = ScanPredicate::merged_ranges(predicates);
435        for range in &merged {
436            if let Some((min, max)) = self.prune_bounds(&range.column) {
437                if !range.overlaps(&min, &max) {
438                    return Vec::new();
439                }
440            }
441        }
442        // Per-chunk liveness from the same merged ranges: every predicate
443        // column resolves here (resolution above already proved all
444        // predicate columns exist), so a missing entry is unreachable and
445        // fails closed to no match.
446        let mut ranges = Vec::with_capacity(merged.len());
447        for range in merged {
448            let Some(col) = self.column_index(&range.column) else {
449                return Vec::new();
450            };
451            ranges.push((col, range));
452        }
453        let live = self.live_row_chunks(&ranges);
454        match candidates {
455            Some(ids) => ids
456                .iter()
457                .copied()
458                .filter(|edge_id| {
459                    let Some(row) = self.mapped_row(*edge_id) else {
460                        return false;
461                    };
462                    if !Self::chunk_is_live(&live, row) {
463                        return false;
464                    }
465                    resolved.iter().all(|(column, predicate)| {
466                        self.pushdown_cell(row, *column, query_ts)
467                            .is_some_and(|value| predicate.matches_value(&value))
468                    })
469                })
470                .collect(),
471            None => self
472                .edge_mappings()
473                .filter_map(|(edge_id, row)| {
474                    let row = row as usize;
475                    if !Self::chunk_is_live(&live, row) {
476                        return None;
477                    }
478                    resolved
479                        .iter()
480                        .all(|(column, predicate)| {
481                            self.pushdown_cell(row, *column, query_ts)
482                                .is_some_and(|value| predicate.matches_value(&value))
483                        })
484                        .then_some(edge_id)
485                })
486                .collect(),
487        }
488    }
489
490    /// Typed column-major batch decode without visibility filtering.
491    ///
492    /// Native edge columnar entry: the caller holds one authority verdict per
493    /// edge, so this decodes straight from the property columns into typed
494    /// [`crate::cursor::ColumnValues`]. Projection resolves once; each column
495    /// decodes according to its declared type and degrades to `General` on
496    /// type mismatch, so one mixed column never forces the whole batch back
497    /// to row transpose. Output order follows the input edge order.
498    pub fn get_typed_columns_batch_by_edge_ids(
499        &self,
500        edge_ids: &[EdgeId],
501        query_ts: Timestamp,
502        projection: Option<&[std::sync::Arc<str>]>,
503    ) -> Vec<(Arc<str>, crate::cursor::ColumnValues)> {
504        use crate::cursor::ColumnValues;
505        use linkrs_core::types::DataType;
506        if self.inline {
507            return Vec::new();
508        }
509        let columns = self.resolve_projection(projection);
510        let mut out = Vec::with_capacity(columns.len());
511        for (col_idx, name) in columns {
512            let data_type = self
513                .property_schema
514                .get(col_idx)
515                .map(|s| s.data_type.clone())
516                .unwrap_or(DataType::Empty);
517            let Some(column) = self.property_columns.get(col_idx) else {
518                out.push((
519                    name.clone(),
520                    ColumnValues::General(vec![None; edge_ids.len()]),
521                ));
522                continue;
523            };
524            let typed = match data_type {
525                DataType::BigInt => {
526                    let mut values = Vec::with_capacity(edge_ids.len());
527                    let mut valid = vec![0u8; edge_ids.len()];
528                    let mut mixed = false;
529                    for (i, edge_id) in edge_ids.iter().enumerate() {
530                        let cell = self
531                            .mapped_row(*edge_id)
532                            .and_then(|pos| column.get_at_ts(pos, query_ts));
533                        match cell {
534                            Some(Value::BigInt(v)) => {
535                                values.push(v);
536                                valid[i] = 1;
537                            }
538                            None => values.push(0),
539                            Some(_) => {
540                                mixed = true;
541                                break;
542                            }
543                        }
544                    }
545                    if mixed {
546                        None
547                    } else {
548                        Some(ColumnValues::I64 { values, valid })
549                    }
550                }
551                DataType::Double => {
552                    let mut values = Vec::with_capacity(edge_ids.len());
553                    let mut valid = vec![0u8; edge_ids.len()];
554                    let mut mixed = false;
555                    for (i, edge_id) in edge_ids.iter().enumerate() {
556                        let cell = self
557                            .mapped_row(*edge_id)
558                            .and_then(|pos| column.get_at_ts(pos, query_ts));
559                        match cell {
560                            Some(Value::Double(v)) => {
561                                values.push(v);
562                                valid[i] = 1;
563                            }
564                            None => values.push(0.0),
565                            Some(_) => {
566                                mixed = true;
567                                break;
568                            }
569                        }
570                    }
571                    if mixed {
572                        None
573                    } else {
574                        Some(ColumnValues::F64 { values, valid })
575                    }
576                }
577                DataType::Int => {
578                    let mut values = Vec::with_capacity(edge_ids.len());
579                    let mut valid = vec![0u8; edge_ids.len()];
580                    let mut mixed = false;
581                    for (i, edge_id) in edge_ids.iter().enumerate() {
582                        let cell = self
583                            .mapped_row(*edge_id)
584                            .and_then(|pos| column.get_at_ts(pos, query_ts));
585                        match cell {
586                            Some(Value::Int(v)) => {
587                                values.push(v);
588                                valid[i] = 1;
589                            }
590                            None => values.push(0),
591                            Some(_) => {
592                                mixed = true;
593                                break;
594                            }
595                        }
596                    }
597                    if mixed {
598                        None
599                    } else {
600                        Some(ColumnValues::I32 { values, valid })
601                    }
602                }
603                DataType::Bool => {
604                    let mut values = Vec::with_capacity(edge_ids.len());
605                    let mut valid = vec![0u8; edge_ids.len()];
606                    let mut mixed = false;
607                    for (i, edge_id) in edge_ids.iter().enumerate() {
608                        let cell = self
609                            .mapped_row(*edge_id)
610                            .and_then(|pos| column.get_at_ts(pos, query_ts));
611                        match cell {
612                            Some(Value::Bool(v)) => {
613                                values.push(u8::from(v));
614                                valid[i] = 1;
615                            }
616                            None => values.push(0),
617                            Some(_) => {
618                                mixed = true;
619                                break;
620                            }
621                        }
622                    }
623                    if mixed {
624                        None
625                    } else {
626                        Some(ColumnValues::Bool { values, valid })
627                    }
628                }
629                DataType::SmallInt => {
630                    let mut values = Vec::with_capacity(edge_ids.len());
631                    let mut valid = vec![0u8; edge_ids.len()];
632                    let mut mixed = false;
633                    for (i, edge_id) in edge_ids.iter().enumerate() {
634                        let cell = self
635                            .mapped_row(*edge_id)
636                            .and_then(|pos| column.get_at_ts(pos, query_ts));
637                        match cell {
638                            Some(Value::SmallInt(v)) => {
639                                values.push(v);
640                                valid[i] = 1;
641                            }
642                            None => values.push(0),
643                            Some(_) => {
644                                mixed = true;
645                                break;
646                            }
647                        }
648                    }
649                    if mixed {
650                        None
651                    } else {
652                        Some(ColumnValues::I16 { values, valid })
653                    }
654                }
655                DataType::Float => {
656                    let mut values = Vec::with_capacity(edge_ids.len());
657                    let mut valid = vec![0u8; edge_ids.len()];
658                    let mut mixed = false;
659                    for (i, edge_id) in edge_ids.iter().enumerate() {
660                        let cell = self
661                            .mapped_row(*edge_id)
662                            .and_then(|pos| column.get_at_ts(pos, query_ts));
663                        match cell {
664                            Some(Value::Float(v)) => {
665                                values.push(v);
666                                valid[i] = 1;
667                            }
668                            None => values.push(0.0),
669                            Some(_) => {
670                                mixed = true;
671                                break;
672                            }
673                        }
674                    }
675                    if mixed {
676                        None
677                    } else {
678                        Some(ColumnValues::F32 { values, valid })
679                    }
680                }
681                _ => None,
682            };
683            match typed {
684                Some(values) => out.push((name.clone(), values)),
685                None => {
686                    let general: Vec<Option<Value>> = edge_ids
687                        .iter()
688                        .map(|edge_id| {
689                            self.mapped_row(*edge_id)
690                                .and_then(|pos| column.get_at_ts(pos, query_ts))
691                        })
692                        .collect();
693                    out.push((name.clone(), ColumnValues::General(general)));
694                }
695            }
696        }
697        out
698    }
699}