Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Cache expensive file processing results using SHA-256 content hashes — path-independent, auto-invalidating, with service layer separation.
.claude/skills/loulanyue-content-hash-cache-pattern/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 12% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 36% | 0% |
| case-04 | ✗→✓ | ▲ Improved | -1% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 23% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 54% | 0% |
Cache expensive file processing results (PDF parsing, text extraction, image analysis) using SHA-256 content hashes as cache keys. Unlike path-based caching, this approach survives file moves/renames and auto-invalidates when content changes.
--cache/--no-cache CLI optionUse file content (not path) as the cache key:
pythonimport hashlib from pathlib import Path _HASH_CHUNK_SIZE = 65536 # 64KB chunks for large files def compute_file_hash(path: Path) -> str: """SHA-256 of file contents (chunked for large files).""" if not path.is_file(): raise FileNotFoundError(f"File not found: {path}") sha256 = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(_HASH_CHUNK_SIZE) if not chunk: break sha256.update(chunk) return sha256.hexdigest()
Why content hash? File rename/move = cache hit. Content change = automatic invalidation. No index file needed.
pythonfrom dataclasses import dataclass @dataclass(frozen=True, slots=True) class CacheEntry: file_hash: str source_path: str document: ExtractedDocument # The cached result
Each cache entry is stored as {hash}.json — O(1) lookup by hash, no index file required.
pythonimport json from typing import Any def write_cache(cache_dir: Path, entry: CacheEntry) -> None: cache_dir.mkdir(parents=True, exist_ok=True) cache_file = cache_dir / f"{entry.file_hash}.json" data = serialize_entry(entry) cache_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") def read_cache(cache_dir: Path, file_hash: str) -> CacheEntry | None: cache_file = cache_dir / f"{file_hash}.json" if not cache_file.is_file(): return None try: raw = cache_file.read_text(encoding="utf-8") data = json.loads(raw) return deserialize_entry(data) except (json.JSONDecodeError, ValueError, KeyError): return None # Treat corruption as cache miss
Keep the processing function pure. Add caching as a separate service layer.
pythondef extract_with_cache( file_path: Path, *, cache_enabled: bool = True, cache_dir: Path = Path(".cache"), ) -> ExtractedDocument: """Service layer: cache check -> extraction -> cache write.""" if not cache_enabled: return extract_text(file_path) # Pure function, no cache knowledge file_hash = compute_file_hash(file_path) # Check cache cached = read_cache(cache_dir, file_hash) if cached is not None: logger.info("Cache hit: %s (hash=%s)", file_path.name, file_hash[:12]) return cached.document # Cache miss -> extract -> store logger.info("Cache miss: %s (hash=%s)", file_path.name, file_hash[:12]) doc = extract_text(file_path) entry = CacheEntry(file_hash=file_hash, source_path=str(file_path), document=doc) write_cache(cache_dir, entry) return doc
| Decision | Rationale | |----------|-----------| | SHA-256 content hash | Path-independent, auto-invalidates on content change | | {hash}.json file naming | O(1) lookup, no index file needed | | Service layer wrapper | SRP: extraction stays pure, cache is a separate concern | | Manual JSON serialization | Full control over frozen dataclass serialization | | Corruption returns None | Graceful degradation, re-processes on next run | | cache_dir.mkdir(parents=True) | Lazy directory creation on first write |
python# BAD: Path-based caching (breaks on file move/rename) cache = {"/path/to/file.pdf": result} # BAD: Adding cache logic inside the processing function (SRP violation) def extract_text(path, *, cache_enabled=False, cache_dir=None): if cache_enabled: # Now this function has two responsibilities ... # BAD: Using dataclasses.asdict() with nested frozen dataclasses # (can cause issues with complex nested types) data = dataclasses.asdict(entry) # Use manual serialization instead
--cache/--no-cache options| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | pass→pass | 22,608 | 20,130 | -11% | 1 | 1 | 0% | 4,060 | 5,314 | +31% | 0 | 0 | — |
case-06 | pass→pass | 10,041 | 8,339 | -17% | 1 | 1 | 0% | 1,880 | 3,064 | +63% | 0 | 0 | — |
case-01 | fail→pass | 20,060 | 11,944 | -40% | 1 | 1 | 0% | 3,754 | 4,210 | +12% | 0 | 0 | — |
case-02 | pass→pass | 20,541 | 13,004 | -37% | 1 | 1 | 0% | 4,391 | 4,343 | -1% | 0 | 0 | — |
case-03 | fail→pass | 18,186 | 15,174 | -17% | 1 | 1 | 0% | 3,618 | 4,931 | +36% | 0 | 0 | — |
case-04 | fail→pass | 22,087 | 11,941 | -46% | 1 | 1 | 0% | 3,487 | 3,460 | -1% | 0 | 0 | — |
case-07 | pass→pass | 9,847 | 8,250 | -16% | 1 | 1 | 0% | 1,737 | 2,961 | +70% | 0 | 0 | — |
case-08 | pass→pass | 10,303 | 5,964 | -42% | 1 | 1 | 0% | 1,862 | 2,540 | +36% | 0 | 0 | — |
case-09 | fail→fail | 16,835 | 13,137 | -22% | 1 | 1 | 0% | 2,746 | 3,772 | +37% | 0 | 0 | — |
case-10 | pass→pass | 14,570 | 7,963 | -45% | 1 | 1 | 0% | 2,499 | 2,959 | +18% | 0 | 0 | — |
case-11 | pass→pass | 12,841 | 7,439 | -42% | 1 | 1 | 0% | 2,337 | 2,905 | +24% | 0 | 0 | — |
case-12 | pass→pass | 12,237 | 9,132 | -25% | 1 | 1 | 0% | 2,181 | 3,425 | +57% | 0 | 0 | — |
case-13 | pass→pass | 12,342 | 5,866 | -52% | 1 | 1 | 0% | 2,253 | 2,544 | +13% | 0 | 0 | — |
case-14 | fail→pass | 9,564 | 2,503 | -74% | 1 | 1 | 0% | 1,591 | 1,957 | +23% | 0 | 0 | — |
case-15 | fail→pass | 12,081 | 9,016 | -25% | 1 | 1 | 0% | 2,132 | 3,292 | +54% | 0 | 0 | — |
case-21 | pass→pass | 13,817 | 11,872 | -14% | 1 | 1 | 0% | 2,235 | 3,494 | +56% | 0 | 0 | — |
case-16 | pass→pass | 12,371 | 5,529 | -55% | 1 | 1 | 0% | 2,058 | 2,474 | +20% | 0 | 0 | — |
case-17 | pass→pass | 8,666 | 7,141 | -18% | 1 | 1 | 0% | 1,556 | 2,781 | +79% | 0 | 0 | — |
case-18 | pass→pass | 9,984 | 5,459 | -45% | 1 | 1 | 0% | 1,654 | 2,451 | +48% | 0 | 0 | — |
case-19 | pass→pass | 10,106 | 6,475 | -36% | 1 | 1 | 0% | 1,767 | 2,682 | +52% | 0 | 0 | — |
case-20 | pass→pass | 13,538 | 8,783 | -35% | 1 | 1 | 0% | 2,200 | 2,932 | +33% | 0 | 0 | — |
case-22 | fail→pass | 11,433 | 3,755 | -67% | 1 | 1 | 0% | 1,749 | 2,159 | +23% | 0 | 0 | — |
case-23 | fail→pass | 7,610 | 2,183 | -71% | 1 | 1 | 0% | 1,256 | 1,872 | +49% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +30 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.