Skip to content
Merged
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
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@
- State the expected verification scope in subagent prompts and explicitly prohibit unnecessary full-suite runs.
- Do not delay committing and pushing a focused fix solely to repeat checks already completed successfully by another agent or CI.

## State and Source of Truth

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

## Direct-Mode Warehouse State

- 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`.
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ E2E_DUCKDB_BUILD_INCREMENTAL_PATHS := \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_capped_microbatch_build.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_concurrent_microbatch_build.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_cursor_runtime_failures.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_dropped_relation_rebuild.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_direct_lifecycle.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_failure_windows.py \
Expand Down
2 changes: 2 additions & 0 deletions src/sqlbuild/cli/output/_helpers/plan_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ def _serialize_warning(warning: PlanWarning) -> dict[str, object]:
}
if warning.model_name is not None:
result["model_name"] = warning.model_name
if warning.code is not None:
result["code"] = warning.code
return result


Expand Down
13 changes: 7 additions & 6 deletions src/sqlbuild/compiler/planner/_helpers/changes/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,22 @@ def detect_model_changes(
relation_exists: bool = model_name in snapshot.existing_relations
fingerprint: Fingerprint | None = snapshot.fingerprints.models.get(model_name)

if not relation_exists and fingerprint is None:
if not relation_exists and model_name in snapshot.renamed_models:
return ChangeDetectionResult(
model_name=model_name,
change_kind=ChangeKind.FIRST_RUN,
backfill=BackfillResult(action=BackfillAction.FULL),
change_kind=ChangeKind.RENAMED,
backfill=BackfillResult(action=BackfillAction.FORWARD_ONLY),
fingerprint_metadata_json=metadata_json,
fingerprint_version_hash=expected_version_hash,
)
if not relation_exists and model_name in snapshot.renamed_models:
if not relation_exists:
return ChangeDetectionResult(
model_name=model_name,
change_kind=ChangeKind.RENAMED,
backfill=BackfillResult(action=BackfillAction.FORWARD_ONLY),
change_kind=ChangeKind.FIRST_RUN,
backfill=BackfillResult(action=BackfillAction.FULL),
fingerprint_metadata_json=metadata_json,
fingerprint_version_hash=expected_version_hash,
recorded_build_relation_missing=fingerprint is not None,
)

query_changed: bool = False
Expand Down
2 changes: 2 additions & 0 deletions src/sqlbuild/compiler/planner/_helpers/output/plan_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def _resolve_seed_plan_reason(
reason: PlanReason | None = (seed_plan_reasons or {}).get(seed_name)
if reason is not None:
return reason
if seed_name not in snapshot.existing_relations:
return PlanReason.FIRST_RUN
fingerprint: Fingerprint | None = snapshot.fingerprints.seeds.get(seed_name)
if fingerprint is None:
return PlanReason.FIRST_RUN
Expand Down
15 changes: 15 additions & 0 deletions src/sqlbuild/compiler/planner/_helpers/output/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlbuild.compiler.compile.models import (
CompiledModel,
)
from sqlbuild.compiler.planner.constants import RECORDED_RELATION_MISSING_WARNING_CODE
from sqlbuild.compiler.planner.exceptions import PlannerInputError
from sqlbuild.compiler.planner.models import (
ChangeDetectionResult,
Expand Down Expand Up @@ -142,6 +143,20 @@ def build_model_warnings(
effective: OnSchemaChange = on_schema_change or _DEFAULT_ON_SCHEMA_CHANGE
warnings: list[PlanWarning] = []

if change_result.recorded_build_relation_missing:
warnings.append(
PlanWarning(
model_name=model_name,
severity=WarningSeverity.WARNING,
code=RECORDED_RELATION_MISSING_WARNING_CODE,
message=(
f"{model_name}: SQLBuild state records a previous build, but the relation "
"no longer exists in the warehouse. It may have been dropped outside "
"SQLBuild; planning a first run to recreate it."
),
)
)

if effective == OnSchemaChange.FAIL and change_result.schema_findings:
warnings.append(
PlanWarning(
Expand Down
1 change: 1 addition & 0 deletions src/sqlbuild/compiler/planner/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"'checks/', or 'loaders/'"
)
EMPTY_FINGERPRINT_METADATA_JSON: str = "{}"
RECORDED_RELATION_MISSING_WARNING_CODE: str = "RECORDED_RELATION_MISSING"
MODEL_SELECTOR_ROOT: str = "models"
SQL_FILE_SELECTOR_SUFFIX: str = ".sql"
MODEL_SELECTOR_ROOT_PREFIX: str = "models/"
Expand Down
1 change: 1 addition & 0 deletions src/sqlbuild/compiler/planner/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ class ChangeDetectionResult:
backfill: BackfillResult = field(
default_factory=lambda: BackfillResult(action=BackfillAction.FORWARD_ONLY)
)
recorded_build_relation_missing: bool = False


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
IncrementalStrategy,
MicrobatchStrategy,
OnSchemaChange,
PlanReason,
)
from sqlbuild.cursor_algebra.main.cap_from_end import cap_from_end
from sqlbuild.cursor_algebra.main.cap_from_start import cap_from_start
Expand Down Expand Up @@ -3086,11 +3085,6 @@ def _prepare_full_refresh_rebuild(
destination=relations.target_qualified,
statement_recorder=state.statement_recorder,
)
elif not live_exists and context.entry.reason == PlanReason.FULL_REFRESH:
raise ExecutorInputError(
"full-refresh reconciliation found neither the live target nor its rebuild "
f"relation for '{context.entry.name}'"
)
elif rebuild_exists:
context.adapter.drop(
connection=context.connection,
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,3 +1113,10 @@ class PythonNodeIdentityBuildE2ETestCase:
edited_text: str
expected_asset_identity_status: str
expected_loader_version_count: int


@dataclass(frozen=True)
class DroppedIncrementalRelationE2ETestCase:
description: str
incremental_strategy: str
expected_rows: tuple[tuple[int, str], ...]
26 changes: 26 additions & 0 deletions tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,3 +862,29 @@ def physical_orders_relation(*, state_path: Path) -> tuple[str, str]:
),
)[0]
return str(schema_name), str(relation_name)


def dropped_incremental_project_files(*, incremental_strategy: str) -> dict[str, str]:
"""Return a DuckDB project with one timestamp-cursor incremental order model."""

return {
"sqlbuild_project.toml": (
'name = "dropped_orders"\nadapter = "duckdb"\n\n'
'[connection]\ndatabase = "dropped_orders.duckdb"\n'
),
"sources/raw.yml": (
"sources:\n - name: raw_orders\n schema: main\n table: raw_orders\n"
),
"models/orders.sql": (
"MODEL (\n"
" materialized incremental,\n"
f" incremental_strategy {incremental_strategy},\n"
" unique_key id,\n"
" cursor ordered_at,\n"
" cursor_type timestamp,\n"
" cursor_grain day,\n"
" cursor_start '2026-01-02',\n"
");\n\n"
'SELECT id, ordered_at FROM __source("raw_orders")\n'
),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""E2E regression tests for incremental relations dropped outside SQLBuild."""

from __future__ import annotations

import json
from pathlib import Path
from subprocess import CompletedProcess

import pytest

from tests.e2e.src.sqlbuild.cli.commands.main.build._test_types import (
DroppedIncrementalRelationE2ETestCase,
)
from tests.e2e.src.sqlbuild.cli.commands.main.build.helpers import (
dropped_incremental_project_files,
)
from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import (
execute_duckdb,
prepare_inline_project,
query_duckdb,
run_sqb,
)


@pytest.mark.parametrize(
"test_case",
[
DroppedIncrementalRelationE2ETestCase(
description="delete insert",
incremental_strategy="delete_insert",
expected_rows=(
(2, "2026-01-02 01:00:00"),
(3, "2026-01-03 01:00:00"),
(4, "2026-01-04 01:00:00"),
),
),
DroppedIncrementalRelationE2ETestCase(
description="append",
incremental_strategy="append",
expected_rows=(
(2, "2026-01-02 01:00:00"),
(3, "2026-01-03 01:00:00"),
(4, "2026-01-04 01:00:00"),
),
),
],
ids=lambda case: case.description,
)
def test_given_incremental_table_dropped_outside_sqlbuild_when_planning_and_building_then_recreates(
tmp_path: Path,
test_case: DroppedIncrementalRelationE2ETestCase,
) -> None:
project_dir: Path = prepare_inline_project(
tmp_path=tmp_path,
project_name="dropped_orders",
repo_files=dropped_incremental_project_files(
incremental_strategy=test_case.incremental_strategy
),
)
db_path: Path = project_dir / "dropped_orders.duckdb"
execute_duckdb(
db_path=db_path,
sql=(
"CREATE TABLE main.raw_orders (id INTEGER, ordered_at TIMESTAMP); "
"INSERT INTO main.raw_orders VALUES "
"(1, '2026-01-01 05:00:00'), (2, '2026-01-02 01:00:00'), "
"(3, '2026-01-03 01:00:00')"
),
)
first_build: CompletedProcess[str] = run_sqb(
command=("--no-color", "build"), project_dir=project_dir
)
assert first_build.returncode == 0, first_build.stdout + first_build.stderr
execute_duckdb(
db_path=db_path,
sql=(
"DROP TABLE main.orders; INSERT INTO main.raw_orders VALUES (4, '2026-01-04 01:00:00')"
),
)

plan: CompletedProcess[str] = run_sqb(command=("plan", "--json"), project_dir=project_dir)
rebuild: CompletedProcess[str] = run_sqb(
command=("--no-color", "build"), project_dir=project_dir
)

assert plan.returncode == 0, plan.stdout + plan.stderr
document: dict[str, object] = json.loads(plan.stdout)
models: object = document["models"]
assert isinstance(models, list)
model: dict[str, object] = models[0]
assert (model["name"], model["action"], model["reason"]) == (
"orders",
"create_table",
"first_run",
)
assert model["built_version_present"] is False
assert model["identity_status"] == "missing"
assert model["cursor_bounds"] == {
"start": "2026-01-02T00:00:00",
"end": "2026-01-05T00:00:00",
}
assert document["warnings"] == [
{
"severity": "warning",
"message": (
"orders: SQLBuild state records a previous build, but the relation no longer "
"exists in the warehouse. It may have been dropped outside SQLBuild; planning a "
"first run to recreate it."
),
"model_name": "orders",
"code": "RECORDED_RELATION_MISSING",
}
]
assert rebuild.returncode == 0, rebuild.stdout + rebuild.stderr
assert "RECORDED_RELATION_MISSING" not in rebuild.stdout
assert "no longer exists in the warehouse" in rebuild.stdout + rebuild.stderr
assert (
tuple(
query_duckdb(
db_path=db_path,
sql="SELECT id, CAST(ordered_at AS VARCHAR) FROM main.orders ORDER BY id",
)
)
== test_case.expected_rows
)
assert (
query_duckdb(
db_path=db_path,
sql=(
"SELECT table_name FROM information_schema.tables "
"WHERE table_name LIKE 'orders\\_\\_%' ESCAPE '\\'"
),
)
== []
)
46 changes: 46 additions & 0 deletions tests/integration/src/sqlbuild/cli/commands/main/_test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,49 @@ class LeadingCteCommentFormatIntegrationTestCase:
description: str
authored_sql: str
expected_fragment: str


@dataclass(frozen=True)
class DroppedRelationRecoveryTestCase:
"""One model whose relation is dropped outside SQLBuild after a successful build."""

description: str
model_sql: str
drop_sql: str
expected_action: str
expected_rows: tuple[tuple[object, ...], ...]
settings_toml: str = ""
stale_artifact_sql: str = "SELECT 1"


@dataclass(frozen=True)
class DroppedIncrementalFirstRunRangeTestCase:
"""One incremental model rebuilt from its configured start after an external drop."""

description: str
model_sql: str
expected_start: str
expected_end: str
expected_rows: tuple[tuple[object, ...], ...]


@dataclass(frozen=True)
class DroppedSeedPlanTestCase:
"""One seed dropped outside SQLBuild after a successful build."""

description: str
model_sql: str
expected_steady_reasons: dict[str, object]
expected_dropped_reasons: dict[str, object]
expected_rows: tuple[tuple[object, ...], ...]


@dataclass(frozen=True)
class AbsentMicrobatchFullRefreshTestCase:
"""One full-refresh build of a microbatch model whose live target is absent."""

description: str
batch_concurrency: int
setup_build_flags: tuple[str, ...]
setup_drop_sql: str
expected_rows: tuple[tuple[object, ...], ...]
Loading
Loading