Fixtures and dependencies
Fixtures are pre-built VM (or whole-lab) snapshots that test files restore from instead of re-doing setup work. This page covers the cache model end-to-end.
The exhaustive method reference is on Lab (vm_fixture, lab_fixture); the userdata is on Snapshot.
Discovery #
A fixture is any *.fixture.lua file under one of the roots directories listed in provium.toml. Test files reference fixtures by their test-root-relative path with the .fixture.lua suffix omitted:
tests/
fixtures/
base.fixture.lua -- referenced as "fixtures/base"
cluster.fixture.lua -- referenced as "fixtures/cluster"
uses-base.test.lua
-- in uses-base.test.lua:
local vm = provium:
If the same name resolves under multiple roots, the first-listed root wins. Missing fixtures error at restore time with fixture \X` not found in any test root`.
Two fixture shapes #
Single-VM fixture (vm_fixture) #
The chunk ends with return vm:snapshot():
-- tests/fixtures/base.fixture.lua
local vm = provium::
vm::
return vm:
Cached as <key>.snap — a single sparse zstd-compressed file.
Lab fixture (lab_fixture) #
The chunk ends with return provium:snapshot():
-- tests/fixtures/cluster.fixture.lua
local lan = provium:
local a = provium::
local b = provium::
lan:
return provium:
Cached as <key>.lab/ — a directory containing per-VM .snap files plus a lab.json index.
What ends up in the cache key #
The cache key is a SHA-256 of, in order:
- The fixture file's source bytes.
- Every transitively-referenced fixture's key (so
vm_fixture("derived")callingvm_fixture("base")invalidates whenbasechanges). - Every
require()d helper's source bytes (recursively — helpers that require other helpers fold in too). - The kernel and initrd identifier of EVERY profile in
provium.toml(sorted by name for determinism). - Every external host-file declared with
vm:push_file("…", …)orlab:depends_on_file("…")(path + mtime + size). See External host-file deps.
Edit any of those, and the next run rebuilds the fixture. This is intentional:
- A fixture builder editing the file → rebuild.
- A helper module the fixture requires gets edited → rebuild.
- A different fixture the fixture references gets rebuilt → rebuild this one too.
- A new kernel image is dropped in → every fixture rebuilds.
Multi-profile cache-key folding means a kernel change on profile B invalidates fixtures even if they only ever boot under profile A. This is a deliberate over-invalidation: it's safer than serving a fixture built against a now-stale kernel.
External host-file deps #
Fixtures routinely push host-side files — a freshly-built binary, a config template, a test corpus — into the guest. The cache key folds those host files in automatically so editing the file on the host invalidates the snapshot.
vm:push_file(host_path, guest_path, opts?) #
Reads host_path on the host and writes its bytes to guest_path in the guest. The host file's identifier (path + mtime + size) is folded into the fixture's cache key by default — rebuild the binary, get a fresh fixture next run.
-- tests/fixtures/uapi-ready.fixture.lua
local vm = provium::
vm:
vm::
return vm:
Relative host_path is resolved against the directory containing the fixture file (or the directory of the require'd helper that calls push_file), not the cwd at invocation time. That keeps things stable regardless of where provium was launched from.
To opt out for a specific call — e.g. a large test corpus you don't want included in the key — pass auto_dep = false:
vm:
The opt-out is detected by static source scanning, so the auto_dep = false must be a literal in the call site. A variable like {auto_dep = x} does NOT opt out (the default is to track).
lab:depends_on_file(host_path) #
Declares an external host-file as a fixture-cache dependency without doing any I/O. Useful when the fixture reads the file on the host side — e.g. a config template parsed in Lua, or a binary the build script invokes locally before pushing post-processed output.
provium:
local rendered = render_template
vm:
return vm:
provium: is the root lab; sub-labs (provium:lab("dc1"):depends_on_file(...)) work too. All declarations on any lab in the fixture fold into the same fixture-level cache key.
Limits of static scanning #
The fold is driven by scanning the fixture and its helpers for literal-string arguments to push_file and depends_on_file. Two cases fall outside that:
- Variable host paths:
vm:push_file(my_path, "/foo")doesn't fold (the scanner can't resolvemy_path). Either inline the literal or follow up with an explicitlab:depends_on_file("…"). - Generated paths:
vm:push_file("build/" .. arch, "/bin/foo")doesn't fold either.
The fix is the same in both cases: add a separate literal lab:depends_on_file("…") declaration alongside the dynamic call. If a fixture's deps genuinely can't be expressed as literals, fall back to provium fixture rebuild <name> after host-side changes.
Cache layout #
~/.cache/provium/fixtures/
c5e6f8….snap -- single-VM fixture snapshot (sparse, zstd)
c5e6f8….lock -- per-key build lock
abc123….lab/ -- lab-fixture directory
lab.json
a.snap
b.snap
abc123….lab.lock
Override the cache directory in provium.toml:
[]
= "/var/cache/provium/fixtures"
Default: ~/.cache/provium/fixtures/.
Build flow #
When a test calls provium:vm_fixture("name"):
flowchart TD
Start[vm_fixture call] --> KeyComp[Compute key — source + deps + kernels]
KeyComp --> Lookup{Key in cache?}
Lookup -->|yes| Restore[Restore from cache]
Lookup -->|no| Lock[Acquire build lock]
Lock --> Recheck{Peer built it while we waited?}
Recheck -->|yes| Restore
Recheck -->|no| Build[Build under lock]
Build --> Compress[Sparse + zstd]
Compress --> Install[Atomic install]
Install --> Restore
Restore --> Return[Return VM]
Build under lock ensures only one process builds a given fixture even when 16 test files reference the same fixture. Other files queue on the lock and either restore from the freshly-installed cache (if the holder finished cleanly) or rebuild themselves (if the holder crashed).
fixture_build_started / fixture_build_done events frame each build. fixture_build_waiting fires when a file queues behind a peer; the payload includes held_by_file so dashboards can show "file X is waiting on file Y to build fixture Z."
Atomic install #
Lab fixtures use renameat2(RENAME_EXCHANGE) to swap the freshly-built <key>.lab/ with any existing one. If the kernel doesn't support the syscall, falls back to a sibling-rename + cleanup.
Single-VM fixtures use plain rename after sparse + zstd compression. Both paths leave the cache in a coherent state — readers either see the old version or the new, never half-installed.
Eviction #
LRU eviction runs at every provium startup, before tests dispatch:
- Sum file sizes under
cache_dir. - Sort entries by access time (atime).
- Delete oldest until total is ≤
cache_max_size.
Default cache_max_size: 20 GiB. Override in provium.toml:
[]
= "100G"
Each successful restore bumps the entry's atime so frequently-used fixtures stay hot. Without the bump, relatime would let every entry's atime collapse together and eviction would degrade to filesystem order.
Corrupt entries #
If a restore fails (decompression error, version mismatch not caught by the key), the harness:
- Prints a warning to stderr naming the fixture.
- Evicts the entry.
- Falls through to the rebuild path.
This is logged but not fatal — the next restore attempt builds fresh.
CLI management #
provium fixture list #
23.45MiB vm c5e6f8…
78.12MiB lab abc123…
2 entries, 101.57MiB
Each entry shows size, kind (vm or lab), and the cache key. Useful for "what's hot in my cache?"
provium fixture build <path> #
Force-build the named fixture. If the entry is already cached, prints already built: <path> and exits.
provium fixture build fixtures/base
Useful in CI to warm the cache before the test run.
provium fixture rebuild <path> #
Evict and rebuild. Useful when you know the fixture should change but the harness's cache key didn't catch it (rare):
provium fixture rebuild fixtures/base
provium fixture clean #
Wipe the entire cache directory:
provium fixture clean
The next run rebuilds everything from scratch. Slow but safe.
provium fixture stale #
List fixtures whose source / dep / kernel hash doesn't match any cached entry:
provium fixture stale
tests/fixtures/base.fixture.lua
1 stale fixture(s)
Useful to check "what would the next provium run rebuild?" without running anything.
Performance notes #
Fixture restores are fast — typical single-VM snapshot restore is < 1 second on warm caches because:
- The snapshot file is sparse-zstd compressed at build time, so reading and decompressing is dominated by kernel buffer cache hits.
- The harness uses
renameat2(RENAME_EXCHANGE)for atomic install (no observable in-between state for readers). - Each successful restore bumps atime, so popular fixtures stay LRU-hot.
Fixture builds are slow — they involve actually booting a VM, doing the setup, and snapshotting. Build them once per cache-key change and amortise across every test that references them.
Common patterns #
"Make a clean baseline available everywhere" #
-- tests/fixtures/clean.fixture.lua
local vm = provium::
return vm:
Then every test starts from a fresh boot without paying the boot cost:
test
"Stack fixtures to amortise expensive setup" #
-- tests/fixtures/with-corpus.fixture.lua
local vm = provium:
vm::
return vm:
with-corpus is keyed off clean's key — when clean rebuilds, with-corpus rebuilds too. When clean is a cache hit, with-corpus only pays the corpus-build cost.
"Cache a multi-VM topology" #
-- tests/fixtures/two-node-cluster.fixture.lua
local lan = provium:
local a, b = provium::, provium::
lan:
a:
b:
return provium:
test
The whole topology — bridge, both VMs, partition state, attached disks, everything — is one cached unit.
"Pre-warm the cache in CI" #
Add to your CI script:
Without pre-warming, the first test to reference each fixture pays the build cost serially.
What can invalidate the cache #
| Change | Invalidates |
|---|---|
Edit <fixture>.fixture.lua | That fixture only. |
Edit a helper that the fixture requires | The fixture and every fixture that references it. |
Edit a fixture that another fixture references via vm_fixture/lab_fixture | Both. |
| New kernel or initrd image (any profile) | Every fixture. |
Change [profiles.<name>].kernel or .initrd path | Every fixture. |
Edit a file declared with vm:push_file or lab:depends_on_file | The fixture (and any fixture that references it). |
provium fixture rebuild / clean | Per command. |
Adding a new profile invalidates the cache (the new profile's kernel/initrd are folded in even if no test uses the new profile). This is by design.
See also #
- Lab reference —
vm_fixture,lab_fixture. - Snapshot reference — what fixture builders return.
- provium.toml reference —
cache_dir,cache_max_size. - The CLI —
provium fixture …subcommands.