Skip to content

perf(cache): hardlink-based output restore (Turbo-style "instant" feel) - #85

Merged
Exelord merged 1 commit into
mainfrom
claude/hardlink-restore
May 13, 2026
Merged

perf(cache): hardlink-based output restore (Turbo-style "instant" feel)#85
Exelord merged 1 commit into
mainfrom
claude/hardlink-restore

Conversation

@Exelord

@Exelord Exelord commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

You said: "turbo output restore is much faster, why? like i see it
instantly while our is like loading"
. You're right — Turbo uses
hardlinks (fs.link()) to materialize cached outputs. Hardlinks
are O(1) per file: a new directory entry pointing to the same inode,
no byte copying. Ours was doing full Bun.write(src→dest)
sequentially for every file.

Change

restoreOutputs (via copyDir) now:

  • Hardlinks each file from <cacheDir>/<hash>/outputs/<rel> to
    <projectDir>/<rel> via fs.link(). Same inode, ~100–1000× faster.
  • Runs in parallel (Promise.all over the whole tree). Was
    sequential.
  • Falls back to Bun.write on EXDEV (cross-filesystem) or other
    link failures. The fallback is "sticky" for the run — once we hit
    EXDEV we skip future link() syscalls and copy directly.
  • Recovers from EEXIST (stale leftover destination): unlink + retry.

save() is NOT hardlinked. If the cache copy shared an inode
with the project file, a later writeFile() to the project path
would truncate the inode and corrupt the cache. Save stays as real
byte copy; restore is safe because cleanOutputs unlinks the
project file first, breaking any prior shared inode.

What hardlinks are (since you asked)

A hardlink is a second directory entry pointing to the same inode
(the actual file bytes on disk). Path A and path B look like two
separate files but the OS stores their content exactly once.

  • Removing one path doesn't affect the other — inodes have a
    reference count; OS frees the bytes only when all paths are
    gone. So rm -rf .vx/cache leaves project files intact.
  • The only risk is in-place modification via O_TRUNC writes (which
    is what tripped my failing test). Both ends of the hardlink see
    the change. That's why save uses real copy.

Test plan

  • bun src/bin.ts run ci — 462 tests pass
  • Four new tests in tests/cache-perf.test.ts:
    • restoreOutputs creates a hardlink (same inode, nlink ≥ 2)
    • save does NOT hardlink — different inodes; corrupting the
      project copy leaves the cache copy intact
    • cleanOutputs unlinks project without affecting cache copy
    • EEXIST stale-destination recovery: unlink + relink works

Expected impact

For the 100-pkg / 300-task benchmark with outputs: ['dist/**'],
restore was likely a few hundred ms on cache hits. With hardlinks
this becomes essentially instant — directory-entry creation only,
no byte movement.

https://claude.ai/code/session_016HXj6HW6bxSn8EYuKcxTD9


Generated by Claude Code

User said: "turbo output restore is much faster, why? like i see it
instantly while our is like loading". They're right. Turbo uses
hardlinks (`fs.link()`) to materialize cached outputs into the
project — O(1) per file, no byte copying. Ours was doing full
`Bun.write(src→dest)` sequentially for every file.

Switched `copyDir` (used by `restoreOutputs`) to:
  - `fs.link(src, dest)` per file — same inode, just a new
    directory entry. ~100-1000× faster than copy on a modern fs.
  - **Parallel** Promise.all across the whole tree (was sequential).
  - Fallback to `Bun.write` on `EXDEV` (cross-filesystem) or other
    link failures. The fallback is "sticky" for the run — once we
    see EXDEV, subsequent files skip the link() syscall.
  - EEXIST recovery: unlink + retry link.

NOT applied to `save()`. Hardlinking at save would mean the cache
copy shares an inode with the project's file; a later `writeFile()`
to the project path truncates the inode and corrupts the cache.
Save stays as real byte copy. Restore is safe because cleanOutputs
unlinks the project file first, breaking any prior shared inode
before linking from cache → project.

Tests: 458 → 462. Four new tests pin the hardlink contract:
  - restoreOutputs creates a hardlink (same inode, nlink ≥ 2)
  - save does NOT hardlink (different inodes; corrupting project
    copy leaves cache copy intact)
  - cleanOutputs unlinks project without affecting cache copy
  - EEXIST stale-destination recovery (unlink + relink works)

Expected impact: the "loading…" feel on cache hit becomes instant
for the materialization step — typically the largest visual delay
on a cached run.
@Exelord
Exelord merged commit 230f4bd into main May 13, 2026
1 check passed
Exelord pushed a commit that referenced this pull request Jul 13, 2026
…S wave 1)

The server half of the SaaS-UI redesign (task #85). Three additions, all
session + CSRF gated where they mutate; no schema bump.

- /v1/auth/me now carries email + displayName. The session principal never
  loaded them (only instance_admin), so the account menu literally could
  not show who is signed in. sessionPrincipalFor selects them and the
  principal response returns them.
- PATCH /v1/auth/me — rename yourself (the one self-service profile field;
  email is the login identity, immutable in v1). Validated (non-empty, ≤200).
- POST /v1/auth/password — change your password: verify the current one
  (argon2), enforce ≥8 chars, re-hash. Session-only; a bearer token 403s.
- GET /v1/notifications — the notification-bell feed: recent invocations
  that broke (failed_count > 0), newest-first, workspace-clamped. One
  indexed scan over the invocations header table (cheap to poll); the
  client derives the unread count from a last-seen watermark. NotificationItem
  is lean by design (runId/startedAt/branch/commit/failed/total).

Tests: auth (me carries email+name; rename CSRF-gated + validated +
reflected; password verify-before-change with login round-trip; token
rejected) + analytics (notifications newest-first, green excluded,
workspace-clamped no leak, limit). Cloud 429 pass, lint clean.
Exelord pushed a commit that referenced this pull request Jul 13, 2026
…ve 2-3)

The UI half of task #85, turning the dashboard into a real SaaS app. The
org + workspace switchers already existed (P4); this adds the identity,
notification, and settings surfaces.

- Account menu: an avatar (initials) + name + email + instance-admin badge,
  with links to Settings and (when privileged) Admin, and sign out. Before,
  it showed only the raw userId — wave 1 made email/name available.
- Notification bell: the workspace's recent broken builds
  (GET /v1/notifications, visibility-aware 30s poll). An unread badge counts
  failures newer than a last-seen watermark (localStorage, per origin+ws);
  opening the panel marks them seen; each item deep-links to /runs/:id. Empty
  state is the honest "all green".
- Settings (/settings): a personal-account hub with Profile (rename via PATCH
  /v1/auth/me — the shell name updates live) and Security (change password via
  POST /v1/auth/password with confirm + client validation) tabs, plus an
  Organization link out to /admin for privileged users. Both forms show inline
  success/error banners.
- api.ts: CurrentUser carries email + displayName; updateProfile /
  changePassword / fetchNotifications + the notification watermark helpers.

Fix found in verification: /v1/notifications fell through to the SPA because
the server's isAnalyticsSurface allowlist didn't include it — added, with a
server e2e pinning that a session reads the feed (green → empty, a broken run
→ surfaced).

Verified end-to-end in a real browser (platform + Chromium, seeded via the
ingest wire): 9/9 flows — bell badge + panel, account-menu email, /settings
rename reflected in the shell, password change round-trips server-side (new
password logs in) — zero console errors. The measured ui-perf guard stays
green (4/4) with the bell's 30s poll. Cloud 429 pass, lint + fmt clean, UI
build + 60 unit tests pass.
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