Repository navigation
v0.0.644
Cache hits copy files, remote transfers keep pace, globs are standard
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.
Enjoy the release!
In this release
- Cache hits copy files instead of unpacking archives
- Remote cache transfers set their own pace
- Your cache server knows which scope each request means
- Branch cache scopes on GitLab CI
- Task globs are standard globs
- vx upgrade re-locks the workspace
- Other noteworthy changes
- Breaking changes
Cache hits copy files instead of unpacking archives
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.
// .vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
export default defineWorkspace({
cache: { store: { embeddings: '32KiB', decompress: true, verify: 'stat' } }, // the defaults
})embeddingsis the largest entry kept inside the index, as a size;
falsekeeps every entry as files.decompress: falsekeeps each entry as onetar.zst. That optimizes
disk space but trades speed, as every restore decompresses it just in
time.verifysets what a hit checks first:'stat'checks size and mtime,
'hash'checks every byte, and'none'checks nothing.
Each setting also has an env var: VX_CACHE_STORE_EMBEDDINGS,
VX_CACHE_STORE_DECOMPRESS and VX_CACHE_STORE_VERIFY. See the
workspace config.
Remote cache transfers set their own pace
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.
// .vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
export default defineWorkspace({
cache: { concurrency: { download: 64, upload: 64, restore: 16 } },
})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.
Branch cache scopes on GitLab CI
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:
// .vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
import { gitlab } from '@vzn/vx-ci'
export default defineWorkspace({ plugins: [gitlab()] })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.
Task globs are standard globs
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.
inputs: { files: ['src/**/*.[jt]s', 'app/\\[id\\]/**'] }@vzn/vx-migrate passes Turbo's and Nx's classes through unchanged and
escapes brackets in the paths it writes.
vx upgrade re-locks the workspace
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:
vx upgrade! vx upgrade vx lock failed: run vx lockvx upgrade -g moves only the global vx and never locks.
Other noteworthy changes
- 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,hasandhasManymake one
request: the branch's entry by default, the default branch's with
false. Writes on a branch stay the branch's. @vzn/vx-historyorders 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:
timeis the run's
wall time. - A run no longer cleans up on exit: no pruning of month-old history, no
eviction.vx cache cleanis the one cleaner; run it from a schedule
or CI, such asvx cache clean --older-than 30d --max-size 10G. - Size settings also read an
iBunit, such as32KiB.
Breaking changes
- The cache version moves to
vx-cache-v48: every cached task misses
once after the upgrade. cache.store.embeddedis nowcache.store.embeddings
(VX_CACHE_STORE_EMBEDDINGS):falseor a size, default'32KiB'.
trueis no longer a value.cache.store.compressis nowcache.store.decompresswith the
meaning inverted (compress: trueisdecompress: false), and its
env var isVX_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,hasandhasManyno
longer fall back to the default branch's entry. Passfalseto 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-sizeinstead.@vzn/vx-migrate
no longer maps Turbo'scacheMaxSize/cacheMaxAgeor Nx's
maxCacheSize.
See also Upgrading.
How to update
vx upgrade