The cache key nobody questions
The obvious way to cache a processed file is to key on its path, maybe with a modification time attached to catch edits. It is also the wrong question for this problem. A path answers "have I seen this location before," and what filemetric-engine actually needs to know is "have I seen these bytes before." Those are different questions with different answers whenever a file gets renamed, copied, or - the common case in a course group - uploaded independently by multiple people who each typed their own filename on the same lecture notes.
A path-keyed cache treats those thirty uploads as thirty distinct files. It reads each one, cleans the text, and stores thirty cache entries for content that is byte-identical thirty times over.
Keying on what the answer actually depends on
The fix is to make the cache key a function of the content, not the location. filemetric-engine hashes the file with SHA-256 and uses that digest as the primary key:
def _read_cached(path, cache):
file_hash = VectorCache.hash_file(path)
if cache:
hit = cache.get(file_hash)
if hit is not None:
return file_hash, hit
cleaned = _clean(path.read_text(encoding="utf-8", errors="ignore"))
if cache:
cache.set(file_hash, cleaned)
return file_hash, cleanedTwo filenames that hash the same never touch _clean twice. That is the entire trick, and it is worth being precise about why it works: the downstream cost being cached - reading the file and normalising the text - is a pure function of the bytes. It does not depend on where the bytes live or what they are called. So the cache key should not depend on those things either. A path-keyed cache was caching the wrong invariant.
The invalidation logic that disappears
A path-and-mtime cache needs invalidation code: compare the stored mtime to the file's current one, decide whether it moved, decide what counts as "changed enough." Content addressing needs none of that, because the key already encodes the answer. If a file's contents change, its hash changes, and the cache misses and reprocesses automatically. If the contents are identical, the hash is identical, and the cache hits - even if the mtime says the file was just touched by an unrelated copy operation, even if the path changed entirely.
There is no stale entry that survives a modification nobody's invalidation check caught, because there is no separate invalidation check to have a bug in. The correctness property is structural rather than something a developer has to keep re-proving as edge cases show up.
What it actually saved
The place this shows up is index builds, where every base file in a group gets read and cleaned before the vectorizer fits. Measured on the same synthetic study-notes corpus used across the filemetric-engine benchmarks, a warm cache against a cold one:
| Files | Cold build | Warm build | Reduction |
|---|---|---|---|
| 50 | 198.6 ms | 144.7 ms | 27% |
| 250 | 1.22 s | 758.4 ms | 38% |
| 1,000 | 6.57 s | 4.33 s | 34% |
Roughly a third of build time gone, and it is worth naming what is left: the vectorizer fit itself. That step depends on the whole corpus at once and cannot be cached per file no matter how the key is chosen, so warm-cache time is a floor set by the actual linear algebra, not by I/O. The cache only ever removes the part of the cost that was redundant work in the first place - and in a system where the same document lands in a group from more than one uploader, that is a real fraction of it.
The bigger win than any single number is structural: cost stops scaling with upload count and starts scaling with distinct-content count. A course where two hundred students upload the same five readings pays for five parses, not two hundred.
One cache, shared across groups
StudySmart organises files into named groups, each with its own index, and the hash-keyed design pays off again at that layer. IndexRegistry keeps one VectorCache shared across every group rather than one cache per group:
# One cache for all groups - files in multiple groups only processed once
self._cache = VectorCache(self.base_dir / self.CACHE_FILE)A per-group cache would have missed the case that actually matters here: the same syllabus PDF uploaded into five different course groups. Because the key is the content hash and not anything tied to a group or a path, that file is parsed once no matter how many groups it ends up in. A path-keyed design could not share a cache this way even if it wanted to - the same bytes at different logical locations would just be different keys again.
The storage layer this depends on
The cache itself is SQLite with WAL enabled, and the hash is the primary key for an O(1) lookup. The one operational detail worth recording is how the connection is managed, because the first version got it wrong in a way that only showed up on Windows:
# One connection guarded by a lock, NOT one per thread. FileIndex.build reads
# through a ThreadPoolExecutor, and a thread-local connection cannot be closed
# from the thread calling close() - that leaked handles and made the index
# directory undeletable on Windows (WinError 32).Index builds read files concurrently through a thread pool, and SQLite's usual advice is one connection per thread. That advice collides with cleanup here: pool threads are already gone by the time anything calls close(), so a thread-local connection is a handle nobody can close. On Windows that is not a warning, it is a locked file - the whole index directory becomes undeletable. A single connection behind a lock sidesteps the problem entirely, and it costs nothing to speak of, because every operation behind that lock is a sub-millisecond primary-key lookup by hash. Contention was never going to be the bottleneck; a leaked file handle would have been.
What generalizes
A cache key should be a function of exactly what the cached value depends on, and nothing else. Path and mtime feel like reasonable proxies for "has this changed," but they are proxies for identity and time, not for content - and the moment two different locations can hold the same content, or the same location can hold different content without its mtime moving in a way you trust, the proxy breaks. Hashing the actual bytes removes the proxy and answers the real question directly. It also collapses invalidation logic into a single equality check, which is less code to get wrong and less code to explain to the next person reading it.