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 |