Skip to main content

concept_store/
column.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! A dense column: one record per ordinal, addressed by position.
5//!
6//! An ordinal is a position, so the record at that position is found by
7//! reading two offsets and slicing, with no search and no per-read
8//! transaction. No spec governs the layout: our own design, the same shape
9//! `concept-graph` stores its adjacency in. Little-endian throughout: a
10//! record count, then one offset per record plus a terminator, then the
11//! records themselves end to end.
12//!
13//! A record the build never wrote has an empty range. Every encoding in
14//! [`crate::record`] is longer than zero bytes, so an empty range means
15//! absent and never an empty record.
16//!
17//! A column is its own file beside the database, written once by the build and
18//! read once when the store opens. Held in the database it arrived twice, as
19//! the value `redb` materialized and as the copy the store serves from, and
20//! the pages of the first stayed with the process for its life (#641).
21
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25use concept_graph::ordinal::{Ordinal, to_usize};
26
27/// The width of the count and of each offset.
28const WIDTH: usize = 4;
29
30/// How many offsets a reader converts per read.
31const CHUNK: usize = 1024;
32
33/// The extension of a column's side file.
34const EXTENSION: &str = "col";
35
36/// A column whose bytes are not the layout this build reads.
37#[derive(Debug, thiserror::Error)]
38pub enum ColumnError {
39    /// The bytes end before the named part is complete.
40    #[error("the column ends inside its {part}")]
41    Truncated {
42        /// Which part the bytes end inside.
43        part: &'static str,
44        /// The underlying error.
45        #[source]
46        source: std::io::Error,
47    },
48    /// An offset lies behind the one before it, so its record has no range.
49    #[error("offset {at} is {offset}, behind its predecessor {previous}")]
50    Offset {
51        /// The index of the offending offset.
52        at: usize,
53        /// Its value.
54        offset: usize,
55        /// The offset before it.
56        previous: usize,
57    },
58    /// The column could not be read.
59    #[error("cannot read the column")]
60    Io(#[source] std::io::Error),
61}
62
63/// Classifies a read failure: an early end names the part it ended inside.
64fn read_failed(part: &'static str) -> impl FnOnce(std::io::Error) -> ColumnError {
65    move |source| match source.kind() {
66        std::io::ErrorKind::UnexpectedEof => ColumnError::Truncated { part, source },
67        _ => ColumnError::Io(source),
68    }
69}
70
71/// Appends one offset, refusing one that lies behind the one before it.
72fn push_offset(
73    offsets: &mut Vec<u32>,
74    previous: &mut u32,
75    word: [u8; WIDTH],
76) -> Result<(), ColumnError> {
77    let offset = u32::from_le_bytes(word);
78    if offset < *previous {
79        return Err(ColumnError::Offset {
80            at: offsets.len(),
81            offset: to_usize(offset),
82            previous: to_usize(*previous),
83        });
84    }
85    *previous = offset;
86    offsets.push(offset);
87    Ok(())
88}
89
90/// A read-only dense column.
91#[derive(Clone, Default, PartialEq, Eq)]
92pub struct Column {
93    /// `count + 1` offsets into `records`, ascending.
94    offsets: Vec<u32>,
95    records: Vec<u8>,
96}
97
98impl fmt::Debug for Column {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.debug_struct("Column")
101            .field("len", &self.len())
102            .field("bytes", &self.records.len())
103            .finish_non_exhaustive()
104    }
105}
106
107impl Column {
108    /// The side file the `name` column of the store at `store` is written to.
109    ///
110    /// The name carries the store's own stem, so two stores in one directory
111    /// cannot write each other's columns.
112    #[must_use]
113    pub fn file(store: &Path, name: &str) -> PathBuf {
114        let stem = store.file_stem().unwrap_or_default().to_string_lossy();
115        store.with_file_name(format!("{stem}.{name}.{EXTENSION}"))
116    }
117
118    /// Writes the column `records` pack, in ordinal order.
119    ///
120    /// A record is placed at its ordinal, and an ordinal no record names gets
121    /// an empty range. The records are borrowed rather than packed into one
122    /// buffer first, so a build writes an edition's column without holding a
123    /// second copy of it.
124    ///
125    /// # Errors
126    ///
127    /// Returns the I/O error from writing.
128    pub fn write_to<'a>(
129        count: u32,
130        records: impl IntoIterator<Item = (Ordinal, &'a [u8])>,
131        out: &mut impl std::io::Write,
132    ) -> std::io::Result<()> {
133        let count = to_usize(count);
134        let mut placed: Vec<&[u8]> = vec![&[]; count];
135        for (ordinal, bytes) in records {
136            let at = to_usize(ordinal.index());
137            if let Some(slot) = placed.get_mut(at) {
138                *slot = bytes;
139            }
140        }
141        out.write_all(&u32::try_from(count).unwrap_or(u32::MAX).to_le_bytes())?;
142        let mut at = 0_u32;
143        for record in &placed {
144            out.write_all(&at.to_le_bytes())?;
145            at = at.saturating_add(u32::try_from(record.len()).unwrap_or(u32::MAX));
146        }
147        out.write_all(&at.to_le_bytes())?;
148        for record in placed {
149            out.write_all(record)?;
150        }
151        Ok(())
152    }
153
154    /// Reads a column, checking its offsets once so a read never can.
155    ///
156    /// The records land in one allocation of their final size, filled from the
157    /// reader, so the column arrives at the address it is served from.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ColumnError`] when the bytes end early, an offset lies behind
162    /// the one before it, or the reader fails.
163    pub fn read_from(reader: &mut impl std::io::Read) -> Result<Self, ColumnError> {
164        let mut word = [0_u8; WIDTH];
165        reader
166            .read_exact(&mut word)
167            .map_err(read_failed("header"))?;
168        let count = to_usize(u32::from_le_bytes(word));
169        let mut offsets: Vec<u32> = Vec::with_capacity(count.saturating_add(1));
170        let mut buffer = [0_u8; WIDTH * CHUNK];
171        let mut previous = 0_u32;
172        let mut remaining = count.saturating_add(1);
173        while remaining >= CHUNK {
174            reader
175                .read_exact(&mut buffer)
176                .map_err(read_failed("offsets"))?;
177            for chunk in buffer.as_chunks::<WIDTH>().0 {
178                push_offset(&mut offsets, &mut previous, *chunk)?;
179            }
180            remaining = remaining.saturating_sub(CHUNK);
181        }
182        for _ in 0..remaining {
183            reader
184                .read_exact(&mut word)
185                .map_err(read_failed("offsets"))?;
186            push_offset(&mut offsets, &mut previous, word)?;
187        }
188        let mut records = vec![0_u8; to_usize(previous)];
189        reader
190            .read_exact(&mut records)
191            .map_err(read_failed("records"))?;
192        Ok(Self { offsets, records })
193    }
194
195    /// Reads a column from bytes already in memory.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`ColumnError`] as [`Self::read_from`] does.
200    pub fn read(bytes: &[u8]) -> Result<Self, ColumnError> {
201        Self::read_from(&mut std::io::Cursor::new(bytes))
202    }
203
204    /// The heap bytes this column holds
205    /// (`concept_graph::footprint`).
206    #[must_use]
207    pub fn size_in_bytes(&self) -> usize {
208        concept_graph::footprint::vector(&self.offsets)
209            .saturating_add(concept_graph::footprint::vector(&self.records))
210    }
211
212    /// How many ordinals the column holds.
213    #[must_use]
214    pub fn len(&self) -> usize {
215        self.offsets.len().saturating_sub(1)
216    }
217
218    /// Whether the column holds no ordinals.
219    #[must_use]
220    pub fn is_empty(&self) -> bool {
221        self.len() == 0
222    }
223
224    /// The record at `ordinal`, `None` beyond the column or where the build
225    /// wrote none.
226    ///
227    /// The offsets were checked when the column was read, so this slices
228    /// without failing.
229    #[must_use]
230    pub fn get(&self, ordinal: Ordinal) -> Option<&[u8]> {
231        let at = to_usize(ordinal.index());
232        let start = to_usize(*self.offsets.get(at)?);
233        let end = to_usize(*self.offsets.get(at.checked_add(1)?)?);
234        if start == end {
235            return None;
236        }
237        self.records.get(start..end)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use std::path::Path;
244
245    use concept_graph::ordinal::Ordinal;
246
247    use super::{Column, ColumnError};
248
249    fn pack<'a>(count: u32, records: impl IntoIterator<Item = (Ordinal, &'a [u8])>) -> Vec<u8> {
250        let mut bytes = Vec::new();
251        Column::write_to(count, records, &mut bytes).expect("a vector takes every write");
252        bytes
253    }
254
255    #[test]
256    fn a_packed_column_reads_back_what_was_placed() {
257        let packed = pack(
258            4,
259            [
260                (Ordinal::new(0), b"first".as_slice()),
261                (Ordinal::new(3), b"fourth".as_slice()),
262            ],
263        );
264        let column = Column::read(&packed).expect("reads");
265        assert_eq!(column.len(), 4);
266        assert!(!column.is_empty());
267        assert_eq!(column.get(Ordinal::new(0)), Some(b"first".as_slice()));
268        assert_eq!(column.get(Ordinal::new(1)), None, "no record was placed");
269        assert_eq!(column.get(Ordinal::new(3)), Some(b"fourth".as_slice()));
270        assert_eq!(column.get(Ordinal::new(4)), None, "beyond the column");
271    }
272
273    #[test]
274    fn a_column_reads_the_same_from_a_reader_as_from_bytes() {
275        // The store reads a file and the tests read bytes; one reader answers
276        // both, so the two cannot drift apart.
277        let packed = pack(
278            2048,
279            (0..2048).map(|at| (Ordinal::new(at), b"x".as_slice())),
280        );
281        let from_bytes = Column::read(&packed).expect("reads");
282        let from_reader =
283            Column::read_from(&mut std::io::Cursor::new(packed.clone())).expect("reads");
284        assert_eq!(
285            from_bytes, from_reader,
286            "more offsets than one read converts"
287        );
288    }
289
290    #[test]
291    fn an_empty_column_holds_nothing() {
292        let column = Column::read(&pack(0, [])).expect("reads");
293        assert!(column.is_empty());
294        assert_eq!(column.get(Ordinal::new(0)), None);
295    }
296
297    #[test]
298    fn a_short_or_crossed_column_is_refused() {
299        assert!(matches!(
300            Column::read(&[0, 0]),
301            Err(ColumnError::Truncated { part: "header", .. })
302        ));
303        let packed = pack(2, [(Ordinal::new(0), b"ab".as_slice())]);
304        assert!(
305            matches!(
306                Column::read(packed.get(..packed.len() - 1).expect("shortened")),
307                Err(ColumnError::Truncated {
308                    part: "records",
309                    ..
310                })
311            ),
312            "the records end before the offsets say they do"
313        );
314        // One record of two bytes, then a terminator that goes backwards.
315        let crossed = [1_u32, 2, 0]
316            .iter()
317            .flat_map(|word| word.to_le_bytes())
318            .chain(*b"ab")
319            .collect::<Vec<u8>>();
320        assert!(matches!(
321            Column::read(&crossed),
322            Err(ColumnError::Offset {
323                at: 1,
324                offset: 0,
325                previous: 2
326            })
327        ));
328    }
329
330    #[test]
331    fn a_column_file_carries_the_store_it_belongs_to() {
332        assert_eq!(
333            Column::file(Path::new("/srv/nl/store.redb"), "concepts"),
334            Path::new("/srv/nl/store.concepts.col")
335        );
336        assert_eq!(
337            Column::file(Path::new("other.redb"), "displays"),
338            Path::new("other.displays.col"),
339            "two stores in one directory keep their columns apart"
340        );
341    }
342}