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}