OXYGENHUB

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(&current, 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