Skip to main content

rudb_graph/
rid.rs

1//! The row id, and the structure that turns one into a physical position.
2//!
3//! A rudb native table is stripes of sixty four parts of 1024 rows, appended in order, so a row
4//! already has an ordinal: its position in append order over the whole table, zero based. That
5//! ordinal is the row id, and [`Places`] is the prefix sum that resolves one.
6//!
7//! The three properties spec/graph/02-the-data-model.md section 2.1 requires of a `rid` are worth
8//! restating here because two of them are properties of this module and one is not. Stability under
9//! append is free from append order. Cheap resolution to a physical position is what [`Places`] is
10//! for. Invalidation rather than corruption by a rewrite is not here at all: it is the generation
11//! stamp on every section in the file, and it works because an ignored section changes no answer.
12
13use rudb_common::{Error, Result};
14
15/// A row's position in append order over the whole table, zero based.
16///
17/// A `u64` in every interface, per section 2.1, and narrower than that in every stored form. The
18/// width is not a type parameter because a `rid` crosses between a link column packed to
19/// twenty eight bits, a key map's permutation packed to twenty four, and a gather that wants a
20/// `usize`, and a newtype per width would be three conversions at every one of those boundaries.
21pub type Rid = u64;
22
23/// The `rid` value reserved to mean *no parent*.
24///
25/// The maximum representable value in whatever width a forward link is packed to, which at the
26/// `u64` interface is this. It covers both a null child key and a child key with no matching
27/// parent, and section 2.4 is explicit that the two are distinguished, where an anti join or a
28/// `NOT IN` needs them to be, by consulting the child column's own validity rather than by
29/// reserving a second value here.
30pub const NO_PARENT: Rid = u64::MAX;
31
32/// Rows in one part, everywhere except the last part of a load.
33///
34/// A power of two, which is what makes the common case of [`Places::place`] a shift and a mask
35/// rather than a search. It is 1024 and not `rudb::VECTOR_SIZE` on purpose: a part is a unit of
36/// storage and a vector is a unit of execution, they have been different numbers since the vector
37/// went to 8192, and a module that assumed they were the same would resolve every `rid` in the file
38/// to the wrong part the next time either one moved.
39pub const PART_ROWS: usize = 1024;
40
41/// The most parts one stripe holds.
42pub const STRIPE_PARTS: usize = 64;
43
44/// Where a `rid` actually is.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Place {
47    /// Which stripe, indexing the table's stripe list.
48    pub stripe: u32,
49    /// Which part within that stripe.
50    pub part: u32,
51    /// Which row within that part.
52    pub offset: u32,
53}
54
55/// One stripe's contribution to the prefix sum.
56#[derive(Debug, Clone)]
57struct StripeSum {
58    /// Rows in the table before this stripe begins.
59    base: u64,
60    /// Rows in this stripe.
61    rows: u64,
62    /// Cumulative rows before each part, with a final total, so `parts.len()` is the part count
63    /// plus one. Held even for a uniform stripe, because 260 bytes a stripe is 400 KB at a hundred
64    /// million rows and a branch that sometimes has the array and sometimes does not is how a
65    /// lookup this hot grows a second code path nobody measures.
66    parts: Vec<u32>,
67    /// Whether every part of this stripe holds exactly [`PART_ROWS`] rows.
68    ///
69    /// True for every stripe but the last of a load, which is the case worth a shift and a mask.
70    uniform: bool,
71}
72
73/// The per-part row count prefix sum of one table, built once at open.
74///
75/// Sixty four `u32` per stripe plus a `u64` per stripe, which for a hundred million rows is about
76/// four hundred kilobytes. Section 4.2 of spec/graph/04-in-memory.md says it is built at open
77/// rather than walked per lookup, and the reason is arithmetic rather than taste: a join gathers
78/// millions of times and an open happens once.
79#[derive(Debug, Clone)]
80pub struct Places {
81    stripes: Vec<StripeSum>,
82    rows: u64,
83}
84
85impl Places {
86    /// Builds the prefix sum from the per-part row counts of every stripe, in stripe order.
87    ///
88    /// # Errors
89    ///
90    /// If a stripe holds more than [`STRIPE_PARTS`] parts, if a part is wider than [`PART_ROWS`],
91    /// if a part other than the last of its stripe is short, or if the total overflows a `u64`.
92    /// Every one of those is a malformed directory rather than a usage error, and the reason they
93    /// are checked here rather than trusted is that this structure is what a link join indexes
94    /// with: a prefix sum that is wrong by one resolves every `rid` past the fault to the wrong
95    /// row, and a wrong row is a wrong answer rather than a slow one.
96    pub fn build(per_stripe: &[Vec<u32>]) -> Result<Self> {
97        let mut stripes = Vec::with_capacity(per_stripe.len());
98        let mut base = 0_u64;
99        for (at, parts) in per_stripe.iter().enumerate() {
100            if parts.len() > STRIPE_PARTS {
101                return Err(malformed(format!(
102                    "stripe {at} has {} parts and a stripe holds at most {STRIPE_PARTS}",
103                    parts.len()
104                )));
105            }
106            let mut cumulative = Vec::with_capacity(parts.len() + 1);
107            cumulative.push(0);
108            let mut total = 0_u32;
109            for (which, &rows) in parts.iter().enumerate() {
110                if rows as usize > PART_ROWS {
111                    return Err(malformed(format!(
112                        "part {which} of stripe {at} holds {rows} rows and a part holds at most \
113                         {PART_ROWS}"
114                    )));
115                }
116                total = total.checked_add(rows).ok_or_else(|| {
117                    malformed(format!("stripe {at} overflows a thirty two bit row count"))
118                })?;
119                cumulative.push(total);
120            }
121            // Only the last part of a stripe may be short, because every earlier one being full is
122            // what licenses the shift and the mask. A stripe with a hole in the middle of it is a
123            // writer bug, and finding it here rather than in a join is the difference between a
124            // failed open and a wrong answer.
125            let uniform = parts.iter().all(|&rows| rows as usize == PART_ROWS);
126            if let Some((_, earlier)) = parts.split_last() {
127                if let Some(which) = earlier.iter().position(|&rows| rows as usize != PART_ROWS) {
128                    return Err(malformed(format!(
129                        "part {which} of stripe {at} holds {} rows and only the last part of a \
130                         stripe may be short",
131                        earlier[which]
132                    )));
133                }
134            }
135            let rows = u64::from(total);
136            stripes.push(StripeSum { base, rows, parts: cumulative, uniform });
137            base = base
138                .checked_add(rows)
139                .ok_or_else(|| malformed("the table overflows a sixty four bit row count"))?;
140        }
141        Ok(Self { stripes, rows: base })
142    }
143
144    /// Committed rows, which is one past the largest resolvable `rid`.
145    #[must_use]
146    pub fn rows(&self) -> u64 {
147        self.rows
148    }
149
150    /// Bytes this structure holds, for the cache budget of section 4.4.
151    #[must_use]
152    pub fn bytes(&self) -> usize {
153        let per_stripe = size_of::<StripeSum>();
154        self.stripes.iter().map(|stripe| per_stripe + stripe.parts.len() * size_of::<u32>()).sum()
155    }
156
157    /// Resolves a `rid` to its physical position, or `None` when it is past the end of the table.
158    ///
159    /// `None` rather than an error because a link may legitimately point past the end: a forward
160    /// link built against one generation and read against another is stale, and section 3.1 of
161    /// spec/graph/03-the-file-format.md wants a stale section ignored rather than raised. The
162    /// generation check is what catches that case first; this is the second line.
163    #[must_use]
164    pub fn place(&self, rid: Rid) -> Option<Place> {
165        if rid >= self.rows {
166            return None;
167        }
168        // Stripes are in ascending base order, so this is a binary search for the last stripe whose
169        // base is at or below the rid. `partition_point` is the branchless form of that and it is
170        // the whole search for a table of one stripe, which is every table under 65,536 rows.
171        let at = self.stripes.partition_point(|stripe| stripe.base <= rid) - 1;
172        let stripe = &self.stripes[at];
173        let within = rid - stripe.base;
174        #[expect(
175            clippy::cast_possible_truncation,
176            reason = "a stripe holds at most 65,536 rows, so `within` fits a u32"
177        )]
178        let within = within as u32;
179        let (part, offset) = if stripe.uniform {
180            // The common case, and the reason PART_ROWS is a power of two.
181            (within >> PART_SHIFT, within & PART_MASK)
182        } else {
183            let part = stripe.parts.partition_point(|&before| before <= within) - 1;
184            #[expect(
185                clippy::cast_possible_truncation,
186                reason = "a stripe holds at most sixty four parts"
187            )]
188            let part = part as u32;
189            (part, within - stripe.parts[part as usize])
190        };
191        #[expect(
192            clippy::cast_possible_truncation,
193            reason = "a table holds at most 4,294,967,295 stripes and the build checked the count"
194        )]
195        let stripe = at as u32;
196        Some(Place { stripe, part, offset })
197    }
198
199    /// The `rid` of a physical position, which is [`Places::place`] backwards.
200    ///
201    /// The forward link build needs this: it walks the parent table in physical order and has to
202    /// record what each row's `rid` is.
203    ///
204    /// `None` when the position is not in the table.
205    #[must_use]
206    pub fn rid(&self, place: Place) -> Option<Rid> {
207        let stripe = self.stripes.get(place.stripe as usize)?;
208        let before = *stripe.parts.get(place.part as usize)?;
209        let rows = stripe.parts.get(place.part as usize + 1)? - before;
210        if place.offset >= rows {
211            return None;
212        }
213        Some(stripe.base + u64::from(before + place.offset))
214    }
215
216    /// Rows in one stripe, or `None` when there is no such stripe.
217    #[must_use]
218    pub fn stripe_rows(&self, stripe: u32) -> Option<u64> {
219        self.stripes.get(stripe as usize).map(|held| held.rows)
220    }
221
222    /// Stripes in the table.
223    #[must_use]
224    pub fn stripes(&self) -> usize {
225        self.stripes.len()
226    }
227}
228
229/// `log2(PART_ROWS)`, for the shift in the uniform case.
230const PART_SHIFT: u32 = PART_ROWS.trailing_zeros();
231
232/// `PART_ROWS - 1`, for the mask in the uniform case.
233#[expect(
234    clippy::cast_possible_truncation,
235    reason = "PART_ROWS is 1024, so the mask fits a u32 with room to spare"
236)]
237const PART_MASK: u32 = (PART_ROWS - 1) as u32;
238
239fn malformed(message: impl Into<String>) -> Error {
240    Error::invalid_input(format!("invalid rudb row id prefix sum: {}", message.into()))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// A stripe list where every stripe but the last is full, which is what a load writes.
248    fn ladder(stripes: usize, tail: u32) -> Vec<Vec<u32>> {
249        #[expect(clippy::cast_possible_truncation, reason = "PART_ROWS is 1024")]
250        let full = PART_ROWS as u32;
251        let mut out = vec![vec![full; STRIPE_PARTS]; stripes.saturating_sub(1)];
252        if stripes > 0 {
253            let whole = (tail / full) as usize;
254            let mut last = vec![full; whole];
255            if tail % full != 0 {
256                last.push(tail % full);
257            }
258            out.push(last);
259        }
260        out
261    }
262
263    #[test]
264    fn a_rid_resolves_to_the_part_and_the_offset_append_order_gave_it() {
265        let places = Places::build(&ladder(1, 3000)).expect("build");
266        assert_eq!(places.rows(), 3000);
267        assert_eq!(places.place(0), Some(Place { stripe: 0, part: 0, offset: 0 }));
268        assert_eq!(places.place(1023), Some(Place { stripe: 0, part: 0, offset: 1023 }));
269        assert_eq!(places.place(1024), Some(Place { stripe: 0, part: 1, offset: 0 }));
270        assert_eq!(places.place(2999), Some(Place { stripe: 0, part: 2, offset: 951 }));
271        assert_eq!(places.place(3000), None);
272    }
273
274    #[test]
275    fn every_rid_of_a_multi_stripe_table_round_trips_through_its_place() {
276        // Two full stripes and a short one, which is the shape of any load that is not an exact
277        // multiple of 65,536 rows, and the shape where an off by one in the prefix sum shows.
278        let places = Places::build(&ladder(3, 5000)).expect("build");
279        assert_eq!(places.rows(), 65_536 * 2 + 5000);
280        for rid in 0..places.rows() {
281            let place = places.place(rid).expect("every rid under the row count resolves");
282            assert_eq!(places.rid(place), Some(rid), "rid {rid} did not round trip");
283        }
284        assert_eq!(places.place(places.rows()), None);
285    }
286
287    #[test]
288    fn the_uniform_path_and_the_searching_path_agree_on_the_same_stripe() {
289        // The same stripe described two ways: once as sixty four full parts, which takes the shift
290        // and the mask, and once with a short part on the end, which takes the search. Every rid
291        // that exists in both has to resolve to the same place, because otherwise the fast path is
292        // a second implementation rather than an optimization of the first.
293        #[expect(clippy::cast_possible_truncation, reason = "PART_ROWS is 1024")]
294        let full = PART_ROWS as u32;
295        let fast = Places::build(&[vec![full; 8]]).expect("build");
296        let slow = Places::build(&[{
297            let mut parts = vec![full; 7];
298            parts.push(full - 1);
299            parts
300        }])
301        .expect("build");
302        for rid in 0..slow.rows() {
303            assert_eq!(fast.place(rid), slow.place(rid), "rid {rid}");
304        }
305    }
306
307    #[test]
308    fn a_hole_in_the_middle_of_a_stripe_is_refused_rather_than_resolved() {
309        #[expect(clippy::cast_possible_truncation, reason = "PART_ROWS is 1024")]
310        let full = PART_ROWS as u32;
311        let complaint = Places::build(&[vec![full, 7, full]]).expect_err("a short middle part");
312        let complaint = complaint.to_string();
313        assert!(complaint.contains("part 1"), "{complaint}");
314        assert!(complaint.contains("only the last part"), "{complaint}");
315    }
316
317    #[test]
318    fn a_part_wider_than_a_part_is_refused() {
319        #[expect(clippy::cast_possible_truncation, reason = "PART_ROWS is 1024")]
320        let over = PART_ROWS as u32 + 1;
321        let complaint = Places::build(&[vec![over]]).expect_err("an oversized part");
322        assert!(complaint.to_string().contains("at most"), "{complaint}");
323    }
324
325    #[test]
326    fn a_stripe_wider_than_a_stripe_is_refused() {
327        #[expect(clippy::cast_possible_truncation, reason = "PART_ROWS is 1024")]
328        let full = PART_ROWS as u32;
329        let complaint =
330            Places::build(&[vec![full; STRIPE_PARTS + 1]]).expect_err("an oversized stripe");
331        assert!(complaint.to_string().contains("at most 64"), "{complaint}");
332    }
333
334    #[test]
335    fn an_empty_table_resolves_nothing_and_says_so_rather_than_panicking() {
336        let places = Places::build(&[]).expect("build");
337        assert_eq!(places.rows(), 0);
338        assert_eq!(places.place(0), None);
339        assert_eq!(places.stripes(), 0);
340    }
341
342    #[test]
343    fn the_prefix_sum_of_a_hundred_million_rows_is_about_four_hundred_kilobytes() {
344        // The size claim in section 4.2, checked rather than asserted in prose. It is checked
345        // because the structure is resident per open table and a factor of ten here is the
346        // difference between a cache and a leak.
347        let stripes = 100_000_000 / (PART_ROWS * STRIPE_PARTS) + 1;
348        let places = Places::build(&ladder(stripes, 4096)).expect("build");
349        let bytes = places.bytes();
350        assert!(bytes < 600 * 1024, "the prefix sum took {bytes} bytes");
351        assert!(bytes > 200 * 1024, "the prefix sum took {bytes} bytes, which is suspiciously few");
352    }
353
354    #[test]
355    fn no_parent_is_not_a_rid_any_table_can_resolve() {
356        // The reserved value has to be outside every table, not merely outside this one, which is
357        // what makes it safe to mean *no parent* in a link of any width.
358        let places = Places::build(&ladder(2, 1)).expect("build");
359        assert_eq!(places.place(NO_PARENT), None);
360    }
361}