rudb_vector/string.rs
1//! The string representation.
2//!
3//! `spec/07-execution.md` section 7.1: a string is a 16 byte structure, 4 bytes of length, 4 bytes
4//! of prefix, and 8 bytes that are either the rest of a short string or a way to find a long one.
5//! Strings of 12 bytes or fewer live entirely inside the structure. The prefix means most
6//! comparisons and most equality tests answer without dereferencing anything, which on the string
7//! heavy queries in ClickBench is the difference between a cache hit and a cache miss per row.
8//!
9//! **Where this differs from the specification, and why.** The document says the last 8 bytes are
10//! a pointer, which is what DuckDB and Umbra do. Here they are a block index and an offset, which
11//! is what Arrow's `StringView` does. The sizes are identical, the prefix trick is identical, and
12//! the prefix trick is the part that makes it fast. The difference is one predictable load against
13//! one pointer chase on the slow path only, and in exchange the whole representation is safe code
14//! with no pinning machinery, which does not exist until the buffer manager arrives at M2. This is
15//! the kind of decision that gets remeasured rather than argued about, and it is tracked as an
16//! issue so that M3 measures it instead of inheriting it.
17
18use rudb_common::{Error, Result};
19
20use crate::buffer::Buffer;
21
22/// The longest string that fits entirely inside a view.
23pub const INLINE_LIMIT: usize = 12;
24
25/// A 16 byte handle on a string.
26///
27/// The layout is a `u32` length and 12 bytes of payload. For a string of 12 bytes or fewer the
28/// payload is the string, zero padded. For a longer one the first 4 bytes are the prefix and the
29/// last 8 are the offset into the column's arena.
30///
31/// Arrow spends 4 of those 8 bytes on a buffer index and 4 on an offset within the buffer, because
32/// an Arrow array is a list of buffers. This column is one arena, so there is no buffer to name and
33/// the whole 8 bytes are the offset, which reads as one load rather than two and takes the reachable
34/// size of a column from 4 GiB to more than anything will ever put in one.
35///
36/// A view on its own cannot produce a long string, only a short one. That is deliberate: the arena
37/// lives in the [`StringColumn`] and the borrow checker is what stops a view from outliving it,
38/// rather than a rule somebody has to remember.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct StringView {
41 length: u32,
42 payload: [u8; 12],
43}
44
45impl StringView {
46 /// A view on a string that fits inline.
47 ///
48 /// # Panics
49 ///
50 /// If the string is longer than [`INLINE_LIMIT`]. Callers that do not know the length go
51 /// through [`StringColumn::push`], which decides.
52 #[must_use]
53 pub fn inline(text: &str) -> Self {
54 assert!(text.len() <= INLINE_LIMIT, "a string of {} bytes is not inline", text.len());
55 let mut payload = [0u8; 12];
56 payload[..text.len()].copy_from_slice(text.as_bytes());
57 Self { length: text.len() as u32, payload }
58 }
59
60 /// A view on a string that lives in the arena.
61 fn indirect(text: &str, offset: u64) -> Self {
62 let mut payload = [0u8; 12];
63 payload[..4].copy_from_slice(&text.as_bytes()[..4]);
64 payload[4..].copy_from_slice(&offset.to_le_bytes());
65 Self { length: text.len() as u32, payload }
66 }
67
68 /// A view on bytes, whatever they are, wherever they turn out to live.
69 ///
70 /// The one constructor that takes bytes rather than a `&str`, and the two callers want it for
71 /// different reasons. A copy between two columns has bytes that were validated on the way into
72 /// the first one and validating again would be work for nothing. A `BLOB` has bytes that were
73 /// never text and are not going to become it. `offset` is where they are in the destination
74 /// arena and is ignored for a string short enough to sit in the view.
75 fn over(bytes: &[u8], offset: u64) -> Self {
76 let mut payload = [0u8; 12];
77 if bytes.len() <= INLINE_LIMIT {
78 payload[..bytes.len()].copy_from_slice(bytes);
79 } else {
80 payload[..4].copy_from_slice(&bytes[..4]);
81 payload[4..].copy_from_slice(&offset.to_le_bytes());
82 }
83 Self { length: bytes.len() as u32, payload }
84 }
85
86 /// The length in bytes.
87 #[must_use]
88 pub fn len(&self) -> usize {
89 self.length as usize
90 }
91
92 /// Whether the string is empty.
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.length == 0
96 }
97
98 /// Whether the whole string is in the view.
99 #[must_use]
100 pub fn is_inline(&self) -> bool {
101 self.len() <= INLINE_LIMIT
102 }
103
104 /// The first four bytes, zero padded.
105 ///
106 /// This is the whole point of the representation. Two strings with different prefixes are
107 /// different, and two strings with the same prefix are usually equal, so a filter on a string
108 /// column resolves without touching the payload on almost every row.
109 #[must_use]
110 pub fn prefix(&self) -> [u8; 4] {
111 [self.payload[0], self.payload[1], self.payload[2], self.payload[3]]
112 }
113
114 /// The bytes, when the whole string is in the view.
115 ///
116 /// A comparison wants bytes rather than a `&str`, because SQL's string order is byte order and
117 /// because [`Self::as_inline_str`] pays for a UTF-8 validation that a comparison has no use
118 /// for. On a filter against a varchar column that validation is the whole cost of the row.
119 #[must_use]
120 pub fn inline_bytes(&self) -> Option<&[u8]> {
121 if self.is_inline() { Some(&self.payload[..self.len()]) } else { None }
122 }
123
124 /// The string, when it is short enough to be in the view.
125 #[must_use]
126 pub fn as_inline_str(&self) -> Option<&str> {
127 if !self.is_inline() {
128 return None;
129 }
130 // `None` rather than a panic for a view that holds a blob, since the payload is whatever
131 // was written and only a column of text can promise that is a string.
132 std::str::from_utf8(&self.payload[..self.len()]).ok()
133 }
134
135 fn offset(&self) -> usize {
136 u64::from_le_bytes([
137 self.payload[4],
138 self.payload[5],
139 self.payload[6],
140 self.payload[7],
141 self.payload[8],
142 self.payload[9],
143 self.payload[10],
144 self.payload[11],
145 ]) as usize
146 }
147
148 /// Whether these two views are definitely different, answered from the view alone.
149 ///
150 /// A `false` here means the payloads have to be compared. A `true` means they do not, which on
151 /// a filter against a selective literal is almost every row.
152 #[must_use]
153 pub fn definitely_differs(&self, other: &Self) -> bool {
154 self.length != other.length || self.prefix() != other.prefix()
155 }
156}
157
158/// A column of strings: the views, and the one arena the long ones live in.
159///
160/// The arena is append only, so an offset recorded in a view stays correct for the life of the
161/// column even though the arena's address does not. That is the property a `Vec<u8>` has and a raw
162/// pointer into it does not, and it is the reason a view holds an offset.
163///
164/// This was a `Vec<Vec<u8>>` of fixed size blocks, which meant reading one long string was two
165/// dependent loads, the outer vector's element to find the block's data pointer and then the bytes.
166/// One arena makes it one, from a base the compiler can keep in a register across a row loop, and it
167/// deletes the case where a string longer than a block needed a block of its own. On server3, over a
168/// chunk of 1024 strings, comparing a column against a literal went from 14.9 nanoseconds a row to
169/// 13.2 at 40 bytes a string and from 14.2 to 12.9 at 120, gathering half the rows from 29.5 to 25.3
170/// and from 36.9 to 29.1, and building the column from 12.0 to 8.9 at 40 bytes.
171///
172/// # The one number that got worse, and what it actually is
173///
174/// Building a column whose payload passes 128 KiB, which at 1024 rows means strings averaging more
175/// than 128 bytes, went the other way: 14.6 nanoseconds a row to 41.0. That is not the copy and it
176/// is not the doubling, it is glibc. An allocation that size comes from `mmap` rather than the heap,
177/// so it is handed back to the kernel when the column is dropped and the next chunk faults every
178/// page of it in again, while sixteen KiB blocks come back off a free list already faulted. Run the
179/// same benchmark with `MALLOC_MMAP_THRESHOLD_` raised and the arena builds that column in 9.6
180/// nanoseconds a row against the blocks' 16.2, so the design is not what is slow there.
181///
182/// The fix is that a chunk's payload should come from a pool the engine owns rather than from
183/// `malloc` per chunk, which is the buffer manager at layer three and is where this belongs.
184/// [`Self::reserve_bytes`] is the part that is available now, and it recovers a quarter of it.
185///
186/// # Equality is about the strings and not about the arena
187///
188/// [`Self::over`] means two columns holding exactly the same strings can hold completely different
189/// arenas, because one of them was built by copying the strings in and the other was built over a
190/// page that already had them somewhere in it with other strings in between. Derived equality would
191/// call those two columns different, and every test in the workspace that compares two vectors would
192/// then be asserting on how a column was built rather than on what is in it. So equality is the
193/// strings, position by position, which is the only definition that survives the seam.
194#[derive(Debug, Clone, Default, Eq)]
195pub struct StringColumn {
196 views: Vec<StringView>,
197 arena: Buffer<u8>,
198}
199
200impl StringColumn {
201 /// An empty column.
202 #[must_use]
203 pub fn new() -> Self {
204 Self::default()
205 }
206
207 /// An empty column with room for `capacity` strings.
208 #[must_use]
209 pub fn with_capacity(capacity: usize) -> Self {
210 Self { views: Vec::with_capacity(capacity), arena: Buffer::new() }
211 }
212
213 /// A column with no strings in it yet, over an arena that already holds bytes.
214 ///
215 /// The seam `spec/engine/03-data-plane.md` section 3.5 asks for. Without it the only way in is
216 /// [`Self::push`], which copies, so a scan reading a Parquet page of strings copies every byte of
217 /// the page into an arena and the query then reads the copy. With it the page is the arena: the
218 /// scan hands the bytes over once, records where each string starts with
219 /// [`Self::push_in_place`], and nothing is copied but the views.
220 ///
221 /// It is useful today, because a reader that already has the page in a `Vec<u8>` can move it in
222 /// rather than copy out of it. It matters at layer three, when the [`Buffer`] is the pinned page
223 /// itself and the move is not even that.
224 ///
225 /// Appending with [`Self::push`] afterwards still works and still appends to the arena. That is
226 /// the case to keep away from once a real page is in here, because writing through a borrowed
227 /// buffer copies it, which is [`Buffer::to_mut`] and is the whole page.
228 #[must_use]
229 pub fn over(arena: Buffer<u8>) -> Self {
230 Self { views: Vec::new(), arena }
231 }
232
233 /// How many strings are in the column.
234 #[must_use]
235 pub fn len(&self) -> usize {
236 self.views.len()
237 }
238
239 /// Whether the column has no strings in it.
240 #[must_use]
241 pub fn is_empty(&self) -> bool {
242 self.views.is_empty()
243 }
244
245 /// The views, for a kernel that wants to compare prefixes without reading any payload.
246 #[must_use]
247 pub fn views(&self) -> &[StringView] {
248 &self.views
249 }
250
251 /// Appends a string and returns its index.
252 pub fn push(&mut self, text: &str) -> usize {
253 let view = if text.len() <= INLINE_LIMIT {
254 StringView::inline(text)
255 } else {
256 let offset = self.arena.len() as u64;
257 self.arena.extend_from_slice(text.as_bytes());
258 StringView::indirect(text, offset)
259 };
260 self.views.push(view);
261 self.views.len() - 1
262 }
263
264 /// Appends the string at `index` of another column, and returns its index here.
265 ///
266 /// This is what a gather and a slice over a string column want, and it is worth having next to
267 /// [`Self::push`] because that one takes a `&str` and the only way to get one out of a column
268 /// is [`Self::get`], which validates UTF-8. Validating there is a waste on this path twice
269 /// over: the bytes were validated on the way into the source column, and a copy cannot make
270 /// valid bytes invalid. Reading a ClickBench partition spent eight percent of its cycles on
271 /// that second validation.
272 ///
273 /// A position past the end of the source appends the empty string, which is what the copy loop
274 /// wants for a row that resolved to nowhere.
275 pub fn push_from(&mut self, source: &Self, index: usize) -> usize {
276 self.push_bytes(source.bytes(index).unwrap_or(b""))
277 }
278
279 /// Appends bytes that are not required to be text, and returns their index.
280 ///
281 /// What a `BLOB` is stored through. The column is the same column either way, because a string
282 /// here is already a length and some bytes and text is the reading rather than the storage, so
283 /// a blob costs nothing extra and shares every kernel that works on views. What it does not
284 /// share is [`Self::get`], which answers `None` for bytes that are not a string, so a caller
285 /// holding blobs reads them with [`Self::bytes`].
286 pub fn push_bytes(&mut self, bytes: &[u8]) -> usize {
287 let offset = self.arena.len() as u64;
288 if bytes.len() > INLINE_LIMIT {
289 self.arena.extend_from_slice(bytes);
290 }
291 self.views.push(StringView::over(bytes, offset));
292 self.views.len() - 1
293 }
294
295 /// Records a string that is already in the arena, and returns its index.
296 ///
297 /// The half of the seam that does the work. [`Self::over`] puts the page in, this says where in
298 /// it a string is, and between them a column of long strings is built without the payload being
299 /// touched at all.
300 ///
301 /// A string short enough to sit inside a view is copied into the view, which is at most twelve
302 /// bytes and is what makes it readable without going near the arena at all. Everything longer
303 /// keeps its bytes where they are and the view records the offset.
304 ///
305 /// # Errors
306 ///
307 /// If the range is not inside the arena, or if the bytes are not valid UTF-8. The validation is
308 /// the one cost this seam does not remove, and it is here rather than skipped because
309 /// [`Self::get`] hands back a `&str` and a column that cannot produce one for a string it claims
310 /// to hold is a wrong answer rather than a slow one. A scan over a page where the format
311 /// guarantees UTF-8 wants to validate the page once instead of once per string, which is a pass
312 /// the layer three reader makes and is not something this type can do on its behalf.
313 pub fn push_in_place(&mut self, offset: usize, len: usize) -> Result<usize> {
314 let end = offset.checked_add(len).ok_or_else(|| {
315 Error::internal(format!(
316 "a string at {offset} of {len} bytes runs off the end of memory"
317 ))
318 })?;
319 let bytes = self.arena.get(offset..end).ok_or_else(|| {
320 Error::internal(format!(
321 "a string at {offset} of {len} bytes is not inside a {} byte arena",
322 self.arena.len()
323 ))
324 })?;
325 let text = std::str::from_utf8(bytes)
326 .map_err(|_| Error::internal(format!("the bytes at {offset} are not valid UTF-8")))?;
327 let view = if len <= INLINE_LIMIT {
328 StringView::inline(text)
329 } else {
330 StringView::indirect(text, offset as u64)
331 };
332 self.views.push(view);
333 Ok(self.views.len() - 1)
334 }
335
336 /// The bytes the long strings live in.
337 ///
338 /// For a column over a page this is the page, including whatever of it no view points at. The
339 /// offsets in the views are offsets into exactly this, which is what makes them meaningful to a
340 /// reader that put the page here in the first place.
341 #[must_use]
342 pub fn arena(&self) -> &[u8] {
343 &self.arena
344 }
345
346 /// The bytes at `index`, or `None` past the end.
347 ///
348 /// This is what a comparison, a hash and an equality check all actually want, and it is worth
349 /// having separately from [`Self::get`] because that one validates UTF-8 and they do not need
350 /// it. Everything in a column arrived through [`Self::push`], which takes a `&str`, so the
351 /// bytes are valid either way and the validation is a scan of the payload that changes no
352 /// answer. On a varchar filter it was measured at most of the per row cost.
353 #[must_use]
354 pub fn bytes(&self, index: usize) -> Option<&[u8]> {
355 let view = self.views.get(index)?;
356 if let Some(inline) = view.inline_bytes() {
357 return Some(inline);
358 }
359 self.arena.get(view.offset()..view.offset() + view.len())
360 }
361
362 /// The string at `index`, or `None` past the end.
363 #[must_use]
364 pub fn get(&self, index: usize) -> Option<&str> {
365 // Written from a `&str` into a block that is append only, so the bytes are the same bytes.
366 std::str::from_utf8(self.bytes(index)?).ok()
367 }
368
369 /// Every string in order.
370 pub fn iter(&self) -> impl Iterator<Item = &str> {
371 (0..self.len()).filter_map(|index| self.get(index))
372 }
373
374 /// Total bytes of payload held in the arena, which is what the memory accounting wants.
375 ///
376 /// For a column over a page it is the page and not the part of it any view points at, which is
377 /// the right answer for accounting, because the page is what is resident.
378 #[must_use]
379 pub fn heap_bytes(&self) -> usize {
380 self.arena.len()
381 }
382
383 /// Room for `bytes` of payload, taken in one allocation rather than as the strings arrive.
384 ///
385 /// A builder that knows the total byte count, which a scan reading a page and a gather copying a
386 /// column both do, saves the doubling entirely. Nothing is wrong without it, which is why it is
387 /// a hint and not a constructor argument.
388 pub fn reserve_bytes(&mut self, bytes: usize) {
389 self.arena.reserve(bytes);
390 }
391}
392
393/// Two columns are equal when they hold the same strings in the same order, whatever their arenas
394/// look like.
395///
396/// See the note on [`StringColumn`]. Comparing the views is not enough on its own either, because
397/// two views of the same long string at different offsets in different arenas are different views,
398/// so the comparison is length, then view by view with the payload read for the ones that are not
399/// inline. The prefix inside the view is what makes that cheap: a pair that differs in the first
400/// four bytes or in the length is settled without either arena being touched.
401impl PartialEq for StringColumn {
402 fn eq(&self, other: &Self) -> bool {
403 self.views.len() == other.views.len()
404 && (0..self.views.len()).all(|index| {
405 let mine = self.views[index];
406 let theirs = other.views[index];
407 if mine.definitely_differs(&theirs) {
408 return false;
409 }
410 if mine.is_inline() {
411 return mine == theirs;
412 }
413 self.bytes(index) == other.bytes(index)
414 })
415 }
416}
417
418impl<'a> Extend<&'a str> for StringColumn {
419 fn extend<T: IntoIterator<Item = &'a str>>(&mut self, iter: T) {
420 for text in iter {
421 self.push(text);
422 }
423 }
424}
425
426impl<'a> FromIterator<&'a str> for StringColumn {
427 fn from_iter<T: IntoIterator<Item = &'a str>>(iter: T) -> Self {
428 let mut column = Self::new();
429 column.extend(iter);
430 column
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::{INLINE_LIMIT, StringColumn, StringView};
437 use crate::buffer::Buffer;
438
439 /// The seam, used the way layer three will use it. The page arrives whole, each string is
440 /// recorded where it already is, and the arena at the end is the page byte for byte, including
441 /// the header this page has in front of the strings and the bytes between them that belong to
442 /// nothing. A column that had copied would have an arena the size of the strings instead.
443 #[test]
444 fn a_column_over_a_page_records_the_strings_without_moving_them() {
445 let page =
446 b"HEADER..a string well past the inline limit!!a second one past the limit".to_vec();
447 let mut column = StringColumn::over(Buffer::from_vec(page.clone()));
448 assert_eq!(column.push_in_place(8, 37).expect("inside the page"), 0);
449 assert_eq!(column.push_in_place(45, 27).expect("inside the page"), 1);
450 assert_eq!(column.get(0), Some("a string well past the inline limit!!"));
451 assert_eq!(column.get(1), Some("a second one past the limit"));
452 assert_eq!(column.arena(), page.as_slice());
453 assert_eq!(column.heap_bytes(), page.len());
454 assert_eq!(column.len(), 2);
455 }
456
457 /// Copying between two columns, which is what a gather and a slice over a string column are.
458 /// A column built over a page has an arena full of bytes no view points at, and the copy has to
459 /// take the strings rather than the arena, so the destination holds the strings and nothing
460 /// else. The last case is the row that resolved to nowhere, which is an empty string here and a
461 /// null in the validity mask beside it.
462 #[test]
463 fn copying_from_another_column_takes_the_strings_and_not_the_page_they_were_in() {
464 let page = b"HEADER..a string well past the inline limit!!short".to_vec();
465 let mut source = StringColumn::over(Buffer::from_vec(page.clone()));
466 source.push_in_place(8, 37).expect("inside the page");
467 source.push_in_place(45, 5).expect("inside the page");
468
469 let mut out = StringColumn::new();
470 assert_eq!(out.push_from(&source, 1), 0);
471 assert_eq!(out.push_from(&source, 0), 1);
472 assert_eq!(out.push_from(&source, 9), 2, "a position that is not there");
473
474 assert_eq!(out.get(0), Some("short"));
475 assert_eq!(out.get(1), Some("a string well past the inline limit!!"));
476 assert_eq!(out.get(2), Some(""));
477 assert!(out.views()[0].is_inline(), "a short string stays in its view");
478 assert!(!out.views()[1].is_inline());
479 assert_eq!(out.views()[1].prefix(), *b"a st", "the prefix is the string's own");
480 assert_eq!(
481 out.arena(),
482 b"a string well past the inline limit!!",
483 "the arena is the long strings and not the page"
484 );
485 }
486
487 /// Bytes that are not text, which is what a `BLOB` holds. Both sides of the inline limit,
488 /// because a short one lives in its view and a long one lives in the arena and the byte that is
489 /// not a character has to survive either way. Reading them back as text is `None` and reading
490 /// them back as bytes is what went in.
491 #[test]
492 fn a_column_holds_bytes_that_are_not_a_string() {
493 let long = b"\xff\xfe and a good deal more than twelve bytes of it";
494 let mut column = StringColumn::new();
495 assert_eq!(column.push_bytes(b"a\xffb"), 0);
496 assert_eq!(column.push_bytes(long), 1);
497 assert_eq!(column.push_bytes(b""), 2);
498
499 assert_eq!(column.bytes(0), Some(b"a\xffb".as_slice()));
500 assert_eq!(column.bytes(1), Some(long.as_slice()));
501 assert_eq!(column.bytes(2), Some(b"".as_slice()));
502 assert_eq!(column.get(0), None, "a stray 0xff is not a character");
503 assert_eq!(column.get(1), None);
504 assert!(column.views()[0].is_inline());
505 assert!(!column.views()[1].is_inline());
506 assert_eq!(column.arena(), long, "only the long one needed the arena");
507 }
508
509 /// A copy of a copy, because the second one reads its bytes out of an arena the first one wrote
510 /// rather than out of a page, and an offset written in one and read in the other is the way
511 /// this goes wrong.
512 #[test]
513 fn copying_from_a_column_that_was_itself_copied_reads_the_same_strings() {
514 let mut first = StringColumn::new();
515 for text in ["a string well past the inline limit", "short", "another long one past it"] {
516 first.push(text);
517 }
518 let mut second = StringColumn::new();
519 for index in (0..first.len()).rev() {
520 second.push_from(&first, index);
521 }
522 let mut third = StringColumn::new();
523 for index in 0..second.len() {
524 third.push_from(&second, index);
525 }
526 assert_eq!(
527 third.iter().collect::<Vec<_>>(),
528 ["another long one past it", "short", "a string well past the inline limit"]
529 );
530 }
531
532 /// A string short enough to live inside its view is copied into the view, which is twelve bytes
533 /// and is what lets it be read without the arena. The page is still the arena and is still
534 /// untouched, so a page of short strings costs the views and nothing else.
535 #[test]
536 fn a_short_string_in_a_page_is_copied_into_its_view() {
537 let mut column = StringColumn::over(Buffer::from_vec(b"one.two".to_vec()));
538 column.push_in_place(0, 3).expect("inside the page");
539 column.push_in_place(4, 3).expect("inside the page");
540 assert!(column.views()[0].is_inline());
541 assert_eq!(column.get(0), Some("one"));
542 assert_eq!(column.get(1), Some("two"));
543 assert_eq!(column.arena(), b"one.two");
544 }
545
546 /// The two ways a caller can be wrong about a page, both of them answered before anything is
547 /// recorded rather than at the point somebody reads the string back and finds nothing there.
548 #[test]
549 fn a_range_outside_the_page_or_bytes_that_are_not_text_are_refused() {
550 let mut column = StringColumn::over(Buffer::from_vec(vec![0xff, 0xfe, 0xfd]));
551 assert!(column.push_in_place(2, 4).is_err());
552 assert!(column.push_in_place(usize::MAX, 1).is_err());
553 assert!(column.push_in_place(0, 3).is_err());
554 assert_eq!(column.len(), 0);
555 }
556
557 /// What the seam does to equality. The same two strings, one column built by copying them in
558 /// and one built over a page that has them in the other order with a gap in the middle, and the
559 /// two arenas have nothing in common. Equality is the strings, so the columns are equal.
560 #[test]
561 fn the_same_strings_over_different_arenas_are_the_same_column() {
562 let copied: StringColumn =
563 ["the first string past the limit", "the second string past the limit"]
564 .into_iter()
565 .collect();
566 let page =
567 b"gap!the second string past the limit....the first string past the limit".to_vec();
568 let mut over = StringColumn::over(Buffer::from_vec(page));
569 over.push_in_place(40, 31).expect("inside the page");
570 over.push_in_place(4, 32).expect("inside the page");
571 assert_ne!(copied.arena(), over.arena());
572 assert_eq!(copied, over);
573
574 let mut different: StringColumn = copied.clone();
575 different.push("a third one past the inline limit");
576 assert_ne!(copied, different);
577 }
578
579 #[test]
580 fn a_view_is_sixteen_bytes_and_stays_sixteen_bytes() {
581 // The number the whole design is built around. A vector of 1024 strings is 16 KiB of
582 // views, which is the budget spec/07-execution.md section 7.1 spends on purpose.
583 assert_eq!(size_of::<StringView>(), 16);
584 assert_eq!(align_of::<StringView>(), 4);
585 }
586
587 #[test]
588 fn twelve_bytes_is_inline_and_thirteen_is_not() {
589 let mut column = StringColumn::new();
590 column.push("123456789012");
591 column.push("1234567890123");
592 assert!(column.views()[0].is_inline());
593 assert!(!column.views()[1].is_inline());
594 assert_eq!(column.get(0), Some("123456789012"));
595 assert_eq!(column.get(1), Some("1234567890123"));
596 assert_eq!(INLINE_LIMIT, 12);
597 }
598
599 #[test]
600 fn a_prefix_answers_the_comparison_without_reading_the_payload() {
601 let mut column = StringColumn::new();
602 column.push("https://example.com/a");
603 column.push("https://example.com/b");
604 column.push("mailto:someone@example.com");
605 let views = column.views();
606 // Same prefix, same length: the payloads have to be read. This is the case the prefix
607 // cannot help with, and on a URL column it is the common case, which is why the
608 // dictionary work at M3 matters more than this does.
609 assert!(!views[0].definitely_differs(&views[1]));
610 // Different prefix: answered from the view.
611 assert!(views[0].definitely_differs(&views[2]));
612 }
613
614 /// A string of any size goes in whole, with the short ones on either side of it still reading
615 /// back. The old layout had a size at which a string stopped fitting a block and got one of its
616 /// own, and one arena has no such size, so the case worth keeping is the one that used to be
617 /// special rather than the branch that used to handle it.
618 #[test]
619 fn a_string_far_larger_than_any_block_would_have_been_goes_in_whole() {
620 let long = "x".repeat(40 * 1024);
621 let mut column = StringColumn::new();
622 column.push("short");
623 column.push(&long);
624 column.push("also short");
625 assert_eq!(column.get(1), Some(long.as_str()));
626 assert_eq!(column.get(2), Some("also short"));
627 assert_eq!(column.heap_bytes(), long.len());
628 }
629
630 /// The property the whole arena rests on. Two thousand strings is tens of reallocations, and
631 /// every one of them moves the bytes to a new address while the offsets recorded in the views
632 /// before it stay exactly as they were. A view holding a pointer would be reading freed memory
633 /// by the end of this test.
634 #[test]
635 fn the_arena_moving_underneath_does_not_move_what_the_views_point_at() {
636 let mut column = StringColumn::new();
637 let strings: Vec<String> =
638 (0..2000).map(|i| format!("value number {i} padded out")).collect();
639 for text in &strings {
640 column.push(text);
641 }
642 for (index, text) in strings.iter().enumerate() {
643 assert_eq!(column.get(index), Some(text.as_str()), "at {index}");
644 }
645 assert_eq!(column.len(), 2000);
646 assert_eq!(column.iter().count(), 2000);
647 }
648
649 #[test]
650 fn reserving_bytes_changes_nothing_but_where_the_allocation_happens() {
651 let mut column = StringColumn::with_capacity(3);
652 column.reserve_bytes(128);
653 for text in ["a string past the limit", "another one past it", "short"] {
654 column.push(text);
655 }
656 assert_eq!(column.get(0), Some("a string past the limit"));
657 assert_eq!(column.get(1), Some("another one past it"));
658 assert_eq!(column.get(2), Some("short"));
659 assert_eq!(column.heap_bytes(), 42);
660 }
661
662 #[test]
663 fn the_empty_string_is_inline_and_reads_back_empty() {
664 let mut column = StringColumn::new();
665 column.push("");
666 assert_eq!(column.get(0), Some(""));
667 assert!(column.views()[0].is_empty());
668 assert_eq!(column.heap_bytes(), 0);
669 }
670
671 #[test]
672 fn multibyte_text_survives_the_inline_boundary() {
673 // The boundary is bytes and not characters, so a four byte emoji is what decides whether
674 // a three character string is inline.
675 let mut column = StringColumn::new();
676 column.push("héllo wörld");
677 column.push("🦀🦀🦀🦀");
678 assert_eq!(column.get(0), Some("héllo wörld"));
679 assert_eq!(column.get(1), Some("🦀🦀🦀🦀"));
680 assert!(!column.views()[1].is_inline());
681 }
682
683 #[test]
684 fn reading_past_the_end_is_none_rather_than_a_panic() {
685 let column: StringColumn = ["a", "b"].into_iter().collect();
686 assert_eq!(column.get(2), None);
687 assert_eq!(column.len(), 2);
688 }
689
690 /// The bytes and the string have to be the same string on both sides of the inline boundary
691 /// and on multibyte text, because the comparison kernels read the bytes and everything else
692 /// reads the string, and a disagreement between them would be a filter that matched a row the
693 /// projection then printed differently.
694 #[test]
695 fn the_bytes_and_the_string_are_the_same_string() {
696 let long = "x".repeat(9000);
697 let words = ["", "a", "twelve bytes", "thirteen bytes", "π is two bytes", &long];
698 let column: StringColumn = words.into_iter().collect();
699 for (index, text) in words.iter().enumerate() {
700 assert_eq!(column.bytes(index), Some(text.as_bytes()), "at {index}");
701 assert_eq!(column.get(index), Some(*text), "at {index}");
702 }
703 assert_eq!(column.bytes(words.len()), None);
704 }
705}