Skip to content

Commit 27481cf

Browse files
committed
fix(janitor): keep the origin of a pending model migration
1 parent 6f52c96 commit 27481cf

10 files changed

Lines changed: 235 additions & 6 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Online: https://sqlbuild.com/docs/cli/janitor/
1212
- Flags
1313
- Lifecycle
1414
- Compatibility views
15+
- Pending migration origins
1516
- Relation age
1617
- Audit record
1718
- Restoring an archive
@@ -74,6 +75,10 @@ A compatibility view is never archived as a stale relation. Once it expires, or
7475

7576
Run the same SQLBuild version for `sqb janitor` as for builds. An older janitor does not know compatibility views and archives them as stale relations.
7677

78+
## Pending migration origins
79+
80+
A relation that a project model still has to migrate from is never archived. While a model declares `migrate_from` and its own relation does not exist yet in the target, the janitor skips the origin and lists it as `pending migration origin for <model>`. A bare `migrate_from` name is matched in each schema the project builds into; a qualified name only at that location. Once the model has built, the origin is handled as usual: after a migration, the old name holds a compatibility view, which expires as described above.
81+
7782
## Relation age
7883

7984
`retention_days` is measured from the last time a relation was written, as reported by the warehouse. For a relation the project no longer builds, that is roughly when it stopped being built.

‎src/sqlbuild/executor/janitor/_helpers/plan.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter
1010
from sqlbuild.adapter.contract.models import RelationInfo, RelationLookup
11+
from sqlbuild.compiler.compile.constants import MIGRATE_FROM_CONFIG_KEY
1112
from sqlbuild.compiler.compile.models import (
1213
CompiledModel,
1314
CompiledProject,
1415
CompiledSeed,
1516
CompiledSource,
1617
)
18+
from sqlbuild.executor.janitor.constants import QUALIFIED_ORIGIN_PARTS
1719
from sqlbuild.executor.janitor.models import JanitorRelationKey
1820

1921

@@ -42,6 +44,59 @@ def collect_desired_keys(project: CompiledProject) -> set[JanitorRelationKey]:
4244
return keys
4345

4446

47+
def pending_migration_origins(
48+
*,
49+
project: CompiledProject,
50+
relations_by_schema: dict[tuple[str | None, str | None], tuple[RelationInfo, ...]],
51+
) -> dict[JanitorRelationKey, str]:
52+
"""Return listed origins of migrations whose destination relation does not exist yet."""
53+
54+
listed: dict[tuple[str | None, str | None, str], JanitorRelationKey] = {}
55+
relations: tuple[RelationInfo, ...]
56+
for relations in relations_by_schema.values():
57+
listed.update({_folded(relation_key(item)): relation_key(item) for item in relations})
58+
schemas: set[tuple[str | None, str | None]] = collect_target_schemas(project)
59+
pending: dict[JanitorRelationKey, str] = {}
60+
model: CompiledModel
61+
for model in project.models:
62+
raw: object | None = model.config.values.get(MIGRATE_FROM_CONFIG_KEY)
63+
destination: JanitorRelationKey = JanitorRelationKey(
64+
database=model.destination.database,
65+
schema=model.destination.schema,
66+
name=model.destination.name,
67+
)
68+
if not isinstance(raw, str) or _folded(destination) in listed:
69+
continue
70+
origin: JanitorRelationKey
71+
for origin in _origin_keys(raw=raw, destination=destination, schemas=schemas):
72+
found: JanitorRelationKey | None = listed.get(_folded(origin))
73+
if found is not None:
74+
pending.setdefault(found, f"pending migration origin for {model.name}")
75+
return pending
76+
77+
78+
def _origin_keys(
79+
*, raw: str, destination: JanitorRelationKey, schemas: set[tuple[str | None, str | None]]
80+
) -> tuple[JanitorRelationKey, ...]:
81+
parts: list[str] = [part.strip().strip('"`') for part in raw.split(".")]
82+
if len(parts) == 1:
83+
return tuple(
84+
JanitorRelationKey(database=database, schema=schema, name=parts[0])
85+
for database, schema in sorted(schemas, key=_schema_sort_key)
86+
)
87+
return (
88+
JanitorRelationKey(
89+
database=parts[0] if len(parts) == QUALIFIED_ORIGIN_PARTS else destination.database,
90+
schema=parts[-2],
91+
name=parts[-1],
92+
),
93+
)
94+
95+
96+
def _folded(key: JanitorRelationKey) -> tuple[str | None, str | None, str]:
97+
return RelationLookup.key(database=key.database, schema=key.schema, name=key.name)
98+
99+
45100
def collect_target_schemas(project: CompiledProject) -> set[tuple[str | None, str | None]]:
46101
"""Collect schemas where the compiled project writes relations, one spelling per schema."""
47102

‎src/sqlbuild/executor/janitor/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@
4343
MALFORMED_ARCHIVE_REASON: str = (
4444
"name resembles a janitor archive but does not match the strict archive grammar"
4545
)
46+
QUALIFIED_ORIGIN_PARTS: int = 3

‎src/sqlbuild/executor/janitor/main/plan.py‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import replace
56
from datetime import UTC, datetime
67
from typing import Any
78

@@ -14,7 +15,11 @@
1415
collect_query_diff_artifact_candidates,
1516
gather_janitor_warehouse_facts,
1617
)
17-
from sqlbuild.executor.janitor._helpers.plan import collect_scan_schemas, collect_target_schemas
18+
from sqlbuild.executor.janitor._helpers.plan import (
19+
collect_scan_schemas,
20+
collect_target_schemas,
21+
pending_migration_origins,
22+
)
1823
from sqlbuild.executor.janitor._helpers.schema_planning import classify_target_schemas
1924
from sqlbuild.executor.janitor.classes.relation_age_reader import JanitorRelationAgeReader
2025
from sqlbuild.executor.janitor.models import (
@@ -23,6 +28,7 @@
2328
JanitorDirectStatePruneCandidate,
2429
JanitorOldNameViewPlanning,
2530
JanitorPlan,
31+
JanitorRelationKey,
2632
JanitorRelationScope,
2733
JanitorSchemaClassification,
2834
JanitorWarehouseFacts,
@@ -107,12 +113,11 @@ def build_janitor_plan(
107113
)
108114
inspection.completed(metadata={"item_count": len(target_schemas)})
109115
old_names: JanitorOldNameViewPlanning
110-
old_names, scope = plan_old_name_view_cleanup(
116+
old_names, scope = _plan_protected_names(
111117
adapter=adapter,
112118
connection=connection,
113119
managed_target_schemas=managed_target_schemas,
114-
relations_by_schema=facts.relations_by_schema,
115-
target_name=project.effective_target_name,
120+
facts=facts,
116121
early_drops=early_old_name_view_drops,
117122
project=project,
118123
scope=scope,
@@ -173,3 +178,56 @@ def build_janitor_plan(
173178
age_metadata_supported=age_supported,
174179
planned_at=now,
175180
)
181+
182+
183+
def _plan_protected_names(
184+
*,
185+
adapter: BaseAdapter,
186+
connection: Any,
187+
managed_target_schemas: set[tuple[str | None, str | None]],
188+
facts: JanitorWarehouseFacts,
189+
early_drops: tuple[str, ...],
190+
project: CompiledProject,
191+
scope: JanitorRelationScope,
192+
now: datetime,
193+
) -> tuple[JanitorOldNameViewPlanning, JanitorRelationScope]:
194+
"""Plan old-name views, then keep general cleanup away from pending migration origins."""
195+
196+
old_names: JanitorOldNameViewPlanning
197+
old_names, scope = plan_old_name_view_cleanup(
198+
adapter=adapter,
199+
connection=connection,
200+
managed_target_schemas=managed_target_schemas,
201+
relations_by_schema=facts.relations_by_schema,
202+
target_name=project.effective_target_name,
203+
early_drops=early_drops,
204+
project=project,
205+
scope=scope,
206+
now=now,
207+
)
208+
return old_names, _protect_pending_origins(
209+
project=project, facts=facts, scope=scope, old_names=old_names
210+
)
211+
212+
213+
def _protect_pending_origins(
214+
*,
215+
project: CompiledProject,
216+
facts: JanitorWarehouseFacts,
217+
scope: JanitorRelationScope,
218+
old_names: JanitorOldNameViewPlanning,
219+
) -> JanitorRelationScope:
220+
pending: dict[JanitorRelationKey, str] = {
221+
key: reason
222+
for key, reason in pending_migration_origins(
223+
project=project, relations_by_schema=facts.relations_by_schema
224+
).items()
225+
if key not in old_names.keys
226+
}
227+
if not pending:
228+
return scope
229+
return replace(
230+
scope,
231+
protected_relation_keys=scope.protected_relation_keys | frozenset(pending),
232+
protected_relation_reasons={**(scope.protected_relation_reasons or {}), **pending},
233+
)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,10 @@ class HiddenOriginE2ETestCase:
9797
description: str
9898
target_settings: str
9999
expected_output: str
100+
101+
102+
@dataclass(frozen=True)
103+
class PendingOriginJanitorE2ETestCase:
104+
description: str
105+
janitor_args: tuple[str, ...]
106+
expected_order_ids: tuple[int, ...]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""CLI e2e coverage for the janitor keeping the origin of a pending model migration."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from tests.e2e.src.sqlbuild.cli.commands.main.refactor._test_types import (
11+
PendingOriginJanitorE2ETestCase,
12+
)
13+
from tests.e2e.src.sqlbuild.cli.commands.main.refactor.helpers import (
14+
load_raw_orders,
15+
order_history_files,
16+
order_ids,
17+
relation_type,
18+
sqb,
19+
write_orders_project,
20+
)
21+
22+
_PENDING_REASON: str = "analytics.order_history pending migration origin for order_ledger"
23+
24+
25+
@pytest.mark.parametrize(
26+
"test_case",
27+
[
28+
PendingOriginJanitorE2ETestCase(
29+
description="janitor keeps the origin until the rename is migrated",
30+
janitor_args=("janitor", "--retention-days", "0", "--auto-approve"),
31+
expected_order_ids=(1, 2, 3, 4),
32+
),
33+
],
34+
ids=lambda case: case.description,
35+
)
36+
def test_given_pending_rename_when_janitor_runs_then_origin_survives_until_migrated(
37+
tmp_path: Path, test_case: PendingOriginJanitorE2ETestCase
38+
) -> None:
39+
project_dir: Path = write_orders_project(
40+
tmp_path=tmp_path, files=order_history_files(materialized="incremental", udf=False)
41+
)
42+
first: subprocess.CompletedProcess[str] = sqb(project_dir, "build")
43+
renamed: subprocess.CompletedProcess[str] = sqb(
44+
project_dir, "rename", "model:order_history", "order_ledger"
45+
)
46+
47+
pending: subprocess.CompletedProcess[str] = sqb(project_dir, *test_case.janitor_args)
48+
origin_after_pending: str | None = relation_type(project_dir=project_dir, name="order_history")
49+
load_raw_orders(project_dir=project_dir, order_ids=(3, 4))
50+
built: subprocess.CompletedProcess[str] = sqb(project_dir, "build")
51+
migrated: tuple[int, ...] = order_ids(project_dir=project_dir, name="order_ledger")
52+
settled: subprocess.CompletedProcess[str] = sqb(project_dir, *test_case.janitor_args)
53+
old_name_after_settled: str | None = relation_type(
54+
project_dir=project_dir, name="order_history"
55+
)
56+
dropped: subprocess.CompletedProcess[str] = sqb(
57+
project_dir, *test_case.janitor_args, "--drop-old-name-view", "order_history"
58+
)
59+
60+
assert first.returncode == 0, first.stdout + first.stderr
61+
assert renamed.returncode == 0, renamed.stdout + renamed.stderr
62+
assert pending.returncode == 0, pending.stdout + pending.stderr
63+
assert _PENDING_REASON in pending.stdout
64+
assert origin_after_pending == "BASE TABLE"
65+
assert built.returncode == 0, built.stdout + built.stderr
66+
assert migrated == test_case.expected_order_ids
67+
assert settled.returncode == 0, settled.stdout + settled.stderr
68+
assert "pending migration origin" not in settled.stdout
69+
assert "analytics.order_history -> model:order_ledger" in settled.stdout
70+
assert old_name_after_settled == "VIEW"
71+
assert dropped.returncode == 0, dropped.stdout + dropped.stderr
72+
assert relation_type(project_dir=project_dir, name="order_history") is None

‎tests/unit/src/sqlbuild/executor/janitor/main/_test_types.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class JanitorPlanTestCase:
2222
tracked_relations: tuple[tuple[str | None, str | None, str], ...] = field(default_factory=tuple)
2323
exclude_patterns: tuple[str, ...] = field(default_factory=tuple)
2424
protected_relation_keys: frozenset[JanitorRelationKey] = frozenset()
25+
migrate_from: str | None = None
2526
expected_candidate_names: tuple[str, ...] = field(default_factory=tuple)
2627
expected_direct_state_table_names: tuple[str, ...] = field(default_factory=tuple)
2728
expected_skipped_relation_reasons: tuple[str, ...] = field(default_factory=tuple)

‎tests/unit/src/sqlbuild/executor/janitor/main/helpers.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ def build_project(
540540
source_database: str | None = None,
541541
destination_database: str | None = None,
542542
destination_schema: str = "analytics",
543+
migrate_from: str | None = None,
543544
) -> CompiledProject:
544545
source: CompiledSource = CompiledSource(
545546
key=CompiledObjectKey(
@@ -569,7 +570,7 @@ def build_project(
569570
name="orders",
570571
relative_path=Path("models/orders.sql"),
571572
query_sql="select 1",
572-
config=CompileModelConfig(),
573+
config=CompileModelConfig(values={"migrate_from": migrate_from}),
573574
destination=CompiledRelationLocation(
574575
database=destination_database,
575576
schema=destination_schema,

‎tests/unit/src/sqlbuild/executor/janitor/main/test_plan.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,29 @@
164164
expected_candidate_names=(),
165165
expected_skipped_relation_reasons=("relation is not tracked by SQLBuild",),
166166
),
167+
JanitorPlanTestCase(
168+
description="origin of a pending migration is kept",
169+
relation_infos=(relation_info("old_orders", created_at=OLD_TIME),),
170+
migrate_from="old_orders",
171+
expected_candidate_names=(),
172+
expected_skipped_relation_reasons=("pending migration origin for orders",),
173+
),
174+
JanitorPlanTestCase(
175+
description="qualified origin of a pending migration is kept",
176+
relation_infos=(relation_info("OLD_ORDERS", created_at=OLD_TIME),),
177+
migrate_from="analytics.old_orders",
178+
expected_candidate_names=(),
179+
expected_skipped_relation_reasons=("pending migration origin for orders",),
180+
),
181+
JanitorPlanTestCase(
182+
description="origin is cleaned normally once the destination exists",
183+
relation_infos=(
184+
relation_info("orders", created_at=OLD_TIME),
185+
relation_info("old_orders", created_at=OLD_TIME),
186+
),
187+
migrate_from="old_orders",
188+
expected_candidate_names=("old_orders",),
189+
),
167190
JanitorPlanTestCase(
168191
description="existing direct state tables are eligible for history pruning",
169192
relation_infos=(
@@ -204,7 +227,9 @@ def test_given_project_and_warehouse_when_building_janitor_plan_then_returns_exp
204227
)
205228

206229
plan: JanitorPlan = build_janitor_plan(
207-
project=build_project(source_schema=test_case.source_schema),
230+
project=build_project(
231+
source_schema=test_case.source_schema, migrate_from=test_case.migrate_from
232+
),
208233
adapter=adapter,
209234
connection=object(),
210235
retention_days=test_case.retention_days,

‎website/src/content/docs/docs/cli/janitor.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ A compatibility view is never archived as a stale relation. Once it expires, or
5959

6060
Run the same SQLBuild version for `sqb janitor` as for builds. An older janitor does not know compatibility views and archives them as stale relations.
6161

62+
## Pending migration origins
63+
64+
A relation that a project model still has to migrate from is never archived. While a model declares `migrate_from` and its own relation does not exist yet in the target, the janitor skips the origin and lists it as `pending migration origin for <model>`. A bare `migrate_from` name is matched in each schema the project builds into; a qualified name only at that location. Once the model has built, the origin is handled as usual: after a migration, the old name holds a compatibility view, which expires as described above.
65+
6266
## Relation age
6367

6468
`retention_days` is measured from the last time a relation was written, as reported by the warehouse. For a relation the project no longer builds, that is roughly when it stopped being built.

0 commit comments

Comments
 (0)