Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packaging/components.nix
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,9 @@ in
nix-util-test-support = callPackage ../src/libutil-test-support/package.nix { };
nix-util-tests = callPackage ../src/libutil-tests/package.nix { };

nix-store = callPackage ../src/libstore/package.nix { };
nix-store = callPackage ../src/libstore/package.nix {
tecnixRevision = (scope.patchedSrc or src).rev or "unknown";
};
nix-store-c = callPackage ../src/libstore-c/package.nix { };
nix-store-test-support = callPackage ../src/libstore-test-support/package.nix { };
nix-store-tests = callPackage ../src/libstore-tests/package.nix { };
Expand Down
3 changes: 3 additions & 0 deletions packaging/dev-shell.nix
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ pkgs.nixComponents2.nix-util.overrideAttrs (
]
++ map (transformFlag "libutil") (ignoreCrossFile pkgs.nixComponents2.nix-util.mesonFlags)
++ map (transformFlag "libstore") (ignoreCrossFile pkgs.nixComponents2.nix-store.mesonFlags)
# A developer tree can change after configuration. Only immutable package
# builds may advertise a source revision for persistent evaluation caching.
++ [ (lib.mesonOption "libstore:tecnix-revision" "unknown") ]
++ map (transformFlag "libfetchers") (ignoreCrossFile pkgs.nixComponents2.nix-fetchers.mesonFlags)
++ lib.optionals havePerl (
map (transformFlag "perl") (ignoreCrossFile pkgs.nixComponents2.nix-perl-bindings.mesonFlags)
Expand Down
16 changes: 9 additions & 7 deletions plans/tecnix-target-eval-caching/explainer.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ It does not, because the derivation is the *output* of evaluation, not its input

Tecnix therefore makes evaluation itself input-addressed, with the source closure (§4) playing the role for evaluation that the input hash plays for builds.

Pure evaluation is load-bearing for this construction. The addressing is sound only if every input to evaluation flows through a channel that can be fingerprinted: the pinned git tree, the overlay of uncommitted changes, and the declared arguments. Impure evaluation may consult environment variables, the clock, or arbitrary filesystem paths, none of which a source closure can certify. The persistent cache therefore engages only under `pure-eval`. Impure evaluation continues to function and is still tracked within a run, but its results are never trusted across runs.
Pure evaluation is load-bearing for this construction. The addressing is sound only if every input to evaluation flows through a channel that can be fingerprinted: the pinned git tree, the overlay of uncommitted changes, and the declared arguments. Impure evaluation may consult environment variables, the clock, or arbitrary filesystem paths, none of which a source closure can certify. The persistent cache requires both `pure-eval` and a full Tecnix evaluator revision stamp. Unknown or abbreviated stamps bypass it rather than sharing an ambiguous namespace. Explicit dependency queries still track their inputs when persistent caching is disabled.

### 2.3 Design constraints

Expand Down Expand Up @@ -335,17 +335,19 @@ The failure policy throughout is to fail closed. If `git status` fails, evaluati

## 8. The Persistent Cache

The cache is a single SQLite database with one physical row family:
The cache is `tecnix-eval-cache-v3.sqlite`, a single SQLite database with one physical row family:

```
DependencyShards(gitDir, resolver, argsKey, shard → multi-target history blob)
DependencyShards(tecnixRevision, gitDir, resolver, argsKey, shard → multi-target history blob)
```

Target discovery (§9) is stored in the same rows, under a reserved key whose candidates carry the discovered target list as a payload; discovery thereby shares the lookup, validation, history, and compaction machinery of ordinary targets rather than maintaining a parallel implementation. The key contains no commit. The `argsKey` column holds the canonical JSON encoding of the caller's `args` value; this is sound as a key because the resolver receives that same value, so results can depend on the arguments only through content that is, by construction, the key.[^ambient-inputs] Validity across trees is established entirely by the closure-matching procedure of §4.1.

Ordinary target candidates carry a versioned `{drvPath, outputName}` payload. Both fields are forced under source tracking, so a source read that changes only output selection still invalidates the candidate. The payload consists of `TXTV1`, a NUL byte, the recipe path, another NUL, and the selected output name. Its fields are viewed directly in the stored bytes, without parsing a JSON object on each hit. A target-value hit also requires a locally valid recipe and an output of that name. Importing the recipe and selecting the recorded output preserves its Nix string contexts — the dependency metadata attached to strings. An unsupported payload, missing recipe, or invalid output selection is an ordinary miss.

[^ambient-inputs]: Ambient inputs that a pure evaluation can still observe — `builtins.nixVersion`, the store directory — are deliberately *not* part of the cache key. This aligns with Nix's existing flake evaluation cache, whose key is likewise content-only. Changes to the evaluator itself, or to Tecnix semantics, are instead handled by bumping the version in the cache's filename (`tecnix-eval-cache-v2.sqlite`), which orphans old rows wholesale rather than mixing results from two evaluator versions in one database.
The `tecnixRevision` column holds the Tecnix evaluator build revision, not the target repository's commit. Source commits and evaluator revisions are distinct: a source tree that has not changed produces identical fingerprints at any commit, so reuse across commits comes from closure proof, while a different evaluator build starts a separate row family regardless of source state.

[^ambient-inputs]: Exact evaluator revision equality is the initial compatibility boundary. Even a Tecnix revision that leaves evaluation behavior unchanged starts a separate row family. Package builds supply the full revision through the `tecnix-revision` Meson option or `.version-tecnix`; unknown and abbreviated stamps disable persistent caching. This isolates evaluator-code changes, not arbitrary changes to ambient configuration. Other pure-observable inputs, such as the store directory, are not separate key fields here and must remain consistent within a local cache namespace.

A dependency shard row is therefore a physical container for many bounded per-target proof histories, not a log indexed by commits. Each target candidate in that history is a complete source closure: a map from observed source paths to the fingerprints they had when the target was evaluated. A cache hit means that one whole candidate for that target still matches the current tree. The commit at which the candidate was learned may be useful metadata for ordering or eviction, but it is never proof of validity.

Expand Down Expand Up @@ -376,11 +378,11 @@ The cache keeps **bounded historical source closures, not per-commit entries.**

A fixed candidate count is the simplest first policy. If measurements show that useful histories are mostly time-shaped rather than count-shaped, a future cache could retain candidates by an approximate 24-hour TTL instead: keep all distinct closures learned in the recent window, then evict by age. That would trade a slightly less predictable row size for a policy closer to the product goal of surviving normal daily branch and merge-queue churn.

Consequently, the cache grows with the logical key space and the bounded history per target, not with repository history. A target's history lives inside the `DependencyShards` row selected by `(gitDir, resolver, argsKey, shard)`, where the shard is a stable hash of the target name; discovery history lives under a reserved key in the same scheme. Within a target history, inserting a freshly evaluated closure deduplicates identical closure content and evicts old candidates by policy when the bound is reached.
Consequently, the cache grows with the logical key space and bounded history per target, not with the target repository's commit history. A target's history lives inside the `DependencyShards` row selected by `(tecnixRevision, gitDir, resolver, argsKey, shard)`, where the shard is a stable hash of the target name; discovery history lives under a reserved key in the same scheme. Within a target history, inserting a freshly evaluated closure deduplicates identical closure content and evicts old candidates by policy when the bound is reached.

The important behavioral consequence is that switching between divergent trees need not thrash the cache. If two branches produce different but recently seen closures for the same target, both can remain as candidates, and either branch can hit by proving its candidate against the current tree. If the useful candidate has been evicted, the result is only a cold re-evaluation; eviction is a performance policy, not a correctness policy.

The unbounded dimensions are the key tuples themselves: each distinct `args` value, resolver path, or repository location materializes its own row set, and abandoned tuples are not currently reclaimed. The validation discipline supplies the operational escape hatch: since no row is ever trusted without proof against the current tree, the database is disposable. Deleting it is always safe and costs cold re-evaluation.
The unbounded dimensions are the key tuples themselves: each evaluator revision, `args` value, resolver path, or repository location materializes its own row set, and abandoned tuples are not currently reclaimed. Old evaluator rows can coexist with new ones, but cannot satisfy the new revision's lookups. The database remains disposable: deleting it is safe and costs cold re-evaluation.

---

Expand Down Expand Up @@ -450,7 +452,7 @@ Everything else — the interning structure, the frames, the accessors, the cach

## 11. Limitations

The following limitations are deliberate and documented. The persistent cache requires `pure-eval` (§2.2). Dirty-file state is captured once per evaluation, so mutating the checkout during a query is outside the contract. Access to the repository root is not representable in the closure format and fails closed. The cache has no key-tuple eviction policy; abandoned `(gitDir, resolver, argsKey)` row sets accumulate until the database is deleted, which is always safe (§8.1).
The following limitations are deliberate and documented. The persistent cache requires `pure-eval` and a full Tecnix revision stamp (§2.2); unversioned development builds evaluate without persistent caching. Dirty-file state is captured once per evaluation, so mutating the checkout during a query is outside the contract. Access to the repository root is not representable in the closure format and fails closed. The cache has no key-tuple eviction policy; abandoned `(tecnixRevision, gitDir, resolver, argsKey)` row sets accumulate until the database is deleted (§8.1).

**Future work.** In a worldtree sandbox, directory and regular-file fingerprints are already single O(1) xattr reads when the daemon serves `user.worldtree.blob-oid` beside `user.worldtree.tree-oid` (§7). The remaining hash fallback covers symlinks — which cannot carry user xattrs at all — and daemons that predate the blob-oid xattr; it is memoized in memory per evaluation. A daemon-side answer for symlink oids (for example serving the parent's raw tree object, whose `(mode, name, oid)` entries are exactly what libgit2 itself reads) would delete the fallback entirely; because every mechanism emits identical fingerprint strings, that change invalidates no stored closure. Additionally, the projection is zone-granular: committed paths outside every visible zone are not observable historically, and mutable-sandbox dirty discovery still assumes a local `git status`, whose worldtree replacement is the daemon's `scoped.status`.

Expand Down
13 changes: 9 additions & 4 deletions plans/tecnix-target-eval-caching/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,17 @@ Use this as a review checklist for source-dependency tracking and target-eval ca
- Partial matches are misses.
- Candidate validation may short-circuit on mismatch, but acceptance requires the whole candidate.

- **Never trust commit identity for cache acceptance.**
- No cache validity by `rev`.
- No per-commit cache key.
- No per-commit/rev fast path.
- **Never trust the target repository's commit identity for cache acceptance.**
- No cache validity by the source `rev`.
- No per-source-commit cache key.
- No per-source-commit fast path.
- Changed-path or tree-diff data may filter affected-target output, but must not accept cache rows.


- **Persistent rows must not cross Tecnix evaluator revisions.**
- Require a full evaluator revision stamp; unknown or abbreviated stamps bypass persistent caching.
- Apply the revision to both reads and writes, including single-shard and multi-shard lookups.

- **Unknown or malformed cache data is a miss.**
- Cache data is an optimization; bad rows must not produce stale answers.

Expand Down
4 changes: 2 additions & 2 deletions plans/tecnix-target-eval-caching/walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ Argument handling is largely routine. Two decisions made at this stage matter la

First, **the repository context is pinned.** The `gitDir`, `rev`, and checkout path configure the evaluator's source accessors, and they do so exactly once per evaluator instance. A second call with a different `rev` produces an error rather than a silent reconfiguration. The reason is that the accessors, fingerprints, and cached content constructed downstream are all built lazily against a single commit; permitting reconfiguration would allow content from two commits to mix without any indication that it had.

Second, **the `args` value becomes part of the cache key.** It is converted to a canonical JSON encoding, called the `argsKey`. This is sound because the resolver receives the same value: results can depend on the arguments only through content that is, by construction, the key. It is worth observing what the cache key does *not* contain: the commit. Validity across commits is established by proof rather than by key, as the next step describes.
Second, **the `args` value becomes part of the cache key.** It is converted to canonical JSON, called the `argsKey`. The resolver receives the same value, so argument-dependent results are scoped by the same content. The key also contains the full Tecnix evaluator revision. It does not contain the target repository's commit: source reuse is established by fingerprint validation. Unknown or abbreviated evaluator revision stamps bypass persistent caching.

## 4. Step ②: The Cache Question

> **Structure: `TecnixEvalCache`.** A SQLite database holding shard rows keyed by `(gitDir, resolver, argsKey, shard)`. Each shard row contains bounded source-closure histories for the targets assigned to that shard. It exists because skipping evaluation requires remembering what would certify the skipped result.
> **Structure: `TecnixEvalCache`.** A SQLite database holding shard rows keyed by `(tecnixRevision, gitDir, resolver, argsKey, shard)`. Each shard holds bounded source-closure histories for its targets, isolated from other evaluator revisions. It exists because skipping evaluation requires remembering what would certify the skipped result.

The shard containing `//services/api` is loaded. A single target uses a point lookup for its shard; when many targets are requested, one range scan retrieves the relevant shard rows, and their validation proceeds outside the database lock. Each row's blob begins with the magic bytes `TXDC` (explainer §8).

Expand Down
5 changes: 3 additions & 2 deletions src/libexpr/include/nix/expr/eval-settings.hh
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,9 @@ struct EvalSettings : Config
true,
"tecnix-eval-cache",
R"(
Whether to use the Tecnix evaluation cache for target-dependency and
target-name discovery.
Whether to cache Tecnix target results, dependencies, and target names.
Persistent caching also requires pure evaluation and a full Tecnix
evaluator revision stamp.

Disabling this forces Tecnix dependency discovery to re-evaluate
instead of reusing results whose recorded source fingerprints still
Expand Down
2 changes: 2 additions & 0 deletions src/libexpr/include/nix/expr/tecnix/eval-cache.hh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
namespace nix {

class EvalState;
/** Persistent caching requires pure evaluation and an exact evaluator revision. */
bool useTecnixEvalCache(const EvalState & state);
struct SourceAccessor;
struct Value;

Expand Down
8 changes: 4 additions & 4 deletions src/libexpr/primops/tecnix.cc
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ static void configureTecnixRepoContext(EvalState & state, const TecnixArgs & arg
*/
static bool tecnixSourceTrackingEnabled(const EvalState & state, const TecnixArgs & tArgs)
{
return tArgs.requireDependencies || (state.settings.pureEval && state.settings.tecnixEvalCache);
return tArgs.requireDependencies || useTecnixEvalCache(state);
}

/** Keyspace separator for modules built without tracking; see
Expand Down Expand Up @@ -555,7 +555,7 @@ struct TecnixDiscoveryResult
static TecnixDiscoveryResult discoverTecnixTargetNames(
EvalState & state, const PosIdx pos, const TecnixArgs & tArgs, DependencyFingerprintCache & fingerprintCache)
{
bool useCache = state.settings.pureEval && state.settings.tecnixEvalCache;
bool useCache = useTecnixEvalCache(state);
bool track = tecnixSourceTrackingEnabled(state, tArgs);

std::string cacheKey{tecnixTargetNamesCacheKey};
Expand Down Expand Up @@ -721,7 +721,7 @@ static void prim_tecnixTargets(EvalState & state, const PosIdx pos, Value ** arg
return;
}

if (state.settings.pureEval && state.settings.tecnixEvalCache) {
if (useTecnixEvalCache(state)) {
prim_tecnixTargetsCached(state, pos, v, tArgs);
return;
}
Expand Down Expand Up @@ -1019,7 +1019,7 @@ static TargetDependencyResults evaluateTecnixTargetDependencies(
DependencyFingerprintCache & fingerprintCache,
bool keepTargetValues = false)
{
bool useCache = state.settings.pureEval && state.settings.tecnixEvalCache;
bool useCache = useTecnixEvalCache(state);
printTalkative(
"tecnixTargets dependencies: planning %d target ref(s), dependency cache %s, eval cores %d",
args.targets.size(),
Expand Down
Loading
Loading