Skip to content

perf(translate): hard-link PDF cache entries instead of copying - #133

Merged
TranBaDat2607 merged 1 commit into
mainfrom
perf/pdf-cache-hardlink
Oct 22, 2026
Merged

TranBaDat2607 merged 1 commit into
mainfrom
perf/pdf-cache-hardlink

Conversation

@TranBaDat2607

Copy link
Copy Markdown
Owner

Summary

A whole-PDF cache entry and the job output it came from hold the same bytes, so they are now one file under two names instead of two copies.

  • New pdf_cache.link_or_copy(src, dest): hard link to dest.tmp, copy if the link is refused, then os.replace. Removes the .tmp on failure; a missing src still raises FileNotFoundError, which the cache-hit path already treats as a miss.
  • store() and the cache-hit materialize in processor.py both use it, replacing two copy-and-rename blocks.
  • Fallback to a copy where links are refused: EXDEV (tmpfs /tmp), FAT/exFAT, some network shares.
  • A .tmp left by a crashed store is removed before linking. Copying onto it would rewrite in place the bytes it may share with an earlier run's output.

Saves one PDF's worth of disk and the copy time per translation.

The invariant this rests on

Nothing may rewrite a translated PDF in place. Removing or replacing a name is safe (the next job's rmtree, LRU eviction, os.replace), but an in-place write would reach the cache entry too. This already holds: every rolling version and job dir is a fresh name, and subset_embedded_fonts / export_pdf stage a new file and rename it. Recorded in CLAUDE.md and docs/architecture/translation-pipeline.md.

Side effects, also documented: on Windows an eviction while the viewer streams the same bytes can fail its unlink (warning; next store of that key replaces the file), and an evicted entry's space returns only once the job's name is gone at the next job.

Tests

Written test-first in tests/test_pdf_cache_store.py:

  • a store links rather than copies (red → green)
  • a refused link (EXDEV, EPERM) gets a copy (red → green)
  • a stale .tmp from a crash is replaced, not written through (red → green)
  • guards: replacing the job output leaves the entry alone; a hit is materialized as a link; an entry evicted before materializing raises FileNotFoundError and leaves nothing. These passed on arrival; checked against a copy-instead-of-link and a skip-the-unlink mutation of the helper, both caught.

Two existing tests adjusted, assertions unchanged:

  • test_the_whole_document_answers_any_selection rewrote the stored file in place, which the pipeline never does; the second artifact now comes from its own job dir.
  • test_a_failed_copy_leaves_no_row_and_no_temp_file now refuses the link as well as the copy, so the failure path still runs.

python -m pytest tests: 1408 passed, 8 skipped. ruff check src tests scripts: clean.

Not covered

process_pdf itself isn't unit-tested. Manual check: translate a document twice in pnpm tauri dev; the second run should be a cache hit and stat on the job's _v999.pdf should show 2 links.

🤖 Generated with Claude Code

A whole-PDF cache entry and the job output it came from hold the same
bytes, so store() and materializing a hit now go through
pdf_cache.link_or_copy: a hard link staged at .tmp and os.replace'd,
falling back to a copy where links are refused (EXDEV from a tmpfs /tmp,
FAT/exFAT, some network shares). Saves one PDF of disk and the copy per
translation.

A .tmp left by a crashed store is removed before linking: copying onto
it would rewrite in place the bytes it may share with an earlier output.

The new rule this rests on — never rewrite a translated PDF in place —
is recorded in CLAUDE.md and translation-pipeline.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TranBaDat2607
TranBaDat2607 merged commit b544bb9 into main Oct 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant