Skip to content

Commit fd4ba57

Browse files
committed
fix(build): audit unread sources only with loading off or when selected
End-of-build source audits ran for every source no model read, on every build, so a plain build over an unmanaged source with a missing table failed. They now run only when loading is off (--no-load or auto_load_sources = false), or for a source selected by name. With loading on and no such selection, a source is audited only before a model that reads it, as before. An unbuilt model is now one that was not built and whose relation the inspected warehouse does not hold, rather than one planned as a first run, so a full refresh on a fresh warehouse skips the end audits over a failed model.
1 parent 0ad4e1a commit fd4ba57

20 files changed

Lines changed: 187 additions & 24 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ E2E_DUCKDB_BUILD_CORE_PATHS := \
112112
tests/e2e/src/sqlbuild/cli/commands/main/build/test_terminal_failure.py \
113113
tests/e2e/src/sqlbuild/cli/commands/main/build/test_no_load_sources.py \
114114
tests/e2e/src/sqlbuild/cli/commands/main/build/test_duckdb_storage_key.py \
115+
tests/e2e/src/sqlbuild/cli/commands/main/build/test_source_end_audits.py \
115116
tests/e2e/src/sqlbuild/cli/commands/main/build/test_unbuilt_dependencies.py \
116117
tests/e2e/src/sqlbuild/cli/commands/main/build/test_validation_failures.py
117118

‎crates/sqlbuild-analysis/src/planner_scope/main/source_selectors.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ use crate::graph::models::ParsedSelector;
77
use crate::planner_scope::constants::{NAME, SOURCE};
88
use std::collections::HashSet;
99

10-
/// Find selected sources explicitly named by upstream source or bare-name selectors.
10+
/// Selected sources named by a `name` or `source` selector, only `+`-prefixed ones if `upstream_only`.
1111
pub fn source_selectors(
1212
select: &[String],
1313
names: &[(String, ObjectKey)],
1414
selected: &[ObjectKey],
15+
upstream_only: bool,
1516
) -> Result<HashSet<ObjectKey>, SelectorError> {
1617
let mut keys: HashSet<ObjectKey> = HashSet::new();
1718
for part in select
@@ -22,12 +23,15 @@ pub fn source_selectors(
2223
let ParsedSelector::Kind {
2324
kind,
2425
value,
25-
upstream: true,
26+
upstream,
2627
..
2728
} = parse_selector(part)?
2829
else {
2930
continue;
3031
};
32+
if upstream_only && !upstream {
33+
continue;
34+
}
3135
if kind != NAME && kind != SOURCE {
3236
continue;
3337
}

‎crates/sqlbuild-python/src/bindings/_helpers/planner_entries/plan_output.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,21 @@ fn planned_sql_tests<'py>(
552552
)
553553
}
554554

555+
/// The names of the planned models whose relation the inspected warehouse already holds.
556+
fn existing_model_names<'py>(
557+
model_entries: &Bound<'py, PyAny>,
558+
existing_relations: &Bound<'py, PyAny>,
559+
) -> PyResult<Bound<'py, PyAny>> {
560+
let mut names: Vec<Bound<'py, PyAny>> = Vec::new();
561+
for entry in model_entries.try_iter()? {
562+
let name = native_field(&entry?, "name")?;
563+
if existing_relations.contains(&name)? {
564+
names.push(name);
565+
}
566+
}
567+
Ok(PyFrozenSet::new(model_entries.py(), names)?.into_any())
568+
}
569+
555570
/// The plan output's fields from planned entries and resolved relation inputs.
556571
pub(crate) fn plan_output_fields<'py>(
557572
inputs: PlanOutputInputs<'py>,
@@ -669,6 +684,17 @@ pub(crate) fn plan_output_fields<'py>(
669684
),
670685
("selected_keys", selected_keys),
671686
("warnings", tuple_of(py, warnings)?),
687+
(
688+
"existing_model_names",
689+
existing_model_names(
690+
&model_entries,
691+
&native_field(&snapshot, "existing_relations")?,
692+
)?,
693+
),
694+
(
695+
"end_audit_source_keys",
696+
native_field(&scope, "end_audit_source_keys")?,
697+
),
672698
("upstream_deps", native_field(&scope, "upstream_deps")?),
673699
("downstream_deps", native_field(&scope, "downstream_deps")?),
674700
(

‎crates/sqlbuild-python/src/bindings/_helpers/planner_records/constants.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const R_PLANNER_SCOPE: RecordSpec = RecordSpec {
5252
FieldDefault::NativeCall(RecordId::SqlTestSelection),
5353
),
5454
FieldSpec::new("load_sources", FieldDefault::True),
55+
FieldSpec::new("end_audit_source_keys", FieldDefault::EmptyFrozenset),
5556
],
5657
};
5758
const R_SQL_TEST_ASSERTION_STEP: RecordSpec = RecordSpec {
@@ -926,6 +927,8 @@ const R_PLAN_OUTPUT: RecordSpec = RecordSpec {
926927
FieldSpec::new("python_identity_fingerprints", FieldDefault::EmptyDict),
927928
FieldSpec::new("metadata", FieldDefault::EmptyDict),
928929
FieldSpec::new("cursor_overrides", FieldDefault::None),
930+
FieldSpec::new("existing_model_names", FieldDefault::EmptyFrozenset),
931+
FieldSpec::new("end_audit_source_keys", FieldDefault::EmptyFrozenset),
929932
],
930933
};
931934

‎crates/sqlbuild-python/src/bindings/_helpers/planner_scope/scope.rs‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,15 @@ pub(crate) fn build_planner_scope<'py>(
9494
let carrier = Carrier::new(py, &texts, || {
9595
Ok(names.iter().map(|(name, _)| name.clone()).collect())
9696
})?;
97-
let source_keys = source_selectors(&carrier.send_all(&texts)?, &names, &keys(&selected)?)
97+
let sent = carrier.send_all(&texts)?;
98+
let selected_names = keys(&selected)?;
99+
let source_keys = source_selectors(&sent, &names, &selected_names, true)
100+
.map_err(|error| carrier.error(error))?;
101+
let named_sources = source_selectors(&sent, &names, &selected_names, false)
98102
.map_err(|error| carrier.error(error))?;
99103
let cache = KeyCache::new(py)?;
100-
let mut executable = PyFrozenSet::new(
101-
py,
102-
cache.keys(&source_keys.into_iter().collect::<Vec<_>>())?,
103-
)?
104-
.into_any();
104+
let explicit_sources: Vec<(String, String)> = source_keys.into_iter().collect();
105+
let mut executable = PyFrozenSet::new(py, cache.keys(&explicit_sources)?)?.into_any();
105106
if !auto_load {
106107
executable = PyFrozenSet::empty(py)?.into_any();
107108
}
@@ -118,6 +119,11 @@ pub(crate) fn build_planner_scope<'py>(
118119
upstream = deps.into_any();
119120
downstream = build_downstream_deps(&upstream)?.into_any();
120121
}
122+
let end_audit_sources: Vec<(String, String)> = keys(&selected)?
123+
.into_iter()
124+
.filter(|key| key.0 == SOURCE && (!auto_load || named_sources.contains(key)))
125+
.collect();
126+
let end_audit_sources = PyFrozenSet::new(py, cache.keys(&end_audit_sources)?)?.into_any();
121127
let origins = build_execution_edge_origins(&project)?.into_any();
122128
let order = topologically_order_keys(&upstream, Some(origins))?.into_any();
123129
let models = PyDict::new(py);
@@ -150,6 +156,7 @@ pub(crate) fn build_planner_scope<'py>(
150156
"load_sources",
151157
PyBool::new(py, auto_load).to_owned().into_any(),
152158
),
159+
("end_audit_source_keys", end_audit_sources),
153160
],
154161
)
155162
}

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/build.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ This replaces the former `sqb run` command. The full lifecycle (tests + audits)
9191

9292
1. Managed sources are loaded (unless `--no-load`, which audits their existing data instead)
9393
2. Selected seeds are loaded
94-
3. Source audits run before their dependent models (unless `--no-audits`)
94+
3. Source audits run before their dependent models (unless `--no-audits`); with `--no-load`, or for a source selected by name, a source no model reads is audited at the end
9595
4. SQL unit tests run before their target model (unless `--no-tests`)
9696
5. Models are materialized in DAG topological order
9797
6. Error-severity audits run against the staging table before promotion to the target (unless `--no-audits`)

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/load.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ sqb build --reload
9595

9696
With loading off, through `--no-load` or `auto_load_sources = false`, no loader runs, even for a
9797
source selected by name such as `sqb build --no-load --select raw_orders`. Managed sources in the
98-
selection stay in the plan, and their audits check the data they already hold. A source outside
99-
the selection is neither loaded nor audited.
98+
selection stay in the plan, and their audits check the data they already hold, at the end of the
99+
build if no model reads them. A source outside the selection is neither loaded nor audited.
100100

101101
See [Loaders](../concepts/python-nodes/loaders.md) for full documentation on write strategies, the loader context API, and source deferral.

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/audits.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,11 @@ sources:
379379
380380
If a source audit with `error` severity fails, all downstream models that depend on that source are blocked. This lets you catch data quality issues at the source before any transformations run.
381381

382+
A source's audits run before the first model in the build that reads it. A source no selected model
383+
reads is audited at the end of the build only when loading is off (`--no-load` or
384+
`auto_load_sources = false`) or when the source is selected by name, such as
385+
`sqb build --select raw_orders`; otherwise its audits do not run.
386+
382387
A source audit that reads other resources waits for them before any model that depends on the
383388
source is built. It may read a model that is not built from the source; reading one that is fails
384389
with `P005`. See [Audits that read other resources](#audits-that-read-other-resources).

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/python-nodes/loaders.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,8 @@ sqb build --reload
340340

341341
With loading off, through `--no-load` or `auto_load_sources = false`, no loader runs, even for a
342342
source selected by name such as `sqb build --no-load --select raw_orders`. Managed sources in the
343-
selection stay in the plan, and their audits check the data they already hold. A source outside
344-
the selection is neither loaded nor audited.
343+
selection stay in the plan, and their audits check the data they already hold, at the end of the
344+
build if no model reads them. A source outside the selection is neither loaded nor audited.
345345

346346
When `--reload` is passed, `ctx.is_reload` is `True` in the loader function. This lets loaders implement different behavior for full reloads versus normal incremental loads.
347347

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/sources.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ sources:
7676

7777
If a source audit with `error` severity fails, all downstream models that depend on that source are blocked.
7878

79+
A source's audits run before the first model in the build that reads it. A source no selected model
80+
reads is audited at the end of the build only when loading is off (`--no-load` or
81+
`auto_load_sources = false`) or when the source is selected by name, such as
82+
`sqb build --select raw_orders`; otherwise its audits do not run.
83+
7984
A source audit may also read other resources; SQLBuild builds them first. See
8085
[Audits that read other resources](audits.md#audits-that-read-other-resources).
8186

0 commit comments

Comments
 (0)