Skip to content

perf(cache): switch local cache to <hash>.tar.zst artifacts (Turbo-style) - #86

Merged
Exelord merged 1 commit into
mainfrom
claude/tar-local-cache
May 16, 2026
Merged

perf(cache): switch local cache to <hash>.tar.zst artifacts (Turbo-style)#86
Exelord merged 1 commit into
mainfrom
claude/tar-local-cache

Conversation

@Exelord

@Exelord Exelord commented May 16, 2026

Copy link
Copy Markdown
Member

Summary

After reading Turbo's actual cache_archive/restore.rs: they use
tar archives, not hardlinks. Converting our local cache to the
same layout.

Artifact layout

<cacheDir>/<hash>.tar.zst
├── outputs/                   ← only when task has output files
│   └── <project-relative>...
├── stdout                     ← only when non-empty
└── stderr                     ← only when non-empty

Empty task (no outputs, no logs) → ~1 KB empty archive.
The artifact is a pure dump of run byproducts; nothing else.

DB layout

entries:
  hash, project, task, command, exit_code, duration_ms,
  size_bytes, created_at, accessed_at

stdout/stderr removed from the entries row — they live in the
artifact so a remote pull carries them with the bytes.
SCHEMA_VERSION bumped v13 → v14 (mismatch nukes per project
convention; pre-alpha).

Why this should feel like Turbo

  1. One sequential disk read of one compressed file instead of
    N small files scattered across <hash>/ trees. Kernel prefetches
    the whole tar in one pass.
  2. One subprocess (tar -xf) handles all file writes in C
    instead of N async Bun.write() calls from JS.
  3. Bun.zstdCompress / zstdDecompress — built in, no external
    zstd binary required.

Group tasks never touch the cache

executeGroupTask returns early with a hash computed from upstream
outcomes; never calls save / get / restoreOutputs. Confirmed
the contract holds.

What got removed

Implementation notes

  • save(): stage outputs/+stdout+stderr in a temp dir, tar to
    stdout, zstd-compress, atomic rename to <hash>.tar.zst. Stage
    contents are conditional — no folder if no outputs, no file if
    no stdout/stderr.
  • get(): read tar once into memory; peekTar() walks header
    blocks directly (no subprocess) to grab the entries list +
    stdout/stderr content in one pass.
  • restoreOutputs(): decompress; if the artifact has an outputs/
    member, tar -xf - --strip-components=1 outputs extracts the
    files into the project dir. Skips when only logs are cached.

Test plan

  • bun src/bin.ts run ci — 3/3 pass (format-check + lint + 458 tests)
  • Updated tests/cache.test.ts assertions (old <hash>/ layout).
  • 6 new tests in tests/cache-perf.test.ts:
    • single <hash>.tar.zst file (no <hash>/ dir)
    • stdout/stderr present in tar listing (not DB)
    • restoreOutputs round-trip
    • no-op when artifact missing
    • outputsPath returns .tar.zst
    • prune deletes .tar.zst

Expected impact

Should match Turbo's "instant" feel — single sequential read +
one tar invocation rather than N copies. Will validate against
your 100-pkg / 300-task benchmark.

https://claude.ai/code/session_016HXj6HW6bxSn8EYuKcxTD9


Generated by Claude Code

…yle)

After actually reading Turbo's cache_archive code, they use tar
archives (NOT hardlinks like my prior PR #85 attempted). Converting
local cache to the same layout:

  <cacheDir>/<hash>.tar.zst    ← per-entry artifact
    outputs/                   ← only if task has output files
      <project-relative>...
    stdout                     ← only if non-empty
    stderr                     ← only if non-empty
  <cacheDir>/cache.db          ← unchanged: hash/project/task/cmd/
                                 exit_code/duration_ms/size/timestamps

Why this should feel "instant" like Turbo:

  1. **One sequential disk read** of one compressed file instead of
     N small file reads scattered across <hash>/ directory trees.
     The kernel prefetches the whole tar in one pass.
  2. **One subprocess invocation** (tar -xf) handles all the file
     writes in C, instead of N async Bun.write() calls from JS.
  3. **Bun.zstdCompress / zstdDecompress** for compression — built
     in, no external `zstd` binary required.

Artifact design per user direction:
  - Outputs/stdout/stderr ONLY appear if non-empty. No empty
    folders, no zero-byte files.
  - Empty task (no outputs, no logs) → ~1 KB empty archive.
  - DB carries the metadata index (command, exit_code, etc.);
    artifact stays a pure dump.
  - Group tasks never touch the cache (executeGroupTask returns
    early without calling save/get/restore).

Implementation:
  - Drop `<hash>/outputs/`, `<hash>/stdout`, `<hash>/stderr` layout.
  - `save()`: stage outputs/+stdout+stderr into temp dir, tar to
    stdout, zstd-compress, atomic rename to `<hash>.tar.zst`.
  - `get()`: read+decompress tar once; `peekTar()` walks header
    blocks to grab entries list + stdout/stderr content in one
    pass. No subprocess.
  - `restoreOutputs()`: decompress; if `outputs/` member present,
    `tar -xf - --strip-components=1 outputs` to projectDir. Otherwise
    skip (the artifact may carry only logs).
  - SCHEMA_VERSION v13 → v14 (entries table no longer has stdout/
    stderr columns). Schema mismatch nukes everything per project
    convention (pre-alpha; documented in CLAUDE.md).
  - Removed obsolete `outputsMatchCache` (the per-file manifest skip
    Turbo does inline during extract is a deferred optimization).
  - Removed obsolete hardlink-restore code (wrong direction — Turbo
    explicitly rejects hardlink tar entries).

Tests: 458/458 pass.
  - 6 new tests in `tests/cache-perf.test.ts` for the v15 layout
    (single file, no <hash>/ dir, stdout/stderr in tar not DB,
    restore round-trips, no-op when artifact missing, outputsPath
    returns .tar.zst, prune removes .tar.zst).
  - Updated 2 cache.test.ts assertions that pinned the old v13
    directory layout.

Expected perf impact: matches Turbo's "instant" feel on restore —
single sequential read + one tar invocation rather than N file
copies. Will validate against the user's 100-pkg / 300-task
benchmark.
@Exelord
Exelord merged commit 011f244 into main May 16, 2026
1 check 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.

2 participants