test_mwp1_generation_authority.py
python
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