feat(hashing): add match_mtime flag to FileHasher cache backends (ITL-522)#225
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When match_mtime=False, the get() method ignores mtime_ns and instead scans for entries matching (path, size), returning the one with the highest mtime_ns. The put() method is unaffected and always stores the full key. The default behavior (match_mtime=True) preserves backward compatibility by requiring all three key fields to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on tests (ITL-522) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in match_mtime switch to file-hash cache backends so cache lookups can ignore mtime_ns and match on path + size (returning the newest mtime_ns entry on multi-hit), and exposes the knob via enable_file_hash_caching().
Changes:
- Add
match_mtime: bool = TruetoInMemoryHashCacher,SqliteHashCacher, andPostgresHashCacher, alteringget()behavior whenFalse. - Thread
match_mtimethroughenable_file_hash_caching(match_mtime=...)into the active cacher backend. - Add unit/integration tests and user documentation describing the new mode and its trade-offs.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/orcapod/hashing/hash_cachers.py | Implements match_mtime for in-memory + SQLite cachers (__init__, get(), __repr__). |
| src/orcapod/hashing/postgres_hash_cacher.py | Implements match_mtime for Postgres cacher (__init__, get(), __repr__). |
| src/orcapod/contexts/init.py | Exposes/forwards match_mtime via enable_file_hash_caching(). |
| tests/test_hashing/test_hash_cacher_match_mtime.py | Adds new tests covering match_mtime behavior and CachedFileHasher integration scenarios. |
| tests/test_hashing/test_hash_cachers.py | Adds end-to-end passthrough test for enable_file_hash_caching(match_mtime=...). |
| tests/test_hashing/test_postgres_hash_cacher.py | Adds Postgres-specific match_mtime tests and repr checks. |
| docs/concepts/file-hash-caching.md | Documents “Ignoring mtime in cache lookups” and the known stale-hit trade-off. |
| superpowers/specs/2026-07-13-itl-522-match-mtime-flag-design.md | Design/spec write-up for ITL-522 (goals, SQL behavior, tests, docs). |
| docs/metamorphic/plans/2026-07-13-itl-522-match-mtime-flag.md | Implementation plan/playbook for the change (agent workflow). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def test_t1_default_mtime_change_causes_miss(self, tmp_path): | ||
| """T1: Default (match_mtime=True), mtime changed, size unchanged → cache miss → re-hash.""" | ||
| f = tmp_path / "file.bin" | ||
| f.write_bytes(b"x" * 50) | ||
|
|
||
| cacher = InMemoryHashCacher(match_mtime=True) | ||
| cached = CachedFileHasher(file_hasher=FileHasher(), cacher=cacher) | ||
| first_hash = cached.hash_file(f) | ||
|
|
||
| stat = f.stat() | ||
| os.utime(f, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000)) | ||
|
|
||
| # match_mtime=True: mtime change causes a cache miss; CachedFileHasher re-hashes | ||
| # the file. Content is unchanged so the returned hash must still be correct. | ||
| second_hash = cached.hash_file(f) | ||
| assert second_hash == first_hash | ||
|
|
There was a problem hiding this comment.
Fixed. The test now wraps the inner FileHasher with unittest.mock.patch.object(..., wraps=...) and asserts spy.call_count == 2 after the second hash_file() call — confirming the inner hasher was re-invoked (cache miss) rather than serving a cached result. The hash value equality assertion is kept alongside it to verify correctness of the re-hashed output.
| def test_t2_match_mtime_false_mtime_change_is_hit(self, tmp_path): | ||
| """T2: match_mtime=False, mtime changed, size unchanged → cache hit.""" | ||
| f = tmp_path / "file.bin" | ||
| f.write_bytes(b"x" * 50) | ||
|
|
||
| cacher = InMemoryHashCacher(match_mtime=False) | ||
| cached = CachedFileHasher(file_hasher=FileHasher(), cacher=cacher) | ||
| first_hash = cached.hash_file(f) | ||
|
|
||
| stat = f.stat() | ||
| os.utime(f, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000)) | ||
|
|
||
| second_hash = cached.hash_file(f) | ||
| assert second_hash == first_hash | ||
|
|
There was a problem hiding this comment.
Fixed. The test now wraps the inner FileHasher with unittest.mock.patch.object(..., wraps=...) and asserts spy.call_count == 1 after the second hash_file() call — confirming the inner hasher was NOT re-invoked, proving the second call returned from cache rather than re-hashing the file.
| cursor = conn.execute( | ||
| "SELECT hash FROM file_hash_cache " | ||
| "WHERE path=? AND size=? ORDER BY mtime_ns DESC LIMIT 1", | ||
| (str(key.path), key.size), | ||
| ) |
There was a problem hiding this comment.
Fixed. Added CREATE INDEX IF NOT EXISTS idx_file_hash_cache_path_size_mtime ON file_hash_cache (path, size, mtime_ns DESC) in SqliteHashCacher._ensure_schema(). This index satisfies the WHERE path=? AND size=? filter + ORDER BY mtime_ns DESC LIMIT 1 scan in a single index range scan, avoiding a full-table scan when match_mtime=False and multiple historical mtime entries exist for the same file.
| row = conn.execute( | ||
| "SELECT hash FROM file_hash_cache " | ||
| "WHERE path=%s AND size=%s ORDER BY mtime_ns DESC LIMIT 1", | ||
| (str(key.path), key.size), | ||
| ).fetchone() |
There was a problem hiding this comment.
Fixed. Added CREATE INDEX IF NOT EXISTS idx_file_hash_cache_path_size_mtime ON file_hash_cache (path, size, mtime_ns DESC) in PostgresHashCacher._ensure_schema(). The index covers the WHERE path=%s AND size=%s ORDER BY mtime_ns DESC LIMIT 1 query for match_mtime=False lookups, allowing the planner to use an index range scan rather than a sequential scan + sort.
| best_key: FileHashKey | None = None | ||
| best_value: ContentHash | None = None | ||
| for cached_key, value in self._cache.items(): | ||
| if cached_key.path == key.path and cached_key.size == key.size: |
There was a problem hiding this comment.
simply combine this into a single if statement as
cached_key.path == key.path and cached_key.size == key.size and (best_key is None or cached_key.mtime_ns > best_key.mtime_ns)
There was a problem hiding this comment.
The nested-dict refactor (see reply on the sibling comment) eliminates the entire loop, so the double-nested if is gone. No standalone fix needed here.
| return self._cache.get(key) | ||
| best_key: FileHashKey | None = None | ||
| best_value: ContentHash | None = None | ||
| for cached_key, value in self._cache.items(): |
There was a problem hiding this comment.
This looks highly inefficient -- shall we instead change the internal data structure to be dict of dict with outer key (path, size) and inner key mtime?
There was a problem hiding this comment.
Done. _cache is now dict[tuple, dict[int, ContentHash]] — outer key (path, size), inner key mtime_ns. Both lookup paths are now O(1)/O(k):
match_mtime=True: two dict lookups —cache[(path, size)][mtime_ns]match_mtime=False: one outer lookup +inner[max(inner)]— O(k) where k is the number of distinct mtimes for that (path, size) pair, effectively always 1
put() uses setdefault((key.path, key.size), {})[key.mtime_ns] = value. The O(n) scan over all entries is gone entirely.
Review round 1 — summary of changesAll four comments addressed in commit T1 test (cache miss verification): Wrapped the inner T2 test (cache hit verification): Same spy approach. Added SQLite supporting index: Added Postgres supporting index: Same index DDL added to |
Review round 2 — summary of changesOne commit Nested-dict restructure for
The double-nested |
Summary
match_mtime: bool = TruetoInMemoryHashCacher,SqliteHashCacher, andPostgresHashCacher. WhenFalse, cache lookups ignoremtime_nsand match on path + size only, returning the entry with the highestmtime_nson multi-hit — useful in environments where mtime is unreliable (rsync, container remounts, restore-from-backup, NFS).enable_file_hash_caching(match_mtime=...)and forwards it to the active cacher backend.put()write paths are unchanged —mtime_nsis always stored regardless of the flag.Test Plan
uv run pytest tests/test_hashing/ --ignore=tests/test_hashing/test_postgres_hash_cacher.py— 619 tests passTestInMemoryHashCacherMatchMtime(8 tests) andTestSqliteHashCacherMatchMtime(8 tests) intest_hash_cacher_match_mtime.pyTestCachedFileHasherMatchMtime(5 tests) covering T1–T5 with real files andos.utime()TestPostgresHashCacherMatchMtime(6 tests) gated with@pytest.mark.postgrestest_match_mtime_kwarg_passes_through_to_cacherverifies end-to-end forwarding throughenable_file_hash_caching()docs/concepts/file-hash-caching.mdFixes ITL-522
🤖 Generated with Claude Code