Skip to main content

concept_store/
column.rs

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