Skip to content

Commit 8ddb3f4

Browse files
committed
fix(rules): key the Rules cache on the native build, not only the version
Local development builds shared one SQLBuild version, so the Rules cache under target/rules-cache reused results from older builds until it was cleared. A build script now hashes the native crate sources and lockfile, and the ruleset fingerprint, model and custom-rule cache identities, and the SQL rule cache use the version plus that hash. Released builds keep a stable identity. Refs: CHI-471
1 parent 08ebe21 commit 8ddb3f4

14 files changed

Lines changed: 184 additions & 13 deletions

File tree

‎crates/sqlbuild-rules-native/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ stacker.workspace = true
3030
sqlparser.workspace = true
3131
toml.workspace = true
3232

33+
[build-dependencies]
34+
sha2.workspace = true
35+
3336
[dev-dependencies]
3437
tempfile.workspace = true
3538

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//! Derive a content identity for this native build so local caches notice source changes.
2+
//!
3+
//! The identity hashes every file under the listed crate entries plus the workspace lockfile,
4+
//! keyed by `/`-separated paths relative to the crate root so it matches across platforms.
5+
//! Entries that do not exist (for example the lockfile outside a workspace checkout) are left
6+
//! out of both the hash and the `rerun-if-changed` list, because Cargo reruns a build script on
7+
//! every build when a watched path is missing.
8+
9+
use sha2::{Digest, Sha256};
10+
use std::path::{Component, Path, PathBuf};
11+
12+
const HASHED_ENTRIES: [&str; 3] = ["Cargo.toml", "src", "function_names"];
13+
const WORKSPACE_LOCKFILE: &str = "../../Cargo.lock";
14+
15+
fn main() -> Result<(), String> {
16+
let crate_dir =
17+
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").map_err(|error| error.to_string())?);
18+
let mut files: Vec<(String, PathBuf)> = Vec::new();
19+
for entry in HASHED_ENTRIES {
20+
let path = crate_dir.join(entry);
21+
if path.exists() {
22+
println!("cargo:rerun-if-changed={}", path.display());
23+
collect_files(&crate_dir, &path, &mut files)?;
24+
}
25+
}
26+
files.sort();
27+
let lockfile = crate_dir.join(WORKSPACE_LOCKFILE);
28+
if lockfile.is_file() {
29+
println!("cargo:rerun-if-changed={}", lockfile.display());
30+
files.push((WORKSPACE_LOCKFILE.to_owned(), lockfile));
31+
}
32+
let mut digest = Sha256::new();
33+
for (label, file) in &files {
34+
digest.update(label.as_bytes());
35+
digest.update([0]);
36+
digest.update(std::fs::read(file).map_err(|error| error.to_string())?);
37+
digest.update([0]);
38+
}
39+
let identity = format!("{:x}", digest.finalize());
40+
println!(
41+
"cargo:rustc-env=SQLBUILD_NATIVE_SOURCE_HASH={}",
42+
&identity[..16]
43+
);
44+
Ok(())
45+
}
46+
47+
fn portable_label(crate_dir: &Path, path: &Path) -> Result<String, String> {
48+
let relative = path
49+
.strip_prefix(crate_dir)
50+
.map_err(|error| error.to_string())?;
51+
let parts: Vec<String> = relative
52+
.components()
53+
.map(|component| match component {
54+
Component::Normal(part) => Ok(part.to_string_lossy().into_owned()),
55+
other => Err(format!("unexpected path component {other:?}")),
56+
})
57+
.collect::<Result<_, _>>()?;
58+
Ok(parts.join("/"))
59+
}
60+
61+
fn collect_files(
62+
crate_dir: &Path,
63+
path: &Path,
64+
files: &mut Vec<(String, PathBuf)>,
65+
) -> Result<(), String> {
66+
if path.is_file() {
67+
files.push((portable_label(crate_dir, path)?, path.to_path_buf()));
68+
return Ok(());
69+
}
70+
if !path.is_dir() {
71+
return Ok(());
72+
}
73+
for entry in std::fs::read_dir(path).map_err(|error| error.to_string())? {
74+
collect_files(
75+
crate_dir,
76+
&entry.map_err(|error| error.to_string())?.path(),
77+
files,
78+
)?;
79+
}
80+
Ok(())
81+
}

‎crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use pyo3::types::{PyDict, PyDictMethods, PyList, PyTuple};
77
use pyo3::{FromPyObject, pyfunction, wrap_pyfunction};
88

99
use crate::configuration::main::load;
10-
use crate::constants::API_VERSION;
10+
use crate::constants::{API_VERSION, NATIVE_BUILD_IDENTITY};
1111
use crate::engine::main::evaluate;
1212
use crate::models::CatalogueResponse;
1313
use crate::rules::main::{catalogue, selected_codes};
@@ -469,5 +469,6 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
469469
module.add_function(wrap_pyfunction!(render_owned_skill, module)?)?;
470470
module.add_function(wrap_pyfunction!(skill_freshness, module)?)?;
471471
module.add("API_VERSION", API_VERSION)?;
472+
module.add("BUILD_IDENTITY", NATIVE_BUILD_IDENTITY)?;
472473
Ok(())
473474
}

‎crates/sqlbuild-rules-native/src/constants.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
pub(crate) const API_VERSION: u32 = 1;
2+
pub(crate) const NATIVE_BUILD_IDENTITY: &str = concat!(
3+
env!("CARGO_PKG_VERSION"),
4+
"+",
5+
env!("SQLBUILD_NATIVE_SOURCE_HASH")
6+
);
27
pub(crate) const SCOPE_METADATA_SCHEMA_VERSION: u32 = 3;
38
pub(crate) const BUILT_IN_RULE_NAMESPACE: &str = "SQBR";
49
pub(crate) const PIPELINE_SUBJECT: &str = "pipeline";

‎crates/sqlbuild-rules-native/src/engine/_helpers/evaluation.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::configuration::main::validate as config;
22
use crate::constants::{
3-
API_VERSION, CUSTOM_HOST_REQUIRED_ERROR, GIT_DIRECTORY, LOGS_DIRECTORY, PYTHON_EXTENSION,
4-
RULES_DIRECTORY, TARGET_DIRECTORY,
3+
API_VERSION, CUSTOM_HOST_REQUIRED_ERROR, GIT_DIRECTORY, LOGS_DIRECTORY, NATIVE_BUILD_IDENTITY,
4+
PYTHON_EXTENSION, RULES_DIRECTORY, TARGET_DIRECTORY,
55
};
66
use crate::engine::_helpers::cache::{Cache, RuleCacheBucket, RuleCacheEntry};
77
use crate::models::{
@@ -552,7 +552,7 @@ fn custom_model_fact_identities(
552552
let mut digest = Sha256::new();
553553
digest.update(b"custom-rule-model-facts-v1");
554554
digest.update(ANALYSIS_BATCH_SCHEMA_VERSION.to_le_bytes());
555-
digest.update(env!("CARGO_PKG_VERSION").as_bytes());
555+
digest.update(NATIVE_BUILD_IDENTITY.as_bytes());
556556
digest.update(serde_json::to_vec(model).map_err(|error| error.to_string())?);
557557
Ok((
558558
model.relative_path.clone(),
@@ -575,6 +575,7 @@ fn custom_model_fact_identities(
575575
fn custom_rule_identity(rule: &RuleMetadata, request: &EvaluateRequest) -> Result<String, String> {
576576
let mut digest = Sha256::new();
577577
digest.update(b"custom-rule-identity-v1");
578+
digest.update(NATIVE_BUILD_IDENTITY.as_bytes());
578579
digest.update(serde_json::to_vec(rule).map_err(|error| error.to_string())?);
579580
digest.update(
580581
serde_json::to_vec(&request.config.rule_options.get(&rule.code))
@@ -623,7 +624,7 @@ fn model_cache_identity(
623624
identity: RuntimeIdentity {
624625
producer: "sqlbuild-rules".to_owned(),
625626
fact_schema: "sqlbuild-rules-model-v1".to_owned(),
626-
runtime: env!("CARGO_PKG_VERSION").to_owned(),
627+
runtime: NATIVE_BUILD_IDENTITY.to_owned(),
627628
rule_pack: ruleset.to_owned(),
628629
configuration: project.unwrap_or("model-local").to_owned(),
629630
},

‎crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use serde_json::{Value, json};
22
use tempfile::TempDir;
33

4+
use crate::constants::NATIVE_BUILD_IDENTITY;
45
use crate::engine::main::evaluate::evaluate_json;
56
use crate::engine::tests::helpers;
67
use crate::engine::tests::test_types;
@@ -1343,3 +1344,22 @@ fn given_threshold_override_change_when_evaluating_then_ruleset_fingerprint_chan
13431344
}
13441345
Ok(())
13451346
}
1347+
1348+
#[test]
1349+
fn given_native_build_when_reading_cache_identity_then_version_carries_source_hash() {
1350+
let test_cases = [test_types::BuildIdentityTestCase {
1351+
description: "cache identity is the package version plus a source content hash",
1352+
expected_prefix: concat!(env!("CARGO_PKG_VERSION"), "+"),
1353+
expected_hash_length: 16,
1354+
}];
1355+
for test_case in &test_cases {
1356+
let hash = NATIVE_BUILD_IDENTITY.strip_prefix(test_case.expected_prefix);
1357+
1358+
assert!(
1359+
hash.is_some_and(|hash| hash.len() == test_case.expected_hash_length
1360+
&& hash.bytes().all(|byte| byte.is_ascii_hexdigit())),
1361+
"{}",
1362+
test_case.description
1363+
);
1364+
}
1365+
}

‎crates/sqlbuild-rules-native/src/engine/tests/test_types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ pub(crate) struct ThresholdEvaluationTestCase {
106106
pub(crate) expected_codes: &'static [&'static str],
107107
}
108108

109+
pub(crate) struct BuildIdentityTestCase {
110+
pub(crate) description: &'static str,
111+
pub(crate) expected_prefix: &'static str,
112+
pub(crate) expected_hash_length: usize,
113+
}
114+
109115
pub(crate) struct ThresholdFingerprintTestCase {
110116
pub(crate) description: &'static str,
111117
pub(crate) base_config: Value,

‎crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::constants::API_VERSION;
1+
use crate::constants::{API_VERSION, NATIVE_BUILD_IDENTITY};
22
use crate::models::RulesCodeGrammar;
33
use crate::models::{CustomRule, ResolveRulesRequest, RuleGuidance, RuleMetadata};
44
use fensu_policy::policy::errors::PolicyError;
@@ -487,6 +487,7 @@ pub(crate) fn fingerprint(
487487
config: &crate::models::RulesConfig,
488488
dialect: &str,
489489
) -> Result<String, String> {
490-
let payload = serde_json::to_vec(&(rules, config, dialect)).map_err(|e| e.to_string())?;
490+
let payload = serde_json::to_vec(&(rules, config, dialect, NATIVE_BUILD_IDENTITY))
491+
.map_err(|e| e.to_string())?;
491492
Ok(format!("{:x}", Sha256::digest(payload)))
492493
}

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ cache-keys = [
139139
{ file = "pyproject.toml" },
140140
{ file = "Cargo.toml" },
141141
{ file = "crates/sqlbuild-rules-native/Cargo.toml" },
142+
{ file = "crates/sqlbuild-rules-native/build.rs" },
142143
{ file = "crates/sqlbuild-rules-native/src/**/*.rs" },
143144
{ file = "src/sqlbuild/_native.pyi" },
144145
]

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/rules/execution-and-caching.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ normal compiler before treating the complete project as valid.
2828

2929
Rules cache under `target/rules-cache`. A cache identity includes the Rule code, implementation and
3030
imported helpers, options, invocation subject, accessed compiler facts, dialect, tracked project
31-
observations, and compatibility versions.
31+
observations, and compatibility versions. The SQLBuild version in that identity also carries a hash of
32+
the native engine's source, so a local development build never reuses results from a different build
33+
of the same version.
3234

3335
Model-subject Rules support fine-grained reuse. Editing one model invalidates affected model
3436
subjects rather than every Rule invocation. Project-subject Rules run once and are appropriate for

0 commit comments

Comments
 (0)