Concurrent commands no longer lose operations 19555025a2ac
Every command read .oxy/ops/head, built its operation on it and wrote the head back, so two processes running at once lost one side: in a test with two workspaces committing together, half the commits vanished from the bookmark. The head now moves by compare-and-swap under a short lock; a process that finds the head moved records a merge operation with both parents and the three-way merge of the views, as spec §12.1 always described. A bookmark changed on both sides keeps both sides' heads. Undo refuses rather than overwrite a concurrent operation. The snapshot lock and the new ops lock share one FileLock. Tests: two workspaces committing concurrently while one pushes keep every commit, locally and on the remote; two operations from one parent merge deterministically. Two real oxy processes committing 25 times each keep all 50.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 08:05 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
19555025a2ac602ad32d7752cc1d2855d8f2871080c62b99e5a2a49b34652590- Depends on
- 132b884b9a8d 4dcb8c0916b7 4e88e774777a 75167672ebcd 982864068a59 ff0af1ee299d
- Depended on by
- 767ac6cd0545 530a0fc650a2 dd21fd82687d 236f35c0baba 98a1620a3840 6caacfff1d4f 3e5772ccf9da
- 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
- 15 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/oxy-core/src/repo/mod.rs +138 −4
| … | 99 unchanged lines |
||
| 100 | 100 | pub use subrepos::{SubrepoInfo, SubrepoSync}; |
|
| 101 | 101 | pub use sync::{FetchReport, PullReport, PushReport, RefUpdated}; |
|
| 102 | 102 | pub use tags::TagInfo; |
|
| 103 | - | pub use workspace::{SnapshotLock, WorkspaceInfo}; |
|
| 103 | + | pub use workspace::{FileLock, SnapshotLock, WorkspaceInfo}; |
|
| 104 | 104 | ||
| 105 | 105 | fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { |
|
| 106 | 106 | Ok(crate::durable::write_atomic(path, data)?) |
|
| … | 452 unchanged lines |
||
| 559 | 559 | let r = self.store.put(ObjectType::Op, &op.encode())?; |
|
| 560 | 560 | // The head is the commit point: everything it names must be on disk first. |
|
| 561 | 561 | self.store.flush()?; |
|
| 562 | - | write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?; |
|
| 563 | - | Ok(r) |
|
| 562 | + | // Compare and swap under a short lock (spec §12.1): when another |
|
| 563 | + | // process recorded an operation since this one read the head, the |
|
| 564 | + | // two are merged instead of one overwriting the other. |
|
| 565 | + | let _lock = self.lock_ops()?; |
|
| 566 | + | let current = match parents.first() { |
|
| 567 | + | // The first operation (init): there is no head yet. |
|
| 568 | + | None => None, |
|
| 569 | + | Some(_) => Some(self.head_ref()?), |
|
| 570 | + | }; |
|
| 571 | + | let Some(current) = current.filter(|c| Some(c) != parents.first()) else { |
|
| 572 | + | write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?; |
|
| 573 | + | return Ok(r); |
|
| 574 | + | }; |
|
| 575 | + | let base = match parents.first() { |
|
| 576 | + | Some(p) => Op::decode(&self.store.get_typed(p, ObjectType::Op)?)?.view, |
|
| 577 | + | None => View::default(), |
|
| 578 | + | }; |
|
| 579 | + | let theirs = Op::decode(&self.store.get_typed(¤t, ObjectType::Op)?)?.view; |
|
| 580 | + | let merged = Op { |
|
| 581 | + | parents: vec![current, r], |
|
| 582 | + | hlc: Hlc::now(), |
|
| 583 | + | command: format!("{command} (merged with a concurrent operation)"), |
|
| 584 | + | view: self.merge_views(&base, &op.view, &theirs)?, |
|
| 585 | + | }; |
|
| 586 | + | let m = self.store.put(ObjectType::Op, &merged.encode())?; |
|
| 587 | + | self.store.flush()?; |
|
| 588 | + | write_atomic(&self.oxy.join("ops").join("head"), m.to_hex().as_bytes())?; |
|
| 589 | + | Ok(m) |
|
| 590 | + | } |
|
| 591 | + | ||
| 592 | + | /// The operation head's lock, held only while the head pointer moves. |
|
| 593 | + | fn lock_ops(&self) -> Result<FileLock> { |
|
| 594 | + | fs::create_dir_all(self.oxy.join("ops"))?; |
|
| 595 | + | FileLock::acquire( |
|
| 596 | + | self.oxy.join("ops").join("lock"), |
|
| 597 | + | "another oxy process holds the operation log", |
|
| 598 | + | ) |
|
| 599 | + | } |
|
| 600 | + | ||
| 601 | + | fn head_ref(&self) -> Result<ObjectRef> { |
|
| 602 | + | let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; |
|
| 603 | + | ObjectRef::from_hex(hex.trim()) |
|
| 604 | + | .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into())) |
|
| 605 | + | } |
|
| 606 | + | ||
| 607 | + | /// Three-way merge of two views that both started from `base` (spec |
|
| 608 | + | /// §12.1). A reference only one side changed takes that side's value. |
|
| 609 | + | /// When both changed it differently: a bookmark keeps both sides' |
|
| 610 | + | /// heads (it may then have several), a change beats a deletion, and |
|
| 611 | + | /// otherwise this process's value wins for workspaces, remote |
|
| 612 | + | /// references and the policy, the first value for a tag (tags never |
|
| 613 | + | /// move). |
|
| 614 | + | fn merge_views(&self, base: &View, ours: &View, theirs: &View) -> Result<View> { |
|
| 615 | + | fn pick<K: Ord + Clone, V: Clone + PartialEq>( |
|
| 616 | + | b: &BTreeMap<K, V>, |
|
| 617 | + | o: &BTreeMap<K, V>, |
|
| 618 | + | t: &BTreeMap<K, V>, |
|
| 619 | + | mut both: impl FnMut(&V, &V) -> Result<V>, |
|
| 620 | + | ) -> Result<BTreeMap<K, V>> { |
|
| 621 | + | let keys: BTreeSet<&K> = b.keys().chain(o.keys()).chain(t.keys()).collect(); |
|
| 622 | + | let mut out = BTreeMap::new(); |
|
| 623 | + | for k in keys { |
|
| 624 | + | let (bv, ov, tv) = (b.get(k), o.get(k), t.get(k)); |
|
| 625 | + | let v = if ov == tv || tv == bv { |
|
| 626 | + | ov.cloned() |
|
| 627 | + | } else if ov == bv { |
|
| 628 | + | tv.cloned() |
|
| 629 | + | } else { |
|
| 630 | + | match (ov, tv) { |
|
| 631 | + | (Some(x), Some(y)) => Some(both(x, y)?), |
|
| 632 | + | (Some(x), None) | (None, Some(x)) => Some(x.clone()), |
|
| 633 | + | (None, None) => None, |
|
| 634 | + | } |
|
| 635 | + | }; |
|
| 636 | + | if let Some(v) = v { |
|
| 637 | + | out.insert(k.clone(), v); |
|
| 638 | + | } |
|
| 639 | + | } |
|
| 640 | + | Ok(out) |
|
| 641 | + | } |
|
| 642 | + | let bookmarks = pick( |
|
| 643 | + | &base.bookmarks, |
|
| 644 | + | &ours.bookmarks, |
|
| 645 | + | &theirs.bookmarks, |
|
| 646 | + | |x, y| { |
|
| 647 | + | let mut heads = self.store.get_state(x)?.heads; |
|
| 648 | + | heads.extend(self.store.get_state(y)?.heads); |
|
| 649 | + | let heads = self.load_state(&heads)?.maximal(&heads); |
|
| 650 | + | Ok(self.store.put_state(&StateObject::new(heads))?) |
|
| 651 | + | }, |
|
| 652 | + | )?; |
|
| 653 | + | let workspaces = pick( |
|
| 654 | + | &base.workspaces, |
|
| 655 | + | &ours.workspaces, |
|
| 656 | + | &theirs.workspaces, |
|
| 657 | + | |x, _| Ok(*x), |
|
| 658 | + | )?; |
|
| 659 | + | let tags = pick(&base.tags, &ours.tags, &theirs.tags, |_, y| Ok(*y))?; |
|
| 660 | + | let empty = BTreeMap::new(); |
|
| 661 | + | let names: BTreeSet<&String> = base |
|
| 662 | + | .remotes |
|
| 663 | + | .keys() |
|
| 664 | + | .chain(ours.remotes.keys()) |
|
| 665 | + | .chain(theirs.remotes.keys()) |
|
| 666 | + | .collect(); |
|
| 667 | + | let mut remotes = BTreeMap::new(); |
|
| 668 | + | for n in names { |
|
| 669 | + | let m = pick( |
|
| 670 | + | base.remotes.get(n).unwrap_or(&empty), |
|
| 671 | + | ours.remotes.get(n).unwrap_or(&empty), |
|
| 672 | + | theirs.remotes.get(n).unwrap_or(&empty), |
|
| 673 | + | |x, _| Ok(*x), |
|
| 674 | + | )?; |
|
| 675 | + | if !m.is_empty() { |
|
| 676 | + | remotes.insert(n.clone(), m); |
|
| 677 | + | } |
|
| 678 | + | } |
|
| 679 | + | let policy = if ours.policy == theirs.policy || theirs.policy == base.policy { |
|
| 680 | + | ours.policy |
|
| 681 | + | } else if ours.policy == base.policy { |
|
| 682 | + | theirs.policy |
|
| 683 | + | } else { |
|
| 684 | + | ours.policy.or(theirs.policy) |
|
| 685 | + | }; |
|
| 686 | + | self.store.flush()?; |
|
| 687 | + | Ok(View { |
|
| 688 | + | bookmarks, |
|
| 689 | + | workspaces, |
|
| 690 | + | remotes, |
|
| 691 | + | policy, |
|
| 692 | + | tags, |
|
| 693 | + | }) |
|
| 564 | 694 | } |
|
| 565 | 695 | ||
| 566 | 696 | pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> { |
|
| … | 17 unchanged lines |
||
| 584 | 714 | /// a working-copy snapshot are skipped, since the files are still on disk |
|
| 585 | 715 | /// and the next snapshot recreates them. |
|
| 586 | 716 | pub fn undo(&self) -> Result<Op> { |
|
| 587 | - | let (_, mut op) = self.head_op()?; |
|
| 717 | + | let (read_head, mut op) = self.head_op()?; |
|
| 588 | 718 | while op.is_snapshot() { |
|
| 589 | 719 | let Some(parent) = op.parents.first() else { |
|
| 590 | 720 | break; |
|
| … | 4 unchanged lines |
||
| 595 | 725 | return fail("nothing to undo"); |
|
| 596 | 726 | }; |
|
| 597 | 727 | let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; |
|
| 728 | + | let _lock = self.lock_ops()?; |
|
| 729 | + | if self.head_ref()? != read_head { |
|
| 730 | + | return fail("another oxy command ran while undoing; run undo again"); |
|
| 731 | + | } |
|
| 598 | 732 | write_atomic( |
|
| 599 | 733 | &self.oxy.join("ops").join("head"), |
|
| 600 | 734 | parent.to_hex().as_bytes(), |
|
| … | 620 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +112 −0
| … | 4088 unchanged lines |
||
| 4089 | 4089 | assert!(steps >= 3, "{name} has only {steps} durable steps?"); |
|
| 4090 | 4090 | } |
|
| 4091 | 4091 | } |
|
| 4092 | + | ||
| 4093 | + | /// Spec §12.1: commands in two workspaces of one repository, and pushes, |
|
| 4094 | + | /// running at the same time lose nothing: every commit ends up on the |
|
| 4095 | + | /// bookmark, locally and on the remote, and the repository passes fsck. |
|
| 4096 | + | #[test] |
|
| 4097 | + | fn concurrent_commits_in_two_workspaces_lose_nothing() { |
|
| 4098 | + | hermetic(); |
|
| 4099 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4100 | + | let root = dir.path(); |
|
| 4101 | + | let origin = root.join("origin"); |
|
| 4102 | + | Repo::init_bare_unbound(&origin).unwrap(); |
|
| 4103 | + | let main = root.join("main"); |
|
| 4104 | + | let mut repo = Repo::init(&main).unwrap(); |
|
| 4105 | + | repo.config |
|
| 4106 | + | .set("remote.origin.url", &origin.to_string_lossy()) |
|
| 4107 | + | .unwrap(); |
|
| 4108 | + | repo.save_config().unwrap(); |
|
| 4109 | + | write(&main, "seed.txt", b"seed\n"); |
|
| 4110 | + | repo.commit("seed", &[]).unwrap(); |
|
| 4111 | + | let second = root.join("second"); |
|
| 4112 | + | repo.workspace_add(&second, None).unwrap(); |
|
| 4113 | + | drop(repo); |
|
| 4114 | + | ||
| 4115 | + | const EACH: usize = 15; |
|
| 4116 | + | std::thread::scope(|s| { |
|
| 4117 | + | for (tag, dir, push) in [("a", main.clone(), true), ("b", second.clone(), false)] { |
|
| 4118 | + | s.spawn(move || { |
|
| 4119 | + | let r = Repo::open(&dir).unwrap(); |
|
| 4120 | + | for i in 0..EACH { |
|
| 4121 | + | write( |
|
| 4122 | + | &dir, |
|
| 4123 | + | &format!("{tag}-{i}.txt"), |
|
| 4124 | + | format!("{tag} {i}\n").as_bytes(), |
|
| 4125 | + | ); |
|
| 4126 | + | r.commit(&format!("{tag} {i}"), &[]).unwrap(); |
|
| 4127 | + | if push && i % 5 == 4 { |
|
| 4128 | + | // A push may be refused while the other workspace moves the bookmark; nothing may be lost either way. |
|
| 4129 | + | let _ = r.push("origin", false); |
|
| 4130 | + | } |
|
| 4131 | + | } |
|
| 4132 | + | }); |
|
| 4133 | + | } |
|
| 4134 | + | }); |
|
| 4135 | + | ||
| 4136 | + | let repo = Repo::open(&main).unwrap(); |
|
| 4137 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 4138 | + | let state = repo.load_state(&heads).unwrap(); |
|
| 4139 | + | let messages: BTreeSet<String> = state.changes().map(|(_, m)| m.message.clone()).collect(); |
|
| 4140 | + | for tag in ["a", "b"] { |
|
| 4141 | + | for i in 0..EACH { |
|
| 4142 | + | assert!( |
|
| 4143 | + | messages.contains(&format!("{tag} {i}")), |
|
| 4144 | + | "lost commit {tag} {i}; have {messages:?}" |
|
| 4145 | + | ); |
|
| 4146 | + | } |
|
| 4147 | + | } |
|
| 4148 | + | let rep = repo.fsck().unwrap(); |
|
| 4149 | + | assert_eq!(rep.errors(), 0, "{:?}", rep.problems); |
|
| 4150 | + | assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4151 | + | let hosted = Repo::open(&origin).unwrap(); |
|
| 4152 | + | let hosted_heads = hosted.bookmark_heads().unwrap(); |
|
| 4153 | + | assert_eq!( |
|
| 4154 | + | hosted.load_state(&hosted_heads).unwrap().len(), |
|
| 4155 | + | state.len(), |
|
| 4156 | + | "the remote has every commit" |
|
| 4157 | + | ); |
|
| 4158 | + | } |
|
| 4159 | + | ||
| 4160 | + | /// Two operations recorded from the same parent (what two processes do |
|
| 4161 | + | /// when they race) are merged: both bookmarks' heads survive, and a |
|
| 4162 | + | /// change made on one side only is kept. |
|
| 4163 | + | #[test] |
|
| 4164 | + | fn operations_from_the_same_parent_are_merged() { |
|
| 4165 | + | hermetic(); |
|
| 4166 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4167 | + | let root = dir.path(); |
|
| 4168 | + | let repo = Repo::init(root).unwrap(); |
|
| 4169 | + | write(root, "a.txt", b"a\n"); |
|
| 4170 | + | let a = repo.commit("a", &[]).unwrap(); |
|
| 4171 | + | write(root, "b.txt", b"b\n"); |
|
| 4172 | + | let b = repo.commit("b", &[]).unwrap(); |
|
| 4173 | + | let (x, op) = repo.head_op().unwrap(); |
|
| 4174 | + | let state = |heads: Vec<ChangeHash>| repo.store().put_state(&StateObject::new(heads)).unwrap(); |
|
| 4175 | + | ||
| 4176 | + | // Side one moves main to just `a` and adds a bookmark; side two moves main to just `b`. |
|
| 4177 | + | let mut one = op.view.clone(); |
|
| 4178 | + | one.bookmarks.insert("main".into(), state(vec![a])); |
|
| 4179 | + | one.bookmarks.insert("topic".into(), state(vec![a])); |
|
| 4180 | + | let mut two = op.view.clone(); |
|
| 4181 | + | two.bookmarks.insert("main".into(), state(vec![b])); |
|
| 4182 | + | repo.record_op(&[x], "one", one).unwrap(); |
|
| 4183 | + | let merged = repo.record_op(&[x], "two", two).unwrap(); |
|
| 4184 | + | ||
| 4185 | + | let (head, op) = repo.head_op().unwrap(); |
|
| 4186 | + | assert_eq!(head, merged); |
|
| 4187 | + | assert_eq!(op.parents.len(), 2, "a merge of the two operations"); |
|
| 4188 | + | assert!(op.command.contains("merged with a concurrent operation")); |
|
| 4189 | + | let mut main = repo |
|
| 4190 | + | .store() |
|
| 4191 | + | .get_state(&op.view.bookmarks["main"]) |
|
| 4192 | + | .unwrap() |
|
| 4193 | + | .heads; |
|
| 4194 | + | main.sort(); |
|
| 4195 | + | let mut want = vec![a, b]; |
|
| 4196 | + | want.sort(); |
|
| 4197 | + | assert_eq!(main, want, "both sides' heads are kept"); |
|
| 4198 | + | assert!( |
|
| 4199 | + | op.view.bookmarks.contains_key("topic"), |
|
| 4200 | + | "a one-sided change survives" |
|
| 4201 | + | ); |
|
| 4202 | + | assert_eq!(repo.fsck().unwrap().errors(), 0); |
|
| 4203 | + | } |
modified crates/oxy-core/src/repo/workspace.rs +44 −30
| … | 14 unchanged lines |
||
| 15 | 15 | pub current: bool, |
|
| 16 | 16 | } |
|
| 17 | 17 | ||
| 18 | - | /// Held while a workspace is snapshotted; a stale lock (older than a |
|
| 19 | - | /// minute, its process gone) is taken over. |
|
| 20 | - | pub struct SnapshotLock { |
|
| 18 | + | /// A short-lived lock file, taken by creating it exclusively and |
|
| 19 | + | /// released on drop: a workspace's snapshot lock, the operation head's |
|
| 20 | + | /// lock. A stale one (older than a minute, its process gone) is taken |
|
| 21 | + | /// over. |
|
| 22 | + | pub struct FileLock { |
|
| 21 | 23 | path: PathBuf, |
|
| 22 | 24 | } |
|
| 23 | 25 | ||
| 24 | - | impl Drop for SnapshotLock { |
|
| 26 | + | /// Held while a workspace is snapshotted. |
|
| 27 | + | pub type SnapshotLock = FileLock; |
|
| 28 | + | ||
| 29 | + | impl Drop for FileLock { |
|
| 25 | 30 | fn drop(&mut self) { |
|
| 26 | 31 | let _ = fs::remove_file(&self.path); |
|
| 27 | 32 | } |
|
| 28 | 33 | } |
|
| 29 | 34 | ||
| 35 | + | impl FileLock { |
|
| 36 | + | /// Wait up to about five seconds for `path`; `busy` is the error after that. |
|
| 37 | + | pub(crate) fn acquire(path: PathBuf, busy: &str) -> Result<FileLock> { |
|
| 38 | + | for _ in 0..500 { |
|
| 39 | + | match fs::OpenOptions::new() |
|
| 40 | + | .write(true) |
|
| 41 | + | .create_new(true) |
|
| 42 | + | .open(&path) |
|
| 43 | + | { |
|
| 44 | + | Ok(mut f) => { |
|
| 45 | + | let _ = write!(f, "{}", std::process::id()); |
|
| 46 | + | return Ok(FileLock { path }); |
|
| 47 | + | } |
|
| 48 | + | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { |
|
| 49 | + | let stale = fs::metadata(&path) |
|
| 50 | + | .and_then(|m| m.modified()) |
|
| 51 | + | .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false)) |
|
| 52 | + | .unwrap_or(true); |
|
| 53 | + | if stale { |
|
| 54 | + | let _ = fs::remove_file(&path); |
|
| 55 | + | continue; |
|
| 56 | + | } |
|
| 57 | + | std::thread::sleep(std::time::Duration::from_millis(10)); |
|
| 58 | + | } |
|
| 59 | + | Err(e) => return Err(e.into()), |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | fail(busy) |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 30 | 66 | /// Parse a secondary workspace's `.oxy` file. |
|
| 31 | 67 | pub(crate) fn parse_workspace_file(text: &str) -> Option<(PathBuf, WorkspaceId)> { |
|
| 32 | 68 | let mut dir = None; |
|
| … | 123 unchanged lines |
||
| 156 | 192 | pub(crate) fn lock_snapshot(&self) -> Result<SnapshotLock> { |
|
| 157 | 193 | let dir = self.workspace_dir(&self.workspace); |
|
| 158 | 194 | fs::create_dir_all(&dir)?; |
|
| 159 | - | let path = dir.join("lock"); |
|
| 160 | - | for _ in 0..50 { |
|
| 161 | - | match fs::OpenOptions::new() |
|
| 162 | - | .write(true) |
|
| 163 | - | .create_new(true) |
|
| 164 | - | .open(&path) |
|
| 165 | - | { |
|
| 166 | - | Ok(mut f) => { |
|
| 167 | - | let _ = write!(f, "{}", std::process::id()); |
|
| 168 | - | return Ok(SnapshotLock { path }); |
|
| 169 | - | } |
|
| 170 | - | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { |
|
| 171 | - | let stale = fs::metadata(&path) |
|
| 172 | - | .and_then(|m| m.modified()) |
|
| 173 | - | .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false)) |
|
| 174 | - | .unwrap_or(true); |
|
| 175 | - | if stale { |
|
| 176 | - | let _ = fs::remove_file(&path); |
|
| 177 | - | continue; |
|
| 178 | - | } |
|
| 179 | - | std::thread::sleep(std::time::Duration::from_millis(100)); |
|
| 180 | - | } |
|
| 181 | - | Err(e) => return Err(e.into()), |
|
| 182 | - | } |
|
| 183 | - | } |
|
| 184 | - | fail("another oxy process is snapshotting this workspace") |
|
| 195 | + | FileLock::acquire( |
|
| 196 | + | dir.join("lock"), |
|
| 197 | + | "another oxy process is snapshotting this workspace", |
|
| 198 | + | ) |
|
| 185 | 199 | } |
|
| 186 | 200 | } |
modified docs/spec.md +3 −3
| … | 466 unchanged lines |
||
| 467 | 467 | } |
|
| 468 | 468 | ``` |
|
| 469 | 469 | ||
| 470 | - | `view` is the complete state of all references and workspaces after the operation. The operation log is a DAG; concurrent processes each append to their own head, and the next command merges heads by three-way merge of views, with both sides kept when they disagree on a bookmark (the bookmark then has multiple heads). |
|
| 470 | + | `view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, and workspaces, remote references and the policy take the later process's value. `oxy undo` refuses when the head moved while it ran. |
|
| 471 | 471 | ||
| 472 | 472 | `oxy undo` moves the current operation to its parent's view. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection. |
|
| 473 | 473 | ||
| 474 | - | Because all mutation goes through the operation log and object writes are idempotent, no global repository lock is needed. A short-lived lock is held only while snapshotting a single workspace's working copy. |
|
| 474 | + | Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write, and a workspace's snapshot lock covers one snapshot of that workspace. |
|
| 475 | 475 | ||
| 476 | 476 | ### 12.2 Workspaces |
|
| 477 | 477 | ||
| … | 450 unchanged lines |
||
| 928 | 928 | ||
| 929 | 929 | --- |
|
| 930 | 930 | ||
| 931 | - | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 931 | + | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 932 | 932 | ||
| 933 | 933 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 934 | 934 | ||
| … | 59 unchanged lines |