Skip to content
vxvx
GitHubBlueskydev.toRSS
Releases

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.

vx 0.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

Section titled “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
})
  • embeddings is the largest entry kept inside the index, as a size; false keeps every entry as files.
  • decompress: false keeps each entry as one tar.zst. That optimizes disk space but trades speed, as every restore decompresses it just in time.
  • verify sets 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.

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

Section titled “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:

.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.

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.

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
vx upgrade
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.

See also Upgrading.

Terminal window
vx upgrade
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.