gabriel / muse public
test_perf_pack.py python
823 lines 30.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Phase 3.4 — Pack and unpack at scale.
2
3 Target metrics (measured on a 2024 MacBook Pro M4, macOS 15):
4
5 build_pack (10 000 objects × 4 KiB): < 60 s [@slow]
6 apply_pack (10 000 objects × 4 KiB): < 60 s [@slow]
7 verify-pack (10 000 objects × 4 KiB): < 120 s [@slow] (in practice much faster)
8 collect_object_ids (1 000 objects): < 1 s (no blob reads)
9
10 Edges verified beyond the plan:
11
12 a. ``build_pack`` loads ALL blob bytes simultaneously — peak RSS ≈ 2× blob total.
13 b. ``have=`` filter correctly reduces both commit count and blob payload.
14 c. ``MAX_PACK_OBJECTS`` applies to total_items (commits + snapshots + objects),
15 not per-type — a pack within per-type limits can still be rejected.
16 d. Oversized object (> MAX_OBJECT_WRITE_BYTES) is silently skipped, not raised —
17 caller sees objects_skipped++ but no error; documented behaviour.
18 e. ``verify-pack --stat`` is purely structural — no SHA-256, near-instant.
19 f. Duplicate OID dedup in ``apply_pack`` — each OID written at most once.
20 g. Round-trip integrity: build → msgpack-serialize → apply → all objects present.
21 """
22
23 from __future__ import annotations
24
25 import datetime
26 import hashlib
27 import pathlib
28 import sys
29 import tempfile
30 import time
31 import tracemalloc
32
33 import msgpack
34 import pytest
35 from unittest.mock import patch
36
37 from muse.core.object_store import write_object
38 from muse.core.pack import (
39 MAX_OBJECT_WRITE_BYTES,
40 MAX_PACK_OBJECTS,
41 ObjectPayload,
42 PackBundle,
43 apply_pack,
44 build_pack,
45 collect_object_ids,
46 )
47 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
48
49 from muse.core._types import Manifest
50 from muse.core.store import (
51 CommitRecord,
52 MAX_PACK_MSGPACK_BYTES,
53 SnapshotRecord,
54 write_branch_ref,
55 write_commit,
56 write_snapshot,
57 )
58
59 # ---------------------------------------------------------------------------
60 # Helpers
61 # ---------------------------------------------------------------------------
62
63
64 def _sha256(data: bytes) -> str:
65 return hashlib.sha256(data).hexdigest()
66
67
68 def _make_repo(tmp: pathlib.Path) -> pathlib.Path:
69 tmp.mkdir(parents=True, exist_ok=True)
70 muse = tmp / ".muse"
71 muse.mkdir()
72 (muse / "repo.json").write_text('{"repo_id":"bench","owner":"bench"}')
73 for d in ("commits", "snapshots", "objects"):
74 (muse / d).mkdir()
75 (muse / "refs" / "heads").mkdir(parents=True)
76 (muse / "HEAD").write_text("ref: refs/heads/main\n")
77 (muse / "config.toml").write_text("")
78 return tmp
79
80
81 def _fresh_repo(tmp: pathlib.Path) -> pathlib.Path:
82 tmp.mkdir(parents=True, exist_ok=True)
83 muse = tmp / ".muse"
84 muse.mkdir()
85 (muse / "repo.json").write_text('{"repo_id":"dst"}')
86 for d in ("commits", "snapshots", "objects"):
87 (muse / d).mkdir()
88 return tmp
89
90
91 def _populate(
92 repo: pathlib.Path,
93 n_commits: int = 10,
94 n_unique_objects: int = 10,
95 blob_size: int = 4096,
96 branch: str = "main",
97 start: int = 0,
98 ) -> tuple[str, dict[str, str]]:
99 """Write *n_unique_objects* blobs and a *n_commits* chain.
100
101 Returns ``(tip_commit_id, {path: oid})`` manifest.
102 """
103 blobs: Manifest = {}
104 for i in range(n_unique_objects):
105 data = f"obj-{i + start:08d}-".encode() + b"x" * blob_size
106 oid = _sha256(data)
107 write_object(repo, oid, data)
108 blobs[f"file_{i:04d}.py"] = oid
109
110 sid = compute_snapshot_id(blobs)
111 write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=blobs))
112
113 parent: str | None = None
114 tip = ""
115 for i in range(n_commits):
116 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
117 msg = f"c{start + i:07d}"
118 cid = compute_commit_id([parent] if parent else [], sid, msg, ts.isoformat())
119 rec = CommitRecord(
120 commit_id=cid,
121 repo_id="bench",
122 branch=branch,
123 snapshot_id=sid,
124 message=msg,
125 committed_at=ts,
126 parent_commit_id=parent,
127 parent2_commit_id=None,
128 author="bench",
129 metadata={},
130 structured_delta=None,
131 sem_ver_bump="none",
132 breaking_changes=[],
133 agent_id="",
134 model_id="",
135 toolchain_id="",
136 prompt_hash="",
137 signature="",
138 signer_key_id="",
139 )
140 write_commit(repo, rec)
141 parent = cid
142 tip = cid
143
144 write_branch_ref(repo, branch, tip)
145 return tip, blobs
146
147
148 # ---------------------------------------------------------------------------
149 # Phase 3.4.1 — build_pack throughput
150 # ---------------------------------------------------------------------------
151
152
153 class TestBuildPackThroughput:
154 """build_pack must sustain ≥ 2 000 objects/sec in the object-read loop.
155
156 build_pack's hot path is ``read_object`` for each unique blob. At the
157 Phase 3.1 floor of 2 000 objects/sec, 100 000 objects take ~50 s, within
158 the 60 s target. The fast test covers 1 000 objects and asserts the rate
159 directly; the slow test covers 10 000 objects and proves on-disk timing.
160 """
161
162 _MIN_OBJECTS_PER_SEC = 2_000
163
164 def test_build_pack_1k_objects_rate(self, tmp_path: pathlib.Path) -> None:
165 """build_pack on 1 000 objects must achieve ≥ 2 000 objects/sec."""
166 repo = _make_repo(tmp_path)
167 N = 1_000
168 tip, blobs = _populate(repo, n_commits=50, n_unique_objects=N)
169
170 t0 = time.perf_counter()
171 bundle = build_pack(repo, [tip])
172 elapsed = time.perf_counter() - t0
173
174 assert len(bundle["objects"]) == N, (
175 f"Expected {N} objects in bundle, got {len(bundle['objects'])}"
176 )
177 rate = N / elapsed
178 assert rate >= self._MIN_OBJECTS_PER_SEC, (
179 f"build_pack throughput {rate:.0f} objects/sec < {self._MIN_OBJECTS_PER_SEC} minimum. "
180 f"({N} objects took {elapsed:.2f}s.)"
181 )
182
183 def test_build_pack_have_filter_excludes_base(
184 self, tmp_path: pathlib.Path
185 ) -> None:
186 """build_pack with have=[base_tip] sends only delta commits, not the full history."""
187 repo = _make_repo(tmp_path)
188 base_tip, base_blobs = _populate(repo, n_commits=50, n_unique_objects=100, start=0)
189
190 # New commits on top of the base, with fresh objects.
191 delta_tip, delta_blobs = _populate(
192 repo, n_commits=20, n_unique_objects=50, start=1000
193 )
194 # Chain delta to base by writing a commit that has base_tip as parent.
195 ts = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
196 sid = compute_snapshot_id(delta_blobs)
197 chained_cid = compute_commit_id([delta_tip, base_tip], sid, "merge", ts.isoformat())
198 chained = CommitRecord(
199 commit_id=chained_cid,
200 repo_id="bench",
201 branch="main",
202 snapshot_id=sid,
203 message="merge",
204 committed_at=ts,
205 parent_commit_id=delta_tip,
206 parent2_commit_id=base_tip,
207 author="bench",
208 metadata={},
209 structured_delta=None,
210 sem_ver_bump="none",
211 breaking_changes=[],
212 agent_id="",
213 model_id="",
214 toolchain_id="",
215 prompt_hash="",
216 signature="",
217 signer_key_id="",
218 )
219 write_commit(repo, chained)
220 write_branch_ref(repo, "main", chained_cid)
221
222 # Full pack (no have).
223 full_bundle = build_pack(repo, [chained_cid])
224 # Delta pack: receiver already has base history.
225 delta_bundle = build_pack(repo, [chained_cid], have=[base_tip])
226
227 assert len(delta_bundle["commits"]) < len(full_bundle["commits"]), (
228 "have= filter must reduce commit count"
229 )
230 # The base objects must not be in the delta bundle since they share the snapshot.
231 full_oids = {o["object_id"] for o in full_bundle["objects"]}
232 delta_oids = {o["object_id"] for o in delta_bundle["objects"]}
233 assert delta_oids.issubset(full_oids), "delta bundle must be a subset of full bundle"
234
235 @pytest.mark.slow
236 def test_build_pack_10k_objects_under_60s(
237 self, tmp_path: pathlib.Path
238 ) -> None:
239 """build_pack on 10 000 objects must complete in < 60 s.
240
241 This extrapolates to 100 000 objects at the same rate: 100k / 2000 ≈ 50 s,
242 within the plan target of 60 s.
243 """
244 repo = _make_repo(tmp_path)
245 N = 10_000
246 tip, _ = _populate(repo, n_commits=100, n_unique_objects=N)
247
248 t0 = time.perf_counter()
249 bundle = build_pack(repo, [tip])
250 elapsed = time.perf_counter() - t0
251
252 assert len(bundle["objects"]) == N
253 assert elapsed < 60.0, (
254 f"build_pack({N} objects) took {elapsed:.1f}s — target < 60 s. "
255 f"Rate: {N/elapsed:.0f} objects/sec."
256 )
257
258
259 class TestCollectObjectIdsThroughput:
260 """collect_object_ids must be significantly faster than build_pack.
261
262 It performs the same BFS + manifest traversal but skips reading blob bytes.
263 This is used for the pre-filter negotiation (POST /filter-objects) so the
264 client only uploads objects the remote actually needs.
265 """
266
267 def test_collect_object_ids_no_blob_reads(
268 self, tmp_path: pathlib.Path
269 ) -> None:
270 """collect_object_ids on 500 objects must be < 1 s (no blob reads)."""
271 repo = _make_repo(tmp_path)
272 N = 500
273 tip, blobs = _populate(repo, n_commits=50, n_unique_objects=N)
274
275 t0 = time.perf_counter()
276 oids = collect_object_ids(repo, [tip])
277 elapsed = time.perf_counter() - t0
278
279 assert len(oids) == N, f"Expected {N} OIDs, got {len(oids)}"
280 assert elapsed < 1.0, (
281 f"collect_object_ids({N}) took {elapsed:.3f}s — expected < 1 s. "
282 "It must not read blob bytes; only BFS + manifest traversal."
283 )
284
285 def test_collect_object_ids_faster_than_build_pack(
286 self, tmp_path: pathlib.Path
287 ) -> None:
288 """collect_object_ids must be faster than build_pack for the same input."""
289 repo = _make_repo(tmp_path)
290 N = 200
291 tip, _ = _populate(repo, n_commits=20, n_unique_objects=N)
292
293 t_collect = time.perf_counter()
294 oids = collect_object_ids(repo, [tip])
295 t_collect = time.perf_counter() - t_collect
296
297 t_build = time.perf_counter()
298 bundle = build_pack(repo, [tip])
299 t_build = time.perf_counter() - t_build
300
301 assert len(oids) == len(bundle["objects"]) == N
302 assert t_collect <= t_build, (
303 f"collect_object_ids ({t_collect:.3f}s) must be ≤ build_pack ({t_build:.3f}s) — "
304 "collect skips blob reads, build reads every byte."
305 )
306
307 def test_collect_object_ids_have_excludes_ancestors(
308 self, tmp_path: pathlib.Path
309 ) -> None:
310 """collect_object_ids with have= returns only new object IDs."""
311 repo = _make_repo(tmp_path)
312 base_tip, base_blobs = _populate(repo, n_commits=10, n_unique_objects=50, start=0)
313 delta_tip, delta_blobs = _populate(repo, n_commits=5, n_unique_objects=30, start=100)
314
315 all_oids = collect_object_ids(repo, [delta_tip])
316 delta_oids = collect_object_ids(repo, [delta_tip], have=[base_tip])
317
318 # Delta must be a subset and contain only the 30 new objects.
319 assert set(delta_blobs.values()).issubset(set(all_oids))
320 assert len(delta_oids) == len(set(delta_blobs.values())), (
321 f"Expected {len(set(delta_blobs.values()))} delta OIDs, got {len(delta_oids)}"
322 )
323
324
325 # ---------------------------------------------------------------------------
326 # Phase 3.4.2 — apply_pack throughput
327 # ---------------------------------------------------------------------------
328
329
330 class TestApplyPackThroughput:
331 """apply_pack must sustain ≥ 1 500 objects/sec in the object-write loop.
332
333 fsync is mocked: the test measures the pack unpacking + hash-verify +
334 mkstemp + fchmod + os.replace pipeline without OS I/O latency. Durability
335 ordering is verified by test_integrity_I2_fsync.py.
336 """
337
338 _MIN_OBJECTS_PER_SEC: int = 1_500
339
340 @pytest.fixture(autouse=True)
341 def no_fsync(self) -> None:
342 """Mock out all fsync calls so the test measures algorithmic throughput."""
343 with patch("muse.core.object_store._fsync_fd", return_value=None), \
344 patch("muse.core.store.os.fsync", return_value=None), \
345 patch("muse.core.store.fcntl.fcntl", return_value=0):
346 yield
347
348 @pytest.mark.perf
349 def test_apply_pack_1k_objects_rate(self, tmp_path: pathlib.Path) -> None:
350 """apply_pack of a 1 000-object bundle must achieve ≥ _MIN_OBJECTS_PER_SEC."""
351 src = _make_repo(tmp_path / "src")
352 N = 1_000
353 tip, _ = _populate(src, n_commits=50, n_unique_objects=N)
354 bundle = build_pack(src, [tip])
355 assert len(bundle["objects"]) == N
356
357 dst = _fresh_repo(tmp_path / "dst")
358
359 t0 = time.perf_counter()
360 result = apply_pack(dst, bundle)
361 elapsed = time.perf_counter() - t0
362
363 assert result["objects_written"] == N
364 rate = N / elapsed
365 assert rate >= self._MIN_OBJECTS_PER_SEC, (
366 f"apply_pack throughput {rate:.0f} objects/sec < {self._MIN_OBJECTS_PER_SEC} minimum. "
367 f"({N} objects took {elapsed:.2f}s.)"
368 )
369
370 def test_apply_pack_idempotent_second_apply_skips_all(
371 self, tmp_path: pathlib.Path
372 ) -> None:
373 """Applying the same bundle twice: second apply must skip every object."""
374 src = _make_repo(tmp_path / "src")
375 tip, _ = _populate(src, n_commits=20, n_unique_objects=100)
376 bundle = build_pack(src, [tip])
377
378 dst = _fresh_repo(tmp_path / "dst")
379 r1 = apply_pack(dst, bundle)
380 r2 = apply_pack(dst, bundle)
381
382 assert r1["objects_written"] == 100
383 assert r2["objects_written"] == 0
384 assert r2["objects_skipped"] == 100, (
385 f"Expected 100 skipped on second apply, got {r2['objects_skipped']}"
386 )
387
388 @pytest.mark.slow
389 def test_apply_pack_10k_objects_under_60s(
390 self, tmp_path: pathlib.Path
391 ) -> None:
392 """apply_pack of a 10 000-object bundle must complete in < 60 s."""
393 src = _make_repo(tmp_path / "src")
394 N = 10_000
395 tip, _ = _populate(src, n_commits=100, n_unique_objects=N)
396 bundle = build_pack(src, [tip])
397
398 dst = _fresh_repo(tmp_path / "dst")
399
400 t0 = time.perf_counter()
401 result = apply_pack(dst, bundle)
402 elapsed = time.perf_counter() - t0
403
404 assert result["objects_written"] == N
405 assert elapsed < 60.0, (
406 f"apply_pack({N} objects) took {elapsed:.1f}s — target < 60 s. "
407 f"Rate: {N/elapsed:.0f} objects/sec."
408 )
409
410
411 # ---------------------------------------------------------------------------
412 # Phase 3.4.3 — verify-pack
413 # ---------------------------------------------------------------------------
414
415
416 class TestVerifyPackIntegrity:
417 """verify-pack must detect hash mismatches and work fast in --stat mode."""
418
419 def test_verify_pack_stat_returns_counts(
420 self, tmp_path: pathlib.Path
421 ) -> None:
422 """verify-pack --stat must count objects/snapshots/commits without hashing."""
423 from tests.cli_test_helper import CliRunner
424 import json
425
426 repo = _make_repo(tmp_path)
427 tip, _ = _populate(repo, n_commits=20, n_unique_objects=50)
428 bundle = build_pack(repo, [tip])
429 raw = msgpack.packb(bundle, use_bin_type=True)
430
431 bundle_file = tmp_path / "pack.muse"
432 bundle_file.write_bytes(raw)
433 (repo / ".muse" / "config.toml").write_text("")
434
435 runner = CliRunner()
436 result = runner.invoke(
437 None,
438 [
439 "verify-pack",
440 "--stat",
441 "--no-local",
442 "--format", "json",
443 "--file", str(bundle_file),
444 ],
445 env={"MUSE_REPO_ROOT": str(repo)},
446 )
447 assert result.exit_code == 0, f"verify-pack --stat failed: {result.output}"
448 payload = json.loads(result.output)
449 assert payload["objects"] == 50
450 assert payload["commits"] == 20
451
452 def test_verify_pack_detects_hash_mismatch(
453 self, tmp_path: pathlib.Path
454 ) -> None:
455 """verify-pack must flag an object whose content hash does not match its ID."""
456 from tests.cli_test_helper import CliRunner
457 import json
458
459 repo = _make_repo(tmp_path)
460 tip, blobs = _populate(repo, n_commits=5, n_unique_objects=10)
461 bundle = build_pack(repo, [tip])
462
463 # Tamper: set a wrong content for the first object while keeping the declared ID.
464 tampered_obj: ObjectPayload = {
465 "object_id": bundle["objects"][0]["object_id"],
466 "content": b"TAMPERED_CONTENT",
467 }
468 tampered_bundle: PackBundle = {
469 "commits": bundle["commits"],
470 "snapshots": bundle["snapshots"],
471 "objects": [tampered_obj] + bundle["objects"][1:],
472 }
473 raw = msgpack.packb(tampered_bundle, use_bin_type=True)
474 bundle_file = tmp_path / "tampered.muse"
475 bundle_file.write_bytes(raw)
476 (repo / ".muse" / "config.toml").write_text("")
477
478 runner = CliRunner()
479 result = runner.invoke(
480 None,
481 [
482 "verify-pack",
483 "--no-local",
484 "--format", "json",
485 "--file", str(bundle_file),
486 ],
487 env={"MUSE_REPO_ROOT": str(repo)},
488 )
489 assert result.exit_code != 0, "verify-pack must exit non-zero when hash mismatches"
490 payload = json.loads(result.output)
491 assert payload["all_ok"] is False
492 assert any("hash mismatch" in f["error"] for f in payload["failures"]), (
493 f"Expected 'hash mismatch' in failures: {payload['failures']}"
494 )
495
496 @pytest.mark.slow
497 def test_verify_pack_10k_objects_under_120s(
498 self, tmp_path: pathlib.Path
499 ) -> None:
500 """verify-pack of a 10 000-object bundle must complete in < 120 s.
501
502 SHA-256 on M4 Silicon processes ~3 GiB/s; 10k × 4 KiB = 40 MiB → < 1 s.
503 The 120 s ceiling catches pathological I/O or per-object overhead.
504 """
505 from tests.cli_test_helper import CliRunner
506 import json
507
508 repo = _make_repo(tmp_path)
509 N = 10_000
510 tip, _ = _populate(repo, n_commits=100, n_unique_objects=N)
511 bundle = build_pack(repo, [tip])
512 raw = msgpack.packb(bundle, use_bin_type=True)
513 bundle_file = tmp_path / "pack10k.muse"
514 bundle_file.write_bytes(raw)
515 (repo / ".muse" / "config.toml").write_text("")
516
517 t0 = time.perf_counter()
518 runner = CliRunner()
519 result = runner.invoke(
520 None,
521 [
522 "verify-pack",
523 "--no-local",
524 "--format", "json",
525 "--file", str(bundle_file),
526 ],
527 env={"MUSE_REPO_ROOT": str(repo)},
528 )
529 elapsed = time.perf_counter() - t0
530
531 assert result.exit_code == 0, f"verify-pack failed: {result.output[:200]}"
532 payload = json.loads(result.output)
533 assert payload["all_ok"] is True
534 assert payload["objects_checked"] == N
535 assert elapsed < 120.0, (
536 f"verify-pack({N} objects) took {elapsed:.1f}s — target < 120 s."
537 )
538
539
540 # ---------------------------------------------------------------------------
541 # Phase 3.4.4 — cap and guard enforcement
542 # ---------------------------------------------------------------------------
543
544
545 class TestPackCapEnforcement:
546 """Pack-bomb and size-cap guards must fire correctly."""
547
548 def test_apply_pack_rejects_bundle_exceeding_max_pack_objects(
549 self, tmp_path: pathlib.Path
550 ) -> None:
551 """apply_pack raises ValueError when total_items > MAX_PACK_OBJECTS.
552
553 MAX_PACK_OBJECTS counts commits + snapshots + objects combined — not
554 per-type. A pack with MAX_PACK_OBJECTS + 1 total items is rejected.
555 """
556 repo = _fresh_repo(tmp_path)
557 oversized: PackBundle = {
558 "commits": [{}] * (MAX_PACK_OBJECTS + 1),
559 "snapshots": [],
560 "objects": [],
561 }
562 with pytest.raises(ValueError, match="Pack rejected"):
563 apply_pack(repo, oversized)
564
565 def test_apply_pack_accepts_bundle_at_exact_cap(
566 self, tmp_path: pathlib.Path
567 ) -> None:
568 """apply_pack does NOT raise when total_items == MAX_PACK_OBJECTS.
569
570 Items are malformed (empty dicts) so they are skipped as bad entries,
571 but the cap check must pass.
572 """
573 repo = _fresh_repo(tmp_path)
574 at_cap: PackBundle = {
575 "commits": [{}] * MAX_PACK_OBJECTS,
576 "snapshots": [],
577 "objects": [],
578 }
579 # Must not raise ValueError for the cap — skips malformed entries instead.
580 result = apply_pack(repo, at_cap)
581 # Each empty-dict commit is missing commit_id and snapshot_id, so every
582 # one is skipped by the essential-field guard added to apply_pack.
583 assert result["commits_written"] == 0, (
584 "All malformed empty-dict commits must be skipped, not written"
585 )
586
587 def test_apply_pack_total_items_cap_is_cross_type(
588 self, tmp_path: pathlib.Path
589 ) -> None:
590 """MAX_PACK_OBJECTS applies across commits+snapshots+objects, not per-type.
591
592 80 000 objects + 20 000 commits + 1 snapshot = 100 001 → rejected.
593 """
594 repo = _fresh_repo(tmp_path)
595 cross_type: PackBundle = {
596 "commits": [{}] * 20_000,
597 "snapshots": [{}] * 1,
598 "objects": [{}] * 80_000,
599 }
600 with pytest.raises(ValueError, match="Pack rejected"):
601 apply_pack(repo, cross_type)
602
603 def test_apply_pack_oversized_object_is_skipped_not_raised(
604 self, tmp_path: pathlib.Path
605 ) -> None:
606 """An object exceeding MAX_OBJECT_WRITE_BYTES is silently skipped.
607
608 This is documented behaviour: the per-object cap logs a warning and
609 increments the loop counter rather than raising an exception, so the
610 rest of the bundle is still applied.
611 """
612 repo = _fresh_repo(tmp_path)
613 good_data = b"x" * 64
614 good_oid = _sha256(good_data)
615 oversized_oid = _sha256(b"y") # real hash — but we'll fake the size check
616 # Construct a bundle with one valid object and one whose content we
617 # claim is MAX_OBJECT_WRITE_BYTES + 1 bytes.
618 # We use a real 1-byte payload but lie about the size by patching
619 # apply_pack's check via len(raw) — we need an actually-oversized payload.
620 # Build a real oversized content string:
621 huge_data = b"z" * (MAX_OBJECT_WRITE_BYTES + 1)
622 huge_oid = _sha256(huge_data)
623 bundle: PackBundle = {
624 "commits": [],
625 "snapshots": [],
626 "objects": [
627 ObjectPayload(object_id=good_oid, content=good_data),
628 ObjectPayload(object_id=huge_oid, content=huge_data),
629 ],
630 }
631 result = apply_pack(repo, bundle)
632 # Good object written; oversized object skipped.
633 assert result["objects_written"] == 1, (
634 f"Expected 1 object written (the good one), got {result['objects_written']}"
635 )
636 # Oversized object must NOT be in the store.
637 from muse.core.object_store import has_object
638 assert not has_object(repo, huge_oid), (
639 "Oversized object must be rejected and not written to store"
640 )
641
642 def test_apply_pack_deduplicates_repeated_oid(
643 self, tmp_path: pathlib.Path
644 ) -> None:
645 """apply_pack writes a repeated OID only once (dedup via seen_object_ids)."""
646 repo = _fresh_repo(tmp_path)
647 data = b"deduplicate-me" * 100
648 oid = _sha256(data)
649 REPEAT = 50
650 bundle: PackBundle = {
651 "commits": [],
652 "snapshots": [],
653 "objects": [ObjectPayload(object_id=oid, content=data)] * REPEAT,
654 }
655 result = apply_pack(repo, bundle)
656 # First occurrence written; remaining 49 skipped.
657 assert result["objects_written"] == 1, (
658 f"Expected 1 write for {REPEAT} identical OIDs, got {result['objects_written']}"
659 )
660 assert result["objects_skipped"] == REPEAT - 1, (
661 f"Expected {REPEAT - 1} skipped, got {result['objects_skipped']}"
662 )
663
664 def test_apply_pack_empty_bundle_is_noop(
665 self, tmp_path: pathlib.Path
666 ) -> None:
667 """apply_pack on a pack with no items returns all-zero counts."""
668 repo = _fresh_repo(tmp_path)
669 empty: PackBundle = {"commits": [], "snapshots": [], "objects": []}
670 result = apply_pack(repo, empty)
671 assert result["commits_written"] == 0
672 assert result["snapshots_written"] == 0
673 assert result["objects_written"] == 0
674 assert result["objects_skipped"] == 0
675
676 def test_have_equals_want_produces_empty_bundle(
677 self, tmp_path: pathlib.Path
678 ) -> None:
679 """build_pack with have=[tip] where tip is also in want returns empty bundle."""
680 repo = _make_repo(tmp_path)
681 tip, _ = _populate(repo, n_commits=10, n_unique_objects=20)
682
683 bundle = build_pack(repo, [tip], have=[tip])
684
685 assert bundle["commits"] == [], (
686 "When have contains the want tip, BFS should yield 0 commits"
687 )
688 assert bundle["objects"] == [], (
689 "Empty commit set must produce empty object list"
690 )
691
692
693 # ---------------------------------------------------------------------------
694 # Phase 3.4.5 — memory ceiling
695 # ---------------------------------------------------------------------------
696
697
698 class TestPackMemoryCeiling:
699 """build_pack and apply_pack peak memory must be proportional to blob payload.
700
701 build_pack holds ALL object bytes in-memory simultaneously — this is a
702 known architectural property, not a bug. The test confirms:
703 1. Peak RSS ≈ total blob bytes (not 10× or 100×).
704 2. build_pack does not accumulate unbounded intermediate structures.
705 """
706
707 def test_build_pack_peak_rss_proportional_to_blob_total(
708 self, tmp_path: pathlib.Path
709 ) -> None:
710 """build_pack peak allocation is ≤ 3× the total blob payload size."""
711 repo = _make_repo(tmp_path)
712 N = 500
713 BLOB_SZ = 4096 # 4 KiB
714 tip, _ = _populate(repo, n_commits=50, n_unique_objects=N, blob_size=BLOB_SZ)
715 blob_total_mib = N * BLOB_SZ / (1024 * 1024)
716
717 tracemalloc.start()
718 tracemalloc.clear_traces()
719 bundle = build_pack(repo, [tip])
720 _, peak_bytes = tracemalloc.get_traced_memory()
721 tracemalloc.stop()
722
723 peak_mib = peak_bytes / (1024 * 1024)
724 ceiling_mib = blob_total_mib * 3 # generous: blobs + msgpack + overhead
725 assert len(bundle["objects"]) == N
726 assert peak_mib <= ceiling_mib, (
727 f"build_pack peak {peak_mib:.1f} MiB exceeds 3× blob total "
728 f"({ceiling_mib:.1f} MiB for {N} × {BLOB_SZ//1024} KiB objects). "
729 "build_pack must not accumulate more than the object bytes themselves."
730 )
731
732 def test_apply_pack_peak_rss_under_64_mib_for_small_objects(
733 self, tmp_path: pathlib.Path
734 ) -> None:
735 """apply_pack of 500 × 4 KiB objects stays under 64 MiB."""
736 src = _make_repo(tmp_path / "src")
737 tip, _ = _populate(src, n_commits=50, n_unique_objects=500, blob_size=4096)
738 bundle = build_pack(src, [tip])
739
740 dst = _fresh_repo(tmp_path / "dst")
741
742 tracemalloc.start()
743 tracemalloc.clear_traces()
744 apply_pack(dst, bundle)
745 _, peak_bytes = tracemalloc.get_traced_memory()
746 tracemalloc.stop()
747
748 peak_mib = peak_bytes / (1024 * 1024)
749 assert peak_mib <= 64, (
750 f"apply_pack(500 × 4 KiB) peak {peak_mib:.1f} MiB — expected ≤ 64 MiB."
751 )
752
753
754 # ---------------------------------------------------------------------------
755 # Phase 3.4.6 — round-trip integrity
756 # ---------------------------------------------------------------------------
757
758
759 class TestPackRoundTrip:
760 """End-to-end round-trip: build_pack → msgpack serialize → apply_pack → verify."""
761
762 def test_roundtrip_all_objects_restored(
763 self, tmp_path: pathlib.Path
764 ) -> None:
765 """build_pack → msgpack → apply_pack round-trip: all objects readable on dst."""
766 src = _make_repo(tmp_path / "src")
767 N_OBJECTS = 200
768 N_COMMITS = 30
769 tip, blobs = _populate(src, n_commits=N_COMMITS, n_unique_objects=N_OBJECTS)
770 bundle = build_pack(src, [tip])
771
772 # Serialize (simulates wire transfer).
773 raw = msgpack.packb(bundle, use_bin_type=True)
774
775 # Re-hydrate using safe_unpackb (the same path as unpack-objects).
776 from muse.core.store import safe_unpackb, MAX_PACK_MSGPACK_BYTES
777 restored_dict = safe_unpackb(raw, context="roundtrip", max_bytes=MAX_PACK_MSGPACK_BYTES, allow_binary=True)
778 assert isinstance(restored_dict, dict)
779
780 from muse.core.pack import ObjectPayload as OP, PackBundle as PB
781 raw_objects = restored_dict.get("objects") or []
782 objects: list[OP] = []
783 for item in raw_objects:
784 if isinstance(item, dict):
785 oid = item.get("object_id", "")
786 content = item.get("content", b"")
787 if isinstance(oid, str) and isinstance(content, (bytes, bytearray)):
788 objects.append(OP(object_id=oid, content=bytes(content)))
789
790 hydrated: PB = {
791 "commits": [c for c in (restored_dict.get("commits") or []) if isinstance(c, dict)],
792 "snapshots": [s for s in (restored_dict.get("snapshots") or []) if isinstance(s, dict)],
793 "objects": objects,
794 }
795 dst = _fresh_repo(tmp_path / "dst")
796 result = apply_pack(dst, hydrated)
797
798 # Every source object must be readable on the destination.
799 from muse.core.object_store import read_object, has_object
800 missing = [oid for oid in blobs.values() if not has_object(dst, oid)]
801 assert not missing, (
802 f"{len(missing)}/{N_OBJECTS} objects missing after round-trip: "
803 f"{missing[:3]}"
804 )
805 assert result["commits_written"] == N_COMMITS
806 assert result["objects_written"] == N_OBJECTS
807
808 def test_roundtrip_msgpack_bundle_size_within_max_pack_bytes(
809 self, tmp_path: pathlib.Path
810 ) -> None:
811 """The serialised bundle for 1 000 × 4 KiB objects must be < MAX_PACK_MSGPACK_BYTES."""
812 src = _make_repo(tmp_path)
813 N = 1_000
814 tip, _ = _populate(src, n_commits=50, n_unique_objects=N, blob_size=4096)
815 bundle = build_pack(src, [tip])
816 raw = msgpack.packb(bundle, use_bin_type=True)
817
818 limit = MAX_PACK_MSGPACK_BYTES
819 assert len(raw) < limit, (
820 f"Bundle for {N} × 4 KiB objects is {len(raw):,} bytes — "
821 f"exceeds MAX_PACK_MSGPACK_BYTES ({limit:,} bytes / "
822 f"{limit // 1024 // 1024} MiB)."
823 )
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago