Skip to content

Commit 19b42fd

Browse files
authored
fix: recreate relations missing from the warehouse snapshot (#818)
* fix: plan relations missing from the warehouse as first runs A model whose relation was dropped outside SQLBuild was still planned as built because a stored fingerprint existed: incremental models kept their cursor bounds and failed on the missing table, and views and tables reported no_change. Change detection now treats the warehouse snapshot as the source of truth for relation existence and consults stored fingerprints only when the relation exists. - Plan a first run for any model whose relation is absent, regardless of stored fingerprints, and emit a RECORDED_RELATION_MISSING plan warning when stored state recorded a previous build. - Report the built version as missing in plan JSON and expose warning codes. - Plan a seed whose relation is absent as a first run. - Let microbatch full-refresh builds create an absent live target instead of failing reconciliation. * docs: document state versus live warehouse source of truth * fix: keep verified rename handover for pending migration destinations After integrating model migrations, the unconditional missing-relation first run preceded the RENAMED branch, so a verified rename whose destination is intentionally not created yet planned as a first run, emitted a spurious RECORDED_RELATION_MISSING warning, and replayed downstream history. Check the migration-verified rename before treating a missing relation as a first run.
1 parent 3515fb5 commit 19b42fd

19 files changed

Lines changed: 932 additions & 14 deletions

File tree

‎AGENTS.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@
6969
- State the expected verification scope in subagent prompts and explicitly prohibit unnecessary full-suite runs.
7070
- Do not delay committing and pushing a focused fix solely to repeat checks already completed successfully by another agent or CI.
7171

72+
## State and Source of Truth
73+
74+
- Do not store what can be calculated. If a value can be derived from the project or the live
75+
warehouse at the point of use, derive it there and then instead of persisting it and reading it
76+
back later.
77+
- Stored state is for history, auditing, logging, and facts that genuinely cannot be recalculated:
78+
what was built, when, at which version, and whether a migration or batch completed.
79+
- The warehouse catalog and planner snapshot are authoritative for live facts such as relation
80+
existence, relation type, columns and types, table type, and retention. Never infer a live fact
81+
from fingerprints, events, bindings, or a plan reason when the warehouse can answer; built-up
82+
state drifts through external drops and edits and then needs reconciliation.
83+
- Consult stored state only after the live facts it describes are confirmed. For example, a model
84+
whose relation is missing plans as a first run regardless of any recorded fingerprint.
85+
7286
## Direct-Mode Warehouse State
7387

7488
- NEVER implement a mutable lifecycle state machine in raw warehouse state tables in direct mode. Do not represent progress by repeatedly updating one row through statuses such as `PLANNED`, `RUNNING`, and `COMPLETE`.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ E2E_DUCKDB_BUILD_INCREMENTAL_PATHS := \
9191
tests/e2e/src/sqlbuild/cli/commands/main/build/test_capped_microbatch_build.py \
9292
tests/e2e/src/sqlbuild/cli/commands/main/build/test_concurrent_microbatch_build.py \
9393
tests/e2e/src/sqlbuild/cli/commands/main/build/test_cursor_runtime_failures.py \
94+
tests/e2e/src/sqlbuild/cli/commands/main/build/test_dropped_relation_rebuild.py \
9495
tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py \
9596
tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_direct_lifecycle.py \
9697
tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_failure_windows.py \

‎src/sqlbuild/cli/output/_helpers/plan_json.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ def _serialize_warning(warning: PlanWarning) -> dict[str, object]:
306306
}
307307
if warning.model_name is not None:
308308
result["model_name"] = warning.model_name
309+
if warning.code is not None:
310+
result["code"] = warning.code
309311
return result
310312

311313

‎src/sqlbuild/compiler/planner/_helpers/changes/detect.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,21 +175,22 @@ def detect_model_changes(
175175
relation_exists: bool = model_name in snapshot.existing_relations
176176
fingerprint: Fingerprint | None = snapshot.fingerprints.models.get(model_name)
177177

178-
if not relation_exists and fingerprint is None:
178+
if not relation_exists and model_name in snapshot.renamed_models:
179179
return ChangeDetectionResult(
180180
model_name=model_name,
181-
change_kind=ChangeKind.FIRST_RUN,
182-
backfill=BackfillResult(action=BackfillAction.FULL),
181+
change_kind=ChangeKind.RENAMED,
182+
backfill=BackfillResult(action=BackfillAction.FORWARD_ONLY),
183183
fingerprint_metadata_json=metadata_json,
184184
fingerprint_version_hash=expected_version_hash,
185185
)
186-
if not relation_exists and model_name in snapshot.renamed_models:
186+
if not relation_exists:
187187
return ChangeDetectionResult(
188188
model_name=model_name,
189-
change_kind=ChangeKind.RENAMED,
190-
backfill=BackfillResult(action=BackfillAction.FORWARD_ONLY),
189+
change_kind=ChangeKind.FIRST_RUN,
190+
backfill=BackfillResult(action=BackfillAction.FULL),
191191
fingerprint_metadata_json=metadata_json,
192192
fingerprint_version_hash=expected_version_hash,
193+
recorded_build_relation_missing=fingerprint is not None,
193194
)
194195

195196
query_changed: bool = False

‎src/sqlbuild/compiler/planner/_helpers/output/plan_output.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ def _resolve_seed_plan_reason(
181181
reason: PlanReason | None = (seed_plan_reasons or {}).get(seed_name)
182182
if reason is not None:
183183
return reason
184+
if seed_name not in snapshot.existing_relations:
185+
return PlanReason.FIRST_RUN
184186
fingerprint: Fingerprint | None = snapshot.fingerprints.seeds.get(seed_name)
185187
if fingerprint is None:
186188
return PlanReason.FIRST_RUN

‎src/sqlbuild/compiler/planner/_helpers/output/strategy.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlbuild.compiler.compile.models import (
66
CompiledModel,
77
)
8+
from sqlbuild.compiler.planner.constants import RECORDED_RELATION_MISSING_WARNING_CODE
89
from sqlbuild.compiler.planner.exceptions import PlannerInputError
910
from sqlbuild.compiler.planner.models import (
1011
ChangeDetectionResult,
@@ -142,6 +143,20 @@ def build_model_warnings(
142143
effective: OnSchemaChange = on_schema_change or _DEFAULT_ON_SCHEMA_CHANGE
143144
warnings: list[PlanWarning] = []
144145

146+
if change_result.recorded_build_relation_missing:
147+
warnings.append(
148+
PlanWarning(
149+
model_name=model_name,
150+
severity=WarningSeverity.WARNING,
151+
code=RECORDED_RELATION_MISSING_WARNING_CODE,
152+
message=(
153+
f"{model_name}: SQLBuild state records a previous build, but the relation "
154+
"no longer exists in the warehouse. It may have been dropped outside "
155+
"SQLBuild; planning a first run to recreate it."
156+
),
157+
)
158+
)
159+
145160
if effective == OnSchemaChange.FAIL and change_result.schema_findings:
146161
warnings.append(
147162
PlanWarning(

‎src/sqlbuild/compiler/planner/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"'checks/', or 'loaders/'"
1111
)
1212
EMPTY_FINGERPRINT_METADATA_JSON: str = "{}"
13+
RECORDED_RELATION_MISSING_WARNING_CODE: str = "RECORDED_RELATION_MISSING"
1314
MODEL_SELECTOR_ROOT: str = "models"
1415
SQL_FILE_SELECTOR_SUFFIX: str = ".sql"
1516
MODEL_SELECTOR_ROOT_PREFIX: str = "models/"

‎src/sqlbuild/compiler/planner/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,7 @@ class ChangeDetectionResult:
736736
backfill: BackfillResult = field(
737737
default_factory=lambda: BackfillResult(action=BackfillAction.FORWARD_ONLY)
738738
)
739+
recorded_build_relation_missing: bool = False
739740

740741

741742
@dataclass(frozen=True)

‎src/sqlbuild/executor/run/_helpers/materializations/microbatch.py‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
IncrementalStrategy,
4444
MicrobatchStrategy,
4545
OnSchemaChange,
46-
PlanReason,
4746
)
4847
from sqlbuild.cursor_algebra.main.cap_from_end import cap_from_end
4948
from sqlbuild.cursor_algebra.main.cap_from_start import cap_from_start
@@ -3086,11 +3085,6 @@ def _prepare_full_refresh_rebuild(
30863085
destination=relations.target_qualified,
30873086
statement_recorder=state.statement_recorder,
30883087
)
3089-
elif not live_exists and context.entry.reason == PlanReason.FULL_REFRESH:
3090-
raise ExecutorInputError(
3091-
"full-refresh reconciliation found neither the live target nor its rebuild "
3092-
f"relation for '{context.entry.name}'"
3093-
)
30943088
elif rebuild_exists:
30953089
context.adapter.drop(
30963090
connection=context.connection,

‎tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,3 +1113,10 @@ class PythonNodeIdentityBuildE2ETestCase:
11131113
edited_text: str
11141114
expected_asset_identity_status: str
11151115
expected_loader_version_count: int
1116+
1117+
1118+
@dataclass(frozen=True)
1119+
class DroppedIncrementalRelationE2ETestCase:
1120+
description: str
1121+
incremental_strategy: str
1122+
expected_rows: tuple[tuple[int, str], ...]

0 commit comments

Comments
 (0)