Skip to main content

rete_core/
recard.rs

1//! Re-carding a finished `.rete` — replacing its Dataset Card and build record
2//! **without rebuilding it**.
3//!
4//! A card used to be fixed at build time: it sits inside the content hash, near
5//! the front of the file, so the only way to a new one was a new build. On the
6//! 2.9 B-statement OpenCitations parts that is three hours each, for a few KB of
7//! JSON. Everything a card is derived from is already in the file — sorted, see
8//! [`crate::card::derive_card_streaming`] — and every other byte of the file is
9//! untouched by a new card, so a re-card is a *rewrite of two sections and a
10//! copy of the rest*:
11//!
12//! * the metadata section (the card) and the build-info section are replaced,
13//!   in place, adjacent as the writers lay them out;
14//! * every other section — dictionary, index, pyramid, named graphs, text
15//!   index, and any section kind this reader does not know — is copied **byte
16//!   for byte**; only its offset in the header moves;
17//! * the content hash is recomputed, because the card is inside it
18//!   ([`crate::format::content_hash_parts`]); it is folded while the bytes go
19//!   past, so the whole job is one read and one write of the file.
20//!
21//! [`plan_card_rewrite`] is the arithmetic, kept apart (like
22//! [`crate::format::plan_build_info`]) so it is testable without a file;
23//! [`rewrite_card`] is the streaming copy.
24
25use std::io::{Seek, SeekFrom, Write};
26
27use crate::file::{content_hash_parts, FileError};
28use crate::header::{Header, HEADER_LEN};
29use crate::reader::RangeReader;
30
31/// What a card rewrite does to a file's layout.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CardRewritePlan {
34    /// The new header: every section offset fixed, metadata and build info at
35    /// their new lengths. The content hash is still the **old** one —
36    /// [`rewrite_card`] writes the new one once it has folded it.
37    pub header: Header,
38    /// The span of the old file copied verbatim **before** the new card,
39    /// `[HEADER_LEN, card_start)` — empty on every file the writers produce;
40    /// non-empty when a section was laid out ahead of the card.
41    pub before: (u64, u64),
42    /// The span of the old file copied verbatim **after** the new card,
43    /// `[card_end, file_len)`.
44    pub after: (u64, u64),
45    /// Where the new metadata section starts (build info follows it).
46    pub card_offset: u64,
47    /// The rewritten file's length.
48    pub new_len: u64,
49}
50
51/// Plan replacing the metadata and build-info sections of a file whose first
52/// [`HEADER_LEN`] bytes are `head` and whose length is `file_len`, with
53/// sections of `metadata_len` and `build_info_len` bytes (`0` = none).
54///
55/// The card occupies one span — the metadata section, with the build-info
56/// section immediately after it (the layout every writer produces, so a card
57/// read is one coalesced range). A file with neither gets them right after the
58/// header. Sections after the span shift by the length delta; sections before
59/// it stay where they are; a section overlapping it, or a build-info section
60/// that is not adjacent to the metadata, is a layout this function will not
61/// reshape and is refused rather than guessed at.
62pub fn plan_card_rewrite(
63    head: &[u8],
64    file_len: u64,
65    metadata_len: u64,
66    build_info_len: u64,
67) -> Result<CardRewritePlan, FileError> {
68    let mut header = Header::from_bytes(head)?;
69    let (m_off, m_len) = (header.metadata_offset, header.metadata_len);
70    let (b_off, b_len) = (header.build_info_offset, header.build_info_len);
71    let (start, end) = match (m_len > 0, b_len > 0) {
72        (true, true) => {
73            if b_off != m_off.saturating_add(m_len) {
74                return Err(FileError::Container(
75                    "the build-info section is not adjacent to the metadata section",
76                ));
77            }
78            (m_off, b_off.saturating_add(b_len))
79        }
80        (true, false) => (m_off, m_off.saturating_add(m_len)),
81        (false, true) => (b_off, b_off.saturating_add(b_len)),
82        (false, false) => (HEADER_LEN as u64, HEADER_LEN as u64),
83    };
84    if start < HEADER_LEN as u64 || end > file_len {
85        return Err(FileError::Container(
86            "the card section lies outside the file",
87        ));
88    }
89    let new_span = metadata_len.saturating_add(build_info_len);
90    let shift = |off: u64| -> u64 { off - (end - start) + new_span };
91
92    // Every other section must lie wholly before or wholly after the span.
93    let check = |off: &mut u64, len: u64| -> Result<(), FileError> {
94        if len == 0 {
95            return Ok(());
96        }
97        let sec_end = off.saturating_add(len);
98        if sec_end > file_len {
99            return Err(FileError::Container("a section overruns the file"));
100        }
101        if sec_end <= start {
102            Ok(())
103        } else if *off >= end {
104            *off = shift(*off);
105            Ok(())
106        } else {
107            Err(FileError::Container(
108                "a section overlaps the card; this layout cannot be re-carded in place",
109            ))
110        }
111    };
112    check(&mut header.dictionary_offset, header.dictionary_len)?;
113    check(&mut header.root_dir_offset, header.root_dir_len)?;
114    check(&mut header.pyramid_meta_offset, header.pyramid_meta_len)?;
115    check(&mut header.named_graphs_offset, header.named_graphs_len)?;
116    check(&mut header.text_index_offset, header.text_index_len)?;
117    for s in &mut header.extra_sections {
118        check(&mut s.offset, s.length)?;
119    }
120
121    header.metadata_offset = if metadata_len > 0 { start } else { 0 };
122    header.metadata_len = metadata_len;
123    header.build_info_offset = if build_info_len > 0 {
124        start + metadata_len
125    } else {
126        0
127    };
128    header.build_info_len = build_info_len;
129    Ok(CardRewritePlan {
130        header,
131        before: (HEADER_LEN as u64, start),
132        after: (end, file_len),
133        card_offset: start,
134        new_len: file_len - (end - start) + new_span,
135    })
136}
137
138/// How the rewriter moves bytes: large enough to keep a disk streaming, small
139/// enough that the peak memory of a 26 GB rewrite is this and nothing else.
140pub const REWRITE_CHUNK: u64 = 8 << 20;
141
142/// Rewrite `src` into `out` with `metadata` as its card and `build_info` as its
143/// build record (either may be empty, which removes the section), copying every
144/// other byte verbatim and folding the new content hash on the way. Returns
145/// the header written at offset 0.
146///
147/// `out` must start empty; it is written front to back, then the header is
148/// rewritten once the hash is known (hence `Seek`). `progress` is called with
149/// the bytes copied so far after each chunk.
150///
151/// When the hashed sections do not follow each other in the file (no writer
152/// lays them out that way, but a reader cannot assume its writers), the hash
153/// is folded in a second pass over `src` instead — the result is the same.
154pub fn rewrite_card<R: RangeReader + ?Sized, W: Write + Seek>(
155    src: &R,
156    out: &mut W,
157    metadata: &[u8],
158    build_info: &[u8],
159    progress: &mut dyn FnMut(u64),
160) -> Result<Header, FileError> {
161    rewrite_card_impl(src, out, metadata, build_info, progress, false)
162}
163
164/// [`rewrite_card`], optionally forcing the second-pass hash — the path no
165/// writer's layout reaches, which the tests take on purpose.
166fn rewrite_card_impl<R: RangeReader + ?Sized, W: Write + Seek>(
167    src: &R,
168    out: &mut W,
169    metadata: &[u8],
170    build_info: &[u8],
171    progress: &mut dyn FnMut(u64),
172    force_second_pass: bool,
173) -> Result<Header, FileError> {
174    let file_len = src.len();
175    if file_len < HEADER_LEN as u64 {
176        return Err(FileError::Container("file shorter than its header"));
177    }
178    let head = src.read_at(0, HEADER_LEN as u64)?;
179    let plan = plan_card_rewrite(
180        &head,
181        file_len,
182        metadata.len() as u64,
183        build_info.len() as u64,
184    )?;
185    let mut header = plan.header.clone();
186
187    // The hashed ranges in **output** coordinates, in hash order. The metadata
188    // part is the new card; every other part is unchanged input bytes.
189    let parts = content_hash_parts(&header);
190    let streamable = !force_second_pass
191        && parts.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
192        && parts
193            .iter()
194            .all(|&(off, len)| off >= plan.card_offset || off + len <= plan.card_offset);
195    let mut hasher = blake3::Hasher::new();
196    let feed = HashFeed {
197        parts: &parts,
198        next: 0,
199    };
200
201    out.write_all(&header.to_bytes())?;
202    let mut sink = Sink {
203        out: &mut *out,
204        written: HEADER_LEN as u64,
205        streamable,
206        feed,
207        hasher: &mut hasher,
208    };
209    let mut copied = 0u64;
210    for (from, to, then) in [
211        (plan.before.0, plan.before.1, Some([metadata, build_info])),
212        (plan.after.0, plan.after.1, None),
213    ] {
214        let mut at = from;
215        while at < to {
216            let n = (to - at).min(REWRITE_CHUNK);
217            let buf = src.read_at(at, n)?;
218            if buf.len() as u64 != n {
219                return Err(FileError::Container("short read while copying"));
220            }
221            sink.write(&buf)?;
222            at += n;
223            copied += n;
224            progress(copied);
225        }
226        for section in then.into_iter().flatten() {
227            sink.write(section)?;
228        }
229    }
230    let (written, folded) = (sink.written, sink.feed.next);
231    if written != plan.new_len {
232        return Err(FileError::Container(
233            "the rewrite came out the wrong length",
234        ));
235    }
236
237    if !streamable || folded < parts.len() {
238        // Second pass: each part from where it lives in the old file (or, for
239        // the metadata part, the new card itself).
240        hasher = blake3::Hasher::new();
241        // Sections after the card moved by `new_len - file_len`; undo that.
242        // (Added before subtracting: the difference can be negative.)
243        let delta = |off: u64| -> u64 {
244            if off >= plan.card_offset + metadata.len() as u64 + build_info.len() as u64 {
245                off + file_len - plan.new_len
246            } else {
247                off
248            }
249        };
250        for &(off, len) in &parts {
251            if metadata.len() as u64 == len && off == plan.card_offset && !metadata.is_empty() {
252                hasher.update(metadata);
253                continue;
254            }
255            let mut at = delta(off);
256            let end = at + len;
257            while at < end {
258                let n = (end - at).min(REWRITE_CHUNK);
259                hasher.update(&src.read_at(at, n)?);
260                at += n;
261            }
262        }
263    }
264    header
265        .content_hash
266        .copy_from_slice(&hasher.finalize().as_bytes()[..16]);
267    out.seek(SeekFrom::Start(0))?;
268    out.write_all(&header.to_bytes())?;
269    out.flush()?;
270    Ok(header)
271}
272
273/// The output side of a rewrite: writes go through here so the hash folds
274/// exactly the bytes that land in the file, at the offsets they land at.
275struct Sink<'a, 'p, W> {
276    out: &'a mut W,
277    written: u64,
278    streamable: bool,
279    feed: HashFeed<'p>,
280    hasher: &'a mut blake3::Hasher,
281}
282
283impl<W: Write> Sink<'_, '_, W> {
284    fn write(&mut self, buf: &[u8]) -> Result<(), FileError> {
285        self.out.write_all(buf)?;
286        if self.streamable {
287            self.feed.fold(self.written, buf, self.hasher);
288        }
289        self.written += buf.len() as u64;
290        Ok(())
291    }
292}
293
294/// Folds the bytes of a sequential write into the hash, for the parts of
295/// `parts` (output coordinates, hash order, ascending) the write overlaps.
296struct HashFeed<'a> {
297    parts: &'a [(u64, u64)],
298    next: usize,
299}
300
301impl HashFeed<'_> {
302    fn fold(&mut self, at: u64, buf: &[u8], hasher: &mut blake3::Hasher) {
303        let end = at + buf.len() as u64;
304        while let Some(&(off, len)) = self.parts.get(self.next) {
305            let part_end = off + len;
306            if off >= end {
307                return;
308            }
309            let lo = off.max(at);
310            let hi = part_end.min(end);
311            if lo < hi {
312                hasher.update(&buf[(lo - at) as usize..(hi - at) as usize]);
313            }
314            if part_end <= end {
315                self.next += 1;
316            } else {
317                return;
318            }
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::card::{derive_card, CardInput};
327    use crate::reader::SliceReader;
328
329    fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
330        (s.into(), p.into(), o.into(), None)
331    }
332
333    fn quads() -> Vec<(String, String, String, Option<String>)> {
334        let ty = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
335        let mut v = Vec::new();
336        for i in 0..20 {
337            let s = format!("<http://ex/item/{i}>");
338            v.push(q(&s, ty, "<http://ex/Item>"));
339            v.push(q(
340                &s,
341                "<http://ex/next>",
342                &format!("<http://ex/item/{}>", (i + 1) % 20),
343            ));
344            v.push(q(&s, "<http://ex/name>", &format!("\"item {i}\"")));
345        }
346        v.push((
347            "<http://ex/a>".into(),
348            "<http://ex/p>".into(),
349            "<http://ex/b>".into(),
350            Some("<http://ex/g>".into()),
351        ));
352        v
353    }
354
355    /// A carded file, with a build record after the card like every writer
356    /// lays them out.
357    fn carded(text_index: bool) -> Vec<u8> {
358        let quads = quads();
359        let card = derive_card(&quads, 50, 1, CardInput::default()).to_json_bytes();
360        let (bytes, _) = crate::ingest::assemble_dataset_with_opts(
361            quads,
362            true,
363            text_index,
364            None,
365            move |_, _| card.clone(),
366        );
367        crate::format::attach_build_info(&bytes, br#"{"schema":1,"builder":"old"}"#).unwrap()
368    }
369
370    fn rewrite(src: &[u8], meta: &[u8], info: &[u8]) -> Vec<u8> {
371        let mut out = std::io::Cursor::new(Vec::new());
372        rewrite_card(&SliceReader::new(src), &mut out, meta, info, &mut |_| {}).unwrap();
373        out.into_inner()
374    }
375
376    /// Every section other than the card and the build record comes through
377    /// byte for byte, the hash is the one `verify` recomputes, and the result
378    /// equals the in-memory splice the build itself uses.
379    #[test]
380    fn a_rewrite_copies_every_other_section_and_rehashes() {
381        for text_index in [false, true] {
382            let src = carded(text_index);
383            let old = Header::from_bytes(&src).unwrap();
384            let meta = br#"{"title":"new card","triple_count":60,"quad_count":61,"named_graph_count":1,"term_count":50,"format_version":5}"#;
385            let info = br#"{"schema":1,"builder":"recard"}"#;
386            let out = rewrite(&src, meta, info);
387            let new = Header::from_bytes(&out).unwrap();
388            assert!(
389                crate::format::verify(&out).unwrap(),
390                "the new hash verifies"
391            );
392            assert_ne!(
393                new.content_hash, old.content_hash,
394                "the card is inside the hash"
395            );
396            let sec = |b: &[u8], off: u64, len: u64| b[off as usize..(off + len) as usize].to_vec();
397            assert_eq!(
398                sec(&out, new.metadata_offset, new.metadata_len),
399                meta.to_vec()
400            );
401            assert_eq!(
402                sec(&out, new.build_info_offset, new.build_info_len),
403                info.to_vec()
404            );
405            for (o, n) in [
406                (
407                    (old.dictionary_offset, old.dictionary_len),
408                    (new.dictionary_offset, new.dictionary_len),
409                ),
410                (
411                    (old.root_dir_offset, old.root_dir_len),
412                    (new.root_dir_offset, new.root_dir_len),
413                ),
414                (
415                    (old.named_graphs_offset, old.named_graphs_len),
416                    (new.named_graphs_offset, new.named_graphs_len),
417                ),
418                (
419                    (old.text_index_offset, old.text_index_len),
420                    (new.text_index_offset, new.text_index_len),
421                ),
422            ] {
423                assert_eq!(o.1, n.1);
424                assert_eq!(sec(&src, o.0, o.1), sec(&out, n.0, n.1));
425            }
426            // The footer survives too.
427            assert_eq!(&out[out.len() - 4..], &src[src.len() - 4..]);
428            // Same bytes as the build's own splice: replace the metadata, then
429            // the build info.
430            let spliced = crate::format::attach_build_info(
431                &crate::format::replace_metadata(&src, meta).unwrap(),
432                info,
433            )
434            .unwrap();
435            assert_eq!(out, spliced);
436            // And the file still answers queries.
437            let rete = crate::Rete::open(&out).unwrap();
438            assert_eq!(rete.dump(None).len(), 60);
439        }
440    }
441
442    /// The second-pass hash — reading each part back from the source in hash
443    /// order — lands on exactly the bytes and the hash the streamed fold does,
444    /// for a growing and for a shrinking card.
445    #[test]
446    fn the_second_pass_hash_agrees_with_the_streamed_one() {
447        let src = carded(false);
448        for meta in [&b"{}"[..], &[b'x'; 5000][..]] {
449            let streamed = rewrite(&src, meta, b"{}");
450            let mut out = std::io::Cursor::new(Vec::new());
451            rewrite_card_impl(
452                &SliceReader::new(&src),
453                &mut out,
454                meta,
455                b"{}",
456                &mut |_| {},
457                true,
458            )
459            .unwrap();
460            assert_eq!(out.into_inner(), streamed);
461        }
462    }
463
464    /// A smaller card and no build record shrink the file; removing the card
465    /// entirely leaves a valid cardless file.
466    #[test]
467    fn a_rewrite_can_shrink_and_remove() {
468        let src = carded(false);
469        let out = rewrite(&src, b"{}", b"");
470        let h = Header::from_bytes(&out).unwrap();
471        assert_eq!(h.build_info_len, 0);
472        assert!(crate::format::verify(&out).unwrap());
473        let out = rewrite(&src, b"", b"");
474        let h = Header::from_bytes(&out).unwrap();
475        assert_eq!((h.metadata_len, h.build_info_len), (0, 0));
476        assert!(crate::format::verify(&out).unwrap());
477        assert!(crate::Rete::open(&out).is_ok());
478    }
479
480    /// A cardless file gains its card right after the header.
481    #[test]
482    fn a_cardless_file_gains_a_card_after_the_header() {
483        let (src, _) =
484            crate::ingest::assemble_dataset_with_opts(quads(), true, false, None, |_, _| {
485                Vec::new()
486            });
487        let out = rewrite(&src, br#"{"title":"t"}"#, br#"{"schema":1}"#);
488        let h = Header::from_bytes(&out).unwrap();
489        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
490        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
491        assert!(crate::format::verify(&out).unwrap());
492    }
493
494    /// A build record that does not follow the card is a layout the plan
495    /// refuses rather than reshapes.
496    #[test]
497    fn a_detached_build_record_is_refused() {
498        let src = carded(false);
499        let mut h = Header::from_bytes(&src).unwrap();
500        h.build_info_offset += 1;
501        let err = plan_card_rewrite(&h.to_bytes(), src.len() as u64, 10, 10).unwrap_err();
502        assert!(err.to_string().contains("not adjacent"), "{err}");
503    }
504
505    /// The streaming hash fold is exercised with a tiny chunk boundary by the
506    /// feed directly: parts split across arbitrary write boundaries.
507    #[test]
508    fn the_hash_feed_folds_parts_across_write_boundaries() {
509        let data: Vec<u8> = (0..200u8).collect();
510        let parts = [(10u64, 30u64), (40, 5), (100, 50)];
511        let mut want = blake3::Hasher::new();
512        for &(o, l) in &parts {
513            want.update(&data[o as usize..(o + l) as usize]);
514        }
515        let mut got = blake3::Hasher::new();
516        let mut feed = HashFeed {
517            parts: &parts,
518            next: 0,
519        };
520        for (i, chunk) in data.chunks(7).enumerate() {
521            feed.fold(i as u64 * 7, chunk, &mut got);
522        }
523        assert_eq!(want.finalize(), got.finalize());
524        assert_eq!(feed.next, parts.len());
525    }
526}