diff --git a/packaging/components.nix b/packaging/components.nix index 962f0ba05..31f0670ba 100644 --- a/packaging/components.nix +++ b/packaging/components.nix @@ -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 { }; diff --git a/packaging/dev-shell.nix b/packaging/dev-shell.nix index 479229b06..5c1b7db0e 100644 --- a/packaging/dev-shell.nix +++ b/packaging/dev-shell.nix @@ -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) diff --git a/plans/tecnix-target-eval-caching/explainer.md b/plans/tecnix-target-eval-caching/explainer.md index ec354f7e5..048a91590 100644 --- a/plans/tecnix-target-eval-caching/explainer.md +++ b/plans/tecnix-target-eval-caching/explainer.md @@ -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 @@ -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. @@ -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. --- @@ -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`. diff --git a/plans/tecnix-target-eval-caching/guardrails.md b/plans/tecnix-target-eval-caching/guardrails.md index 58ab7e273..85fd14bdc 100644 --- a/plans/tecnix-target-eval-caching/guardrails.md +++ b/plans/tecnix-target-eval-caching/guardrails.md @@ -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. diff --git a/plans/tecnix-target-eval-caching/walkthrough.md b/plans/tecnix-target-eval-caching/walkthrough.md index 1c701a990..9b752a61d 100644 --- a/plans/tecnix-target-eval-caching/walkthrough.md +++ b/plans/tecnix-target-eval-caching/walkthrough.md @@ -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). diff --git a/src/libexpr/include/nix/expr/eval-settings.hh b/src/libexpr/include/nix/expr/eval-settings.hh index 19ad2299f..27d05ef0b 100644 --- a/src/libexpr/include/nix/expr/eval-settings.hh +++ b/src/libexpr/include/nix/expr/eval-settings.hh @@ -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 diff --git a/src/libexpr/include/nix/expr/tecnix/eval-cache.hh b/src/libexpr/include/nix/expr/tecnix/eval-cache.hh index dd5153892..a2744a955 100644 --- a/src/libexpr/include/nix/expr/tecnix/eval-cache.hh +++ b/src/libexpr/include/nix/expr/tecnix/eval-cache.hh @@ -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; diff --git a/src/libexpr/primops/tecnix.cc b/src/libexpr/primops/tecnix.cc index 8cdfd595c..4a6515da4 100644 --- a/src/libexpr/primops/tecnix.cc +++ b/src/libexpr/primops/tecnix.cc @@ -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 @@ -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}; @@ -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; } @@ -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(), diff --git a/src/libexpr/tecnix/eval-cache.cc b/src/libexpr/tecnix/eval-cache.cc index f368021dc..1f58ee571 100644 --- a/src/libexpr/tecnix/eval-cache.cc +++ b/src/libexpr/tecnix/eval-cache.cc @@ -8,6 +8,7 @@ #include "nix/expr/tecnix/eval-cache.hh" #include "nix/expr/eval-inline.hh" +#include "nix/expr/eval-settings.hh" #include "nix/expr/tecnix/source-accessors.hh" #include "nix/store/globals.hh" #include "nix/store/sqlite.hh" @@ -31,6 +32,16 @@ namespace nix { +bool useTecnixEvalCache(const EvalState & state) +{ + // Unknown or abbreviated version stamps cannot identify one evaluator. + static const bool knownRevision = + tecnixVersion.size() == 40 && std::all_of(tecnixVersion.begin(), tecnixVersion.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); + return state.settings.pureEval && state.settings.tecnixEvalCache && knownRevision; +} + static std::atomic nextDependencyFingerprintCacheGeneration{1}; DependencyFingerprintCache::DependencyFingerprintCache() @@ -56,13 +67,14 @@ static DependencyFingerprintThreadLocalCache & getDependencyFingerprintThreadCac // validation (a miss), and deleting the database is always safe. static const char * tecnixEvalCacheSchema = R"sql( create table if not exists DependencyShards ( + tecnixRevision text not null, gitDir text not null, resolver text not null, argsKey text not null, shard integer not null, dependencies blob not null, timestamp integer not null, - primary key (gitDir, resolver, argsKey, shard) + primary key (tecnixRevision, gitDir, resolver, argsKey, shard) ); )sql"; @@ -86,8 +98,8 @@ struct TecnixEvalCache { auto state(_state.lock()); - // v2: target candidates carry the evaluated drvPath as their payload. - auto dbPath = getCacheDir() / "tecnix-eval-cache-v2.sqlite"; + // v3: revision-scoped rows isolate results from different evaluator builds. + auto dbPath = getCacheDir() / "tecnix-eval-cache-v3.sqlite"; createDirs(dbPath.parent_path()); state->db = SQLite(dbPath, {.useWAL = settings.useSQLiteWAL}); @@ -96,14 +108,16 @@ struct TecnixEvalCache state->upsertShard.create( state->db, - "insert or replace into DependencyShards(gitDir, resolver, argsKey, shard, dependencies, timestamp) " - "values (?, ?, ?, ?, ?, ?)"); + "insert or replace into DependencyShards(tecnixRevision, gitDir, resolver, argsKey, shard, dependencies, timestamp) " + "values (?, ?, ?, ?, ?, ?, ?)"); state->lookupShard.create( state->db, - "select dependencies from DependencyShards where gitDir = ? and resolver = ? and argsKey = ? and shard = ?"); + "select dependencies from DependencyShards " + "where tecnixRevision = ? and gitDir = ? and resolver = ? and argsKey = ? and shard = ?"); state->lookupAllShards.create( state->db, - "select shard, dependencies from DependencyShards where gitDir = ? and resolver = ? and argsKey = ?"); + "select shard, dependencies from DependencyShards " + "where tecnixRevision = ? and gitDir = ? and resolver = ? and argsKey = ?"); } static constexpr std::string_view dependencyBlobMagic = "TXDC"; @@ -924,8 +938,12 @@ struct TecnixEvalCache auto state(_state.lock()); if (indicesByShard.size() == 1) { auto shard = indicesByShard.begin()->first; - auto stmt( - state->lookupShard.use().apply(scope.gitDir).apply(scope.resolver).apply(scope.argsKey).apply(shard)); + auto stmt(state->lookupShard.use() + .apply(tecnixVersion) + .apply(scope.gitDir) + .apply(scope.resolver) + .apply(scope.argsKey) + .apply(shard)); if (!stmt.next()) return blobs; auto blobView = stmt.getBlob(0); @@ -935,7 +953,11 @@ struct TecnixEvalCache return blobs; } - auto stmt(state->lookupAllShards.use().apply(scope.gitDir).apply(scope.resolver).apply(scope.argsKey)); + auto stmt(state->lookupAllShards.use() + .apply(tecnixVersion) + .apply(scope.gitDir) + .apply(scope.resolver) + .apply(scope.argsKey)); while (stmt.next()) { auto shard = static_cast(stmt.getInt(0)); auto indices = indicesByShard.find(shard); @@ -1015,14 +1037,22 @@ struct TecnixEvalCache existingBlobs.reserve(updatesByShard.size()); if (updatesByShard.size() == 1) { auto shard = updatesByShard.begin()->first; - auto stmt( - state->lookupShard.use().apply(scope.gitDir).apply(scope.resolver).apply(scope.argsKey).apply(shard)); + auto stmt(state->lookupShard.use() + .apply(tecnixVersion) + .apply(scope.gitDir) + .apply(scope.resolver) + .apply(scope.argsKey) + .apply(shard)); if (stmt.next()) { auto blobView = stmt.getBlob(0); existingBlobs.emplace(shard, std::string(blobView.data(), blobView.size())); } } else { - auto stmt(state->lookupAllShards.use().apply(scope.gitDir).apply(scope.resolver).apply(scope.argsKey)); + auto stmt(state->lookupAllShards.use() + .apply(tecnixVersion) + .apply(scope.gitDir) + .apply(scope.resolver) + .apply(scope.argsKey)); while (stmt.next()) { auto shard = static_cast(stmt.getInt(0)); if (updatesByShard.find(shard) == updatesByShard.end()) @@ -1045,6 +1075,7 @@ struct TecnixEvalCache auto timestamp = time(nullptr); for (auto & [shard, blob] : blobs) { state->upsertShard.use() + .apply(tecnixVersion) .apply(scope.gitDir) .apply(scope.resolver) .apply(scope.argsKey) diff --git a/src/libstore/meson.build b/src/libstore/meson.build index f97902ed0..76533be07 100644 --- a/src/libstore/meson.build +++ b/src/libstore/meson.build @@ -31,10 +31,11 @@ configdata_priv.set_quoted( fs.read('../../.version-determinate').strip(), ) -configdata_priv.set_quoted( - 'TECNIX_VERSION', - fs.read('../../.version-tecnix').strip(), -) +tecnix_version = get_option('tecnix-revision') +if tecnix_version == '' + tecnix_version = fs.read('../../.version-tecnix').strip() +endif +configdata_priv.set_quoted('TECNIX_VERSION', tecnix_version) subdir('nix-meson-build-support/default-system-cpu') diff --git a/src/libstore/meson.options b/src/libstore/meson.options index 6bae2ab11..b99040cf3 100644 --- a/src/libstore/meson.options +++ b/src/libstore/meson.options @@ -1,5 +1,12 @@ # vim: filetype=meson +option( + 'tecnix-revision', + type : 'string', + value : '', + description : 'full Tecnix source revision, overriding .version-tecnix', +) + option( 'embedded-sandbox-shell', type : 'boolean', diff --git a/src/libstore/package.nix b/src/libstore/package.nix index 9643edfc0..2c721be5f 100644 --- a/src/libstore/package.nix +++ b/src/libstore/package.nix @@ -22,6 +22,7 @@ # Configuration Options version, + tecnixRevision ? null, embeddedSandboxShell ? stdenv.hostPlatform.isStatic && !stdenv.hostPlatform.isDarwin, @@ -87,6 +88,7 @@ mkMesonLibrary (finalAttrs: { (lib.mesonEnable "s3-aws-auth" withAWS) (lib.mesonEnable "wasm" enableWasm) ] + ++ lib.optional (tecnixRevision != null) (lib.mesonOption "tecnix-revision" tecnixRevision) ++ lib.optionals stdenv.hostPlatform.isLinux [ (lib.mesonOption "sandbox-shell" "${busybox-sandbox-shell}/bin/busybox") ]; diff --git a/tests/functional/tecnix/builtins.sh b/tests/functional/tecnix/builtins.sh index 2678253a7..f7202b474 100755 --- a/tests/functional/tecnix/builtins.sh +++ b/tests/functional/tecnix/builtins.sh @@ -1201,6 +1201,60 @@ grepQuiet "tecnixTargetNames: discovery cache hit" "$TEST_ROOT/drv-report-warm-p grepQuiet "tecnixTargets: 2 target value(s) served from the cache" "$TEST_ROOT/drv-report-warm-parallel.err" grepQuietInverse "drv-world-resolver-evaluated" "$TEST_ROOT/drv-report-warm-parallel.err" +# ============================================================ +# Eval cache: Tecnix revision isolation +# ============================================================ +# The Tecnix evaluator revision (embedded at build time) scopes cache +# rows. Relabeling stored rows as another revision must force +# re-evaluation, even though source inputs and payloads are unchanged. + +echo "Testing cache isolation between Tecnix revisions..." + +# Relabel all stored rows as another evaluator build. Exercise both +# single-shard and multi-shard lookups. +sqlite3 "$EVAL_CACHE_DB" "UPDATE DependencyShards SET tecnixRevision = '0000000000000000000000000000000000000000'" + +# Single-target lookup: the relabeled row misses, the resolver runs, and +# the correct drvPath is re-evaluated and stored under the real revision. +revision_alpha_values=$(tecnix_eval_json_cache "builtins.mapAttrs (id: t: t.drvPath) (builtins.tecnixTargets (($drv_args) // { targets = [ \"alpha\" ]; }))" 2> "$TEST_ROOT/drv-revision-single.err") +assert_json_equal "$revision_alpha_values" "$(jq '{alpha}' <<< "$cold_drv_values")" \ + "another evaluator revision's single-target entry should re-evaluate correctly" +grepQuiet "drv-world-resolver-evaluated" "$TEST_ROOT/drv-revision-single.err" +grepQuietInverse "served from the cache" "$TEST_ROOT/drv-revision-single.err" + +# Mixed batch: alpha was just re-evaluated under the real revision (a hit), +# while beta still carries the relabeled revision (a miss). The resolver +# runs for beta only; alpha is served from the cache. +revision_batch_values=$(tecnix_eval_json_cache "$drv_paths_expr" 2> "$TEST_ROOT/drv-revision-batch.err") +assert_json_equal "$revision_batch_values" "$cold_drv_values" \ + "another evaluator revision's remaining batch entry should re-evaluate correctly" +grepQuiet "drv-world-resolver-evaluated" "$TEST_ROOT/drv-revision-batch.err" +grepQuiet "tecnixTargets: 1 target value(s) served from the cache" "$TEST_ROOT/drv-revision-batch.err" + +# Rewarm: both entries are now stored under the real revision and serve +# from the cache without calling the resolver. +revision_warm_values=$(tecnix_eval_json_cache "$drv_paths_expr" 2> "$TEST_ROOT/drv-revision-warm.err") +assert_json_equal "$revision_warm_values" "$cold_drv_values" "new evaluator entries should become reusable" +grepQuietInverse "drv-world-resolver-evaluated" "$TEST_ROOT/drv-revision-warm.err" +grepQuiet "tecnixTargets: 2 target value(s) served from the cache" "$TEST_ROOT/drv-revision-warm.err" + +# The target repository's commit is not the evaluator revision. Identical +# source contents at another commit must remain reusable without re-evaluation. +echo "Testing unchanged source commit reuse..." +SOURCE_HEAD=$(get_head_sha "$DRV_WORLD") +source_args="(($drv_args) // { rev = \"$SOURCE_HEAD\"; })" +source_values=$(tecnix_eval_json_cache "builtins.mapAttrs (id: t: t.drvPath) (builtins.tecnixTargets (($source_args) // { targets = [ \"alpha\" \"beta\" ]; }))") +( + cd "$DRV_WORLD" + git commit --allow-empty -m "advance the source commit without changing inputs" +) +UNCHANGED_HEAD=$(get_head_sha "$DRV_WORLD") +unchanged_args="(($drv_args) // { rev = \"$UNCHANGED_HEAD\"; })" +unchanged_values=$(tecnix_eval_json_cache "builtins.mapAttrs (id: t: t.drvPath) (builtins.tecnixTargets (($unchanged_args) // { targets = [ \"alpha\" \"beta\" ]; }))" 2> "$TEST_ROOT/drv-source-commit.err") +assert_json_equal "$unchanged_values" "$source_values" "unchanged sources should reuse answers across source commits" +grepQuietInverse "drv-world-resolver-evaluated" "$TEST_ROOT/drv-source-commit.err" +grepQuiet "tecnixTargets: 2 target value(s) served from the cache" "$TEST_ROOT/drv-source-commit.err" + # Dependency-only queries store value payloads too: a dependency pass (CI # warming the cache) is enough for a later plain tecnixTargets call in the # same scope to be answered without evaluating anything.