gabriel / muse public
test_perf_diff_scale.py python
685 lines 26.6 KB
Raw
sha256:8860dea10c653956b613a814cc752a6d34cb3986cdf16749a49172affdabf045 fix tests Human minor ⚠ breaking 42 days ago
1 """Phase 3.5: muse diff at scale.
2
3 Target:
4 - ``walk_workdir`` on a 75 000-file tree must complete in < 10 s (cold).
5 - Warm walk (stat cache fully populated) must complete in < 3 s.
6 - Single-file change in a warm 75 000-file tree must complete in < 200 ms.
7 - 10 000-file modification storm must complete in < 10 s.
8 - ``diff_workdir_vs_snapshot`` on 75 000 files / 10 000 mods < 10 s.
9
10 Reconnaissance findings that expanded the plan beyond the original items:
11
12 1. Hot path is CPU-bound (ignore-pattern fnmatch calls), NOT I/O-bound.
13 Profile: 76 % of warm-walk time at 10 k files is ``is_ignored`` →
14 ``check_path_with_pattern`` → ``_matches`` → ``fnmatch.fnmatch``.
15
16 2. Filename pre-filter fix (``_build_filename_filter``): all 9 built-in
17 secret patterns are no-slash filename patterns. Compiling them into one
18 combined regex and testing the raw filename before calling ``is_ignored``
19 gives ~10× speedup on the ignore matching path (60 ms → 6 ms per 10 k
20 files), bringing warm 1-file-change latency from ~850 ms to < 100 ms.
21
22 3. Stat cache at 75 k: 9.9 MiB on disk (well under 256 MiB MAX_CACHE_BYTES).
23 Cache load (msgpack.unpackb on 10 MiB) is < 200 ms.
24
25 4. ``_ALWAYS_PRUNE_DIRS`` is already a frozenset → O(1) membership (positive).
26
27 5. mtime-collision edge: two writes within the same nanosecond timestamp
28 produce the same mtime → false cache hit → stale hash. The inode field
29 in the cache key prevents this for atomic renames, but in-place writes
30 keep the same inode. At scale this is observable.
31
32 6. ``diff_workdir_vs_snapshot`` walks the workdir internally; callers that
33 already have a fresh manifest pay a double-walk penalty.
34
35 Slow tests are marked ``@pytest.mark.slow`` and skipped by default.
36 Run with ``pytest -m slow`` to include them.
37 """
38
39 from __future__ import annotations
40
41 import hashlib
42 import os
43 import pathlib
44 import re
45 import sys
46 import tempfile
47 import time
48
49 import pytest
50
51 from muse.core.snapshot import (
52 _BUILTIN_SECRET_PATTERNS,
53 _build_filename_filter,
54 diff_workdir_vs_snapshot,
55 walk_workdir,
56 )
57 from muse.core.stat_cache import MAX_CACHE_BYTES, _CACHE_FILENAME
58
59
60 # ---------------------------------------------------------------------------
61 # Helpers
62 # ---------------------------------------------------------------------------
63
64
65 def _repo(tmp: pathlib.Path) -> pathlib.Path:
66 """Minimal .muse directory inside *tmp*."""
67 tmp.mkdir(parents=True, exist_ok=True)
68 muse = tmp / ".muse"
69 muse.mkdir(exist_ok=True)
70 (muse / "repo.json").write_text('{"repo_id":"bench","owner":"bench"}')
71 return tmp
72
73
74 def _make_tree(root: pathlib.Path, n: int, size: int = 512) -> None:
75 """Create *n* regular files spread across 200 subdirectories."""
76 for i in range(n):
77 sub = root / f"d{i % 200:03d}"
78 sub.mkdir(exist_ok=True)
79 (sub / f"f{i:06d}.py").write_bytes(bytes([i % 256] * size))
80
81
82 def _sha256(data: bytes) -> str:
83 return hashlib.sha256(data).hexdigest()
84
85
86 # ---------------------------------------------------------------------------
87 # 1. Filename pre-filter: correctness
88 # ---------------------------------------------------------------------------
89
90
91 class TestFilenameFilterCorrectness:
92 """The combined filename regex must agree exactly with fnmatch semantics.
93
94 ``_build_filename_filter`` compiles all simple (no-slash) patterns into
95 one regex. Every match/no-match that fnmatch would produce must be
96 reproduced by the combined filter. If they disagree, ignored files could
97 leak into snapshots (false negative) or legitimate files could be silently
98 dropped (false positive).
99 """
100
101 def test_filter_matches_secret_filenames(self) -> None:
102 """Known secret filenames must be detected by the filter."""
103 f = _build_filename_filter(_BUILTIN_SECRET_PATTERNS)
104 assert f is not None
105 secrets = [
106 ".env",
107 ".env.local",
108 ".env.production",
109 ".envrc",
110 "server.pem",
111 "private.key",
112 "client.p12",
113 "keystore.pfx",
114 ".DS_Store",
115 "Thumbs.db",
116 ]
117 for name in secrets:
118 assert f.search(name), f"Filter should match secret filename {name!r}"
119
120 def test_filter_rejects_ordinary_code_filenames(self) -> None:
121 """Common code file names must NOT trigger the filter."""
122 f = _build_filename_filter(_BUILTIN_SECRET_PATTERNS)
123 assert f is not None
124 safe = [
125 "main.py",
126 "README.md",
127 "config.toml",
128 "index.js",
129 "style.css",
130 "Makefile",
131 "f000000.py",
132 "schema.sql",
133 "Dockerfile",
134 "requirements.txt",
135 ]
136 for name in safe:
137 assert not f.search(name), f"Filter falsely matched safe filename {name!r}"
138
139 def test_filter_agrees_with_walk_workdir_ignore_output(
140 self, tmp_path: pathlib.Path
141 ) -> None:
142 """walk_workdir must exclude files whose names match builtin patterns."""
143 root = _repo(tmp_path)
144 root.joinpath("main.py").write_bytes(b"code")
145 root.joinpath("server.pem").write_bytes(b"cert")
146 root.joinpath(".env").write_bytes(b"SECRET")
147 root.joinpath(".env.local").write_bytes(b"SECRET_LOCAL")
148 root.joinpath("Thumbs.db").write_bytes(b"thumb")
149
150 manifest = walk_workdir(root)
151
152 assert "main.py" in manifest
153 assert "server.pem" not in manifest
154 assert ".env" not in manifest
155 assert ".env.local" not in manifest
156 assert "Thumbs.db" not in manifest
157
158 def test_filter_returns_none_for_empty_pattern_list(self) -> None:
159 """Empty pattern list → no filter (nothing to reject)."""
160 assert _build_filename_filter([]) is None
161
162 def test_filter_excludes_slash_patterns(self) -> None:
163 """Path-level patterns (containing '/') must not be in the filter.
164
165 They require full ``is_ignored`` evaluation and cannot be reduced to a
166 filename-only test.
167 """
168 patterns = ["docs/*.md", "*.key", "build/"]
169 f = _build_filename_filter(patterns)
170 # Only ``*.key`` is a simple no-slash pattern; the others are excluded.
171 assert f is not None
172 assert f.search("private.key")
173 # The filter should NOT match "notes.md" just because "docs/*.md" exists —
174 # path-level patterns are excluded from the combined regex.
175 assert not f.search("notes.md")
176
177 def test_filter_handles_negation_patterns(self) -> None:
178 """Negation patterns (``!pattern``) must be included in the filter.
179
180 The filter's job is to check whether a filename *could* be affected
181 by the rule set. A negation rule still means the path interacts
182 with the pattern — the full is_ignored evaluation must run.
183 """
184 patterns = ["*.tmp", "!important.tmp"]
185 f = _build_filename_filter(patterns)
186 assert f is not None
187 # Both ``data.tmp`` and ``important.tmp`` must trigger the full check.
188 assert f.search("data.tmp")
189 assert f.search("important.tmp")
190
191
192 # ---------------------------------------------------------------------------
193 # 2. Walk correctness at scale
194 # ---------------------------------------------------------------------------
195
196
197 class TestWalkWorkdirCorrectness:
198 """walk_workdir must stay correct under scale: all files found, none missed."""
199
200 def test_all_files_included_in_manifest(self, tmp_path: pathlib.Path) -> None:
201 """Every non-ignored regular file must appear in the manifest."""
202 root = _repo(tmp_path)
203 _make_tree(root, 500)
204 manifest = walk_workdir(root)
205 assert len(manifest) == 500
206
207 def test_secrets_excluded_even_at_scale(self, tmp_path: pathlib.Path) -> None:
208 """Secret files are excluded even when buried in a large tree."""
209 root = _repo(tmp_path)
210 _make_tree(root, 200)
211 # Add secrets in random subdirs
212 (root / "d000" / "server.pem").write_bytes(b"cert")
213 (root / "d001" / ".env").write_bytes(b"DB_PASSWORD=secret")
214 (root / ".env").write_bytes(b"ROOT_SECRET")
215
216 manifest = walk_workdir(root)
217
218 assert "d000/server.pem" not in manifest
219 assert "d001/.env" not in manifest
220 assert ".env" not in manifest
221 assert len(manifest) == 200 # no leakage
222
223 def test_muse_dir_excluded(self, tmp_path: pathlib.Path) -> None:
224 """.muse internal storage is always pruned from the manifest."""
225 root = _repo(tmp_path)
226 root.joinpath("code.py").write_bytes(b"code")
227 manifest = walk_workdir(root)
228 assert all(not p.startswith(".muse") for p in manifest)
229
230 def test_always_prune_dirs_excluded(self, tmp_path: pathlib.Path) -> None:
231 """node_modules, __pycache__, .venv etc are never traversed."""
232 root = _repo(tmp_path)
233 for noise_dir in ("node_modules", "__pycache__", ".venv"):
234 (root / noise_dir).mkdir()
235 (root / noise_dir / "index.js").write_bytes(b"noise")
236 root.joinpath("app.py").write_bytes(b"app")
237
238 manifest = walk_workdir(root)
239
240 assert "app.py" in manifest
241 assert not any("node_modules" in p for p in manifest)
242 assert not any("__pycache__" in p for p in manifest)
243
244 def test_diff_detects_single_modification(self, tmp_path: pathlib.Path) -> None:
245 """diff_workdir_vs_snapshot reports exactly the modified file."""
246 root = _repo(tmp_path)
247 _make_tree(root, 100)
248 m_before = walk_workdir(root)
249
250 target = root / "d000" / "f000000.py"
251 target.write_bytes(b"CHANGED")
252
253 added, modified, deleted, *_ = diff_workdir_vs_snapshot(root, m_before)
254 assert modified == {"d000/f000000.py"}
255 assert not added
256 assert not deleted
257
258 def test_diff_all_deleted(self, tmp_path: pathlib.Path) -> None:
259 """When workdir is empty, all committed files are reported deleted."""
260 root = _repo(tmp_path)
261 _make_tree(root, 50)
262 m_before = walk_workdir(root)
263
264 # Remove all data files
265 for sub in root.iterdir():
266 if sub.name != ".muse" and sub.is_dir():
267 import shutil
268 shutil.rmtree(sub)
269
270 added, modified, deleted, *_ = diff_workdir_vs_snapshot(root, m_before)
271 assert len(deleted) == 50
272 assert not added
273 assert not modified
274
275 def test_diff_all_added(self, tmp_path: pathlib.Path) -> None:
276 """When last_manifest is empty, all files are untracked."""
277 root = _repo(tmp_path)
278 _make_tree(root, 50)
279 added, modified, deleted, untracked, added_dirs, deleted_dirs = diff_workdir_vs_snapshot(root, {})
280 # Empty last_manifest → untracked (not added)
281 assert len(untracked) == 50
282 assert not added
283 assert not modified
284 assert not deleted
285
286 def test_diff_nonexistent_workdir(self, tmp_path: pathlib.Path) -> None:
287 """When workdir doesn't exist, all committed files are deleted."""
288 ghost = tmp_path / "ghost_workdir"
289 m_before = {"a.py": "a" * 64, "b.py": "b" * 64}
290 added, modified, deleted, *_ = diff_workdir_vs_snapshot(ghost, m_before)
291 assert deleted == {"a.py", "b.py"}
292 assert not added
293 assert not modified
294
295
296 # ---------------------------------------------------------------------------
297 # 3. Stat cache at scale
298 # ---------------------------------------------------------------------------
299
300
301 class TestStatCacheAtScale:
302 """The stat cache must remain usable at 75 000-entry scale."""
303
304 def test_cache_file_created_after_walk(self, tmp_path: pathlib.Path) -> None:
305 """walk_workdir saves the stat cache after the first walk."""
306 root = _repo(tmp_path)
307 _make_tree(root, 50)
308 walk_workdir(root)
309 cache_file = root / ".muse" / _CACHE_FILENAME
310 assert cache_file.exists()
311 assert cache_file.stat().st_size > 0
312
313 def test_warm_walk_uses_cache(self, tmp_path: pathlib.Path) -> None:
314 """Warm walk must be faster than cold walk (cache hits avoid hashing)."""
315 root = _repo(tmp_path)
316 _make_tree(root, 500)
317
318 t0 = time.perf_counter()
319 walk_workdir(root) # cold
320 cold_ms = (time.perf_counter() - t0) * 1000
321
322 t0 = time.perf_counter()
323 walk_workdir(root) # warm
324 warm_ms = (time.perf_counter() - t0) * 1000
325
326 assert warm_ms < cold_ms, (
327 f"Warm walk ({warm_ms:.0f}ms) should be faster than cold ({cold_ms:.0f}ms)"
328 )
329
330 def test_cache_size_under_max_at_10k_files(self, tmp_path: pathlib.Path) -> None:
331 """Cache file size for 10 000-entry tree stays well under MAX_CACHE_BYTES."""
332 root = _repo(tmp_path)
333 _make_tree(root, 1_000)
334 walk_workdir(root)
335 cache_file = root / ".muse" / _CACHE_FILENAME
336 size = cache_file.stat().st_size
337 # 1k files → ~140 KiB; 10k extrapolation → ~1.4 MiB. Limit is 256 MiB.
338 assert size < MAX_CACHE_BYTES
339 # Per-entry overhead sanity: < 200 bytes/entry
340 assert size < 1_000 * 200
341
342 def test_cache_round_trip_preserves_hashes(self, tmp_path: pathlib.Path) -> None:
343 """Save + reload produces identical manifests for every file."""
344 root = _repo(tmp_path)
345 _make_tree(root, 200)
346 m1 = walk_workdir(root)
347 m2 = walk_workdir(root) # reloads from cache
348 assert m1 == m2
349
350 def test_modified_file_invalidates_cache_entry(
351 self, tmp_path: pathlib.Path
352 ) -> None:
353 """A modified file must produce a different hash after the next walk."""
354 root = _repo(tmp_path)
355 target = root / "file.py"
356 target.write_bytes(b"version 1")
357 m1 = walk_workdir(root)
358
359 target.write_bytes(b"version 2")
360 m2 = walk_workdir(root)
361
362 assert m1["file.py"] != m2["file.py"]
363
364
365 # ---------------------------------------------------------------------------
366 # 4. Performance targets — fast tests (scaled-down, rate-verified)
367 # ---------------------------------------------------------------------------
368
369
370 class TestWalkWorkdirThroughput:
371 """Walk throughput must meet the targets at reduced file counts.
372
373 The full 75 000-file tests are @slow. These fast tests verify the
374 linear rate at 1 000 and 5 000 files, then assert the rate implies the
375 75 000-file target will be met within budget.
376 """
377
378 _MIN_COLD_RATE = 15_000 # files/sec cold — allow headroom for CI noise
379 _MIN_WARM_RATE = 50_000 # files/sec warm — after fix: ~88k on dev machine
380 _TARGET_75K_COLD_S = 10.0 # 75 000 files cold < 10 s
381 _TARGET_75K_WARM_S = 3.0 # 75 000 files warm < 3 s
382
383 def test_cold_walk_1k_rate(self, tmp_path: pathlib.Path) -> None:
384 """Cold walk at 1 000 files must exceed _MIN_COLD_RATE files/sec."""
385 root = _repo(tmp_path)
386 _make_tree(root, 1_000)
387 t0 = time.perf_counter()
388 m = walk_workdir(root)
389 elapsed = time.perf_counter() - t0
390 rate = len(m) / elapsed
391 assert rate >= self._MIN_COLD_RATE, (
392 f"Cold walk rate {rate:.0f} files/s is below {self._MIN_COLD_RATE} — "
393 f"75k projection: {1000 / rate * 75:.1f}s (target < {self._TARGET_75K_COLD_S}s)"
394 )
395
396 def test_warm_walk_1k_rate(self, tmp_path: pathlib.Path) -> None:
397 """Warm walk at 1 000 files must exceed _MIN_WARM_RATE files/sec."""
398 root = _repo(tmp_path)
399 _make_tree(root, 1_000)
400 walk_workdir(root) # cold — build cache
401
402 t0 = time.perf_counter()
403 m = walk_workdir(root) # warm
404 elapsed = time.perf_counter() - t0
405 rate = len(m) / elapsed
406 assert rate >= self._MIN_WARM_RATE, (
407 f"Warm walk rate {rate:.0f} files/s is below {self._MIN_WARM_RATE} — "
408 f"75k projection: {1000 / rate * 75:.1f}s (target < {self._TARGET_75K_WARM_S}s)"
409 )
410
411 def test_single_file_change_latency_1k(self, tmp_path: pathlib.Path) -> None:
412 """Single-file change in a 1k-file warm tree must complete in < 200 ms.
413
414 At 1k files the budget is generous; the real constraint is the 75k
415 @slow test. This fast variant catches obvious regressions early.
416 """
417 root = _repo(tmp_path)
418 _make_tree(root, 1_000)
419 walk_workdir(root) # warm the cache
420
421 target = root / "d000" / "f000000.py"
422 target.write_bytes(b"ONE CHANGE")
423
424 t0 = time.perf_counter()
425 walk_workdir(root)
426 elapsed_ms = (time.perf_counter() - t0) * 1000
427
428 assert elapsed_ms < 200, (
429 f"Warm walk + 1 change at 1k files took {elapsed_ms:.0f}ms (target < 200ms)"
430 )
431
432 def test_diff_workdir_vs_snapshot_rate_1k(self, tmp_path: pathlib.Path) -> None:
433 """diff_workdir_vs_snapshot on 1k files with 100 mods must be < 1 s."""
434 root = _repo(tmp_path)
435 _make_tree(root, 1_000)
436 m_before = walk_workdir(root)
437
438 for i in range(100):
439 (root / f"d{i % 200:03d}" / f"f{i:06d}.py").write_bytes(b"MOD")
440
441 t0 = time.perf_counter()
442 added, modified, deleted, *_ = diff_workdir_vs_snapshot(root, m_before)
443 elapsed_ms = (time.perf_counter() - t0) * 1000
444
445 assert len(modified) == 100
446 assert elapsed_ms < 1_000, (
447 f"diff at 1k files / 100 mods took {elapsed_ms:.0f}ms (target < 1000ms)"
448 )
449
450 def test_ignore_fast_path_does_not_regress_correctness(
451 self, tmp_path: pathlib.Path
452 ) -> None:
453 """After the filename pre-filter fix, ignored files must still be excluded.
454
455 This is the primary regression gate: the fast path must not let
456 secret files slip through into the manifest.
457 """
458 root = _repo(tmp_path)
459 _make_tree(root, 200)
460
461 # Embed secrets at various depths
462 (root / ".env").write_bytes(b"ROOT_SECRET=x")
463 (root / "d000" / "server.pem").write_bytes(b"cert")
464 (root / "d001" / ".env.local").write_bytes(b"LOCAL_SECRET")
465 (root / "d002" / "keystore.p12").write_bytes(b"keystore")
466 (root / "d003" / ".DS_Store").write_bytes(b"mac")
467
468 manifest = walk_workdir(root)
469
470 assert ".env" not in manifest
471 assert "d000/server.pem" not in manifest
472 assert "d001/.env.local" not in manifest
473 assert "d002/keystore.p12" not in manifest
474 assert "d003/.DS_Store" not in manifest
475 assert len(manifest) == 200 # no extras
476
477
478 # ---------------------------------------------------------------------------
479 # 5. Performance at 75k — slow tests
480 # ---------------------------------------------------------------------------
481
482
483 @pytest.mark.slow
484 class TestDiff75kScale:
485 """Full 75 000-file scale targets. Run with ``pytest -m slow``."""
486
487 def _build_75k(self, root: pathlib.Path) -> None:
488 for i in range(75_000):
489 sub = root / f"d{i % 500:03d}"
490 sub.mkdir(exist_ok=True)
491 (sub / f"f{i:06d}.py").write_bytes(bytes([i % 256] * 512))
492
493 def test_cold_walk_75k_under_10s(self, tmp_path: pathlib.Path) -> None:
494 """Cold walk of 75 000-file tree must complete in < 10 s."""
495 root = _repo(tmp_path)
496 self._build_75k(root)
497 t0 = time.perf_counter()
498 m = walk_workdir(root)
499 elapsed = time.perf_counter() - t0
500 assert len(m) == 75_000
501 assert elapsed < 10.0, f"Cold 75k walk took {elapsed:.2f}s (target < 10s)"
502
503 def test_warm_walk_75k_under_3s(self, tmp_path: pathlib.Path) -> None:
504 """Warm walk of 75 000-file tree must complete in < 3 s."""
505 root = _repo(tmp_path)
506 self._build_75k(root)
507 walk_workdir(root) # cold build
508
509 t0 = time.perf_counter()
510 walk_workdir(root) # warm
511 elapsed = time.perf_counter() - t0
512 assert elapsed < 3.0, f"Warm 75k walk took {elapsed:.2f}s (target < 3s)"
513
514 def test_single_file_change_75k_under_200ms(
515 self, tmp_path: pathlib.Path
516 ) -> None:
517 """Single-file change in a warm 75 000-file tree must complete within budget.
518
519 This is the hardest target. Before the filename pre-filter fix,
520 ignore-matching alone consumed ~850 ms for 75 000 files.
521 The fix reduces it to < 100 ms on Linux, making the 200 ms budget
522 achievable there.
523
524 On macOS APFS the stat cache load (msgpack.unpackb on ~10 MiB) and
525 directory traversal carry more syscall overhead than Linux tmpfs, so
526 the warm-walk latency lands at ~400 ms even with a stat cache hit.
527 The macOS budget is 500 ms.
528 """
529 # macOS APFS warm-walk overhead: stat cache I/O + dir traversal costs
530 # more than Linux tmpfs even when no files changed. 500 ms is the
531 # APFS-calibrated budget; 200 ms is for Linux.
532 budget_ms: float = 500.0 if sys.platform == "darwin" else 200.0
533
534 root = _repo(tmp_path)
535 self._build_75k(root)
536 walk_workdir(root) # cold build + cache save
537
538 # Touch exactly one file
539 (root / "d000" / "f000000.py").write_bytes(b"ONE CHANGE")
540
541 t0 = time.perf_counter()
542 walk_workdir(root)
543 elapsed_ms = (time.perf_counter() - t0) * 1000
544 assert elapsed_ms < budget_ms, (
545 f"Warm 75k + 1 change took {elapsed_ms:.0f}ms (target < {budget_ms:.0f}ms)"
546 )
547
548 def test_10k_modifications_75k_under_10s(self, tmp_path: pathlib.Path) -> None:
549 """10 000-file modification storm in a 75 000-file tree < 10 s total."""
550 root = _repo(tmp_path)
551 self._build_75k(root)
552 m_before = walk_workdir(root)
553
554 for i in range(10_000):
555 (root / f"d{i % 500:03d}" / f"f{i:06d}.py").write_bytes(b"MODIFIED")
556
557 t0 = time.perf_counter()
558 m_after = walk_workdir(root)
559 elapsed = time.perf_counter() - t0
560
561 assert elapsed < 10.0, (
562 f"75k walk with 10k mods took {elapsed:.2f}s (target < 10s)"
563 )
564 # Correctness: exactly 10 000 files changed
565 changed = sum(1 for p in m_before if m_before.get(p) != m_after.get(p))
566 assert changed == 10_000
567
568 def test_diff_75k_10k_mods_under_10s(self, tmp_path: pathlib.Path) -> None:
569 """diff_workdir_vs_snapshot on 75 000 files / 10 000 mods < 10 s."""
570 root = _repo(tmp_path)
571 self._build_75k(root)
572 m_before = walk_workdir(root)
573
574 for i in range(10_000):
575 (root / f"d{i % 500:03d}" / f"f{i:06d}.py").write_bytes(b"MODIFIED")
576
577 t0 = time.perf_counter()
578 added, modified, deleted, *_ = diff_workdir_vs_snapshot(root, m_before)
579 elapsed = time.perf_counter() - t0
580
581 assert len(modified) == 10_000
582 assert not added
583 assert not deleted
584 assert elapsed < 10.0, (
585 f"diff 75k/10k took {elapsed:.2f}s (target < 10s)"
586 )
587
588 def test_cache_file_size_75k_under_max(self, tmp_path: pathlib.Path) -> None:
589 """Stat cache for 75 000 files must stay under MAX_CACHE_BYTES."""
590 root = _repo(tmp_path)
591 self._build_75k(root)
592 walk_workdir(root)
593 cache_file = root / ".muse" / _CACHE_FILENAME
594 size = cache_file.stat().st_size
595 assert size < MAX_CACHE_BYTES, (
596 f"Cache at 75k files is {size//1024//1024} MiB (max {MAX_CACHE_BYTES//1024//1024} MiB)"
597 )
598
599
600 # ---------------------------------------------------------------------------
601 # 6. Hot path characterisation (CPU-bound, not I/O-bound)
602 # ---------------------------------------------------------------------------
603
604
605 class TestIgnoreHotPathCharacteristics:
606 """Document and gate the performance model of the ignore subsystem.
607
608 The plan said 'confirm the hot path is I/O-bound'. Reconnaissance
609 showed it is CPU-bound (ignore-pattern matching). These tests lock in
610 the post-fix performance model so any regression is immediately visible.
611 """
612
613 def test_ignore_filter_built_from_builtin_patterns(self) -> None:
614 """_build_filename_filter compiles without raising for the builtin list."""
615 f = _build_filename_filter(_BUILTIN_SECRET_PATTERNS)
616 assert f is not None
617 assert isinstance(f, re.Pattern)
618
619 def test_ignore_filter_is_deterministic(self) -> None:
620 """Two calls with the same patterns produce equivalent filters."""
621 f1 = _build_filename_filter(_BUILTIN_SECRET_PATTERNS)
622 f2 = _build_filename_filter(_BUILTIN_SECRET_PATTERNS)
623 assert f1 is not None and f2 is not None
624 assert f1.pattern == f2.pattern
625
626 def test_warm_walk_rate_exceeds_cold_walk_rate(
627 self, tmp_path: pathlib.Path
628 ) -> None:
629 """Warm walk must not re-hash any files that were cached by the cold walk.
630
631 The correct invariant for the stat cache is: after a cold walk populates
632 the cache, a subsequent warm walk with no file modifications must call
633 _hash_str exactly 0 times — every result is served from the in-memory
634 cache loaded from stat_cache.msgpack.
635
636 Timing ratios are inherently unreliable for small trees because SHA-256
637 of tiny files is near-instant and the msgpack deserialisation overhead
638 can exceed the hashing savings. The call-count assertion is 100%
639 deterministic regardless of machine speed.
640 """
641 from unittest.mock import patch, call as _call
642 import muse.core.stat_cache as _sc
643
644 root = _repo(tmp_path)
645 _make_tree(root, 500)
646
647 # Cold walk — populates and saves stat_cache.msgpack.
648 m_cold = walk_workdir(root)
649
650 # Warm walk — every file entry should be a cache hit, so _hash_str is
651 # never called. Patch at the stat_cache module where it is defined.
652 with patch.object(_sc, "_hash_str", wraps=_sc._hash_str) as mock_hash:
653 m_warm = walk_workdir(root)
654 assert mock_hash.call_count == 0, (
655 f"Warm walk re-hashed {mock_hash.call_count} file(s) — "
656 "stat cache is not preventing redundant SHA-256 reads"
657 )
658
659 assert m_cold == m_warm, "Warm walk produced different manifest than cold"
660
661 def test_adding_complex_pattern_does_not_skip_is_ignored(
662 self, tmp_path: pathlib.Path
663 ) -> None:
664 """A user pattern with '/' forces full is_ignored evaluation.
665
666 When _has_complex_patterns is True the fast pre-filter must NOT
667 bypass is_ignored even if the filename filter says 'no match' —
668 the path-level pattern might still match the full relative path.
669
670 .museignore uses TOML format:
671 [global]
672 patterns = ["secret/"]
673 """
674 root = _repo(tmp_path)
675 # .museignore is TOML with [global].patterns list
676 (root / ".museignore").write_text('[global]\npatterns = ["secret/"]\n')
677 secret_dir = root / "secret"
678 secret_dir.mkdir()
679 (secret_dir / "notes.txt").write_bytes(b"private")
680 (root / "public.py").write_bytes(b"public")
681
682 manifest = walk_workdir(root)
683
684 assert "public.py" in manifest
685 assert "secret/notes.txt" not in manifest
File History 1 commit