gabriel / muse public
test_phase7_bundle_atomicity.py python
212 lines 7.6 KB
Raw
sha256:8860dea10c653956b613a814cc752a6d34cb3986cdf16749a49172affdabf045 fix tests Human minor ⚠ breaking 15 days ago
1 """Phase 7 — MPack atomicity: objects before refs, topological commit order.
2
3 Invariants:
4 1. apply_mpack writes objects → snapshots → commits → (caller advances refs).
5 A crash after objects but before refs leaves reachable but ref-less objects
6 (safe — GC-able). A crash after refs but before objects leaves a ref
7 pointing to a commit whose snapshot has no objects (broken checkout).
8 The safe order is already enforced by apply_mpack: objects first, refs last.
9
10 2. Commits in a mpack may arrive newest-first (BFS order). Phase 2's
11 MissingParentError guard rejects a commit whose parent hasn't been written
12 yet. apply_mpack must retry deferred commits until all parents in the
13 mpack are resolved, or give up if a parent is genuinely absent.
14
15 Testing tiers
16 -------------
17 Unit apply_mpack handles newest-first commit ordering without error
18 Unit apply_mpack retries and eventually writes all commits when parents
19 arrive after children in the mpack
20 Unit apply_mpack logs and skips commits with truly absent parents
21 (not in mpack, not in store)
22 Integration mpack create → unbundle round-trip writes all commits to store
23 Data after unbundle, every commit in the mpack is readable from store
24 """
25
26 from __future__ import annotations
27
28 import datetime
29 import pathlib
30
31 import msgpack
32 import pytest
33
34 from muse.core.mpack import apply_mpack, MPack
35 from muse.core.commits import (
36 CommitRecord,
37 commit_exists,
38 read_commit,
39 write_commit,
40 )
41 from muse.core.snapshots import (
42 SnapshotRecord,
43 write_snapshot,
44 )
45 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
46 from muse.core.types import fake_id, long_id
47
48
49 # ---------------------------------------------------------------------------
50 # Helpers
51 # ---------------------------------------------------------------------------
52
53 _REPO_ID = "repo-phase7-test"
54 _BRANCH = "main"
55
56
57
58 def _make_real_commit(
59 repo: pathlib.Path,
60 tag: str,
61 parent_id: str | None,
62 content: str = "hello",
63 ) -> CommitRecord:
64 """Write a fully content-addressed commit to *repo* and return it."""
65 manifest = {"file.txt": fake_id(f"obj-{content}")}
66 dirs: dict[str, list[str]] = {}
67 snap_id = compute_snapshot_id(manifest, dirs)
68 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest, directories=dirs))
69
70 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
71 commit_id = compute_commit_id(
72 parent_ids=[parent_id] if parent_id else [],
73 snapshot_id=snap_id,
74 message=tag,
75 committed_at_iso=committed_at.isoformat(),
76 author="gabriel",
77 )
78 rec = CommitRecord(
79 commit_id=commit_id,
80 branch=_BRANCH,
81 snapshot_id=snap_id,
82 message=tag,
83 committed_at=committed_at,
84 parent_commit_id=parent_id,
85 author="gabriel",
86 )
87 write_commit(repo, rec)
88 return rec
89
90
91 # ---------------------------------------------------------------------------
92 # Unit — topological retry: newest-first ordering succeeds
93 # ---------------------------------------------------------------------------
94
95 class TestTopologicalRetry:
96 def test_newest_first_ordering_writes_all_commits(self, tmp_path: pathlib.Path) -> None:
97 """apply_mpack must succeed even when commits arrive newest-first."""
98 root_commit = _make_real_commit(tmp_path, "root", None)
99 child_commit = _make_real_commit(tmp_path, "child", root_commit.commit_id)
100 grandchild = _make_real_commit(tmp_path, "grandchild", child_commit.commit_id)
101
102 # Fresh repo — no commits yet
103 dest = tmp_path / "dest"
104 dest.mkdir()
105
106 # MPack commits in newest-first order (BFS from tip)
107 mpack: MPack = {
108 "blobs": [],
109 "snapshots": [
110 grandchild.__class__.from_dict # not used
111 ] if False else [],
112 "commits": [
113 grandchild.to_dict(), # newest — parent not written yet
114 child_commit.to_dict(), # middle
115 root_commit.to_dict(), # oldest — no parent
116 ],
117 }
118
119 result = apply_mpack(dest, mpack)
120
121 assert result["commits_written"] == 3, (
122 f"Expected 3 commits written, got {result['commits_written']}. "
123 "apply_mpack may not be retrying MissingParentError commits."
124 )
125 assert commit_exists(dest, root_commit.commit_id)
126 assert commit_exists(dest, child_commit.commit_id)
127 assert commit_exists(dest, grandchild.commit_id)
128
129 def test_correct_order_still_works(self, tmp_path: pathlib.Path) -> None:
130 """Oldest-first ordering (already correct) must still succeed."""
131 root_commit = _make_real_commit(tmp_path, "root2", None)
132 child_commit = _make_real_commit(tmp_path, "child2", root_commit.commit_id)
133
134 dest = tmp_path / "dest2"
135 dest.mkdir()
136
137 mpack: MPack = {
138 "blobs": [],
139 "snapshots": [],
140 "commits": [
141 root_commit.to_dict(),
142 child_commit.to_dict(),
143 ],
144 }
145
146 result = apply_mpack(dest, mpack)
147 assert result["commits_written"] == 2
148
149 def test_absent_parent_skipped_gracefully(self, tmp_path: pathlib.Path) -> None:
150 """A commit whose parent is not in the mpack or store must be skipped,
151 not crash apply_mpack."""
152 root_commit = _make_real_commit(tmp_path, "root3", None)
153 child_commit = _make_real_commit(tmp_path, "child3", root_commit.commit_id)
154
155 dest = tmp_path / "dest3"
156 dest.mkdir()
157
158 # MPack only has the child — root is absent
159 mpack: MPack = {
160 "blobs": [],
161 "snapshots": [],
162 "commits": [
163 child_commit.to_dict(), # parent (root) not in mpack or dest
164 ],
165 }
166
167 # Should not raise — should log and skip
168 result = apply_mpack(dest, mpack)
169
170 assert result["commits_written"] == 0, (
171 "commit with missing parent should have been skipped"
172 )
173 assert not commit_exists(dest, child_commit.commit_id), (
174 "commit with missing parent was written despite dangling parent"
175 )
176
177
178 # ---------------------------------------------------------------------------
179 # Data — objects present after unbundle before refs are advanced
180 # ---------------------------------------------------------------------------
181
182 class TestObjectsBeforeRefs:
183 def test_apply_mpack_writes_commits_before_caller_advances_refs(
184 self, tmp_path: pathlib.Path
185 ) -> None:
186 """apply_mpack (object writes) completes before write_branch_ref is called.
187
188 This is verified structurally: apply_mpack returns successfully before
189 the caller's write_branch_ref call. If objects were not yet written
190 at the time refs were advanced, a checkout immediately after would fail.
191 """
192 root_commit = _make_real_commit(tmp_path, "root4", None)
193
194 dest = tmp_path / "dest4"
195 dest.mkdir()
196
197 mpack: MPack = {
198 "blobs": [],
199 "snapshots": [],
200 "commits": [root_commit.to_dict()],
201 }
202
203 # apply_mpack returns — at this point commits are durable
204 result = apply_mpack(dest, mpack)
205 assert result["commits_written"] == 1
206
207 # read_commit must work immediately — no ref advancement needed
208 read_back = read_commit(dest, root_commit.commit_id)
209 assert read_back is not None, (
210 "commit not readable after apply_mpack — write did not complete"
211 )
212 assert read_back.commit_id == root_commit.commit_id
File History 1 commit