perf(cache): hardlink-based output restore (Turbo-style "instant" feel) - #85
Merged
Conversation
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.
3 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Hardlinksare 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(viacopyDir) now:<cacheDir>/<hash>/outputs/<rel>to<projectDir>/<rel>viafs.link(). Same inode, ~100–1000× faster.sequential.
Bun.writeonEXDEV(cross-filesystem) or otherlink failures. The fallback is "sticky" for the run — once we hit
EXDEV we skip future link() syscalls and copy directly.
EEXIST(stale leftover destination): unlink + retry.save()is NOT hardlinked. If the cache copy shared an inodewith the project file, a later
writeFile()to the project pathwould truncate the inode and corrupt the cache. Save stays as real
byte copy; restore is safe because
cleanOutputsunlinks theproject 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.
reference count; OS frees the bytes only when all paths are
gone. So
rm -rf .vx/cacheleaves project files intact.O_TRUNCwrites (whichis 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 passtests/cache-perf.test.ts:project copy leaves the cache copy intact
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