linkrs-storage 0.1.0

Storage engine with CSR and memory-mapped containers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use std::collections::HashSet;
use std::sync::Arc;

use linkrs_core::types::Timestamp;

use super::core::EdgeStore;
use super::stats::ScanPruneReport;
use crate::cursor::ScanPredicate;
use crate::edge::node_group::ShardCsrIterator;
use crate::edge::{EdgeRecord, Nbr};

/// Default adjacency batch size for batched traversal.
pub const DEFAULT_ADJACENCY_BATCH: usize = 256;

/// Reusable-buffer batch accessor for adjacency reads.
///
/// Traversal and batch queries use the accessor with a caller-provided
/// buffer so peak memory stays proportional to the batch size instead of
/// the row degree. The buffer is valid only for the snapshot timestamp the
/// accessor was created with and must not be held across snapshots.
/// Visibility still goes through the version authority; no second decision
/// source exists.
pub struct AdjacencyBatchAccessor<'a> {
    table: &'a EdgeStore,
    outgoing: bool,
    ts: Timestamp,
}

impl<'a> AdjacencyBatchAccessor<'a> {
    pub fn new(table: &'a EdgeStore, outgoing: bool, ts: Timestamp) -> Self {
        Self {
            table,
            outgoing,
            ts,
        }
    }

    fn csr(&self) -> &crate::edge::node_group::CsrShardSet {
        if self.outgoing {
            &self.table.out_csr
        } else {
            &self.table.in_csr
        }
    }

    /// Fill a caller buffer with every visible neighbor of one row.
    ///
    /// Clears the buffer first and never allocates internally beyond the
    /// buffer growth the caller owns. Missing legs fill nothing.
    pub fn fill_into(&self, src: u32, out: &mut Vec<Nbr>) {
        out.clear();
        if !self.table.is_direction_available(self.outgoing) {
            return;
        }
        self.table.fill_visible_into(self.csr(), src, self.ts, out);
    }

    /// Fill a caller buffer with the first `limit` visible neighbors.
    ///
    /// Stops the physical visit after `limit` hits so high-degree `LIMIT`
    /// queries never decode the full adjacency. Missing legs fill nothing.
    pub fn fill_limited(&self, src: u32, out: &mut Vec<Nbr>, limit: usize) {
        out.clear();
        if limit == 0 || !self.table.is_direction_available(self.outgoing) {
            return;
        }
        out.reserve(limit.min(32));
        let table = self.table;
        let ts = self.ts;
        self.csr().visit_physical(src, |nbr| {
            if table.is_visible(nbr.edge_id, ts) {
                out.push(nbr);
                out.len() < limit
            } else {
                true
            }
        });
    }

    /// Visit visible neighbors in fixed-size batches reusing one caller
    /// buffer. The callback receives each full batch slice and returns false
    /// to stop early. Peak memory stays proportional to `batch_size`. A zero
    /// `batch_size` selects the default adjacency batch.
    pub fn visit_batched<F>(&self, src: u32, scratch: &mut Vec<Nbr>, batch_size: usize, mut f: F)
    where
        F: FnMut(&[Nbr]) -> bool,
    {
        let batch_size = if batch_size == 0 {
            DEFAULT_ADJACENCY_BATCH
        } else {
            batch_size
        };
        scratch.clear();
        if !self.table.is_direction_available(self.outgoing) {
            return;
        }
        let table = self.table;
        let ts = self.ts;
        let mut done = false;
        self.csr().visit_physical(src, |nbr| {
            if done {
                return false;
            }
            if table.is_visible(nbr.edge_id, ts) {
                scratch.push(nbr);
                if scratch.len() >= batch_size {
                    if !f(scratch.as_slice()) {
                        done = true;
                        return false;
                    }
                    scratch.clear();
                }
            }
            true
        });
        if !done && !scratch.is_empty() {
            f(scratch.as_slice());
            scratch.clear();
        }
    }

    /// Point lookup through the shared merged row-location logic.
    pub fn lookup(&self, src: u32, dst: u32, rank: i64) -> Option<Nbr> {
        if !self.table.is_direction_available(self.outgoing) {
            return None;
        }
        let csr = self.csr();
        let ts = self.ts;
        let table = self.table;
        let mut found = None;
        csr.visit_physical(src, |nbr| {
            if nbr.endpoint == dst && nbr.rank == rank && table.is_visible(nbr.edge_id, ts) {
                found = Some(nbr);
                false
            } else {
                true
            }
        });
        found
    }

    /// Fill a caller buffer with the visible neighbors of many rows.
    ///
    /// Multi-row fan-out sharing one staging buffer: each row stages into
    /// `scratch` (reused across rows, never reallocated per row) and the
    /// survivors append to `out` in row order. Missing legs contribute
    /// nothing. Topology only; properties still decode on demand.
    pub fn fill_many_into(&self, srcs: &[u32], out: &mut Vec<Nbr>, scratch: &mut Vec<Nbr>) {
        out.clear();
        if !self.table.is_direction_available(self.outgoing) {
            return;
        }
        let csr = self.csr();
        for src in srcs {
            self.table.fill_visible_into(csr, *src, self.ts, scratch);
            out.extend(scratch.iter().copied());
        }
    }

    /// Fill a caller buffer with the visible neighbors of many rows paired
    /// with inline values (`None` for NULL slots and non-bundled forms).
    ///
    /// Paired counterpart of [`Self::fill_many_into`] and the default batch
    /// read for bundled tables: one paired walk per row instead of a
    /// topology walk plus one value lookup per edge. Missing legs contribute
    /// nothing; visibility goes through the version authority alone.
    pub fn fill_many_with_values_into(
        &self,
        srcs: &[u32],
        out: &mut Vec<(Nbr, Option<u64>)>,
        scratch: &mut Vec<(Nbr, Option<u64>)>,
    ) {
        out.clear();
        if !self.table.is_direction_available(self.outgoing) {
            return;
        }
        let csr = self.csr();
        for src in srcs {
            self.table
                .fill_visible_with_values_into(csr, *src, self.ts, scratch);
            out.extend(scratch.iter().copied());
        }
    }
}

/// Streaming full-table edge scan.
///
/// Holds the underlying sharded row iterator and decodes one record per
/// `next()` call: no record vector is materialized at construction time, so
/// peak memory stays proportional to a single record. The `max_records`
/// limit is enforced on the advancing side inside `next()`. Groups are
/// visited in group order.
///
/// Segment pruning runs before decoding: groups whose flushed statistics
/// provably exclude the pushed predicates are skipped without touching
/// property columns, and per-row predicate checks at the column-scan layer
/// drop misses before the full projection decodes. Pruning is a pure
/// pre-filter; the prune report exposes the observed prune and filter rates.
pub struct EdgeTableScanIterator<'a> {
    table: &'a EdgeStore,
    inner: ShardCsrIterator<'a>,
    outgoing: bool,
    ts: Timestamp,
    /// Maximum number of records to return (None = unlimited)
    max_records: Option<usize>,
    /// Current record count
    current_count: usize,
    projection: Option<Vec<Arc<str>>>,
    predicates: Vec<ScanPredicate>,
    pruned_groups: HashSet<usize>,
    segments_total: usize,
    segments_pruned: usize,
    rows_scanned: usize,
    rows_filtered: usize,
    /// Row id of the last visited entry plus its prune verdict. Physical
    /// entries arrive row by row, so the group routing plus prune lookup
    /// runs once per row instead of once per entry.
    last_row: Option<u32>,
    last_row_pruned: bool,
}

impl<'a> EdgeTableScanIterator<'a> {
    pub fn new(table: &'a EdgeStore, ts: Timestamp) -> Self {
        Self::with_limit(table, ts, None)
    }

    pub fn with_projection(
        table: &'a EdgeStore,
        ts: Timestamp,
        projection: Option<Vec<Arc<str>>>,
    ) -> Self {
        Self::with_predicates(table, ts, projection, Vec::new())
    }

    /// Scan with pushed equality and range predicates.
    ///
    /// Predicates filter at the column-scan layer before the full projection
    /// decodes; segment statistics prune whole groups first. Uses the same
    /// row-location logic as adjacency and point lookups, and never builds
    /// an intermediate materialized iterator. Scans the stored leg so
    /// single-direction tables iterate their one leg instead of an empty one.
    pub fn with_predicates(
        table: &'a EdgeStore,
        ts: Timestamp,
        projection: Option<Vec<Arc<str>>>,
        predicates: Vec<ScanPredicate>,
    ) -> Self {
        let outgoing = table.has_out_edges();
        let shards = if outgoing {
            &table.out_csr
        } else {
            &table.in_csr
        };
        let existing = shards.existing_group_ids();
        let segments_total = existing.len();
        let mut pruned_groups = HashSet::new();
        if !predicates.is_empty() {
            for gid in &existing {
                if !table.segment_may_contain(*gid as u32, &predicates) {
                    pruned_groups.insert(*gid);
                }
            }
        }
        let segments_pruned = pruned_groups.len();
        Self {
            table,
            inner: shards.iter_all(),
            outgoing,
            ts,
            max_records: None,
            current_count: 0,
            projection,
            predicates,
            pruned_groups,
            segments_total,
            segments_pruned,
            rows_scanned: 0,
            rows_filtered: 0,
            last_row: None,
            last_row_pruned: false,
        }
    }

    /// Observed prune and filter rates for this scan.
    pub fn prune_report(&self) -> ScanPruneReport {
        ScanPruneReport {
            segments_total: self.segments_total,
            segments_pruned: self.segments_pruned,
            rows_scanned: self.rows_scanned,
            rows_filtered: self.rows_filtered,
        }
    }

    /// Create a scan iterator with a maximum record limit.
    ///
    /// The limit is enforced while advancing, not at construction:
    /// constructing this iterator performs no table access. Physical entries
    /// are visited and visibility is decided by the version authority alone;
    /// row stamps never filter reads.
    pub fn with_limit(table: &'a EdgeStore, ts: Timestamp, max_records: Option<usize>) -> Self {
        // Sharded scan: every physical entry in every group is visited once
        // in group order, so no cross-group deduplication is needed. The
        // stored leg is scanned so single-direction tables stay scannable.
        let outgoing = table.has_out_edges();
        let shards = if outgoing {
            &table.out_csr
        } else {
            &table.in_csr
        };
        let existing = shards.existing_group_ids();
        let segments_total = existing.len();
        Self {
            table,
            inner: shards.iter_all(),
            outgoing,
            ts,
            max_records,
            current_count: 0,
            projection: None,
            predicates: Vec::new(),
            pruned_groups: HashSet::new(),
            segments_total,
            segments_pruned: 0,
            rows_scanned: 0,
            rows_filtered: 0,
            last_row: None,
            last_row_pruned: false,
        }
    }
}

impl<'a> Iterator for EdgeTableScanIterator<'a> {
    type Item = EdgeRecord;

    fn next(&mut self) -> Option<Self::Item> {
        if let Some(max) = self.max_records {
            if self.current_count >= max {
                return None;
            }
        }

        for (row_vid, nbr) in self.inner.by_ref() {
            self.rows_scanned += 1;
            let Some(row) = row_vid.as_internal_u32() else {
                continue;
            };
            // Row-granular prune verdict: entries arrive row by row, so a
            // pruned row skips every remaining entry without another group
            // lookup, and the limit below stops the walk as soon as enough
            // records are collected.
            let pruned = match self.last_row {
                Some(cached) if cached == row => self.last_row_pruned,
                _ => {
                    let shards = if self.outgoing {
                        &self.table.out_csr
                    } else {
                        &self.table.in_csr
                    };
                    let pruned = shards
                        .group_of(row)
                        .is_some_and(|gid| self.pruned_groups.contains(&gid));
                    self.last_row = Some(row);
                    self.last_row_pruned = pruned;
                    pruned
                }
            };
            if pruned {
                continue;
            }
            // Single authority verdict per entry: predicate and projection
            // below reuse it instead of querying the authority twice more.
            if !self.table.is_visible(nbr.edge_id, self.ts) {
                continue;
            }
            if !self.predicates.is_empty()
                && !self.table.matches_pushdown_assume_visible(
                    nbr.edge_id,
                    self.ts,
                    &self.predicates,
                )
            {
                self.rows_filtered += 1;
                continue;
            }
            self.current_count += 1;
            if self.outgoing {
                return Some(self.table.edge_record_from_nbr_projected_assume_visible(
                    row,
                    nbr,
                    self.ts,
                    self.projection.as_deref(),
                ));
            }
            return Some(self.table.edge_record_from_in_nbr_assume_visible(
                row,
                nbr,
                self.ts,
                self.projection.as_deref(),
            ));
        }
        None
    }
}