OXYGENHUB

Read a change's skeleton for history-only states a4fad5b085c7

The Linux tree's first change is an 81 MiB object, most of it the lines
of small files kept inline, and a history-only load decoded all of it
into a generic value and then copied every line (0.9 s, 364 MB, per
load; a switch loads two states). A history-only load now walks the
encoding with a CBOR cursor: tree operations and edit headers are
decoded, and each inline insert is recorded as the byte range of its
lines, read from the change only when that file's graph is built, as a
stored insert's text blob already was. Canonical-form checks still
apply. A one-file switch on the Linux tip takes 5.5 s and 0.8 GB (from
7.2 s and 1.3 GB).

refs #35
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 22:27 UTC
Signature
✔ signed by Andy McCall
Hash
a4fad5b085c7122589cdf9a4084f3915ffed03cb0a390943c1dd58c26444aac7
Depends on
0bdc3f91a39f 1d360c91a289 6f8aeb996558 86e1824f2d0b 982864068a59 c5ce0367eddf cc7a00565161 e8d38e47e9d7 ff0af1ee299d
Depended on by
5552e69c7325 3f1b8fe25ec9 f9575918477f 4d6c9c1b358c 0f93f49ce059 57d8dc05c9d4 519c7c061bcb 01a47cd7f0c8 17f94b13ae54 0554aaa7ddef d3cd7ef95da6
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
32 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/oxy-core/src/cbor.rs +66 −0
…
185 unchanged lines
186 186
187 187
const MAX_DEPTH: u32 = 256;
188 188
189 +
/// A cursor over canonical CBOR, for decoders that read part of an item
190 +
/// and skip or defer the rest (history-only loads of large changes).
191 +
pub struct Cursor<'a> {
192 +
    d: Decoder<'a>,
193 +
}
194 +
195 +
impl<'a> Cursor<'a> {
196 +
    pub fn new(bytes: &'a [u8]) -> Cursor<'a> {
197 +
        Cursor {
198 +
            d: Decoder {
199 +
                b: bytes,
200 +
                pos: 0,
201 +
                depth: 0,
202 +
            },
203 +
        }
204 +
    }
205 +
206 +
    /// The offset of the next item.
207 +
    pub fn pos(&self) -> usize {
208 +
        self.d.pos
209 +
    }
210 +
211 +
    /// Whether every byte was read.
212 +
    pub fn at_end(&self) -> bool {
213 +
        self.d.pos == self.d.b.len()
214 +
    }
215 +
216 +
    /// The next head: (major type, argument).
217 +
    pub fn head(&mut self) -> Result<(u8, u64), CborError> {
218 +
        self.d.head()
219 +
    }
220 +
221 +
    /// The next item, decoded.
222 +
    pub fn value(&mut self) -> Result<Value, CborError> {
223 +
        self.d.value()
224 +
    }
225 +
226 +
    /// Step over the next item, checking only its structure.
227 +
    pub fn skip(&mut self) -> Result<(), CborError> {
228 +
        let ib = *self.d.b.get(self.d.pos).ok_or(CborError::UnexpectedEnd)?;
229 +
        if ib >> 5 == 7 {
230 +
            self.d.pos += 1;
231 +
            return Ok(());
232 +
        }
233 +
        let (major, n) = self.d.head()?;
234 +
        match major {
235 +
            0 => {}
236 +
            2 | 3 => {
237 +
                self.d.take(n)?;
238 +
            }
239 +
            4 => {
240 +
                for _ in 0..n {
241 +
                    self.skip()?;
242 +
                }
243 +
            }
244 +
            5 => {
245 +
                for _ in 0..n.saturating_mul(2) {
246 +
                    self.skip()?;
247 +
                }
248 +
            }
249 +
            _ => return Err(CborError::Unsupported("negative integer or tag")),
250 +
        }
251 +
        Ok(())
252 +
    }
253 +
}
254 +
189 255
struct Decoder<'a> {
190 256
    b: &'a [u8],
191 257
    pos: usize,
…
343 unchanged lines
modified crates/oxy-core/src/objects.rs +201 −1
…
729 unchanged lines
730 730
        })
731 731
    }
732 732
733 -
    fn from_value_with(v: &Value, resolve: &mut Resolver) -> Result<Change, ObjectError> {
733 +
    pub(crate) fn from_value_with(
734 +
        v: &Value,
735 +
        resolve: &mut Resolver,
736 +
    ) -> Result<Change, ObjectError> {
734 737
        let m = map(v, "change")?;
735 738
        let version = uint(field(m, 0, "version")?, "version")?;
736 739
        if !(1..=CHANGE_VERSION).contains(&version) {
…
40 unchanged lines
777 780
/// A resolver may also return no lines, leaving a placeholder for a reader
778 781
/// that fills it in later (history-only states, spec §11).
779 782
pub type Resolver<'a> = dyn FnMut(&ObjectRef, u32) -> Result<Vec<Vec<u8>>, ObjectError> + 'a;
783 +
784 +
/// Where a history-only load finds an insert's lines when it builds that
785 +
/// file's graph (spec §11): it keeps no line until then.
786 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
787 +
pub enum TextRef {
788 +
    /// A stored insert: its text blob and line count.
789 +
    Blob(ObjectRef, u32),
790 +
    /// An inline insert: the byte range of its lines' array in the
791 +
    /// encoding of `change`.
792 +
    Inline {
793 +
        change: ChangeHash,
794 +
        start: u32,
795 +
        end: u32,
796 +
    },
797 +
}
798 +
799 +
/// The lines of an inline insert from its array's bytes (see
800 +
/// [`TextRef::Inline`]).
801 +
pub fn inline_lines(array: &[u8]) -> Result<Vec<Vec<u8>>, ObjectError> {
802 +
    let v = cbor::decode(array)?;
803 +
    let lines = array_lines(&v)?;
804 +
    if lines.is_empty() {
805 +
        return schema("insert with no lines");
806 +
    }
807 +
    Ok(lines)
808 +
}
809 +
810 +
fn array_lines(v: &Value) -> Result<Vec<Vec<u8>>, ObjectError> {
811 +
    array(v, "lines")?
812 +
        .iter()
813 +
        .map(|l| bytes(l, "line").map(|b| b.to_vec()))
814 +
        .collect()
815 +
}
816 +
817 +
impl Change {
818 +
    /// Decode a change for a history-only state (spec §11): every insert is
819 +
    /// left without its lines (an empty placeholder), and the result says
820 +
    /// where each one's lines are, in order. The inline lines are stepped
821 +
    /// over, not decoded: for a change that adds a whole tree, most of its
822 +
    /// bytes. `me` is the change's hash.
823 +
    pub fn decode_skeleton(
824 +
        bytes: &[u8],
825 +
        me: ChangeHash,
826 +
    ) -> Result<(Change, Vec<TextRef>), ObjectError> {
827 +
        let mut c = cbor::Cursor::new(bytes);
828 +
        let (major, n) = c.head()?;
829 +
        if major != 5 {
830 +
            return schema("change: expected map");
831 +
        }
832 +
        let mut fields = Map::new();
833 +
        let mut edits = Vec::new();
834 +
        let mut texts = Vec::new();
835 +
        let mut version = None;
836 +
        let mut last: Option<u64> = None;
837 +
        for _ in 0..n {
838 +
            let k = map_key(&mut c, &mut last)?;
839 +
            if k == 7 {
840 +
                let Some(v) = version else {
841 +
                    return schema("change: version must come first");
842 +
                };
843 +
                let (am, an) = c.head()?;
844 +
                if am != 4 {
845 +
                    return schema("edits: expected array");
846 +
                }
847 +
                for _ in 0..an {
848 +
                    edits.push(edit_skeleton(&mut c, v, me, &mut texts)?);
849 +
                }
850 +
                fields.insert(7, Value::Array(Vec::new()));
851 +
            } else {
852 +
                let val = c.value()?;
853 +
                if k == 0 {
854 +
                    version = Some(uint(&val, "version")?);
855 +
                }
856 +
                fields.insert(k, val);
857 +
            }
858 +
        }
859 +
        if !c.at_end() {
860 +
            return Err(ObjectError::Cbor(cbor::CborError::TrailingBytes));
861 +
        }
862 +
        let mut change = Change::from_value(&Value::Map(fields))?;
863 +
        change.edits = edits;
864 +
        Ok((change, texts))
865 +
    }
866 +
}
867 +
868 +
/// A canonical map key: an unsigned integer above the previous one.
869 +
fn map_key(c: &mut cbor::Cursor, last: &mut Option<u64>) -> Result<u64, ObjectError> {
870 +
    let (km, k) = c.head()?;
871 +
    if km != 0 || last.is_some_and(|l| k <= l) {
872 +
        return Err(ObjectError::Cbor(cbor::CborError::BadMapKey));
873 +
    }
874 +
    *last = Some(k);
875 +
    Ok(k)
876 +
}
877 +
878 +
/// One edit of [`Change::decode_skeleton`]: an insert's lines are left out.
879 +
fn edit_skeleton(
880 +
    c: &mut cbor::Cursor,
881 +
    version: u64,
882 +
    me: ChangeHash,
883 +
    texts: &mut Vec<TextRef>,
884 +
) -> Result<Edit, ObjectError> {
885 +
    let (major, n) = c.head()?;
886 +
    if major != 5 {
887 +
        return schema("edit: expected map");
888 +
    }
889 +
    let mut fields = Map::new();
890 +
    let mut inline: Option<(usize, usize, u64)> = None;
891 +
    let mut last: Option<u64> = None;
892 +
    for _ in 0..n {
893 +
        let k = map_key(c, &mut last)?;
894 +
        if k == 4 {
895 +
            let start = c.pos();
896 +
            let (am, count) = c.head()?;
897 +
            if am != 4 {
898 +
                return schema("lines: expected array");
899 +
            }
900 +
            for _ in 0..count {
901 +
                c.skip()?;
902 +
            }
903 +
            inline = Some((start, c.pos(), count));
904 +
            continue;
905 +
        }
906 +
        fields.insert(k, c.value()?);
907 +
    }
908 +
    let Some((start, end, count)) = inline else {
909 +
        return Edit::from_value(&Value::Map(fields), version, &mut |text, count| {
910 +
            texts.push(TextRef::Blob(*text, count));
911 +
            Ok(Vec::new())
912 +
        });
913 +
    };
914 +
    if uint(field(&fields, 0, "edit tag")?, "edit tag")? != 0 || fields.contains_key(&5) {
915 +
        return schema("only an insert has inline lines, and not with a text blob");
916 +
    }
917 +
    if count == 0 {
918 +
        return schema("insert with no lines");
919 +
    }
920 +
    if version >= 2 && end - start >= TEXT_BLOB_MIN {
921 +
        return schema("an insert this large is stored as a text blob in a version 2 change");
922 +
    }
923 +
    let (Ok(start), Ok(end)) = (u32::try_from(start), u32::try_from(end)) else {
924 +
        return schema("change too large");
925 +
    };
926 +
    texts.push(TextRef::Inline {
927 +
        change: me,
928 +
        start,
929 +
        end,
930 +
    });
931 +
    Ok(Edit::Insert {
932 +
        node: node_id(field(&fields, 1, "edit.node")?, "edit.node")?,
933 +
        after: AtomRef::from_value(field(&fields, 2, "after")?, "after")?,
934 +
        before: AtomRef::from_value(field(&fields, 3, "before")?, "before")?,
935 +
        lines: Vec::new(),
936 +
    })
937 +
}
780 938
781 939
/// A large insert's lines as stored (spec §6.1): the blob and its content,
782 940
/// the canonical CBOR array of the lines' byte strings.
…
344 unchanged lines
1127 1285
        assert!(
1128 1286
            matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("text blob"))
1129 1287
        );
1288 +
    }
1289 +
1290 +
    #[test]
1291 +
    fn a_skeleton_leaves_every_insert_out_and_says_where_its_lines_are() {
1292 +
        let mut c = with_insert(sample_change(), big_lines(500));
1293 +
        c.edits.push(Edit::Insert {
1294 +
            node: NodeId([8; 16]),
1295 +
            after: AtomRef::Start,
1296 +
            before: AtomRef::End,
1297 +
            lines: vec![b"small\n".to_vec(), b"insert\n".to_vec()],
1298 +
        });
1299 +
        let (bytes, stored) = c.encode_parts();
1300 +
        let me = ObjectRef([4; 32]);
1301 +
        let (skel, texts) = Change::decode_skeleton(&bytes, me).unwrap();
1302 +
        assert_eq!(texts.len(), 2);
1303 +
        let mut filled = skel.clone();
1304 +
        let mut next = texts.iter();
1305 +
        for e in filled.edits.iter_mut() {
1306 +
            if let Edit::Insert { lines, .. } = e {
1307 +
                assert!(lines.is_empty(), "no line kept");
1308 +
                *lines = match next.next().unwrap() {
1309 +
                    TextRef::Blob(r, n) => {
1310 +
                        assert_eq!(*r, stored[0].blob.hash());
1311 +
                        text_lines(&stored[0].content, *n).unwrap()
1312 +
                    }
1313 +
                    TextRef::Inline { change, start, end } => {
1314 +
                        assert_eq!(*change, me);
1315 +
                        inline_lines(&bytes[*start as usize..*end as usize]).unwrap()
1316 +
                    }
1317 +
                };
1318 +
            }
1319 +
        }
1320 +
        assert_eq!(filled, c, "filled in, it is the change");
1321 +
        // A version 1 change's large inline insert is fine; in version 2 it is not.
1322 +
        let mut v1 = with_insert(sample_change(), big_lines(500));
1323 +
        v1.version = 1;
1324 +
        assert!(Change::decode_skeleton(&v1.encode(), me).is_ok());
1325 +
        let mut v = v1.to_value();
1326 +
        if let Value::Map(m) = &mut v {
1327 +
            m.insert(0, Value::Uint(2));
1328 +
        }
1329 +
        assert!(Change::decode_skeleton(&cbor::encode(&v), me).is_err());
1130 1330
    }
1131 1331
1132 1332
    #[test]
…
97 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +27 −10
…
824 unchanged lines
825 825
826 826
    /// Build these file graphs in a history-only state.
827 827
    pub fn load_graphs_of(&self, state: &mut State, nodes: &[crate::ids::NodeId]) -> Result<()> {
828 -
        Ok(state.load_graphs(nodes, |refs| {
829 -
            self.read_blobs(refs).map_err(|e| {
830 -
                crate::objects::ObjectError::Schema(format!("checkpoint graph blobs: {e}"))
831 -
            })
832 -
        })?)
828 +
        Ok(state.load_graphs(
829 +
            nodes,
830 +
            |refs| {
831 +
                self.read_blobs(refs).map_err(|e| {
832 +
                    crate::objects::ObjectError::Schema(format!("graph and text blobs: {e}"))
833 +
                })
834 +
            },
835 +
            |h| self.change_bytes(h),
836 +
        )?)
833 837
    }
834 838
835 839
    /// Build every graph a history-only state still lacks, making it full.
836 840
    pub fn load_all_graphs(&self, state: &mut State) -> Result<()> {
837 -
        Ok(state.load_all_graphs(|refs| {
838 -
            self.read_blobs(refs).map_err(|e| {
839 -
                crate::objects::ObjectError::Schema(format!("checkpoint graph blobs: {e}"))
840 -
            })
841 -
        })?)
841 +
        Ok(state.load_all_graphs(
842 +
            |refs| {
843 +
                self.read_blobs(refs).map_err(|e| {
844 +
                    crate::objects::ObjectError::Schema(format!("graph and text blobs: {e}"))
845 +
                })
846 +
            },
847 +
            |h| self.change_bytes(h),
848 +
        )?)
849 +
    }
850 +
851 +
    /// A change's encoding, for building graphs of its inline inserts.
852 +
    fn change_bytes(
853 +
        &self,
854 +
        h: &ChangeHash,
855 +
    ) -> std::result::Result<Vec<u8>, crate::objects::ObjectError> {
856 +
        self.store.get_typed(h, ObjectType::Change).map_err(|e| {
857 +
            crate::objects::ObjectError::Schema(format!("change {}: {e}", h.short(12)))
858 +
        })
842 859
    }
843 860
844 861
    /// Load the dependency closure of `heads` change by change.
…
810 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +4 −1
…
5800 unchanged lines
5801 5801
    };
5802 5802
    let other_text = texts
5803 5803
        .iter()
5804 -
        .map(|(r, _)| *r)
5804 +
        .filter_map(|t| match t {
5805 +
            crate::objects::TextRef::Blob(r, _) => Some(*r),
5806 +
            _ => None,
5807 +
        })
5805 5808
        .find(|r| content(r).windows(12).any(|w| w == b"other line 1"))
5806 5809
        .unwrap();
5807 5810
    fs::remove_file(repo.store().path_for(&other_text)).unwrap();
…
107 unchanged lines
modified crates/oxy-core/src/state.rs +75 −34
…
70 unchanged lines
71 71
    deferred: Option<HashMap<NodeId, Deferred>>,
72 72
}
73 73
74 -
/// A stored insert's text blob and line count.
75 -
type TextRef = (ObjectRef, u32);
74 +
use crate::objects::TextRef;
76 75
77 76
/// A file graph a history-only state has not built: its base from a
78 77
/// version 2 checkpoint, if any, and the edits of later changes in the
…
5 unchanged lines
84 83
    edits: Vec<(ChangeHash, Vec<Edit>)>,
85 84
    /// The text blob and line count of each insert in `edits` left without
86 85
    /// its lines, in order: read when the graph is built.
87 -
    texts: Vec<(ObjectRef, u32)>,
86 +
    texts: Vec<TextRef>,
88 87
}
89 88
90 89
#[derive(Clone)]
…
192 unchanged lines
283 282
        &mut self,
284 283
        nodes: &[NodeId],
285 284
        load: impl FnOnce(&[ObjectRef]) -> Result<Vec<Vec<u8>>, ObjectError>,
285 +
        read_change: impl Fn(&ChangeHash) -> Result<Vec<u8>, ObjectError>,
286 286
    ) -> Result<(), ObjectError> {
287 287
        let Some(deferred) = self.deferred.as_mut() else {
288 288
            return Ok(());
…
11 unchanged lines
300 300
            .collect();
301 301
        // Text blobs of inserts read without their lines, after the bases.
302 302
        let bases = refs.len();
303 -
        refs.extend(
304 -
            taken
305 -
                .iter()
306 -
                .flat_map(|(_, d)| d.texts.iter().map(|(r, _)| *r)),
307 -
        );
303 +
        refs.extend(taken.iter().flat_map(|(_, d)| {
304 +
            d.texts.iter().filter_map(|t| match t {
305 +
                TextRef::Blob(r, _) => Some(*r),
306 +
                TextRef::Inline { .. } => None,
307 +
            })
308 +
        }));
309 +
        // And the encodings of changes whose inline inserts are needed,
310 +
        // each read once.
311 +
        let mut changes: HashMap<ChangeHash, Vec<u8>> = HashMap::new();
312 +
        for t in taken.iter().flat_map(|(_, d)| d.texts.iter()) {
313 +
            if let TextRef::Inline { change, .. } = t {
314 +
                if !changes.contains_key(change) {
315 +
                    changes.insert(*change, read_change(change)?);
316 +
                }
317 +
            }
318 +
        }
308 319
        let mut blobs = if refs.is_empty() {
309 320
            Vec::new()
310 321
        } else {
…
14 unchanged lines
325 336
                for e in edits.iter_mut() {
326 337
                    if let Edit::Insert { lines, .. } = e {
327 338
                        if lines.is_empty() {
328 -
                            let (_, count) = want.next().ok_or_else(|| {
329 -
                                ObjectError::Schema("an insert's text is missing".into())
330 -
                            })?;
331 -
                            let content = texts.next().ok_or_else(|| {
332 -
                                ObjectError::Schema("text blobs: wrong count".into())
333 -
                            })?;
334 -
                            *lines = crate::objects::text_lines(&content, count)?;
339 +
                            *lines = match want.next() {
340 +
                                Some(TextRef::Blob(_, count)) => {
341 +
                                    let content = texts.next().ok_or_else(|| {
342 +
                                        ObjectError::Schema("text blobs: wrong count".into())
343 +
                                    })?;
344 +
                                    crate::objects::text_lines(&content, count)?
345 +
                                }
346 +
                                Some(TextRef::Inline { change, start, end }) => {
347 +
                                    let bytes = &changes[&change];
348 +
                                    let range = bytes
349 +
                                        .get(start as usize..end as usize)
350 +
                                        .ok_or_else(|| {
351 +
                                            ObjectError::Schema(
352 +
                                                "an inline insert lies outside its change".into(),
353 +
                                            )
354 +
                                        })?;
355 +
                                    crate::objects::inline_lines(range)?
356 +
                                }
357 +
                                None => {
358 +
                                    return Err(ObjectError::Schema(
359 +
                                        "an insert's text is missing".into(),
360 +
                                    ))
361 +
                                }
362 +
                            };
335 363
                        }
336 364
                    }
337 365
                }
…
43 unchanged lines
381 409
    pub fn load_all_graphs(
382 410
        &mut self,
383 411
        load: impl FnOnce(&[ObjectRef]) -> Result<Vec<Vec<u8>>, ObjectError>,
412 +
        read_change: impl Fn(&ChangeHash) -> Result<Vec<u8>, ObjectError>,
384 413
    ) -> Result<(), ObjectError> {
385 414
        let nodes: Vec<NodeId> = match &self.deferred {
386 415
            Some(d) => d.keys().copied().collect(),
387 416
            None => return Ok(()),
388 417
        };
389 -
        self.load_graphs(&nodes, load)?;
418 +
        self.load_graphs(&nodes, load, read_change)?;
390 419
        self.deferred = None;
391 420
        Ok(())
392 421
    }
…
82 unchanged lines
475 504
    /// `texts` names, in order, the inserts left without their lines (see
476 505
    /// [`crate::store::Store::get_change_lazy`]); only a history-only state
477 506
    /// takes them, and only for graphs it has not built.
478 -
    pub fn add_known(&mut self, h: ChangeHash, change: Change, texts: Vec<(ObjectRef, u32)>) {
507 +
    pub fn add_known(&mut self, h: ChangeHash, change: Change, texts: Vec<TextRef>) {
479 508
        if self.changes.contains_key(&h) {
480 509
            return;
481 510
        }
…
307 unchanged lines
789 818
        Ok(())
790 819
    }
791 820
792 -
    fn apply_with(&mut self, h: ChangeHash, c: Change, texts: Vec<(ObjectRef, u32)>) {
821 +
    fn apply_with(&mut self, h: ChangeHash, c: Change, texts: Vec<TextRef>) {
793 822
        for (i, op) in c.tree_ops.iter().enumerate() {
794 823
            self.tree.apply(h, i as u32, &resolve_tree_op(op, h));
795 824
        }
…
680 unchanged lines
1476 1505
        assert_ne!(key(&s2, &f), key(&s3, &f));
1477 1506
        assert_ne!(key(&s1, &f), key(&s1, &g), "different files differ");
1478 1507
        let mut built = load(&[&c1]);
1479 -
        built.load_graphs(&[f], |_| unreachable!()).unwrap();
1508 +
        built
1509 +
            .load_graphs(&[f], |_| unreachable!(), |_| unreachable!())
1510 +
            .unwrap();
1480 1511
        assert!(built.graph_key(&f).is_none(), "a built graph has no key");
1481 1512
        let mut full = State::new();
1482 1513
        full.add(c1.clone()).unwrap();
…
20 unchanged lines
1503 1534
        // Partial rendering: skipped files are listed and empty, the others
1504 1535
        // render as in a full state.
1505 1536
        let mut part = load(&[&c1, &c2]);
1506 -
        part.load_graphs(&[g], |_| unreachable!()).unwrap();
1537 +
        part.load_graphs(&[g], |_| unreachable!(), |_| unreachable!())
1538 +
            .unwrap();
1507 1539
        let snap = part.materialize_except(&[f].into_iter().collect());
1508 1540
        assert_eq!(snap.unrendered, ["f".to_string()].into_iter().collect());
1509 1541
        let mut whole = State::new();
…
98 unchanged lines
1608 1640
        }
1609 1641
        assert!(!hist.is_full() && hist.files.is_empty(), "no graph built");
1610 1642
        assert_eq!(hist.len(), 3);
1611 -
        hist.load_graphs(&[big], |_| unreachable!("no checkpoint"))
1612 -
            .unwrap();
1643 +
        hist.load_graphs(
1644 +
            &[big],
1645 +
            |_| unreachable!("no checkpoint"),
1646 +
            |_| unreachable!(),
1647 +
        )
1648 +
        .unwrap();
1613 1649
        assert_eq!(
1614 1650
            hist.render_file(&big).0,
1615 1651
            full.render_file(&big).0,
1616 1652
            "one file on its own"
1617 1653
        );
1618 1654
        assert!(!hist.is_full());
1619 -
        hist.load_graphs(&[small, big], |_| unreachable!()).unwrap();
1655 +
        hist.load_graphs(&[small, big], |_| unreachable!(), |_| unreachable!())
1656 +
            .unwrap();
1620 1657
        assert!(hist.is_full());
1621 1658
        assert_eq!(hist.checkpoint_payload(), full.checkpoint_payload());
1622 1659
…
13 unchanged lines
1636 1673
        assert!(!from_cp.is_full());
1637 1674
        let mut fetched = 0;
1638 1675
        from_cp
1639 -
            .load_graphs(&[small, big], |refs| {
1640 -
                fetched += refs.len();
1641 -
                refs.iter()
1642 -
                    .map(|r| {
1643 -
                        blobs
1644 -
                            .get(r)
1645 -
                            .cloned()
1646 -
                            .ok_or_else(|| ObjectError::Schema("missing".into()))
1647 -
                    })
1648 -
                    .collect()
1649 -
            })
1676 +
            .load_graphs(
1677 +
                &[small, big],
1678 +
                |refs| {
1679 +
                    fetched += refs.len();
1680 +
                    refs.iter()
1681 +
                        .map(|r| {
1682 +
                            blobs
1683 +
                                .get(r)
1684 +
                                .cloned()
1685 +
                                .ok_or_else(|| ObjectError::Schema("missing".into()))
1686 +
                        })
1687 +
                        .collect()
1688 +
                },
1689 +
                |_| unreachable!(),
1690 +
            )
1650 1691
            .unwrap();
1651 1692
        assert_eq!(
1652 1693
            fetched, 1,
…
214 unchanged lines
modified crates/oxy-core/src/store.rs +6 −12
…
928 unchanged lines
929 929
        }
930 930
    }
931 931
932 -
    /// A change whose stored inserts are left without their lines (empty
933 -
    /// placeholders), with each one's text blob and line count in order:
934 -
    /// for history-only states, which read text only for the graphs they
935 -
    /// build.
936 -
    pub fn get_change_lazy(&self, r: &ObjectRef) -> Result<(Change, Vec<(ObjectRef, u32)>)> {
932 +
    /// A change with every insert left without its lines (empty
933 +
    /// placeholders), and where each one's lines are, in order: for
934 +
    /// history-only states, which read text only for the graphs they build
935 +
    /// (see [`Change::decode_skeleton`]).
936 +
    pub fn get_change_lazy(&self, r: &ObjectRef) -> Result<(Change, Vec<crate::objects::TextRef>)> {
937 937
        let bytes = self.get_typed(r, ObjectType::Change)?;
938 -
        let mut texts = Vec::new();
939 -
        let c = Change::decode_with(&bytes, &mut |text, count| {
940 -
            texts.push((*text, count));
941 -
            Ok(Vec::new())
942 -
        })
943 -
        .map_err(|e| StoreError::Corrupt(*r, e.to_string()))?;
944 -
        Ok((c, texts))
938 +
        Change::decode_skeleton(&bytes, *r).map_err(|e| StoreError::Corrupt(*r, e.to_string()))
945 939
    }
946 940
947 941
    fn read_text(&self, r: &ObjectRef) -> Result<(Blob, Vec<u8>)> {
…
753 unchanged lines
modified crates/oxy-core/src/timing.rs +1 −1
…
4 unchanged lines
5 5
use std::sync::OnceLock;
6 6
use std::time::Instant;
7 7
8 -
fn enabled() -> bool {
8 +
pub fn enabled() -> bool {
9 9
    static ON: OnceLock<bool> = OnceLock::new();
10 10
    *ON.get_or_init(|| std::env::var_os("OXY_TIMINGS").is_some_and(|v| !v.is_empty() && v != "0"))
11 11
}
…
33 unchanged lines
modified docs/benchmarks.md +6 −1
…
318 unchanged lines
319 319
| Switch bookmark (one file differs) | killed at 14 GB | 7.1 to 7.4 s, 1.3 GB | 0.18 to 0.22 s, 39 MB |
320 320
| Local clone | killed at 14 GB | 98 s, 7.9 GB | 9.8 s, 469 MB |
321 321
322 -
Most of a switch is now loading history: the base's and the target's
322 +
A history-only load now reads a change's skeleton: the inline lines of
323 +
its inserts are stepped over, not decoded, and are read from the change
324 +
only for a graph that is built. A one-file switch then takes 5.4 to 5.6 s
325 +
and 0.8 GB, and the status after it 3.3 s and 600 MB (from 3.8 s).
326 +
327 +
Before that, most of a switch was loading history: the base's and the target's
323 328
history each decode the one huge first change (1.2 s and 364 MB each).
324 329
A clone holds the whole bundle in memory.
325 330