Checkpoints: serialized states per bookmark, automatic every core.checkpoint-interval changes, oxy checkpoint, checkpoint-accelerated load_state; states keep change metadata only 252e8cb23fbc
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 22:10 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
252e8cb23fbca10a4fa4cb4332bebce7af26f16c91c41985808bc8fc62f0e69b- Depends on
- 2b5ee371060e 55e5a16a2b9c 614541c3cebd 83e0677b07fe 86e682c242c6 982864068a59 ba160f60219e c07e0e7d2d85 c3069537d7c5 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 80b069facc69 f3811eb20bea d3b270909c78 c2e1d09047db c6e69e4b8d8f 74b4a3c62ea4 b689779e9e74 cb8b8082fa73 79b5a90ffd43 7505194116e7 d8d9367878a6 98a1620a3840 ce14f0d68b15 05059d434aca f9575918477f e83354cdac22 2306a4d24779 f6451fbbae3f 4d6c9c1b358c 01a47cd7f0c8 54cd08a1e7bf e8d38e47e9d7 afaba5f1306d 0bdc3f91a39f d42369abf180 3e5772ccf9da e7ddd552b619 387335ff7f4d 119bbf50cb86 75167672ebcd c6aa5bd9d1c8 4dee277c327a b3c5a59a875a 8f10a07a1dbe 6706bdcfb63e 94084087a876 132b884b9a8d 2863456a0f6d 7421309f5372 135212de5370
- 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
- 52 atoms, 1 tree operation, 14 files
Browse the tree at this change
added crates/oxy-core/src/repo/checkpoint.rs +119 −0
| 1 | + | //! Checkpoints (spec §11): a serialized state kept per bookmark so loading |
|
| 2 | + | //! a state costs the changes since the checkpoint rather than all of them. |
|
| 3 | + | //! |
|
| 4 | + | //! The newest checkpoint of each bookmark is pointed at by |
|
| 5 | + | //! `.oxy/checkpoints/<bookmark>`; older ones become unreachable and are |
|
| 6 | + | //! garbage collected. A checkpoint applies to a requested head set when all |
|
| 7 | + | //! of its heads lie in that set's closure, which the loader establishes while |
|
| 8 | + | //! walking from the requested heads and stopping at checkpoint members. |
|
| 9 | + | ||
| 10 | + | use super::*; |
|
| 11 | + | use crate::objects::Checkpoint; |
|
| 12 | + | ||
| 13 | + | impl Repo { |
|
| 14 | + | fn checkpoint_pointer(&self, bookmark: &str) -> PathBuf { |
|
| 15 | + | self.oxy.join("checkpoints").join(bookmark) |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | /// The newest checkpoint recorded for a bookmark. |
|
| 19 | + | pub fn latest_checkpoint(&self, bookmark: &str) -> Result<Option<(ObjectRef, Checkpoint)>> { |
|
| 20 | + | match fs::read_to_string(self.checkpoint_pointer(bookmark)) { |
|
| 21 | + | Ok(h) => { |
|
| 22 | + | let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt checkpoint pointer".into()))?; |
|
| 23 | + | match self.store.get_typed(&r, ObjectType::Checkpoint) { |
|
| 24 | + | Ok(bytes) => Ok(Some((r, Checkpoint::decode(&bytes)?))), |
|
| 25 | + | Err(StoreError::NotFound(_)) => Ok(None), |
|
| 26 | + | Err(e) => Err(e.into()), |
|
| 27 | + | } |
|
| 28 | + | } |
|
| 29 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
|
| 30 | + | Err(e) => Err(e.into()), |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// Every checkpoint pointer, for garbage collection roots. |
|
| 35 | + | pub(crate) fn checkpoint_refs(&self) -> Result<Vec<ObjectRef>> { |
|
| 36 | + | let dir = self.oxy.join("checkpoints"); |
|
| 37 | + | let mut out = Vec::new(); |
|
| 38 | + | if let Ok(rd) = fs::read_dir(&dir) { |
|
| 39 | + | for e in rd.flatten() { |
|
| 40 | + | if let Ok(h) = fs::read_to_string(e.path()) { |
|
| 41 | + | if let Some(r) = ObjectRef::from_hex(h.trim()) { |
|
| 42 | + | out.push(r); |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | Ok(out) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// Write a checkpoint of `state` (whose heads are `heads`) for `bookmark`. |
|
| 51 | + | pub(crate) fn write_checkpoint(&self, bookmark: &str, state: &State, heads: &[ChangeHash]) -> Result<(ObjectRef, usize)> { |
|
| 52 | + | let payload = state.checkpoint_payload(); |
|
| 53 | + | let blob = self.store_blob(&payload)?; |
|
| 54 | + | let cp = Checkpoint { heads: StateObject::new(heads.to_vec()).heads, count: state.len() as u64, payload: blob }; |
|
| 55 | + | let r = self.store.put(ObjectType::Checkpoint, &cp.encode())?; |
|
| 56 | + | fs::create_dir_all(self.oxy.join("checkpoints"))?; |
|
| 57 | + | write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())?; |
|
| 58 | + | Ok((r, payload.len())) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// Checkpoint the current bookmark's state now. |
|
| 62 | + | pub fn checkpoint(&self) -> Result<(ObjectRef, usize, usize)> { |
|
| 63 | + | let heads = self.bookmark_heads()?; |
|
| 64 | + | let state = self.load_state_full(&heads)?; |
|
| 65 | + | let (r, bytes) = self.write_checkpoint(&self.config.bookmark, &state, &heads)?; |
|
| 66 | + | Ok((r, state.len(), bytes)) |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// Load the state of `heads` using a checkpoint when one applies. |
|
| 70 | + | pub(crate) fn load_state_checkpointed(&self, heads: &[ChangeHash]) -> Result<Option<State>> { |
|
| 71 | + | let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()]; |
|
| 72 | + | if let Ok(rd) = fs::read_dir(self.oxy.join("checkpoints")) { |
|
| 73 | + | for e in rd.flatten() { |
|
| 74 | + | let name = e.file_name().to_string_lossy().to_string(); |
|
| 75 | + | if !bookmarks.contains(&name) { |
|
| 76 | + | bookmarks.push(name); |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | for b in bookmarks { |
|
| 81 | + | let Some((_, cp)) = self.latest_checkpoint(&b)? else { continue }; |
|
| 82 | + | // Cheap pre-check: every checkpoint head must be stored. |
|
| 83 | + | if cp.heads.iter().any(|h| !self.store.has(h)) { |
|
| 84 | + | continue; |
|
| 85 | + | } |
|
| 86 | + | let payload = self.read_blob(&cp.payload)?; |
|
| 87 | + | let base = State::from_checkpoint_payload(&payload)?; |
|
| 88 | + | let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect(); |
|
| 89 | + | let mut found: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 90 | + | let mut delta: Vec<Change> = Vec::new(); |
|
| 91 | + | let mut seen: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 92 | + | let mut stack: Vec<ChangeHash> = heads.to_vec(); |
|
| 93 | + | while let Some(h) = stack.pop() { |
|
| 94 | + | if !seen.insert(h) { |
|
| 95 | + | continue; |
|
| 96 | + | } |
|
| 97 | + | if base.contains(&h) { |
|
| 98 | + | if cp_heads.contains(&h) { |
|
| 99 | + | found.insert(h); |
|
| 100 | + | } |
|
| 101 | + | continue; |
|
| 102 | + | } |
|
| 103 | + | let c = self.store.get_change(&h)?; |
|
| 104 | + | stack.extend(c.deps.iter().copied()); |
|
| 105 | + | delta.push(c); |
|
| 106 | + | } |
|
| 107 | + | if found.len() != cp_heads.len() { |
|
| 108 | + | continue; |
|
| 109 | + | } |
|
| 110 | + | let mut state = base; |
|
| 111 | + | for c in delta { |
|
| 112 | + | state.add_unchecked(c); |
|
| 113 | + | } |
|
| 114 | + | state.validate()?; |
|
| 115 | + | return Ok(Some(state)); |
|
| 116 | + | } |
|
| 117 | + | Ok(None) |
|
| 118 | + | } |
|
| 119 | + | } |
modified crates/oxy-core/src/graph.rs +89 −0
| … | 352 unchanged lines |
||
| 353 | 353 | } |
|
| 354 | 354 | } |
|
| 355 | 355 | ||
| 356 | + | fn vertex_value(v: &Vertex) -> crate::cbor::Value { |
|
| 357 | + | use crate::cbor::Value; |
|
| 358 | + | match v { |
|
| 359 | + | Vertex::Start => Value::Uint(0), |
|
| 360 | + | Vertex::End => Value::Uint(1), |
|
| 361 | + | Vertex::Atom((c, i)) => Value::Array(vec![Value::Bytes(c.0.to_vec()), Value::Uint(*i as u64)]), |
|
| 362 | + | } |
|
| 363 | + | } |
|
| 364 | + | ||
| 365 | + | fn vertex_from(v: &crate::cbor::Value) -> Result<Vertex, crate::objects::ObjectError> { |
|
| 366 | + | use crate::cbor::Value; |
|
| 367 | + | use crate::objects::ObjectError; |
|
| 368 | + | match v { |
|
| 369 | + | Value::Uint(0) => Ok(Vertex::Start), |
|
| 370 | + | Value::Uint(1) => Ok(Vertex::End), |
|
| 371 | + | Value::Array(a) if a.len() == 2 => Ok(Vertex::Atom((crate::objects::oref(&a[0], "vertex")?, crate::objects::u32_(&a[1], "vertex")?))), |
|
| 372 | + | _ => Err(ObjectError::Schema("vertex: expected 0, 1, or [hash, index]".into())), |
|
| 373 | + | } |
|
| 374 | + | } |
|
| 375 | + | ||
| 376 | + | impl FileGraph { |
|
| 377 | + | /// Serialize for a checkpoint: `{0: atoms [[hash, index, content|null, [deleters]]], 1: edges [[from, to]]}`. |
|
| 378 | + | pub fn to_value(&self) -> crate::cbor::Value { |
|
| 379 | + | use crate::cbor::Value; |
|
| 380 | + | let mut atoms: Vec<(&AtomKey, &AtomInfo)> = self.atoms.iter().collect(); |
|
| 381 | + | atoms.sort_by_key(|(k, _)| **k); |
|
| 382 | + | let mut edges: Vec<(Vertex, Vertex)> = self.succ.iter().flat_map(|(a, bs)| bs.iter().map(move |b| (*a, *b))).collect(); |
|
| 383 | + | edges.sort(); |
|
| 384 | + | Value::map([ |
|
| 385 | + | ( |
|
| 386 | + | 0, |
|
| 387 | + | Value::Array( |
|
| 388 | + | atoms |
|
| 389 | + | .into_iter() |
|
| 390 | + | .map(|((c, i), info)| { |
|
| 391 | + | Value::Array(vec![ |
|
| 392 | + | Value::Bytes(c.0.to_vec()), |
|
| 393 | + | Value::Uint(*i as u64), |
|
| 394 | + | info.content.as_ref().map(|b| Value::Bytes(b.clone())).unwrap_or(Value::Null), |
|
| 395 | + | Value::Array(info.deleted_by.iter().map(|d| Value::Bytes(d.0.to_vec())).collect()), |
|
| 396 | + | ]) |
|
| 397 | + | }) |
|
| 398 | + | .collect(), |
|
| 399 | + | ), |
|
| 400 | + | ), |
|
| 401 | + | (1, Value::Array(edges.iter().map(|(a, b)| Value::Array(vec![vertex_value(a), vertex_value(b)])).collect())), |
|
| 402 | + | ]) |
|
| 403 | + | } |
|
| 404 | + | ||
| 405 | + | pub fn from_value(v: &crate::cbor::Value) -> Result<FileGraph, crate::objects::ObjectError> { |
|
| 406 | + | use crate::objects::{array, bytes, field, map, oref, u32_, ObjectError}; |
|
| 407 | + | let m = map(v, "file graph")?; |
|
| 408 | + | let mut g = FileGraph { atoms: HashMap::new(), succ: HashMap::new(), pred: HashMap::new() }; |
|
| 409 | + | for a in array(field(m, 0, "atoms")?, "atoms")? { |
|
| 410 | + | let e = array(a, "atom")?; |
|
| 411 | + | if e.len() != 4 { |
|
| 412 | + | return Err(ObjectError::Schema("atom: expected 4 fields".into())); |
|
| 413 | + | } |
|
| 414 | + | let key = (oref(&e[0], "atom change")?, u32_(&e[1], "atom index")?); |
|
| 415 | + | let content = if e[2].is_null() { None } else { Some(bytes(&e[2], "atom content")?.to_vec()) }; |
|
| 416 | + | let deleted_by = array(&e[3], "deleters")?.iter().map(|d| oref(d, "deleter")).collect::<Result<BTreeSet<_>, _>>()?; |
|
| 417 | + | g.atoms.insert(key, AtomInfo { content, deleted_by }); |
|
| 418 | + | } |
|
| 419 | + | for e in array(field(m, 1, "edges")?, "edges")? { |
|
| 420 | + | let pair = array(e, "edge")?; |
|
| 421 | + | if pair.len() != 2 { |
|
| 422 | + | return Err(ObjectError::Schema("edge: expected [from, to]".into())); |
|
| 423 | + | } |
|
| 424 | + | g.edge(vertex_from(&pair[0])?, vertex_from(&pair[1])?); |
|
| 425 | + | } |
|
| 426 | + | Ok(g) |
|
| 427 | + | } |
|
| 428 | + | } |
|
| 429 | + | ||
| 356 | 430 | /// Split a plain run into `Lines` and `Zombie` segments around ghost atoms. |
|
| 357 | 431 | fn split_zombies(atoms: Vec<AtomKey>, ghosts: &HashSet<AtomKey>) -> Vec<Segment> { |
|
| 358 | 432 | let mut out = Vec::new(); |
|
| … | 187 unchanged lines |
||
| 546 | 620 | assert_eq!(text(&g, &g.materialize(&NoDeps)), "no newline"); |
|
| 547 | 621 | } |
|
| 548 | 622 | ||
| 623 | + | #[test] |
|
| 624 | + | fn serialization_round_trip() { |
|
| 625 | + | let mut g = FileGraph::new(); |
|
| 626 | + | g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"])); |
|
| 627 | + | g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["x\n"])); |
|
| 628 | + | g.delete(h(3), &AtomRange { change: h(1), from: 1, to: 2 }); |
|
| 629 | + | let v = g.to_value(); |
|
| 630 | + | let bytes = crate::cbor::encode(&v); |
|
| 631 | + | let back = FileGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap(); |
|
| 632 | + | let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect()); |
|
| 633 | + | assert_eq!(back.materialize(&deps), g.materialize(&deps)); |
|
| 634 | + | assert_eq!(text(&back, &back.materialize(&deps)), "a\nx\n"); |
|
| 635 | + | assert_eq!(crate::cbor::encode(&back.to_value()), bytes, "deterministic"); |
|
| 636 | + | } |
|
| 637 | + | ||
| 549 | 638 | /// Spec §7.3: the graph is a function of the set of changes, not their order. |
|
| 550 | 639 | #[test] |
|
| 551 | 640 | fn application_order_is_irrelevant() { |
|
| … | 36 unchanged lines |
modified crates/oxy-core/src/objects.rs +41 −6
| … | 101 unchanged lines |
||
| 102 | 102 | } |
|
| 103 | 103 | ||
| 104 | 104 | impl NodeKind { |
|
| 105 | - | fn tag(self) -> u64 { |
|
| 105 | + | pub(crate) fn tag(self) -> u64 { |
|
| 106 | 106 | match self { |
|
| 107 | 107 | NodeKind::File => 0, |
|
| 108 | 108 | NodeKind::Dir => 1, |
|
| 109 | 109 | NodeKind::Symlink => 2, |
|
| 110 | 110 | } |
|
| 111 | 111 | } |
|
| 112 | - | fn from_tag(t: u64) -> Result<NodeKind, ObjectError> { |
|
| 112 | + | pub(crate) fn from_tag(t: u64) -> Result<NodeKind, ObjectError> { |
|
| 113 | 113 | Ok(match t { |
|
| 114 | 114 | 0 => NodeKind::File, |
|
| 115 | 115 | 1 => NodeKind::Dir, |
|
| … | 13 unchanged lines |
||
| 129 | 129 | impl Mode { |
|
| 130 | 130 | pub const TEXT: Mode = Mode { exec: false, opaque: false }; |
|
| 131 | 131 | pub const OPAQUE: Mode = Mode { exec: false, opaque: true }; |
|
| 132 | - | fn bits(self) -> u64 { |
|
| 132 | + | pub(crate) fn bits(self) -> u64 { |
|
| 133 | 133 | (self.exec as u64) | ((self.opaque as u64) << 1) |
|
| 134 | 134 | } |
|
| 135 | - | fn from_bits(b: u64) -> Result<Mode, ObjectError> { |
|
| 135 | + | pub(crate) fn from_bits(b: u64) -> Result<Mode, ObjectError> { |
|
| 136 | 136 | if b > 3 { |
|
| 137 | 137 | return schema(format!("unknown mode bits {b}")); |
|
| 138 | 138 | } |
|
| … | 280 unchanged lines |
||
| 419 | 419 | } |
|
| 420 | 420 | ||
| 421 | 421 | impl ChangeKind { |
|
| 422 | - | fn tag(self) -> u64 { |
|
| 422 | + | pub(crate) fn tag(self) -> u64 { |
|
| 423 | 423 | match self { |
|
| 424 | 424 | ChangeKind::Normal => 0, |
|
| 425 | 425 | ChangeKind::Resolution => 1, |
|
| 426 | 426 | ChangeKind::WorkingCopy => 2, |
|
| 427 | 427 | } |
|
| 428 | 428 | } |
|
| 429 | - | fn from_tag(t: u64) -> Result<ChangeKind, ObjectError> { |
|
| 429 | + | pub(crate) fn from_tag(t: u64) -> Result<ChangeKind, ObjectError> { |
|
| 430 | 430 | Ok(match t { |
|
| 431 | 431 | 0 => ChangeKind::Normal, |
|
| 432 | 432 | 1 => ChangeKind::Resolution, |
|
| … | 123 unchanged lines |
||
| 556 | 556 | } |
|
| 557 | 557 | } |
|
| 558 | 558 | ||
| 559 | + | // ---------------------------------------------------------------- checkpoint |
|
| 560 | + | ||
| 561 | + | /// A materialized state (spec §11): the heads it stands for, how many changes |
|
| 562 | + | /// it holds, and a blob with the serialized graphs (`State::checkpoint_payload`). |
|
| 563 | + | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 564 | + | pub struct Checkpoint { |
|
| 565 | + | pub heads: Vec<ChangeHash>, |
|
| 566 | + | pub count: u64, |
|
| 567 | + | pub payload: ObjectRef, |
|
| 568 | + | } |
|
| 569 | + | ||
| 570 | + | impl Checkpoint { |
|
| 571 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 572 | + | cbor::encode(&Value::map([ |
|
| 573 | + | (0, Value::Uint(FORMAT_VERSION)), |
|
| 574 | + | (1, Value::Array(self.heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())), |
|
| 575 | + | (2, Value::Uint(self.count)), |
|
| 576 | + | (3, Value::Bytes(self.payload.0.to_vec())), |
|
| 577 | + | ])) |
|
| 578 | + | } |
|
| 579 | + | pub fn hash(&self) -> ObjectRef { |
|
| 580 | + | hash_object(ObjectType::Checkpoint, &self.encode()) |
|
| 581 | + | } |
|
| 582 | + | pub fn decode(bytes: &[u8]) -> Result<Checkpoint, ObjectError> { |
|
| 583 | + | let v = cbor::decode(bytes)?; |
|
| 584 | + | let m = map(&v, "checkpoint")?; |
|
| 585 | + | check_version(m)?; |
|
| 586 | + | Ok(Checkpoint { |
|
| 587 | + | heads: array(field(m, 1, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?, |
|
| 588 | + | count: uint(field(m, 2, "count")?, "count")?, |
|
| 589 | + | payload: oref(field(m, 3, "payload")?, "payload")?, |
|
| 590 | + | }) |
|
| 591 | + | } |
|
| 592 | + | } |
|
| 593 | + | ||
| 559 | 594 | // ---------------------------------------------------------------- state |
|
| 560 | 595 | ||
| 561 | 596 | /// A state named by its heads (spec §10.1): what a bookmark or tag points at. |
|
| … | 141 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +6 −0
| … | 26 unchanged lines |
||
| 27 | 27 | pub op_retention_days: u64, |
|
| 28 | 28 | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| 29 | 29 | pub gc_grace_days: u64, |
|
| 30 | + | /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11). |
|
| 31 | + | pub checkpoint_interval: u64, |
|
| 30 | 32 | } |
|
| 31 | 33 | ||
| 32 | 34 | impl Config { |
|
| … | 11 unchanged lines |
||
| 44 | 46 | remote_cas: BTreeMap::new(), |
|
| 45 | 47 | op_retention_days: 90, |
|
| 46 | 48 | gc_grace_days: 14, |
|
| 49 | + | checkpoint_interval: 256, |
|
| 47 | 50 | } |
|
| 48 | 51 | } |
|
| 49 | 52 | ||
| … | 45 unchanged lines |
||
| 95 | 98 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 96 | 99 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| 97 | 100 | "core.gc-grace" => self.gc_grace_days = parse_days(value)?, |
|
| 101 | + | "core.checkpoint-interval" => { |
|
| 102 | + | self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?; |
|
| 103 | + | } |
|
| 98 | 104 | k if k.starts_with("remote.") => { |
|
| 99 | 105 | let rest = &k["remote.".len()..]; |
|
| 100 | 106 | let Some((name, field)) = rest.rsplit_once('.') else { |
|
| … | 81 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +11 −0
| … | 47 unchanged lines |
||
| 48 | 48 | policies.extend(op.view.policy); |
|
| 49 | 49 | } |
|
| 50 | 50 | } |
|
| 51 | + | // Checkpoints: the object, its payload blob, and the blob's chunks. |
|
| 52 | + | for r in self.checkpoint_refs()? { |
|
| 53 | + | if let Ok(bytes) = self.store.get_typed(&r, ObjectType::Checkpoint) { |
|
| 54 | + | keep.insert(r); |
|
| 55 | + | let cp = crate::objects::Checkpoint::decode(&bytes)?; |
|
| 56 | + | keep.insert(cp.payload); |
|
| 57 | + | if let Ok(b) = self.store.get_typed(&cp.payload, ObjectType::Blob) { |
|
| 58 | + | keep.extend(Blob::decode(&b)?.chunks); |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 51 | 62 | // Reference attestations. |
|
| 52 | 63 | for name in self.view()?.bookmarks.keys() { |
|
| 53 | 64 | if let Some((r, _)) = self.latest_ref_attest(name)? { |
|
| … | 56 unchanged lines |
modified crates/oxy-core/src/repo/history.rs +2 −1
| … | 89 unchanged lines |
||
| 90 | 90 | if old_change.kind == ChangeKind::WorkingCopy { |
|
| 91 | 91 | return fail("that is the working copy; commit it instead"); |
|
| 92 | 92 | } |
|
| 93 | - | let rewritten = propagate(&state, *old, new, corr).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?; |
|
| 93 | + | let load = |h: &ChangeHash| self.store.get_change(h).ok(); |
|
| 94 | + | let rewritten = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?; |
|
| 94 | 95 | ||
| 95 | 96 | let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new(); |
|
| 96 | 97 | let new_hash = match new { |
|
| … | 113 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +14 −5
| … | 51 unchanged lines |
||
| 52 | 52 | } |
|
| 53 | 53 | ||
| 54 | 54 | mod checkout; |
|
| 55 | + | mod checkpoint; |
|
| 55 | 56 | mod config; |
|
| 56 | 57 | mod gc; |
|
| 57 | 58 | mod git; |
|
| … | 317 unchanged lines |
||
| 375 | 376 | ||
| 376 | 377 | // ------------------------------------------------------------ states |
|
| 377 | 378 | ||
| 378 | - | /// Load the dependency closure of `heads` from the store. |
|
| 379 | + | /// Load the dependency closure of `heads`, through a checkpoint when one applies. |
|
| 379 | 380 | pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> { |
|
| 381 | + | if let Some(st) = self.load_state_checkpointed(heads)? { |
|
| 382 | + | return Ok(st); |
|
| 383 | + | } |
|
| 384 | + | self.load_state_full(heads) |
|
| 385 | + | } |
|
| 386 | + | ||
| 387 | + | /// Load the dependency closure of `heads` change by change. |
|
| 388 | + | pub fn load_state_full(&self, heads: &[ChangeHash]) -> Result<State> { |
|
| 380 | 389 | let mut state = State::new(); |
|
| 381 | 390 | let mut stack: Vec<ChangeHash> = heads.to_vec(); |
|
| 382 | 391 | let mut seen: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| … | 104 unchanged lines |
||
| 487 | 496 | heads.push(wc_hash); |
|
| 488 | 497 | let state = self.load_state(&heads)?; |
|
| 489 | 498 | let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect(); |
|
| 490 | - | let mut entries: Vec<LogEntry> = state |
|
| 491 | - | .changes() |
|
| 492 | - | .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }) |
|
| 493 | - | .collect(); |
|
| 499 | + | let mut entries: Vec<LogEntry> = Vec::new(); |
|
| 500 | + | for (h, _) in state.changes() { |
|
| 501 | + | entries.push(LogEntry { hash: *h, change: self.store.get_change(h)?, is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }); |
|
| 502 | + | } |
|
| 494 | 503 | entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash))); |
|
| 495 | 504 | Ok(entries) |
|
| 496 | 505 | } |
|
| … | 80 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +6 −0
| … | 196 unchanged lines |
||
| 197 | 197 | let mut with_new = old_heads.clone(); |
|
| 198 | 198 | with_new.push(hash); |
|
| 199 | 199 | let new_heads = st.maximal(&with_new); |
|
| 200 | + | // Automatic checkpoint once enough changes accumulated (spec §11). |
|
| 201 | + | let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0); |
|
| 202 | + | if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval { |
|
| 203 | + | let cp_state = self.load_state_full(&new_heads)?; |
|
| 204 | + | self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?; |
|
| 205 | + | } |
|
| 200 | 206 | let mut wc_base = old_base.clone(); |
|
| 201 | 207 | wc_base.push(hash); |
|
| 202 | 208 | let wc_base = st.maximal(&wc_base); |
|
| … | 386 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +51 −0
| … | 1061 unchanged lines |
||
| 1062 | 1062 | let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap(); |
|
| 1063 | 1063 | assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); |
|
| 1064 | 1064 | } |
|
| 1065 | + | ||
| 1066 | + | #[test] |
|
| 1067 | + | fn checkpoints_accelerate_loading_and_stay_correct() { |
|
| 1068 | + | hermetic(); |
|
| 1069 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1070 | + | let root = dir.path(); |
|
| 1071 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 1072 | + | repo.config.set("core.checkpoint-interval", "5").unwrap(); |
|
| 1073 | + | repo.save_config().unwrap(); |
|
| 1074 | + | let repo = Repo::open(root).unwrap().with_home(root.join("home")); |
|
| 1075 | + | let mut hashes = Vec::new(); |
|
| 1076 | + | for i in 0..12 { |
|
| 1077 | + | write(root, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes()); |
|
| 1078 | + | write(root, &format!("f{i}.txt"), b"x\n"); |
|
| 1079 | + | hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap()); |
|
| 1080 | + | } |
|
| 1081 | + | let (_, cp) = repo.latest_checkpoint("main").unwrap().expect("automatic checkpoint"); |
|
| 1082 | + | assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count); |
|
| 1083 | + | // Loading through the checkpoint equals loading everything. |
|
| 1084 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 1085 | + | let fast = repo.load_state(&heads).unwrap(); |
|
| 1086 | + | let full = repo.load_state_full(&heads).unwrap(); |
|
| 1087 | + | assert_eq!(fast.len(), full.len()); |
|
| 1088 | + | assert_eq!(fast.heads(), full.heads()); |
|
| 1089 | + | let (a, b) = (fast.materialize(), full.materialize()); |
|
| 1090 | + | assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>()); |
|
| 1091 | + | for (p, f) in &a.files { |
|
| 1092 | + | assert_eq!(f.content, b.files[p].content, "{p}"); |
|
| 1093 | + | } |
|
| 1094 | + | assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); |
|
| 1095 | + | // A head set that does not contain the checkpoint's heads falls back. |
|
| 1096 | + | assert!(repo.load_state_checkpointed(&[hashes[0]]).unwrap().is_none()); |
|
| 1097 | + | assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1); |
|
| 1098 | + | // Everything else keeps working on top of the checkpoint. |
|
| 1099 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1100 | + | let h = repo.describe(&hashes[3], "c3 described").unwrap(); |
|
| 1101 | + | assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described"); |
|
| 1102 | + | let (r, n, bytes) = repo.checkpoint().unwrap(); |
|
| 1103 | + | assert!(n >= 12 && bytes > 0); |
|
| 1104 | + | assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r); |
|
| 1105 | + | write(root, "a.txt", b"after\n"); |
|
| 1106 | + | let h_after = repo.commit("after", &[]).unwrap(); |
|
| 1107 | + | let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); |
|
| 1108 | + | assert!(st.contains(&h_after)); |
|
| 1109 | + | assert!(matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n")); |
|
| 1110 | + | assert_eq!(repo.log().unwrap().len(), 14); |
|
| 1111 | + | // Checkpoints survive gc. |
|
| 1112 | + | repo.gc(true).unwrap(); |
|
| 1113 | + | assert!(repo.latest_checkpoint("main").unwrap().is_some()); |
|
| 1114 | + | assert!(repo.load_state_checkpointed(&repo.bookmark_heads().unwrap()).unwrap().is_some()); |
|
| 1115 | + | } |
modified crates/oxy-core/src/rewrite.rs +24 −13
| … | 64 unchanged lines |
||
| 65 | 65 | /// `None` when `old` is removed) and the correspondence between them. |
|
| 66 | 66 | /// Returns `(old hash, rewritten change)` pairs in dependency order, not |
|
| 67 | 67 | /// including `old` → `new` itself. |
|
| 68 | - | pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence) -> Result<Vec<(ChangeHash, Change)>, RewriteError> { |
|
| 68 | + | /// |
|
| 69 | + | /// `load` fetches a change's full object (a state keeps only metadata). |
|
| 70 | + | pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Vec<(ChangeHash, Change)>, RewriteError> { |
|
| 69 | 71 | let old_change = state.get(&old).ok_or(RewriteError::Missing(old))?; |
|
| 70 | 72 | let mut mapping: HashMap<ChangeHash, Replacement> = HashMap::new(); |
|
| 71 | 73 | mapping.insert(old, match new { |
|
| … | 10 unchanged lines |
||
| 82 | 84 | ||
| 83 | 85 | let mut out = Vec::new(); |
|
| 84 | 86 | for h in dependents { |
|
| 85 | - | let c = state.get(&h).ok_or(RewriteError::Missing(h))?; |
|
| 86 | - | let rewritten = rewrite_one(&h, c, &mapping, &corrs)?; |
|
| 87 | + | let c = load(&h).ok_or(RewriteError::Missing(h))?; |
|
| 88 | + | let rewritten = rewrite_one(&h, &c, &mapping, &corrs)?; |
|
| 87 | 89 | let rh = rewritten.hash(); |
|
| 88 | 90 | corrs.insert(h, Correspondence::identity(&rewritten)); |
|
| 89 | 91 | mapping.insert(h, Replacement::Change(rh)); |
|
| … | 115 unchanged lines |
||
| 205 | 207 | // Rewrite c1's message; everything downstream follows. |
|
| 206 | 208 | let mut n1 = c1.clone(); |
|
| 207 | 209 | n1.message = "one, described".into(); |
|
| 208 | - | let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1)).unwrap(); |
|
| 210 | + | let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2.clone()), (h3, c3.clone())].into_iter().collect(); |
|
| 211 | + | let load = |h: &ChangeHash| all.get(h).cloned(); |
|
| 212 | + | let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap(); |
|
| 209 | 213 | assert_eq!(rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(), vec![h2, h3]); |
|
| 210 | 214 | let mut st2 = State::new(); |
|
| 211 | 215 | st2.add(n1.clone()).unwrap(); |
|
| … | 22 unchanged lines |
||
| 234 | 238 | let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one"); |
|
| 235 | 239 | let h1 = st.add(c1.clone()).unwrap(); |
|
| 236 | 240 | let c2 = change(vec![h1], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two"); |
|
| 237 | - | let h2 = st.add(c2).unwrap(); |
|
| 241 | + | let h2 = st.add(c2.clone()).unwrap(); |
|
| 242 | + | let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2)].into_iter().collect(); |
|
| 243 | + | let load = |h: &ChangeHash| all.get(h).cloned(); |
|
| 238 | 244 | // The rewritten c1 drops line "b": atom 1 has no counterpart. |
|
| 239 | 245 | let mut n1 = c1.clone(); |
|
| 240 | 246 | n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }]; |
|
| 241 | 247 | let corr = Correspondence { atoms: [(0, 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() }; |
|
| 242 | - | let err = propagate(&st, h1, Some(&n1), &corr).unwrap_err(); |
|
| 248 | + | let err = propagate(&st, h1, Some(&n1), &corr, &load).unwrap_err(); |
|
| 243 | 249 | assert_eq!(err, RewriteError::Orphaned { change: h2, what: "atom 1".into() }); |
|
| 244 | - | assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32]))); |
|
| 250 | + | assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr, &load).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32]))); |
|
| 245 | 251 | } |
|
| 246 | 252 | ||
| 247 | 253 | #[test] |
|
| … | 2 unchanged lines |
||
| 250 | 256 | let f = NodeId::random(); |
|
| 251 | 257 | let g = NodeId::random(); |
|
| 252 | 258 | let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![], "one"); |
|
| 253 | - | let h1 = st.add(c1).unwrap(); |
|
| 259 | + | let h1 = st.add(c1.clone()).unwrap(); |
|
| 254 | 260 | // c2 merely depends on c1 explicitly (touches another file). |
|
| 255 | 261 | let c2 = change(vec![h1], vec![TreeOp::AddNode { node: g, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "g".into() }], vec![], "two"); |
|
| 256 | 262 | let h2 = st.add(c2.clone()).unwrap(); |
|
| 257 | 263 | let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: g, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }], "three"); |
|
| 258 | - | let h3 = st.add(c3).unwrap(); |
|
| 264 | + | let h3 = st.add(c3.clone()).unwrap(); |
|
| 265 | + | let mut all: HashMap<ChangeHash, Change> = [(h1, c1), (h2, c2), (h3, c3)].into_iter().collect(); |
|
| 266 | + | let load_all = all.clone(); |
|
| 267 | + | let load = move |h: &ChangeHash| load_all.get(h).cloned(); |
|
| 259 | 268 | // Removing c2 (an explicit-only dependency of nothing that references it) works: c3 inherits h1. |
|
| 260 | - | let out = propagate(&st, h2, None, &Correspondence::default()).unwrap(); |
|
| 269 | + | let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap(); |
|
| 261 | 270 | assert_eq!(out.len(), 1); |
|
| 262 | 271 | assert_eq!(out[0].0, h3); |
|
| 263 | 272 | assert_eq!(out[0].1.deps, vec![h1]); |
|
| 264 | 273 | // Removing c1 fails: c2 does not reference c1 (only an explicit dep), but |
|
| 265 | 274 | // nothing else does either, so it succeeds and c2 inherits no deps. |
|
| 266 | - | let out = propagate(&st, h1, None, &Correspondence::default()).unwrap(); |
|
| 275 | + | let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap(); |
|
| 267 | 276 | assert!(out.iter().any(|(h, c)| *h == h2 && c.deps.is_empty())); |
|
| 268 | 277 | // Removing c2 when c3 references its placement fails. |
|
| 269 | 278 | let c4 = change(vec![h2], vec![TreeOp::RemoveNode { node: g, placement: OpRef { change: h2, index: 0 } }], vec![], "four"); |
|
| 270 | - | let h4 = st.add(c4).unwrap(); |
|
| 271 | - | let err = propagate(&st, h2, None, &Correspondence::default()).unwrap_err(); |
|
| 279 | + | let h4 = st.add(c4.clone()).unwrap(); |
|
| 280 | + | all.insert(h4, c4); |
|
| 281 | + | let load2 = |h: &ChangeHash| all.get(h).cloned(); |
|
| 282 | + | let err = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap_err(); |
|
| 272 | 283 | assert!(matches!(err, RewriteError::Orphaned { change, .. } if change == h4 || change == h3), "{err:?}"); |
|
| 273 | 284 | } |
|
| 274 | 285 | } |
modified crates/oxy-core/src/state.rs +144 −5
| … | 3 unchanged lines |
||
| 4 | 4 | use crate::graph::{AtomKey, DepOracle, FileGraph, Materialized, Vertex}; |
|
| 5 | 5 | use crate::hash::{ChangeHash, ObjectRef}; |
|
| 6 | 6 | use crate::ids::NodeId; |
|
| 7 | - | use crate::objects::{AtomRef, Change, Edit, Mode, NodeKind, TreeOp}; |
|
| 7 | + | use crate::cbor::{self, Value}; |
|
| 8 | + | use crate::hash::KeyId; |
|
| 9 | + | use crate::objects::{self as o, AtomRef, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, ObjectError, TreeOp}; |
|
| 8 | 10 | use crate::tree::{TreeGraph, TreeView}; |
|
| 9 | 11 | use std::cell::RefCell; |
|
| 10 | 12 | use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; |
|
| … | 8 unchanged lines |
||
| 19 | 21 | /// One `Replace` edit: `(change, old blob, new blob)`. |
|
| 20 | 22 | type ReplaceRecord = (ChangeHash, Option<ObjectRef>, Option<ObjectRef>); |
|
| 21 | 23 | ||
| 24 | + | /// What a state remembers about a change: everything but its operations, |
|
| 25 | + | /// which live in the graphs once applied (and in the store). |
|
| 26 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 27 | + | pub struct ChangeMeta { |
|
| 28 | + | pub change_id: crate::ids::ChangeId, |
|
| 29 | + | pub deps: Vec<ChangeHash>, |
|
| 30 | + | pub author: KeyId, |
|
| 31 | + | pub hlc: Hlc, |
|
| 32 | + | pub message: String, |
|
| 33 | + | pub kind: ChangeKind, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl ChangeMeta { |
|
| 37 | + | pub fn of(c: &Change) -> ChangeMeta { |
|
| 38 | + | ChangeMeta { change_id: c.change_id, deps: c.deps.clone(), author: c.author, hlc: c.hlc, message: c.message.clone(), kind: c.kind } |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 22 | 42 | #[derive(Default, Clone)] |
|
| 23 | 43 | pub struct State { |
|
| 24 | - | changes: HashMap<ChangeHash, Change>, |
|
| 44 | + | changes: HashMap<ChangeHash, ChangeMeta>, |
|
| 25 | 45 | tree: TreeGraph, |
|
| 26 | 46 | files: HashMap<NodeId, FileGraph>, |
|
| 27 | 47 | replaces: HashMap<NodeId, Vec<ReplaceRecord>>, |
|
| … | 81 unchanged lines |
||
| 109 | 129 | pub fn contains(&self, h: &ChangeHash) -> bool { |
|
| 110 | 130 | self.changes.contains_key(h) |
|
| 111 | 131 | } |
|
| 112 | - | pub fn get(&self, h: &ChangeHash) -> Option<&Change> { |
|
| 132 | + | pub fn get(&self, h: &ChangeHash) -> Option<&ChangeMeta> { |
|
| 113 | 133 | self.changes.get(h) |
|
| 114 | 134 | } |
|
| 115 | - | pub fn changes(&self) -> impl Iterator<Item = (&ChangeHash, &Change)> { |
|
| 135 | + | pub fn changes(&self) -> impl Iterator<Item = (&ChangeHash, &ChangeMeta)> { |
|
| 116 | 136 | self.changes.iter() |
|
| 117 | 137 | } |
|
| 118 | 138 | pub fn tree(&self) -> &TreeGraph { |
|
| … | 24 unchanged lines |
||
| 143 | 163 | return h; |
|
| 144 | 164 | } |
|
| 145 | 165 | self.apply(h, &change); |
|
| 146 | - | self.changes.insert(h, change); |
|
| 166 | + | self.changes.insert(h, ChangeMeta::of(&change)); |
|
| 147 | 167 | self.closure.borrow_mut().clear(); |
|
| 148 | 168 | h |
|
| 149 | 169 | } |
|
| 150 | 170 | ||
| 171 | + | // ------------------------------------------------------------ checkpoints (spec §11) |
|
| 172 | + | ||
| 173 | + | /// Serialize the whole state: change metadata, tree, file graphs, replaces. |
|
| 174 | + | pub fn checkpoint_payload(&self) -> Vec<u8> { |
|
| 175 | + | let mut hashes: Vec<&ChangeHash> = self.changes.keys().collect(); |
|
| 176 | + | hashes.sort(); |
|
| 177 | + | let changes = Value::Array( |
|
| 178 | + | hashes |
|
| 179 | + | .into_iter() |
|
| 180 | + | .map(|h| { |
|
| 181 | + | let m = &self.changes[h]; |
|
| 182 | + | Value::Array(vec![ |
|
| 183 | + | Value::Bytes(h.0.to_vec()), |
|
| 184 | + | Value::Bytes(m.change_id.0.to_vec()), |
|
| 185 | + | Value::Array(m.deps.iter().map(|d| Value::Bytes(d.0.to_vec())).collect()), |
|
| 186 | + | Value::Text(m.message.clone()), |
|
| 187 | + | Value::Uint(m.hlc.wall_ms), |
|
| 188 | + | Value::Uint(m.hlc.logical as u64), |
|
| 189 | + | Value::Uint(m.kind.tag()), |
|
| 190 | + | Value::Bytes(m.author.0.to_vec()), |
|
| 191 | + | ]) |
|
| 192 | + | }) |
|
| 193 | + | .collect(), |
|
| 194 | + | ); |
|
| 195 | + | let mut nodes: Vec<&NodeId> = self.files.keys().collect(); |
|
| 196 | + | nodes.sort(); |
|
| 197 | + | let files = Value::Array(nodes.into_iter().map(|n| Value::Array(vec![Value::Bytes(n.0.to_vec()), self.files[n].to_value()])).collect()); |
|
| 198 | + | let mut rnodes: Vec<&NodeId> = self.replaces.keys().collect(); |
|
| 199 | + | rnodes.sort(); |
|
| 200 | + | let opt = |o: &Option<ObjectRef>| o.map(|r| Value::Bytes(r.0.to_vec())).unwrap_or(Value::Null); |
|
| 201 | + | let replaces = Value::Array( |
|
| 202 | + | rnodes |
|
| 203 | + | .into_iter() |
|
| 204 | + | .map(|n| { |
|
| 205 | + | Value::Array(vec![ |
|
| 206 | + | Value::Bytes(n.0.to_vec()), |
|
| 207 | + | Value::Array(self.replaces[n].iter().map(|(c, o, nw)| Value::Array(vec![Value::Bytes(c.0.to_vec()), opt(o), opt(nw)])).collect()), |
|
| 208 | + | ]) |
|
| 209 | + | }) |
|
| 210 | + | .collect(), |
|
| 211 | + | ); |
|
| 212 | + | cbor::encode(&Value::map([(0, Value::Uint(o::FORMAT_VERSION)), (1, changes), (2, self.tree.to_value()), (3, files), (4, replaces)])) |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | pub fn from_checkpoint_payload(bytes: &[u8]) -> Result<State, ObjectError> { |
|
| 216 | + | let v = cbor::decode(bytes)?; |
|
| 217 | + | let m = o::map(&v, "checkpoint payload")?; |
|
| 218 | + | o::check_version(m)?; |
|
| 219 | + | let mut st = State::new(); |
|
| 220 | + | for c in o::array(o::field(m, 1, "changes")?, "changes")? { |
|
| 221 | + | let e = o::array(c, "change meta")?; |
|
| 222 | + | if e.len() != 8 { |
|
| 223 | + | return Err(ObjectError::Schema("change meta: expected 8 fields".into())); |
|
| 224 | + | } |
|
| 225 | + | let hash = o::oref(&e[0], "hash")?; |
|
| 226 | + | let meta = ChangeMeta { |
|
| 227 | + | change_id: crate::ids::ChangeId::from_slice(o::bytes(&e[1], "change id")?).ok_or_else(|| ObjectError::Schema("change id: expected 16 bytes".into()))?, |
|
| 228 | + | deps: o::array(&e[2], "deps")?.iter().map(|d| o::oref(d, "dep")).collect::<Result<Vec<_>, _>>()?, |
|
| 229 | + | message: o::text(&e[3], "message")?.to_owned(), |
|
| 230 | + | hlc: Hlc { wall_ms: o::uint(&e[4], "wall")?, logical: o::u32_(&e[5], "logical")? }, |
|
| 231 | + | kind: ChangeKind::from_tag(o::uint(&e[6], "kind")?)?, |
|
| 232 | + | author: o::oref(&e[7], "author")?, |
|
| 233 | + | }; |
|
| 234 | + | st.changes.insert(hash, meta); |
|
| 235 | + | } |
|
| 236 | + | st.tree = TreeGraph::from_value(o::field(m, 2, "tree")?)?; |
|
| 237 | + | for f in o::array(o::field(m, 3, "files")?, "files")? { |
|
| 238 | + | let e = o::array(f, "file")?; |
|
| 239 | + | if e.len() != 2 { |
|
| 240 | + | return Err(ObjectError::Schema("file: expected [node, graph]".into())); |
|
| 241 | + | } |
|
| 242 | + | let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?; |
|
| 243 | + | st.files.insert(node, FileGraph::from_value(&e[1])?); |
|
| 244 | + | } |
|
| 245 | + | let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> { if v.is_null() { Ok(None) } else { o::oref(v, "blob").map(Some) } }; |
|
| 246 | + | for r in o::array(o::field(m, 4, "replaces")?, "replaces")? { |
|
| 247 | + | let e = o::array(r, "replace")?; |
|
| 248 | + | if e.len() != 2 { |
|
| 249 | + | return Err(ObjectError::Schema("replace: expected [node, records]".into())); |
|
| 250 | + | } |
|
| 251 | + | let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?; |
|
| 252 | + | let mut recs = Vec::new(); |
|
| 253 | + | for rec in o::array(&e[1], "records")? { |
|
| 254 | + | let x = o::array(rec, "record")?; |
|
| 255 | + | if x.len() != 3 { |
|
| 256 | + | return Err(ObjectError::Schema("record: expected 3 fields".into())); |
|
| 257 | + | } |
|
| 258 | + | recs.push((o::oref(&x[0], "change")?, opt(&x[1])?, opt(&x[2])?)); |
|
| 259 | + | } |
|
| 260 | + | st.replaces.insert(node, recs); |
|
| 261 | + | } |
|
| 262 | + | Ok(st) |
|
| 263 | + | } |
|
| 264 | + | ||
| 151 | 265 | pub fn validate(&self) -> Result<(), StateError> { |
|
| 152 | 266 | for (h, c) in &self.changes { |
|
| 153 | 267 | for d in &c.deps { |
|
| … | 273 unchanged lines |
||
| 427 | 541 | } |
|
| 428 | 542 | ||
| 429 | 543 | #[test] |
|
| 544 | + | fn checkpoint_payload_round_trip_and_replay() { |
|
| 545 | + | let mut st = State::new(); |
|
| 546 | + | let f = NodeId::random(); |
|
| 547 | + | let h1 = st.add(change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "a".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"1\n".to_vec(), b"2\n".to_vec()] }])).unwrap(); |
|
| 548 | + | let h2 = st.add(change(vec![h1], vec![], vec![Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] }])).unwrap(); |
|
| 549 | + | let payload = st.checkpoint_payload(); |
|
| 550 | + | let back = State::from_checkpoint_payload(&payload).unwrap(); |
|
| 551 | + | assert_eq!(back.len(), 2); |
|
| 552 | + | assert_eq!(back.heads(), vec![h2]); |
|
| 553 | + | assert_eq!(back.get(&h1).unwrap().message, "m"); |
|
| 554 | + | assert_eq!(back.checkpoint_payload(), payload, "deterministic"); |
|
| 555 | + | // Replaying a further change onto the restored state equals a full build. |
|
| 556 | + | let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"3\n".to_vec()] }]); |
|
| 557 | + | let mut full = st.clone(); |
|
| 558 | + | full.add(c3.clone()).unwrap(); |
|
| 559 | + | let mut restored = back; |
|
| 560 | + | restored.add(c3).unwrap(); |
|
| 561 | + | let (a, b) = (full.materialize(), restored.materialize()); |
|
| 562 | + | assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>()); |
|
| 563 | + | assert_eq!(a.files["a"].content, b.files["a"].content); |
|
| 564 | + | assert!(matches!(&b.files["a"].content, FileContent::Text { bytes, .. } if bytes == b"2\n3\n")); |
|
| 565 | + | assert!(State::from_checkpoint_payload(b"nonsense").is_err()); |
|
| 566 | + | } |
|
| 567 | + | ||
| 568 | + | #[test] |
|
| 430 | 569 | fn maximal_elements() { |
|
| 431 | 570 | let mut st = State::new(); |
|
| 432 | 571 | let h1 = st.add(change(vec![], vec![], vec![])).unwrap(); |
|
| … | 10 unchanged lines |
modified crates/oxy-core/src/tree.rs +139 −0
| … | 263 unchanged lines |
||
| 264 | 264 | } |
|
| 265 | 265 | } |
|
| 266 | 266 | ||
| 267 | + | impl TreeGraph { |
|
| 268 | + | /// Serialize for a checkpoint. |
|
| 269 | + | pub fn to_value(&self) -> crate::cbor::Value { |
|
| 270 | + | use crate::cbor::Value; |
|
| 271 | + | let opkey = |k: &OpKey| vec![Value::Bytes(k.0 .0.to_vec()), Value::Uint(k.1 as u64)]; |
|
| 272 | + | let deleters = |m: &HashMap<OpKey, BTreeSet<ChangeHash>>| { |
|
| 273 | + | let mut ks: Vec<&OpKey> = m.keys().collect(); |
|
| 274 | + | ks.sort(); |
|
| 275 | + | Value::Array( |
|
| 276 | + | ks.into_iter() |
|
| 277 | + | .map(|k| { |
|
| 278 | + | let mut v = opkey(k); |
|
| 279 | + | v.push(Value::Array(m[k].iter().map(|d| Value::Bytes(d.0.to_vec())).collect())); |
|
| 280 | + | Value::Array(v) |
|
| 281 | + | }) |
|
| 282 | + | .collect(), |
|
| 283 | + | ) |
|
| 284 | + | }; |
|
| 285 | + | let mut nodes: Vec<&NodeId> = self.kinds.keys().collect(); |
|
| 286 | + | nodes.sort(); |
|
| 287 | + | let mut pks: Vec<&OpKey> = self.placements.keys().collect(); |
|
| 288 | + | pks.sort(); |
|
| 289 | + | let mut mks: Vec<&OpKey> = self.modes.keys().collect(); |
|
| 290 | + | mks.sort(); |
|
| 291 | + | Value::map([ |
|
| 292 | + | ( |
|
| 293 | + | 0, |
|
| 294 | + | Value::Array( |
|
| 295 | + | nodes |
|
| 296 | + | .into_iter() |
|
| 297 | + | .map(|n| { |
|
| 298 | + | Value::Array(vec![ |
|
| 299 | + | Value::Bytes(n.0.to_vec()), |
|
| 300 | + | Value::Uint(self.kinds[n].tag()), |
|
| 301 | + | self.introduced.get(n).map(|h| Value::Bytes(h.0.to_vec())).unwrap_or(Value::Null), |
|
| 302 | + | ]) |
|
| 303 | + | }) |
|
| 304 | + | .collect(), |
|
| 305 | + | ), |
|
| 306 | + | ), |
|
| 307 | + | ( |
|
| 308 | + | 1, |
|
| 309 | + | Value::Array( |
|
| 310 | + | pks.into_iter() |
|
| 311 | + | .map(|k| { |
|
| 312 | + | let p = &self.placements[k]; |
|
| 313 | + | let mut v = opkey(k); |
|
| 314 | + | v.extend([Value::Bytes(p.node.0.to_vec()), Value::Bytes(p.parent.0.to_vec()), Value::Text(p.name.clone())]); |
|
| 315 | + | Value::Array(v) |
|
| 316 | + | }) |
|
| 317 | + | .collect(), |
|
| 318 | + | ), |
|
| 319 | + | ), |
|
| 320 | + | (2, deleters(&self.deleted_placements)), |
|
| 321 | + | ( |
|
| 322 | + | 3, |
|
| 323 | + | Value::Array( |
|
| 324 | + | mks.into_iter() |
|
| 325 | + | .map(|k| { |
|
| 326 | + | let (n, m) = &self.modes[k]; |
|
| 327 | + | let mut v = opkey(k); |
|
| 328 | + | v.extend([Value::Bytes(n.0.to_vec()), Value::Uint(m.bits())]); |
|
| 329 | + | Value::Array(v) |
|
| 330 | + | }) |
|
| 331 | + | .collect(), |
|
| 332 | + | ), |
|
| 333 | + | ), |
|
| 334 | + | (4, deleters(&self.deleted_modes)), |
|
| 335 | + | ]) |
|
| 336 | + | } |
|
| 337 | + | ||
| 338 | + | pub fn from_value(v: &crate::cbor::Value) -> Result<TreeGraph, crate::objects::ObjectError> { |
|
| 339 | + | use crate::objects::{array, bytes, field, map, oref, text, u32_, uint, ObjectError}; |
|
| 340 | + | let m = map(v, "tree graph")?; |
|
| 341 | + | let node = |v: &crate::cbor::Value| NodeId::from_slice(bytes(v, "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into())); |
|
| 342 | + | let opkey = |e: &[crate::cbor::Value]| -> Result<OpKey, ObjectError> { Ok((oref(&e[0], "op change")?, u32_(&e[1], "op index")?)) }; |
|
| 343 | + | let mut g = TreeGraph::default(); |
|
| 344 | + | for n in array(field(m, 0, "nodes")?, "nodes")? { |
|
| 345 | + | let e = array(n, "node entry")?; |
|
| 346 | + | if e.len() != 3 { |
|
| 347 | + | return Err(ObjectError::Schema("node entry: expected 3 fields".into())); |
|
| 348 | + | } |
|
| 349 | + | let id = node(&e[0])?; |
|
| 350 | + | g.kinds.insert(id, NodeKind::from_tag(uint(&e[1], "kind")?)?); |
|
| 351 | + | if !e[2].is_null() { |
|
| 352 | + | g.introduced.insert(id, oref(&e[2], "introducer")?); |
|
| 353 | + | } |
|
| 354 | + | } |
|
| 355 | + | for p in array(field(m, 1, "placements")?, "placements")? { |
|
| 356 | + | let e = array(p, "placement")?; |
|
| 357 | + | if e.len() != 5 { |
|
| 358 | + | return Err(ObjectError::Schema("placement: expected 5 fields".into())); |
|
| 359 | + | } |
|
| 360 | + | let k = opkey(e)?; |
|
| 361 | + | let pl = Placement { node: node(&e[2])?, parent: node(&e[3])?, name: text(&e[4], "name")?.to_owned() }; |
|
| 362 | + | g.placements_of.entry(pl.node).or_default().insert(k); |
|
| 363 | + | g.placements.insert(k, pl); |
|
| 364 | + | } |
|
| 365 | + | let deleters = |v: &crate::cbor::Value, what: &str| -> Result<HashMap<OpKey, BTreeSet<ChangeHash>>, ObjectError> { |
|
| 366 | + | let mut out = HashMap::new(); |
|
| 367 | + | for d in array(v, what)? { |
|
| 368 | + | let e = array(d, what)?; |
|
| 369 | + | if e.len() != 3 { |
|
| 370 | + | return Err(ObjectError::Schema(format!("{what}: expected 3 fields"))); |
|
| 371 | + | } |
|
| 372 | + | out.insert(opkey(e)?, array(&e[2], "deleters")?.iter().map(|x| oref(x, "deleter")).collect::<Result<BTreeSet<_>, _>>()?); |
|
| 373 | + | } |
|
| 374 | + | Ok(out) |
|
| 375 | + | }; |
|
| 376 | + | g.deleted_placements = deleters(field(m, 2, "deleted placements")?, "deleted placement")?; |
|
| 377 | + | for md in array(field(m, 3, "modes")?, "modes")? { |
|
| 378 | + | let e = array(md, "mode")?; |
|
| 379 | + | if e.len() != 4 { |
|
| 380 | + | return Err(ObjectError::Schema("mode: expected 4 fields".into())); |
|
| 381 | + | } |
|
| 382 | + | let k = opkey(e)?; |
|
| 383 | + | let n = node(&e[2])?; |
|
| 384 | + | g.modes.insert(k, (n, Mode::from_bits(uint(&e[3], "mode bits")?)?)); |
|
| 385 | + | g.modes_of.entry(n).or_default().insert(k); |
|
| 386 | + | } |
|
| 387 | + | g.deleted_modes = deleters(field(m, 4, "deleted modes")?, "deleted mode")?; |
|
| 388 | + | Ok(g) |
|
| 389 | + | } |
|
| 390 | + | } |
|
| 391 | + | ||
| 267 | 392 | pub fn op_key(r: OpRef) -> OpKey { |
|
| 268 | 393 | (r.change, r.index) |
|
| 269 | 394 | } |
|
| … | 81 unchanged lines |
||
| 351 | 476 | } |
|
| 352 | 477 | ||
| 353 | 478 | #[test] |
|
| 479 | + | fn serialization_round_trip() { |
|
| 480 | + | let mut g = TreeGraph::new(); |
|
| 481 | + | add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "src"); |
|
| 482 | + | add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "main.rs"); |
|
| 483 | + | g.apply(h(2), 0, &TreeOp::Move { node: nid(2), old: OpRef { change: h(1), index: 1 }, parent: nid(1), name: "lib.rs".into() }); |
|
| 484 | + | g.apply(h(3), 0, &TreeOp::SetMode { node: nid(2), old: OpRef { change: h(1), index: 1 }, mode: Mode { exec: true, opaque: false } }); |
|
| 485 | + | let bytes = crate::cbor::encode(&g.to_value()); |
|
| 486 | + | let back = TreeGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap(); |
|
| 487 | + | assert_eq!(back.view(), g.view()); |
|
| 488 | + | assert_eq!(back.introducer(&nid(2)), Some(h(1))); |
|
| 489 | + | assert_eq!(crate::cbor::encode(&back.to_value()), bytes); |
|
| 490 | + | } |
|
| 491 | + | ||
| 492 | + | #[test] |
|
| 354 | 493 | fn mode_conflict_and_resolution() { |
|
| 355 | 494 | let mut g = TreeGraph::new(); |
|
| 356 | 495 | add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "run.sh"); |
|
| … | 16 unchanged lines |
modified src/cli/mod.rs +4 −1
| … | 62 unchanged lines |
||
| 63 | 63 | Resolve, |
|
| 64 | 64 | /// Undo the last operation |
|
| 65 | 65 | Undo, |
|
| 66 | + | /// Write a checkpoint of the current bookmark's state now |
|
| 67 | + | Checkpoint, |
|
| 66 | 68 | /// Pack loose objects and prune unreachable ones older than the grace period |
|
| 67 | 69 | Gc { |
|
| 68 | 70 | /// Ignore the grace period (core.gc-grace) and prune immediately |
|
| … | 56 unchanged lines |
||
| 125 | 127 | #[command(subcommand)] |
|
| 126 | 128 | cmd: PolicyCmd, |
|
| 127 | 129 | }, |
|
| 128 | - | /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 130 | + | /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 129 | 131 | Config { |
|
| 130 | 132 | key: String, |
|
| 131 | 133 | value: String, |
|
| … | 85 unchanged lines |
||
| 217 | 219 | Cmd::Resolve => workspace::resolve(), |
|
| 218 | 220 | Cmd::Undo => workspace::undo(), |
|
| 219 | 221 | Cmd::Gc { now } => workspace::gc(now), |
|
| 222 | + | Cmd::Checkpoint => workspace::checkpoint(), |
|
| 220 | 223 | Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(), |
|
| 221 | 224 | Cmd::Clone { url, dest } => sync::clone(&url, dest), |
|
| 222 | 225 | Cmd::Remote { cmd } => match cmd { |
|
| … | 25 unchanged lines |
modified src/cli/workspace.rs +7 −0
| … | 243 unchanged lines |
||
| 244 | 244 | println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12)); |
|
| 245 | 245 | Ok(0) |
|
| 246 | 246 | } |
|
| 247 | + | ||
| 248 | + | pub fn checkpoint() -> Result<i32> { |
|
| 249 | + | let repo = open()?; |
|
| 250 | + | let (r, changes, bytes) = repo.checkpoint()?; |
|
| 251 | + | println!("Checkpoint #{} of {} ({} bytes) written for {}.", r.short(12), plural(changes, "change"), bytes, repo.config.bookmark); |
|
| 252 | + | Ok(0) |
|
| 253 | + | } |