Cache hits copy files, remote transfers keep pace, globs are standard
A local cache hit copies plain files instead of unpacking an archive, a remote hit is decompressed once straight into the store, remote downloads and uploads set their own pace, a cache server learns each request's scope, GitLab CI gets branch scopes, [...] in a task glob is a character class, and vx upgrade re-locks.
A local cache hit now copies plain files instead of unpacking an
archive, and remote downloads and uploads no longer wait on the run’s
workers. A cache server learns which scope each request means, GitLab CI
gets the branch scopes GitHub Actions has, task globs read [...] the
way Turbo, Nx and most glob tools do, and vx upgrade re-locks the
workspace for you.
This release is breaking: every cached task misses once after the
upgrade, two cache.store settings are renamed, cache.retention is
gone, and a glob with a literal [...] directory needs an escape.
Read the breaking changes first.
Each local cache hit used to decompress and untar a tar.zst, even
though the same entry was restored again and again on one machine.
The local cache now keeps each entry as its plain files, unpacked once
when it is saved or downloaded. A download of up to 4 MiB is fetched
whole, decompressed once and written straight into the store, with no
temp archive between. Filling 200 entries of 1 MB each from a remote
went from 1,015 ms to 773 ms. A hit checks each file’s size and
mtime, then copies it out. The kernel does the copy, and can clone the
file on a filesystem that supports it. vx never hard-links, so a build
that later edits a restored file leaves the cache’s copy as it was. A
remote still gets one tar.zst per entry, packed when it is uploaded.
Small entries, up to 32 KiB by default, stay inside the cache index as
before.
Downloads from a remote cache ran only as many at once as the run had
workers, and uploads ran four at once, so on a remote with real latency
most of a run was spent waiting on round trips.
Downloads and uploads now each have their own limit. By default each is
min(96, max(cores × 3, 64)), the formula pnpm uses, and the upload
limit applies to each remote. Local restores stay at twice the run’s
worker count. On a remote 30 ms away, uploading 1,000 entries went from
9.4 s to 3.8 s, and restoring them from the remote from 5.1 s to 2.6 s.
A run sets the same limits with --cache-concurrency-download,
--cache-concurrency-upload and --cache-concurrency-restore, or with
VX_CACHE_CONCURRENCY_DOWNLOAD, VX_CACHE_CONCURRENCY_UPLOAD and
VX_CACHE_CONCURRENCY_RESTORE.
Your cache server knows which scope each request means
A branch’s entries live under their own scope, but the server saw only
a key. A server that keeps a bucket per branch had to guess, or look in
several.
turboCache() and nxCache() now send an x-vx-cache-scope header on
every request: trusted for a task’s own key (main’s entries), else the
branch scope, such as acme/app:feat-x. The key is the same as before.
The header states intent only: the server still decides from the CI ID
token whether a write to trusted is allowed, and a request without the
header, from an older client, leaves the layering to the server.
A GitHub merge queue’s run (merge_group) builds what is about to land
on main, so github() now treats it as trusted: it reads and writes
main’s entries, compared with the queue’s base commit. Before, it wrote
under its gh-readonly-queue/... branch, a scope nobody reads. See
Cache scopes and tokens.
On GitLab every pipeline wrote main’s entries, so a branch’s builds
landed where main reads, and a laptop on the branch found nothing CI had
built for it.
gitlab() from @vzn/vx-ci names the run’s branch from GitLab’s own
variables, as github() does on Actions:
The default branch keeps main’s entries. A branch, a tag or a merge
request writes <project path>:<branch>: a subgroup’s project is
group/sub/app:feat-x, and a fork’s merge request names the fork. A
merge request is compared with its diff base, so the job needs no git
history, and a merge train reads and writes main’s entries. A laptop on
feat-x keeps a subgroup’s whole project path too, so it reads the same
scope. See GitLab CI.
A [...] in a task glob was literal, so a Next.js route directory like
app/[id] needed no escape, but src/*.[jt]s matched nothing, unlike Turbo, Nx and most glob tools. Now brackets are a character class everywhere:
src/*.[jt]s matches .js and .ts, and a literal route directory is
escaped.
A new release can record .vx/projects.lock.json differently, so after
vx upgrade you had to remember to run vx lock yourself.
Now, inside a workspace, an upgrade that installed something runs the
new vx’s vx lock at the workspace root. If the lock fails, the upgrade
still stands and vx says so:
Terminal window
vxupgrade
Terminal window
! vx upgrade vx lock failed: run vx lock
vx upgrade -g moves only the global vx and never locks.
A task that looks up the remote before the run’s batch probe answers
now waits for that answer, so a key the batch reports missing costs no
request of its own.
On a branch, a plugin’s ctx.cache.get, has and hasMany make one
request: the branch’s entry by default, the default branch’s with
false. Writes on a branch stay the branch’s.
@vzn/vx-schedule-history orders a branch by its own CI’s timings:
CI on a branch writes its commit’s timings under the branch’s scope,
and a run on the branch reads main’s timings, then its last commit’s.
A task the branch has timed uses the branch’s time.
With embeddings off, a remote hit for a task with no outputs
used to fail to store and ran as a miss. It now stores.
Opening the cache no longer walks the shared store’s directories, a
cost that grew with every stored entry and hit every run, no-op runs
included.
The run footer no longer prints a “saved” time: time is the run’s
wall time.
A run no longer cleans up on exit: no pruning of month-old history, no
eviction. vx cache clean is the one cleaner; run it from a schedule
or CI, such as vx cache clean --older-than 30d --max-size 10G.
Size settings also read an iB unit, such as 32KiB.
The cache version moves to vx-cache-v48: every cached task misses
once after the upgrade.
cache.store.embedded is now cache.store.embeddings
(VX_CACHE_STORE_EMBEDDINGS): false or a size, default '32KiB'.
true is no longer a value.
cache.store.compress is now cache.store.decompress with the
meaning inverted (compress: true is decompress: false), and its
env var is VX_CACHE_STORE_DECOMPRESS.
A [...] in a task glob is a character class. Escape a literal route
directory: app/\[id\]/**.
Plugin API: on a branch, ctx.cache.get, has and hasMany no
longer fall back to the default branch’s entry. Pass false to read
it.
cache.retention (VX_CACHE_RETENTION_OLDER_THAN,
VX_CACHE_RETENTION_MAX_SIZE) is removed: no run evicts. Run
vx cache clean --older-than / --max-size instead. @vzn/vx-migrate
no longer maps Turbo’s cacheMaxSize / cacheMaxAge or Nx’s
maxCacheSize.
vx init installs vx for you, a cache server keeps branches apart by the CI token instead of trusting the client, a slow download gives way to running the task, and a local hit copies its files out unchecked.
A remote cache scope is now the branch, named owner/repo:branch, so a laptop on a branch reads what CI built there. Each task asks the remote one key, and the local cache never splits by scope. --cache-scope is gone and run.affectedBase is now baseBranch.
Editing a file: or link: dependency, or patching one, now rebuilds what uses it. A hit brings back empty folders and symlinks as the build left them. A remote cache token lives in your keychain or comes from GitHub OIDC, warm runs are faster at scale, and Ctrl-C stops at once.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.