Skip to main content

linkrs_storage/edge/edge_table/
iterator.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use linkrs_core::types::Timestamp;
5
6use super::core::EdgeStore;
7use super::stats::ScanPruneReport;
8use crate::cursor::ScanPredicate;
9use crate::edge::node_group::ShardCsrIterator;
10use crate::edge::{EdgeRecord, Nbr};
11
12/// Default adjacency batch size for batched traversal.
13pub const DEFAULT_ADJACENCY_BATCH: usize = 256;
14
15/// Reusable-buffer batch accessor for adjacency reads.
16///
17/// Traversal and batch queries use the accessor with a caller-provided
18/// buffer so peak memory stays proportional to the batch size instead of
19/// the row degree. The buffer is valid only for the snapshot timestamp the
20/// accessor was created with and must not be held across snapshots.
21/// Visibility still goes through the version authority; no second decision
22/// source exists.
23pub struct AdjacencyBatchAccessor<'a> {
24    table: &'a EdgeStore,
25    outgoing: bool,
26    ts: Timestamp,
27}
28
29impl<'a> AdjacencyBatchAccessor<'a> {
30    pub fn new(table: &'a EdgeStore, outgoing: bool, ts: Timestamp) -> Self {
31        Self {
32            table,
33            outgoing,
34            ts,
35        }
36    }
37
38    fn csr(&self) -> &crate::edge::node_group::CsrShardSet {
39        if self.outgoing {
40            &self.table.out_csr
41        } else {
42            &self.table.in_csr
43        }
44    }
45
46    /// Fill a caller buffer with every visible neighbor of one row.
47    ///
48    /// Clears the buffer first and never allocates internally beyond the
49    /// buffer growth the caller owns. Missing legs fill nothing.
50    pub fn fill_into(&self, src: u32, out: &mut Vec<Nbr>) {
51        out.clear();
52        if !self.table.is_direction_available(self.outgoing) {
53            return;
54        }
55        self.table.fill_visible_into(self.csr(), src, self.ts, out);
56    }
57
58    /// Fill a caller buffer with the first `limit` visible neighbors.
59    ///
60    /// Stops the physical visit after `limit` hits so high-degree `LIMIT`
61    /// queries never decode the full adjacency. Missing legs fill nothing.
62    pub fn fill_limited(&self, src: u32, out: &mut Vec<Nbr>, limit: usize) {
63        out.clear();
64        if limit == 0 || !self.table.is_direction_available(self.outgoing) {
65            return;
66        }
67        out.reserve(limit.min(32));
68        let table = self.table;
69        let ts = self.ts;
70        self.csr().visit_physical(src, |nbr| {
71            if table.is_visible(nbr.edge_id, ts) {
72                out.push(nbr);
73                out.len() < limit
74            } else {
75                true
76            }
77        });
78    }
79
80    /// Visit visible neighbors in fixed-size batches reusing one caller
81    /// buffer. The callback receives each full batch slice and returns false
82    /// to stop early. Peak memory stays proportional to `batch_size`. A zero
83    /// `batch_size` selects the default adjacency batch.
84    pub fn visit_batched<F>(&self, src: u32, scratch: &mut Vec<Nbr>, batch_size: usize, mut f: F)
85    where
86        F: FnMut(&[Nbr]) -> bool,
87    {
88        let batch_size = if batch_size == 0 {
89            DEFAULT_ADJACENCY_BATCH
90        } else {
91            batch_size
92        };
93        scratch.clear();
94        if !self.table.is_direction_available(self.outgoing) {
95            return;
96        }
97        let table = self.table;
98        let ts = self.ts;
99        let mut done = false;
100        self.csr().visit_physical(src, |nbr| {
101            if done {
102                return false;
103            }
104            if table.is_visible(nbr.edge_id, ts) {
105                scratch.push(nbr);
106                if scratch.len() >= batch_size {
107                    if !f(scratch.as_slice()) {
108                        done = true;
109                        return false;
110                    }
111                    scratch.clear();
112                }
113            }
114            true
115        });
116        if !done && !scratch.is_empty() {
117            f(scratch.as_slice());
118            scratch.clear();
119        }
120    }
121
122    /// Point lookup through the shared merged row-location logic.
123    pub fn lookup(&self, src: u32, dst: u32, rank: i64) -> Option<Nbr> {
124        if !self.table.is_direction_available(self.outgoing) {
125            return None;
126        }
127        let csr = self.csr();
128        let ts = self.ts;
129        let table = self.table;
130        let mut found = None;
131        csr.visit_physical(src, |nbr| {
132            if nbr.endpoint == dst && nbr.rank == rank && table.is_visible(nbr.edge_id, ts) {
133                found = Some(nbr);
134                false
135            } else {
136                true
137            }
138        });
139        found
140    }
141
142    /// Fill a caller buffer with the visible neighbors of many rows.
143    ///
144    /// Multi-row fan-out sharing one staging buffer: each row stages into
145    /// `scratch` (reused across rows, never reallocated per row) and the
146    /// survivors append to `out` in row order. Missing legs contribute
147    /// nothing. Topology only; properties still decode on demand.
148    pub fn fill_many_into(&self, srcs: &[u32], out: &mut Vec<Nbr>, scratch: &mut Vec<Nbr>) {
149        out.clear();
150        if !self.table.is_direction_available(self.outgoing) {
151            return;
152        }
153        let csr = self.csr();
154        for src in srcs {
155            self.table.fill_visible_into(csr, *src, self.ts, scratch);
156            out.extend(scratch.iter().copied());
157        }
158    }
159
160    /// Fill a caller buffer with the visible neighbors of many rows paired
161    /// with inline values (`None` for NULL slots and non-bundled forms).
162    ///
163    /// Paired counterpart of [`Self::fill_many_into`] and the default batch
164    /// read for bundled tables: one paired walk per row instead of a
165    /// topology walk plus one value lookup per edge. Missing legs contribute
166    /// nothing; visibility goes through the version authority alone.
167    pub fn fill_many_with_values_into(
168        &self,
169        srcs: &[u32],
170        out: &mut Vec<(Nbr, Option<u64>)>,
171        scratch: &mut Vec<(Nbr, Option<u64>)>,
172    ) {
173        out.clear();
174        if !self.table.is_direction_available(self.outgoing) {
175            return;
176        }
177        let csr = self.csr();
178        for src in srcs {
179            self.table
180                .fill_visible_with_values_into(csr, *src, self.ts, scratch);
181            out.extend(scratch.iter().copied());
182        }
183    }
184}
185
186/// Streaming full-table edge scan.
187///
188/// Holds the underlying sharded row iterator and decodes one record per
189/// `next()` call: no record vector is materialized at construction time, so
190/// peak memory stays proportional to a single record. The `max_records`
191/// limit is enforced on the advancing side inside `next()`. Groups are
192/// visited in group order.
193///
194/// Segment pruning runs before decoding: groups whose flushed statistics
195/// provably exclude the pushed predicates are skipped without touching
196/// property columns, and per-row predicate checks at the column-scan layer
197/// drop misses before the full projection decodes. Pruning is a pure
198/// pre-filter; the prune report exposes the observed prune and filter rates.
199pub struct EdgeTableScanIterator<'a> {
200    table: &'a EdgeStore,
201    inner: ShardCsrIterator<'a>,
202    outgoing: bool,
203    ts: Timestamp,
204    /// Maximum number of records to return (None = unlimited)
205    max_records: Option<usize>,
206    /// Current record count
207    current_count: usize,
208    projection: Option<Vec<Arc<str>>>,
209    predicates: Vec<ScanPredicate>,
210    pruned_groups: HashSet<usize>,
211    segments_total: usize,
212    segments_pruned: usize,
213    rows_scanned: usize,
214    rows_filtered: usize,
215    /// Row id of the last visited entry plus its prune verdict. Physical
216    /// entries arrive row by row, so the group routing plus prune lookup
217    /// runs once per row instead of once per entry.
218    last_row: Option<u32>,
219    last_row_pruned: bool,
220}
221
222impl<'a> EdgeTableScanIterator<'a> {
223    pub fn new(table: &'a EdgeStore, ts: Timestamp) -> Self {
224        Self::with_limit(table, ts, None)
225    }
226
227    pub fn with_projection(
228        table: &'a EdgeStore,
229        ts: Timestamp,
230        projection: Option<Vec<Arc<str>>>,
231    ) -> Self {
232        Self::with_predicates(table, ts, projection, Vec::new())
233    }
234
235    /// Scan with pushed equality and range predicates.
236    ///
237    /// Predicates filter at the column-scan layer before the full projection
238    /// decodes; segment statistics prune whole groups first. Uses the same
239    /// row-location logic as adjacency and point lookups, and never builds
240    /// an intermediate materialized iterator. Scans the stored leg so
241    /// single-direction tables iterate their one leg instead of an empty one.
242    pub fn with_predicates(
243        table: &'a EdgeStore,
244        ts: Timestamp,
245        projection: Option<Vec<Arc<str>>>,
246        predicates: Vec<ScanPredicate>,
247    ) -> Self {
248        let outgoing = table.has_out_edges();
249        let shards = if outgoing {
250            &table.out_csr
251        } else {
252            &table.in_csr
253        };
254        let existing = shards.existing_group_ids();
255        let segments_total = existing.len();
256        let mut pruned_groups = HashSet::new();
257        if !predicates.is_empty() {
258            for gid in &existing {
259                if !table.segment_may_contain(*gid as u32, &predicates) {
260                    pruned_groups.insert(*gid);
261                }
262            }
263        }
264        let segments_pruned = pruned_groups.len();
265        Self {
266            table,
267            inner: shards.iter_all(),
268            outgoing,
269            ts,
270            max_records: None,
271            current_count: 0,
272            projection,
273            predicates,
274            pruned_groups,
275            segments_total,
276            segments_pruned,
277            rows_scanned: 0,
278            rows_filtered: 0,
279            last_row: None,
280            last_row_pruned: false,
281        }
282    }
283
284    /// Observed prune and filter rates for this scan.
285    pub fn prune_report(&self) -> ScanPruneReport {
286        ScanPruneReport {
287            segments_total: self.segments_total,
288            segments_pruned: self.segments_pruned,
289            rows_scanned: self.rows_scanned,
290            rows_filtered: self.rows_filtered,
291        }
292    }
293
294    /// Create a scan iterator with a maximum record limit.
295    ///
296    /// The limit is enforced while advancing, not at construction:
297    /// constructing this iterator performs no table access. Physical entries
298    /// are visited and visibility is decided by the version authority alone;
299    /// row stamps never filter reads.
300    pub fn with_limit(table: &'a EdgeStore, ts: Timestamp, max_records: Option<usize>) -> Self {
301        // Sharded scan: every physical entry in every group is visited once
302        // in group order, so no cross-group deduplication is needed. The
303        // stored leg is scanned so single-direction tables stay scannable.
304        let outgoing = table.has_out_edges();
305        let shards = if outgoing {
306            &table.out_csr
307        } else {
308            &table.in_csr
309        };
310        let existing = shards.existing_group_ids();
311        let segments_total = existing.len();
312        Self {
313            table,
314            inner: shards.iter_all(),
315            outgoing,
316            ts,
317            max_records,
318            current_count: 0,
319            projection: None,
320            predicates: Vec::new(),
321            pruned_groups: HashSet::new(),
322            segments_total,
323            segments_pruned: 0,
324            rows_scanned: 0,
325            rows_filtered: 0,
326            last_row: None,
327            last_row_pruned: false,
328        }
329    }
330}
331
332impl<'a> Iterator for EdgeTableScanIterator<'a> {
333    type Item = EdgeRecord;
334
335    fn next(&mut self) -> Option<Self::Item> {
336        if let Some(max) = self.max_records {
337            if self.current_count >= max {
338                return None;
339            }
340        }
341
342        for (row_vid, nbr) in self.inner.by_ref() {
343            self.rows_scanned += 1;
344            let Some(row) = row_vid.as_internal_u32() else {
345                continue;
346            };
347            // Row-granular prune verdict: entries arrive row by row, so a
348            // pruned row skips every remaining entry without another group
349            // lookup, and the limit below stops the walk as soon as enough
350            // records are collected.
351            let pruned = match self.last_row {
352                Some(cached) if cached == row => self.last_row_pruned,
353                _ => {
354                    let shards = if self.outgoing {
355                        &self.table.out_csr
356                    } else {
357                        &self.table.in_csr
358                    };
359                    let pruned = shards
360                        .group_of(row)
361                        .is_some_and(|gid| self.pruned_groups.contains(&gid));
362                    self.last_row = Some(row);
363                    self.last_row_pruned = pruned;
364                    pruned
365                }
366            };
367            if pruned {
368                continue;
369            }
370            // Single authority verdict per entry: predicate and projection
371            // below reuse it instead of querying the authority twice more.
372            if !self.table.is_visible(nbr.edge_id, self.ts) {
373                continue;
374            }
375            if !self.predicates.is_empty()
376                && !self.table.matches_pushdown_assume_visible(
377                    nbr.edge_id,
378                    self.ts,
379                    &self.predicates,
380                )
381            {
382                self.rows_filtered += 1;
383                continue;
384            }
385            self.current_count += 1;
386            if self.outgoing {
387                return Some(self.table.edge_record_from_nbr_projected_assume_visible(
388                    row,
389                    nbr,
390                    self.ts,
391                    self.projection.as_deref(),
392                ));
393            }
394            return Some(self.table.edge_record_from_in_nbr_assume_visible(
395                row,
396                nbr,
397                self.ts,
398                self.projection.as_deref(),
399            ));
400        }
401        None
402    }
403}