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 && let Some(which) = earlier.iter().position(|&rows| rows as usize != PART_ROWS)
128 {
129 return Err(malformed(format!(
130 "part {which} of stripe {at} holds {} rows and only the last part of a \
131 stripe may be short",
132 earlier[which]
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.is_multiple_of(full) {
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}