gabriel / musehub public
test_mwp1_generation_authority.py python
1,041 lines 39.0 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
1 """MWP-1 Phase 0 (RED) — commit-graph generation authority.
2
3 Sub-ticket: musehub#106 — https://staging.musehub.ai/gabriel/musehub/issues/106
4 Master: muse#58 — https://staging.musehub.ai/gabriel/muse/issues/58
5
6 Reproduces the clone-after-push staleness root cause (RC-1): both generation
7 computers fall back to ``generation 0`` when a commit's parent generation cannot
8 be resolved —
9
10 musehub_wire_push.py:926 (wire_push_unpack_mpack, step 9b)
11 musehub_wire_push.py:1188 (_build_commit_graph_from_raw)
12
13 both: ``gen = (max(parent_gens) + 1) if parent_gens else 0``
14
15 That conflates a genuine root commit (parent_ids == [], gen 0 correct) with a
16 commit whose parents exist but were not resolved (must be parent_gen + 1). A
17 gen-0 tip then poisons the generation-bounded range scan in ``_walk_commit_delta``
18 (``generation <= max_want_gen``), truncating the fetch walk and shipping a clone
19 that is missing the newest commits.
20
21 Expected Phase 0 status:
22 - MWP1_01 RED (currently yields generation 0 instead of 3)
23 - MWP1_02 GREEN (guard — genuine root must stay 0 through every later phase)
24 - MWP1_03 RED (push path yields generation 0 for the new tip)
25
26 The fix (Phases 1–2) makes generations authoritative by backfilling missing
27 parent generations from ``musehub_commits`` (the source-of-truth DAG).
28 """
29 from __future__ import annotations
30
31 import datetime
32 import logging
33 from unittest.mock import AsyncMock, MagicMock, patch
34
35 import pytest
36 from sqlalchemy import delete as _sa_delete, select
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from muse.core.ids import hash_snapshot
40 from muse.core.mpack import build_wire_mpack, parse_wire_mpack
41 from muse.core.types import blob_id
42 from musehub.core.genesis import compute_branch_id, compute_identity_id
43 from musehub.db.musehub_repo_models import (
44 MusehubBranch,
45 MusehubCommit,
46 MusehubCommitGraph,
47 )
48 from musehub.services.musehub_repository import create_repo
49 from musehub.services.musehub_wire_fetch import wire_fetch_mpack
50 from musehub.services.musehub_wire_push import (
51 _build_commit_graph_from_raw,
52 _resolve_generation_with_backfill,
53 check_commit_graph_invariant,
54 repair_corrupt_commit_generations,
55 wire_push_unpack_mpack,
56 )
57
58 _OWNER = "gabriel"
59 _IDENTITY_ID = compute_identity_id(b"gabriel")
60
61
62 # ---------------------------------------------------------------------------
63 # Helpers
64 # ---------------------------------------------------------------------------
65
66
67 def _cid(seed: str) -> str:
68 """Deterministic sha256: commit id from a seed string."""
69 return blob_id(f"mwp1-commit-{seed}".encode())
70
71
72 def _now() -> datetime.datetime:
73 return datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
74
75
76 def _raw_commit(
77 cid: str,
78 parent: str | None,
79 snap_id: str,
80 *,
81 branch: str = "main",
82 ) -> dict:
83 """A raw commit dict in the shape both generation computers consume."""
84 return {
85 "commit_id": cid,
86 "branch": branch,
87 "message": f"commit {cid[:12]}",
88 "author": _OWNER,
89 "committed_at": _now().isoformat(),
90 "parent_commit_id": parent,
91 "parent2_commit_id": None,
92 "snapshot_id": snap_id,
93 "agent_id": "",
94 "model_id": "",
95 "toolchain_id": "",
96 "sem_ver_bump": "none",
97 "breaking_changes": [],
98 "signature": "",
99 "signer_key_id": "",
100 "signer_public_key": "",
101 "prompt_hash": "",
102 }
103
104
105 async def _seed_commit_row(
106 session: AsyncSession,
107 cid: str,
108 parent_ids: list[str],
109 snap_id: str,
110 ) -> None:
111 """Insert a musehub_commits row (source-of-truth DAG) without a graph row."""
112 session.add(MusehubCommit(
113 commit_id=cid,
114 branch="main",
115 message=f"commit {cid[:12]}",
116 author=_OWNER,
117 timestamp=_now(),
118 parent_ids=parent_ids,
119 snapshot_id=snap_id,
120 ))
121 await session.flush()
122
123
124 async def _seed_graph_row(
125 session: AsyncSession,
126 cid: str,
127 parent_ids: list[str],
128 generation: int,
129 snap_id: str,
130 ) -> None:
131 session.add(MusehubCommitGraph(
132 commit_id=cid,
133 parent_ids=parent_ids,
134 generation=generation,
135 snapshot_id=snap_id,
136 created_at=_now(),
137 ))
138 await session.flush()
139
140
141 async def _graph_gen(session: AsyncSession, cid: str) -> int | None:
142 return (await session.execute(
143 select(MusehubCommitGraph.generation).where(MusehubCommitGraph.commit_id == cid)
144 )).scalar_one_or_none()
145
146
147 def _mock_backend(mpack_bytes: bytes) -> MagicMock:
148 backend = MagicMock()
149 backend.get_mpack = AsyncMock(return_value=mpack_bytes)
150 backend.put = AsyncMock(return_value=None)
151 backend.put_mpack = AsyncMock(return_value=None)
152 backend.quarantine_mpack = AsyncMock(return_value=None)
153 backend.presign_get = AsyncMock(return_value="")
154 backend.presign_mpack_get = AsyncMock(return_value="")
155 return backend
156
157
158 class _InMemBackend:
159 """In-memory backend for end-to-end push→fetch tests.
160
161 Pre-seed push mpacks with ``backend._mpacks[mpack_key] = mpack_bytes``
162 BEFORE calling ``wire_push_unpack_mpack``. The fetch path writes its
163 assembled mpack via ``put_mpack`` and the test reads it back directly.
164 """
165
166 def __init__(self) -> None:
167 self._mpacks: dict[str, bytes] = {}
168
169 async def put_mpack(self, mpack_id: str, data: bytes) -> None:
170 self._mpacks[mpack_id] = data
171
172 async def get_mpack(self, mpack_id: str) -> bytes | None:
173 return self._mpacks.get(mpack_id)
174
175 async def presign_mpack_get(self, mpack_id: str, ttl: int = 3600) -> str:
176 return f"inmem://{mpack_id}"
177
178 async def put(self, oid: str, data: bytes) -> str:
179 return oid
180
181 async def get(self, oid: str) -> bytes | None:
182 return None
183
184 async def presign_get(self, oid: str, ttl: int = 3600) -> str:
185 return f"inmem://{oid}"
186
187 async def quarantine_mpack(self, *args: object, **kwargs: object) -> None:
188 pass
189
190
191 # ---------------------------------------------------------------------------
192 # MWP1_01 — unresolved parent must get parent_gen + 1, never 0
193 # ---------------------------------------------------------------------------
194
195
196 @pytest.mark.asyncio
197 async def test_mwp1_01_unresolved_parent_gets_correct_generation(
198 db_session: AsyncSession,
199 ) -> None:
200 """RED: a child whose parent is absent from both the mpack and the commit
201 graph must receive ``parent_generation + 1`` — not 0.
202
203 Precondition: C1<-C2<-C3 exist in musehub_commits (the authoritative DAG)
204 but NONE of them have a musehub_commit_graph row. We then compute the graph
205 for [C4] alone (C4's parent C3 is the external, ungraphed parent).
206
207 True generations: C1=0, C2=1, C3=2, therefore C4 must be 3.
208 Buggy behaviour: db_parent_gens is empty -> C4 gets generation 0.
209 """
210 c1, c2, c3, c4 = _cid("01-c1"), _cid("01-c2"), _cid("01-c3"), _cid("01-c4")
211 s1, s2, s3, s4 = (hash_snapshot({"f.txt": blob_id(f"01-{n}".encode())})
212 for n in ("s1", "s2", "s3", "s4"))
213
214 await _seed_commit_row(db_session, c1, [], s1)
215 await _seed_commit_row(db_session, c2, [c1], s2)
216 await _seed_commit_row(db_session, c3, [c2], s3)
217
218 await _build_commit_graph_from_raw(db_session, [_raw_commit(c4, c3, s4)])
219 await db_session.flush()
220
221 gen_c4 = await _graph_gen(db_session, c4)
222 assert gen_c4 == 3, (
223 f"C4 must inherit parent_generation + 1 = 3 (C1=0,C2=1,C3=2,C4=3); "
224 f"got {gen_c4}. A gen-0 tip poisons the fetch range scan and ships a "
225 f"stale clone."
226 )
227
228
229 # ---------------------------------------------------------------------------
230 # MWP1_02 — genuine root commit stays generation 0 (guard, GREEN)
231 # ---------------------------------------------------------------------------
232
233
234 @pytest.mark.asyncio
235 async def test_mwp1_02_root_commit_generation_zero(
236 db_session: AsyncSession,
237 ) -> None:
238 """GREEN guard: a commit with no parents is a genuine root — generation 0.
239
240 Later phases must not over-correct the gen-0 fallback into breaking the
241 legitimate root case.
242 """
243 root = _cid("02-root")
244 s_root = hash_snapshot({"f.txt": blob_id(b"02-root")})
245
246 await _build_commit_graph_from_raw(db_session, [_raw_commit(root, None, s_root)])
247 await db_session.flush()
248
249 gen_root = await _graph_gen(db_session, root)
250 assert gen_root == 0, f"root commit (no parents) must be generation 0; got {gen_root}"
251
252
253 # ---------------------------------------------------------------------------
254 # MWP1_03 — push of a child of an ungraphed remote tip (push-path site)
255 # ---------------------------------------------------------------------------
256
257
258 @pytest.mark.asyncio
259 async def test_mwp1_03_push_child_of_ungraphed_remote_tip(
260 db_session: AsyncSession,
261 ) -> None:
262 """RED: pushing C4 (child of remote tip C3) when C3 has no graph row must
263 backfill C3's generation and assign C4 = C3 + 1.
264
265 This exercises the OTHER generation site — wire_push_unpack_mpack step 9b.
266
267 Precondition mirrors a real backfill gap:
268 - musehub_commits: C1<-C2<-C3 (authoritative DAG)
269 - musehub_commit_graph: C1=0, C2=1 only (C3 deliberately absent)
270 - branch main -> C3 (remote tip)
271 Push C4 (parent C3, in the 'have' set so excluded from the mpack).
272
273 True result: C3 backfilled to 2, C4 = 3.
274 Buggy result: external parent C3 not found in graph -> C4 gets generation 0.
275
276 force=True isolates step-9 generation logic from the FF/ancestor check, which
277 itself reads the commit graph we are deliberately leaving incomplete.
278 """
279 repo = await create_repo(
280 db_session,
281 name="mwp1-03",
282 owner=_OWNER,
283 owner_user_id=_IDENTITY_ID,
284 visibility="public",
285 initialize=False,
286 )
287 await db_session.commit()
288
289 c1, c2, c3, c4 = _cid("03-c1"), _cid("03-c2"), _cid("03-c3"), _cid("03-c4")
290 s1 = hash_snapshot({"f1.txt": blob_id(b"03-1")})
291 s2 = hash_snapshot({"f2.txt": blob_id(b"03-2")})
292 s3 = hash_snapshot({"f3.txt": blob_id(b"03-3")})
293
294 # Authoritative DAG: all three commits exist in musehub_commits.
295 await _seed_commit_row(db_session, c1, [], s1)
296 await _seed_commit_row(db_session, c2, [c1], s2)
297 await _seed_commit_row(db_session, c3, [c2], s3)
298 # Graph has C1, C2 — but NOT C3 (the backfill gap).
299 await _seed_graph_row(db_session, c1, [], 0, s1)
300 await _seed_graph_row(db_session, c2, [c1], 1, s2)
301 # Branch tip is C3.
302 db_session.add(MusehubBranch(
303 branch_id=compute_branch_id(repo.repo_id, "main"),
304 repo_id=repo.repo_id,
305 name="main",
306 head_commit_id=c3,
307 ))
308 await db_session.commit()
309
310 # Build a MUSE-binary mpack carrying ONLY C4 (its parent C3 is external).
311 oid4 = blob_id(b"03-c4-content")
312 s4 = hash_snapshot({"f4.txt": oid4})
313 mpack_bytes = build_wire_mpack({
314 "commits": [_raw_commit(c4, c3, s4)],
315 "snapshots": [{
316 "snapshot_id": s4,
317 "parent_snapshot_id": None,
318 "delta_upsert": {"f4.txt": oid4},
319 "delta_remove": [],
320 "directories": [],
321 }],
322 "blobs": [{"object_id": oid4, "content": b"03-c4-content"}],
323 "tags": [],
324 })
325 mpack_key = blob_id(mpack_bytes)
326 backend = _mock_backend(mpack_bytes)
327
328 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
329 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
330 patch("musehub.storage.backends.get_backend", return_value=backend):
331 await wire_push_unpack_mpack(
332 db_session,
333 repo.repo_id,
334 mpack_key,
335 pusher_id=_OWNER,
336 branch="main",
337 head_commit_id=c4,
338 commits_count=1,
339 blobs_count=1,
340 force=True,
341 )
342 await db_session.flush()
343
344 gen_c3 = await _graph_gen(db_session, c3)
345 gen_c4 = await _graph_gen(db_session, c4)
346 assert gen_c3 == 2, f"C3 must be backfilled to generation 2; got {gen_c3}"
347 assert gen_c4 == 3, (
348 f"C4 (child of remote tip C3) must be generation 3; got {gen_c4}. "
349 f"A gen-0 new tip is the clone-after-push staleness bug."
350 )
351
352
353 # ---------------------------------------------------------------------------
354 # MWP1_04 — Phase 1: seam test for _build_commit_graph_from_raw
355 # ---------------------------------------------------------------------------
356
357 _WIRE_SHARED_LOGGER = "musehub.services.musehub_wire_shared"
358 _UNRESOLVED_MARKER = "[MWP1] unresolved parent generations"
359
360
361 @pytest.mark.asyncio
362 async def test_mwp1_04_seam_root_vs_unresolved_build_commit_graph(
363 db_session: AsyncSession,
364 caplog: pytest.LogCaptureFixture,
365 ) -> None:
366 """GREEN: _build_commit_graph_from_raw separates 'genuine root' from
367 'unresolved parent' at the generation seam, then backfills via Phase 2.
368
369 - Root commit (parent_ids == []) → generation 0, NO unresolved warning.
370 - Child of a parent in musehub_commits but NOT in the graph → fires the
371 unresolved branch, logs a warning, then backfills correctly.
372 """
373 root = _cid("04-root")
374 child = _cid("04-child")
375 ungraphed_parent = _cid("04-ungraphed")
376 s_root = hash_snapshot({"r.txt": blob_id(b"04-root")})
377 s_ungraphed = hash_snapshot({"u.txt": blob_id(b"04-ungraphed")})
378 s_child = hash_snapshot({"c.txt": blob_id(b"04-child")})
379
380 # Seed ungraphed_parent in musehub_commits (authoritative DAG) but NOT in
381 # musehub_commit_graph — this is the realistic backfill-gap scenario.
382 await _seed_commit_row(db_session, ungraphed_parent, [], s_ungraphed)
383 await db_session.flush()
384
385 with caplog.at_level(logging.WARNING, logger=_WIRE_SHARED_LOGGER):
386 # Root case — must NOT emit unresolved warning.
387 await _build_commit_graph_from_raw(db_session, [_raw_commit(root, None, s_root)])
388 await db_session.flush()
389
390 root_warnings = [
391 r.message for r in caplog.records
392 if _UNRESOLVED_MARKER in r.message
393 ]
394 assert root_warnings == [], (
395 f"Root commit must not trigger the unresolved-parent warning; got: {root_warnings}"
396 )
397
398 caplog.clear()
399
400 with caplog.at_level(logging.WARNING, logger=_WIRE_SHARED_LOGGER):
401 # Unresolved case — ungraphed_parent is in musehub_commits but NOT in graph.
402 # The seam must fire the unresolved branch, log a warning, then backfill.
403 await _build_commit_graph_from_raw(
404 db_session,
405 [_raw_commit(child, ungraphed_parent, s_child)],
406 )
407 await db_session.flush()
408
409 unresolved_warnings = [
410 r.message for r in caplog.records
411 if _UNRESOLVED_MARKER in r.message
412 ]
413 assert unresolved_warnings, (
414 "Expected an unresolved-parent warning from _build_commit_graph_from_raw "
415 "when the parent has no graph row — none was emitted. "
416 "The Phase 1 seam is not wired correctly at site 2."
417 )
418 warning_text = unresolved_warnings[0]
419 assert "_build_commit_graph_from_raw" in warning_text, (
420 f"Warning should identify the site (_build_commit_graph_from_raw); got: {warning_text!r}"
421 )
422 assert ungraphed_parent in warning_text or child in warning_text, (
423 f"Warning should mention the commit or unresolved parent; got: {warning_text!r}"
424 )
425
426
427 # ---------------------------------------------------------------------------
428 # MWP1_05 — Phase 1: seam test for wire_push_unpack_mpack (step 9b)
429 # ---------------------------------------------------------------------------
430
431
432 @pytest.mark.asyncio
433 async def test_mwp1_05_seam_push_path_logs_unresolved_warning(
434 db_session: AsyncSession,
435 caplog: pytest.LogCaptureFixture,
436 ) -> None:
437 """GREEN: the wire_push_unpack_mpack generation site logs the unresolved
438 warning when a pushed commit's parent has no graph row.
439
440 This is the MWP1_03 scenario restated as a spy/log assertion (Phase 1 seam
441 for the push-path site). The generation value is still wrong (0) until
442 Phase 2; but the seam is wired and observable.
443 """
444 repo = await create_repo(
445 db_session,
446 name="mwp1-05",
447 owner=_OWNER,
448 owner_user_id=_IDENTITY_ID,
449 visibility="public",
450 initialize=False,
451 )
452 await db_session.commit()
453
454 c1, c2, c3, c4 = _cid("05-c1"), _cid("05-c2"), _cid("05-c3"), _cid("05-c4")
455 s1 = hash_snapshot({"f1.txt": blob_id(b"05-1")})
456 s2 = hash_snapshot({"f2.txt": blob_id(b"05-2")})
457 s3 = hash_snapshot({"f3.txt": blob_id(b"05-3")})
458
459 await _seed_commit_row(db_session, c1, [], s1)
460 await _seed_commit_row(db_session, c2, [c1], s2)
461 await _seed_commit_row(db_session, c3, [c2], s3)
462 await _seed_graph_row(db_session, c1, [], 0, s1)
463 await _seed_graph_row(db_session, c2, [c1], 1, s2)
464 # C3 deliberately absent from musehub_commit_graph.
465 db_session.add(MusehubBranch(
466 branch_id=compute_branch_id(repo.repo_id, "main"),
467 repo_id=repo.repo_id,
468 name="main",
469 head_commit_id=c3,
470 ))
471 await db_session.commit()
472
473 oid4 = blob_id(b"05-c4-content")
474 s4 = hash_snapshot({"f4.txt": oid4})
475 mpack_bytes = build_wire_mpack({
476 "commits": [_raw_commit(c4, c3, s4)],
477 "snapshots": [{
478 "snapshot_id": s4,
479 "parent_snapshot_id": None,
480 "delta_upsert": {"f4.txt": oid4},
481 "delta_remove": [],
482 "directories": [],
483 }],
484 "blobs": [{"object_id": oid4, "content": b"05-c4-content"}],
485 "tags": [],
486 })
487 mpack_key = blob_id(mpack_bytes)
488 backend = _mock_backend(mpack_bytes)
489
490 with caplog.at_level(logging.WARNING, logger=_WIRE_SHARED_LOGGER), \
491 patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
492 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
493 patch("musehub.storage.backends.get_backend", return_value=backend):
494 await wire_push_unpack_mpack(
495 db_session,
496 repo.repo_id,
497 mpack_key,
498 pusher_id=_OWNER,
499 branch="main",
500 head_commit_id=c4,
501 commits_count=1,
502 blobs_count=1,
503 force=True,
504 )
505 await db_session.flush()
506
507 unresolved_warnings = [
508 r.message for r in caplog.records
509 if _UNRESOLVED_MARKER in r.message
510 ]
511 assert unresolved_warnings, (
512 "Expected the unresolved-parent warning from wire_push_unpack_mpack "
513 "when C4's parent C3 has no graph row — none was emitted. "
514 "The Phase 1 seam is not wired at site 1."
515 )
516 warning_text = unresolved_warnings[0]
517 assert "wire_push_unpack_mpack" in warning_text, (
518 f"Warning should identify the site (wire_push_unpack_mpack); got: {warning_text!r}"
519 )
520
521
522 # ---------------------------------------------------------------------------
523 # MWP1_06/MWP1_07 — Phase 2: backfill resolves full missing-parent chain
524 # ---------------------------------------------------------------------------
525
526
527 @pytest.mark.asyncio
528 async def test_mwp1_07_backfill_resolves_chain_of_depth_n(
529 db_session: AsyncSession,
530 ) -> None:
531 """GREEN: _build_commit_graph_from_raw now calls _resolve_generation_with_backfill,
532 which walks musehub_commits.parent_ids to compute generations for all missing
533 ancestors.
534
535 Scenario: C1<-C2<-C3<-C4 exist in musehub_commits but NONE in the graph.
536 Push [C5] (parent C4). True generations: C1=0 C2=1 C3=2 C4=3 C5=4.
537 After Phase 2 the backfill must upsert C1..C4 and compute C5=4.
538 """
539 c1, c2, c3, c4, c5 = (_cid(f"07-c{i}") for i in range(1, 6))
540 snaps = {
541 c1: hash_snapshot({"a.txt": blob_id(b"07-1")}),
542 c2: hash_snapshot({"b.txt": blob_id(b"07-2")}),
543 c3: hash_snapshot({"c.txt": blob_id(b"07-3")}),
544 c4: hash_snapshot({"d.txt": blob_id(b"07-4")}),
545 c5: hash_snapshot({"e.txt": blob_id(b"07-5")}),
546 }
547
548 # Seed the authoritative DAG — no graph rows.
549 for cid, parent in [(c1, None), (c2, c1), (c3, c2), (c4, c3)]:
550 await _seed_commit_row(db_session, cid, [parent] if parent else [], snaps[cid])
551
552 await _build_commit_graph_from_raw(db_session, [_raw_commit(c5, c4, snaps[c5])])
553 await db_session.flush()
554
555 expected = {c1: 0, c2: 1, c3: 2, c4: 3, c5: 4}
556 for cid, exp_gen in expected.items():
557 actual = await _graph_gen(db_session, cid)
558 assert actual == exp_gen, (
559 f"commit {cid[:16]} expected generation {exp_gen}; got {actual}. "
560 f"Backfill must compute bottom-up from the root."
561 )
562
563
564 # ---------------------------------------------------------------------------
565 # MWP1_08 — Phase 2: missing-from-musehub_commits raises integrity error
566 # ---------------------------------------------------------------------------
567
568
569 @pytest.mark.asyncio
570 async def test_mwp1_08_absent_parent_raises_integrity_error(
571 db_session: AsyncSession,
572 ) -> None:
573 """GREEN: pushing a commit whose parent is absent from musehub_commits raises
574 ValueError with the [MWP1] integrity error tag.
575
576 C2 claims C1 as its parent, but C1 is NOT in musehub_commits. The fast-forward
577 push invariant requires every referenced commit to already be on the server.
578 """
579 phantom = _cid("08-phantom") # not inserted into musehub_commits
580 c2 = _cid("08-c2")
581 s2 = hash_snapshot({"f.txt": blob_id(b"08-2")})
582
583 with pytest.raises(ValueError, match=r"\[MWP1\] integrity error"):
584 await _resolve_generation_with_backfill(
585 db_session, [phantom],
586 inline_gen={},
587 db_gen={},
588 )
589
590 # Also verify via the full build path (C2 -> phantom, phantom absent).
591 with pytest.raises(ValueError, match=r"\[MWP1\] integrity error"):
592 await _build_commit_graph_from_raw(
593 db_session, [_raw_commit(c2, phantom, s2)]
594 )
595
596
597 # ---------------------------------------------------------------------------
598 # MWP1_09 — Phase 2 stress: 1 000-commit chain backfills correctly and bounded
599 # ---------------------------------------------------------------------------
600
601
602 @pytest.mark.asyncio
603 async def test_mwp1_09_stress_1000_commit_chain(
604 db_session: AsyncSession,
605 ) -> None:
606 """GREEN: backfill over a 1,000-commit missing chain completes with correct
607 generations and stops at the first anchor (a commit already in the graph).
608
609 Scenario:
610 - C0 in musehub_commit_graph with generation=0 (anchor).
611 - C1..C999 in musehub_commits only — no graph rows.
612 - Call _resolve_generation_with_backfill([C999]).
613
614 Expected: C999=999. The walk must stop at C0 (the anchor) without walking
615 its parents (C0 has no parents, but the point is the graph lookup short-circuits).
616 The chain must be fully backfilled: C1=1, C500=500, C999=999.
617 """
618 n = 1000
619 # Build commit IDs: C0 is the root anchor; C1..C999 are ungraphed.
620 chain = [_cid(f"09-c{i:04d}") for i in range(n)]
621 snap = hash_snapshot({"f.txt": blob_id(b"09-root")})
622
623 # Seed musehub_commits for ALL n commits.
624 for i, cid in enumerate(chain):
625 parent = [chain[i - 1]] if i > 0 else []
626 await _seed_commit_row(db_session, cid, parent, snap)
627
628 # Seed musehub_commit_graph ONLY for C0 (the anchor).
629 await _seed_graph_row(db_session, chain[0], [], 0, snap)
630 await db_session.flush()
631
632 # Backfill from C999 upward.
633 result = await _resolve_generation_with_backfill(
634 db_session, [chain[n - 1]],
635 inline_gen={},
636 db_gen={},
637 )
638 await db_session.flush()
639
640 assert result == n - 1, (
641 f"_resolve_generation_with_backfill([C{n-1}]) must return {n - 1}; got {result}"
642 )
643
644 # Spot-check a few points in the chain.
645 for i in [1, 100, 500, 999]:
646 actual = await _graph_gen(db_session, chain[i])
647 assert actual == i, (
648 f"C{i} must have generation {i} after backfill; got {actual}"
649 )
650
651 # Anchor C0 must not have been modified (it was already gen=0).
652 assert await _graph_gen(db_session, chain[0]) == 0, (
653 "Anchor commit C0 must remain at generation 0 after backfill."
654 )
655
656
657 # ---------------------------------------------------------------------------
658 # MWP1_10 — Phase 3: repair routine corrects pre-existing corrupt rows
659 # ---------------------------------------------------------------------------
660
661
662 @pytest.mark.asyncio
663 async def test_mwp1_10_repair_corrects_corrupt_rows(
664 db_session: AsyncSession,
665 ) -> None:
666 """GREEN: repair_corrupt_commit_generations fixes graph rows that were written
667 with generation=0 but have non-empty parent_ids (the RC-1 bug artefacts).
668
669 Scenario mirrors a database state left behind by the pre-Phase-2 push path:
670 - C1: graph row gen=0, parent_ids=[] (true root — must NOT be touched)
671 - C2: graph row gen=0, parent_ids=[C1] (corrupt — parent is a true root)
672 - C3: graph row gen=0, parent_ids=[C2] (corrupt — parent is also corrupt)
673 - C4: graph row gen=0, parent_ids=[C3] (corrupt — deepest leaf)
674
675 After repair: C1=0 (unchanged), C2=1, C3=2, C4=3.
676 """
677 c1, c2, c3, c4 = (_cid(f"10-c{i}") for i in range(1, 5))
678 snap = hash_snapshot({"f.txt": blob_id(b"10-root")})
679
680 # Pre-seed corrupt graph rows (simulating the old buggy push output).
681 # ALL four rows have generation=0 — C1 is a true root, C2/C3/C4 are corrupt.
682 await _seed_graph_row(db_session, c1, [], 0, snap) # true root — correct
683 await _seed_graph_row(db_session, c2, [c1], 0, snap) # corrupt
684 await _seed_graph_row(db_session, c3, [c2], 0, snap) # corrupt
685 await _seed_graph_row(db_session, c4, [c3], 0, snap) # corrupt
686 await db_session.flush()
687
688 result = await repair_corrupt_commit_generations(db_session)
689 await db_session.flush()
690
691 assert result["total_corrupt"] == 3, (
692 f"Repair must identify exactly 3 corrupt rows (C2,C3,C4); got {result}"
693 )
694 assert result["repaired"] == 3, (
695 f"Repair must correct all 3 corrupt rows; got {result}"
696 )
697
698 # Verify correct generations after repair.
699 expected = {c1: 0, c2: 1, c3: 2, c4: 3}
700 for cid, exp_gen in expected.items():
701 actual = await _graph_gen(db_session, cid)
702 assert actual == exp_gen, (
703 f"After repair: commit {cid[:16]} expected gen={exp_gen}; got {actual}"
704 )
705
706
707 # ---------------------------------------------------------------------------
708 # MWP1_11 — Phase 3: re-running the repair is a no-op (idempotent)
709 # ---------------------------------------------------------------------------
710
711
712 @pytest.mark.asyncio
713 async def test_mwp1_11_repair_is_idempotent(
714 db_session: AsyncSession,
715 ) -> None:
716 """GREEN: calling repair_corrupt_commit_generations twice is a no-op on the
717 second call — it finds zero corrupt rows and returns immediately.
718
719 Uses the same scenario as MWP1_10 to confirm that after the first repair pass
720 all previously-corrupt rows now have correct non-zero generations, so the
721 WHERE generation=0 AND cardinality(parent_ids)>0 predicate matches nothing.
722 """
723 c1, c2, c3 = (_cid(f"11-c{i}") for i in range(1, 4))
724 snap = hash_snapshot({"f.txt": blob_id(b"11-root")})
725
726 await _seed_graph_row(db_session, c1, [], 0, snap) # true root
727 await _seed_graph_row(db_session, c2, [c1], 0, snap) # corrupt
728 await _seed_graph_row(db_session, c3, [c2], 0, snap) # corrupt
729 await db_session.flush()
730
731 # First call — repairs C2 and C3.
732 first = await repair_corrupt_commit_generations(db_session)
733 await db_session.flush()
734
735 assert first["total_corrupt"] == 2
736 assert first["repaired"] == 2
737
738 # Second call — must find nothing to repair.
739 second = await repair_corrupt_commit_generations(db_session)
740 await db_session.flush()
741
742 assert second["total_corrupt"] == 0, (
743 f"Second repair call must find 0 corrupt rows; got {second}"
744 )
745 assert second["repaired"] == 0, (
746 f"Second repair call must repair 0 rows; got {second}"
747 )
748
749 # Generations must still be correct after both passes.
750 assert await _graph_gen(db_session, c1) == 0
751 assert await _graph_gen(db_session, c2) == 1
752 assert await _graph_gen(db_session, c3) == 2
753
754
755 # ---------------------------------------------------------------------------
756 # MWP1_12 — Phase 4: data-integrity invariant
757 # ---------------------------------------------------------------------------
758
759
760 @pytest.mark.asyncio
761 async def test_mwp1_12_commit_graph_invariant(
762 db_session: AsyncSession,
763 ) -> None:
764 """GREEN: check_commit_graph_invariant detects corrupt rows (gen=0 with parents)
765 and reports clean after repair_corrupt_commit_generations fixes them.
766
767 This is the assertable invariant for CI / verify: no musehub_commit_graph
768 row may have generation==0 AND non-empty parent_ids.
769 """
770 c1, c2, c3 = (_cid(f"12-c{i}") for i in range(1, 4))
771 snap = hash_snapshot({"f.txt": blob_id(b"12-root")})
772
773 # Seed: C1 is a true root (gen=0, no parents — valid).
774 # C2 and C3 are corrupt (gen=0 with parents).
775 await _seed_graph_row(db_session, c1, [], 0, snap) # valid root
776 await _seed_graph_row(db_session, c2, [c1], 0, snap) # corrupt
777 await _seed_graph_row(db_session, c3, [c2], 0, snap) # corrupt
778 await db_session.flush()
779
780 # Invariant must report violations before repair.
781 pre = await check_commit_graph_invariant(db_session)
782 assert pre["valid"] is False, (
783 f"Invariant must detect corrupt rows before repair; got {pre}"
784 )
785 assert pre["violations"] == 2, (
786 f"Invariant must count exactly 2 violations (C2, C3); got {pre}"
787 )
788
789 # Repair, then re-check.
790 await repair_corrupt_commit_generations(db_session)
791 await db_session.flush()
792
793 post = await check_commit_graph_invariant(db_session)
794 assert post["valid"] is True, (
795 f"Invariant must pass after repair; got {post}"
796 )
797 assert post["violations"] == 0, (
798 f"Invariant must report 0 violations after repair; got {post}"
799 )
800
801
802 # ---------------------------------------------------------------------------
803 # MWP1_13 — Phase 4: fetch-side guard fires on snapshot mismatch
804 # ---------------------------------------------------------------------------
805
806
807 @pytest.mark.asyncio
808 async def test_mwp1_13_fetch_side_guard_fires_on_snapshot_mismatch(
809 db_session: AsyncSession,
810 ) -> None:
811 """GREEN: wire_fetch_mpack calls repair_corrupt_commit_generations when the
812 CommitGraph's max-gen snapshot disagrees with the authoritative MusehubCommit
813 snapshot (the BLOB-DEBUG mismatch condition).
814
815 Setup:
816 - tip in CommitGraph: generation=0, snapshot_id=OLD_SNAP (stale — RC-1 artefact)
817 - tip in MusehubCommit: snapshot_id=NEW_SNAP (authoritative)
818
819 The guard detects want_tip_snap_id (OLD_SNAP) ∉ want_snap_ids_from_commit_rows
820 ({NEW_SNAP}) and calls repair_corrupt_commit_generations before assembling.
821 """
822 repo = await create_repo(
823 db_session,
824 name="mwp1-13",
825 owner=_OWNER,
826 owner_user_id=_IDENTITY_ID,
827 visibility="public",
828 initialize=False,
829 )
830 await db_session.commit()
831
832 tip = _cid("13-tip")
833 old_snap = hash_snapshot({"old.txt": blob_id(b"13-old")})
834 new_snap = hash_snapshot({"new.txt": blob_id(b"13-new")})
835
836 # Authoritative DAG: tip has new_snap (what the client should receive).
837 await _seed_commit_row(db_session, tip, [], new_snap)
838 # CommitGraph: tip has gen=0 with OLD snapshot (the stale RC-1 state).
839 await _seed_graph_row(db_session, tip, [], 0, old_snap)
840 await db_session.flush()
841
842 backend = _mock_backend(b"")
843
844 with patch(
845 "musehub.services.musehub_wire_fetch.repair_corrupt_commit_generations",
846 new_callable=AsyncMock,
847 ) as mock_repair, \
848 patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
849 patch("musehub.services.musehub_wire_fetch.get_backend", return_value=backend), \
850 patch("musehub.storage.backends.get_backend", return_value=backend):
851
852 mock_repair.return_value = {"total_corrupt": 0, "repaired": 0}
853
854 try:
855 await wire_fetch_mpack(
856 db_session,
857 repo.repo_id,
858 want=[tip],
859 have=[],
860 force_build=True,
861 )
862 except Exception:
863 pass # function may fail on missing blobs — only the guard call matters
864
865 mock_repair.assert_called_once_with(db_session)
866
867
868 # ---------------------------------------------------------------------------
869 # MWP1_14 — Phase 5: end-to-end regression proof
870 # ---------------------------------------------------------------------------
871
872
873 @pytest.mark.asyncio
874 async def test_mwp1_14_end_to_end_push_clone_push_clone(
875 db_session: AsyncSession,
876 ) -> None:
877 """GREEN: push C1→C2→C3, clone, push C4, clone again → the second clone
878 contains all 4 commits (C1 through C4) and C4's blob.
879
880 This is the acceptance gate for MWP-1. It directly reproduces the
881 clone-after-push staleness (RC-1 bug) and proves the fix holds:
882
883 1. Push C1/C2/C3 individually.
884 2. Corrupt C3's CommitGraph row (delete it) — simulates the backfill gap
885 that the RC-1 bug created: a parent that is on the server in
886 musehub_commits but absent from musehub_commit_graph.
887 3. Push C4 (parent C3).
888 - WITHOUT the fix: C3 not in graph → parent_gens empty → C4 gets gen=0.
889 The range scan (gen ≤ max_want_gen=0) returns only C4 (and possibly C1)
890 so the clone is missing C2 and C3.
891 - WITH the fix: _resolve_generation_with_backfill restores C3=2, so
892 C4 gets gen=3 and the full history is returned.
893 4. Clone with want=[C4], have=[] → assert all 4 commits and C4's blob.
894 """
895 repo = await create_repo(
896 db_session,
897 name="mwp1-14",
898 owner=_OWNER,
899 owner_user_id=_IDENTITY_ID,
900 visibility="public",
901 initialize=False,
902 )
903 await db_session.commit()
904
905 c1, c2, c3, c4 = (_cid(f"14-c{i}") for i in range(1, 5))
906 oid1 = blob_id(b"14-c1-blob")
907 oid2 = blob_id(b"14-c2-blob")
908 oid3 = blob_id(b"14-c3-blob")
909 oid4 = blob_id(b"14-c4-blob")
910 s1 = hash_snapshot({"f1.txt": oid1})
911 s2 = hash_snapshot({"f2.txt": oid2})
912 s3 = hash_snapshot({"f3.txt": oid3})
913 s4 = hash_snapshot({"f4.txt": oid4})
914
915 backend = _InMemBackend()
916
917 def _single_commit_mpack(
918 cid: str, parent: str | None, snap_id: str,
919 fpath: str, oid: str, data: bytes,
920 ) -> tuple[bytes, str]:
921 mpack_bytes = build_wire_mpack({
922 "commits": [_raw_commit(cid, parent, snap_id)],
923 "snapshots": [{
924 "snapshot_id": snap_id,
925 "parent_snapshot_id": None,
926 "delta_upsert": {fpath: oid},
927 "delta_remove": [],
928 "directories": [],
929 }],
930 "blobs": [{"object_id": oid, "content": data}],
931 "tags": [],
932 })
933 mpack_key = blob_id(mpack_bytes)
934 backend._mpacks[mpack_key] = mpack_bytes
935 return mpack_bytes, mpack_key
936
937 async def _push(
938 cid: str, parent: str | None, snap_id: str,
939 fpath: str, oid: str, data: bytes,
940 ) -> None:
941 _, mpack_key = _single_commit_mpack(cid, parent, snap_id, fpath, oid, data)
942 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
943 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
944 patch("musehub.storage.backends.get_backend", return_value=backend):
945 await wire_push_unpack_mpack(
946 db_session,
947 repo.repo_id,
948 mpack_key,
949 pusher_id=_OWNER,
950 branch="main",
951 head_commit_id=cid,
952 commits_count=1,
953 blobs_count=1,
954 force=True,
955 )
956 await db_session.flush()
957
958 # Step 1: push C1, C2, C3 in sequence.
959 await _push(c1, None, s1, "f1.txt", oid1, b"14-c1-blob")
960 await _push(c2, c1, s2, "f2.txt", oid2, b"14-c2-blob")
961 await _push(c3, c2, s3, "f3.txt", oid3, b"14-c3-blob")
962
963 # Verify correct generations after initial pushes.
964 assert await _graph_gen(db_session, c1) == 0
965 assert await _graph_gen(db_session, c2) == 1
966 assert await _graph_gen(db_session, c3) == 2
967
968 # Step 2: simulate the RC-1 backfill gap — delete C3 from CommitGraph.
969 # This is the state that the pre-fix push path produced when a parent's
970 # graph row was missing: the next push would compute gen=0 for C4.
971 await db_session.execute(
972 _sa_delete(MusehubCommitGraph).where(MusehubCommitGraph.commit_id == c3)
973 )
974 await db_session.flush()
975 assert await _graph_gen(db_session, c3) is None, "C3 must be absent from graph after deletion"
976
977 # Step 3: push C4 (parent C3). Phase 2 fix backfills C3=2, so C4 gets gen=3.
978 await _push(c4, c3, s4, "f4.txt", oid4, b"14-c4-blob")
979
980 gen_c3_after = await _graph_gen(db_session, c3)
981 gen_c4 = await _graph_gen(db_session, c4)
982 assert gen_c3_after == 2, (
983 f"C3 must be backfilled to generation 2 after pushing C4; got {gen_c3_after}. "
984 f"Without the fix C3 stays absent and C4 gets gen=0."
985 )
986 assert gen_c4 == 3, (
987 f"C4 must receive generation 3 (C3+1); got {gen_c4}. "
988 f"A gen-0 tip causes the range scan to miss C1/C2/C3 in the second clone."
989 )
990
991 # Step 4: clone with want=[C4], have=[] — the second clone must include all history.
992 fetch_result: dict | None = None
993 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
994 patch("musehub.services.musehub_wire_fetch.get_backend", return_value=backend), \
995 patch("musehub.storage.backends.get_backend", return_value=backend):
996 fetch_result = await wire_fetch_mpack(
997 db_session,
998 repo.repo_id,
999 want=[c4],
1000 have=[],
1001 force_build=True,
1002 )
1003
1004 assert fetch_result is not None
1005 assert fetch_result["mpack_id"] is not None, "Fetch must return a mpack_id"
1006
1007 assembled_mpack_id = fetch_result["mpack_id"]
1008 assert assembled_mpack_id in backend._mpacks, (
1009 "Assembled mpack must be stored in the backend"
1010 )
1011
1012 assembled_bytes = backend._mpacks[assembled_mpack_id]
1013 parsed = parse_wire_mpack(assembled_bytes)
1014
1015 cloned_commit_ids = {c["commit_id"] for c in parsed.get("commits", [])}
1016 cloned_blob_oids = {b["object_id"] for b in parsed.get("blobs", [])}
1017
1018 # Primary acceptance gate: C4 and its blob must be in the second clone.
1019 assert c4 in cloned_commit_ids, (
1020 f"C4 (the just-pushed commit) must be in the second clone; "
1021 f"commits found: {[cid[:16] for cid in cloned_commit_ids]}"
1022 )
1023 assert oid4 in cloned_blob_oids, (
1024 f"C4's blob (oid4) must be in the second clone's blobs; "
1025 f"blobs found: {[o[:16] for o in cloned_blob_oids]}"
1026 )
1027
1028 # Full-history gate: a fresh clone must include the complete ancestor chain.
1029 # WITHOUT the fix: C4 gets gen=0, max_want_gen=0, range scan misses C2/C3
1030 # (gen=1,2 are > max_want_gen=0), so they are absent from the clone.
1031 assert c1 in cloned_commit_ids, (
1032 f"C1 must be in the second clone (full history); "
1033 f"WITHOUT the fix this fails because C4 gets gen=0 and the range scan "
1034 f"(gen <= 0) misses C1's ancestors. commits: {[cid[:16] for cid in cloned_commit_ids]}"
1035 )
1036 assert c2 in cloned_commit_ids, (
1037 f"C2 must be in the second clone (full history); got {[cid[:16] for cid in cloned_commit_ids]}"
1038 )
1039 assert c3 in cloned_commit_ids, (
1040 f"C3 must be in the second clone (full history); got {[cid[:16] for cid in cloned_commit_ids]}"
1041 )
File History 5 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago