Skip to content

Commit aeb4097

Browse files
committed
Fix selected plans rehashing unselected external models
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent 966e34d commit aeb4097

2 files changed

Lines changed: 182 additions & 2 deletions

File tree

‎sqlmesh/core/selector.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,27 @@ def get_model(fqn: str) -> t.Optional[Model]:
146146
models[model.fqn] = model
147147

148148
if needs_update:
149+
schema_update_fqns = set(dag.graph)
150+
unselected_external_hashes = {
151+
fqn: (model.json(), model.data_hash, model.metadata_hash)
152+
for fqn, model in models.items()
153+
if fqn in env_models
154+
and fqn not in all_selected_models
155+
and fqn in schema_update_fqns
156+
and model.kind.is_external
157+
}
149158
update_model_schemas(dag, models=models, cache_dir=self._cache_dir)
159+
for fqn, (
160+
serialized_model,
161+
data_hash,
162+
metadata_hash,
163+
) in unselected_external_hashes.items():
164+
model = models.get(fqn)
165+
if model and model.json() == serialized_model:
166+
# Schema updates can rehash an unselected external model loaded from state.
167+
# Keep its deployed hash when its serialized definition did not change.
168+
model._data_hash = data_hash
169+
model._metadata_hash = metadata_hash
150170

151171
return models, all_selected_models
152172

‎tests/core/test_selector_native.py‎

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,177 @@
99
import subprocess
1010

1111
from sqlmesh.core import dialect as d
12+
from sqlmesh.core.model import schema as schema_module
13+
from sqlmesh.core import selector as selector_module
1214
from sqlmesh.core.audit import StandaloneAudit
1315
from sqlmesh.core.environment import Environment
14-
from sqlmesh.core.model import Model, SqlModel
16+
from sqlmesh.core.model import ExternalModel, Model, SqlModel, create_external_model
1517
from sqlmesh.core.model.common import ParsableSql
1618
from sqlmesh.core.selector import NativeSelector
17-
from sqlmesh.core.snapshot import SnapshotChangeCategory
19+
from sqlmesh.core.snapshot import Snapshot, SnapshotChangeCategory
20+
from sqlmesh.core.snapshot.cache import SnapshotCache
21+
from sqlmesh.core.snapshot.definition import Node
1822
from sqlmesh.utils import UniqueKeyDict
1923
from sqlmesh.utils.date import now_timestamp
2024
from sqlmesh.utils.git import GitClient
2125

2226

27+
def _external_schema_refresh_case(
28+
mocker: MockerFixture, tmp_path: Path
29+
) -> t.Tuple[
30+
ExternalModel,
31+
ExternalModel,
32+
SqlModel,
33+
t.Dict[str, Snapshot],
34+
UniqueKeyDict[str, Model],
35+
NativeSelector,
36+
]:
37+
external_a = create_external_model("db.external_a", columns={"id": "int"})
38+
external_b = create_external_model("db.external_b", columns={"id": "int"})
39+
child = SqlModel(
40+
name="db.child",
41+
query=d.parse_one(
42+
"SELECT a.id FROM db.external_a AS a JOIN db.external_b AS b ON a.id = b.id"
43+
),
44+
)
45+
46+
# Inject inconsistent persisted state for this invariant test: the stored external model
47+
# has optimize_query=False, but its fingerprint is seeded from the model with None. The
48+
# persisted metadata hash is synthetic too. These mismatches do not imply an upstream
49+
# migration produces this state.
50+
stored_b = t.cast(ExternalModel, external_b.copy(update={"optimize_query": False}))
51+
stored_b._data_hash = external_b.data_hash
52+
stored_b._metadata_hash = "persisted-external-metadata-hash"
53+
54+
old_nodes: t.Dict[str, Node] = {
55+
external_a.fqn: external_a,
56+
stored_b.fqn: stored_b,
57+
child.fqn: child,
58+
}
59+
old_snapshots = {
60+
name: Snapshot.from_node(node, nodes=old_nodes) for name, node in old_nodes.items()
61+
}
62+
for snapshot in old_snapshots.values():
63+
snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
64+
65+
current_models: UniqueKeyDict[str, Model] = UniqueKeyDict("models")
66+
current_models[external_a.fqn] = external_a
67+
current_models[external_b.fqn] = external_b
68+
current_models[child.fqn] = child
69+
70+
# Simulate the process worker's fresh fingerprint calculation synchronously. This avoids
71+
# forking from pytest while still exercising the schema worker's hash recomputation.
72+
mocker.patch("sqlmesh.core.constants.MAX_FORK_WORKERS", 1)
73+
original_load = schema_module.load_optimized_query_and_mapping
74+
75+
def load_with_fresh_hashes(model: Model, mapping: t.Dict) -> t.Any:
76+
model._data_hash = None
77+
model._metadata_hash = None
78+
return original_load(model, mapping)
79+
80+
mocker.patch(
81+
"sqlmesh.core.model.schema.load_optimized_query_and_mapping",
82+
side_effect=load_with_fresh_hashes,
83+
)
84+
85+
state_reader = mocker.Mock()
86+
state_reader.get_environment.return_value = Environment(
87+
name="prod",
88+
snapshots=[snapshot.table_info for snapshot in old_snapshots.values()],
89+
start_at="2023-01-01",
90+
end_at="2023-01-02",
91+
plan_id="test_plan",
92+
)
93+
94+
snapshot_cache = SnapshotCache(tmp_path / "snapshot_cache")
95+
96+
def load_snapshots(infos):
97+
snapshot_ids = {info.snapshot_id for info in infos}
98+
snapshots, _ = snapshot_cache.get_or_load(
99+
snapshot_ids,
100+
lambda ids: [
101+
snapshot.copy(deep=True)
102+
for snapshot in old_snapshots.values()
103+
if snapshot.snapshot_id in ids
104+
],
105+
)
106+
return snapshots
107+
108+
state_reader.get_snapshots.side_effect = load_snapshots
109+
110+
selector = NativeSelector(
111+
state_reader,
112+
current_models,
113+
context_path=tmp_path,
114+
cache_dir=tmp_path,
115+
)
116+
return external_a, external_b, child, old_snapshots, current_models, selector
117+
118+
119+
def _current_snapshots(models: UniqueKeyDict[str, Model]) -> t.Dict[str, Snapshot]:
120+
nodes = t.cast(t.Dict[str, Node], dict(models))
121+
return {model.fqn: Snapshot.from_node(model, nodes=nodes) for model in models.values()}
122+
123+
124+
def test_unselected_external_coparent_is_not_directly_modified_by_schema_refresh(
125+
mocker: MockerFixture, tmp_path: Path
126+
) -> None:
127+
external_a, external_b, child, old_snapshots, _, selector = _external_schema_refresh_case(
128+
mocker, tmp_path
129+
)
130+
131+
schema_refresh = mocker.spy(selector_module, "update_model_schemas")
132+
models, selected_fqns = selector.select_models([external_a.fqn], "prod")
133+
current = _current_snapshots(models)
134+
135+
assert external_a.fqn in selected_fqns
136+
schema_refresh.assert_called_once()
137+
assert (
138+
current[external_b.fqn].fingerprint.data_hash
139+
== old_snapshots[external_b.fqn].fingerprint.data_hash
140+
)
141+
assert not current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
142+
assert (
143+
current[external_b.fqn].fingerprint.metadata_hash
144+
== old_snapshots[external_b.fqn].fingerprint.metadata_hash
145+
)
146+
assert not current[child.fqn].is_indirectly_modified(old_snapshots[child.fqn])
147+
148+
149+
def test_selected_external_change_remains_directly_modified(
150+
mocker: MockerFixture, tmp_path: Path
151+
) -> None:
152+
_, external_b, _, old_snapshots, current_models, selector = _external_schema_refresh_case(
153+
mocker, tmp_path
154+
)
155+
changed_b = create_external_model("db.external_b", columns={"id": "int", "new": "text"})
156+
current_models.update({changed_b.fqn: changed_b})
157+
158+
models, selected_fqns = selector.select_models([external_b.fqn], "prod")
159+
current = _current_snapshots(models)
160+
161+
assert external_b.fqn in selected_fqns
162+
assert current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
163+
164+
165+
def test_selected_dependency_schema_change_remains_visible(
166+
mocker: MockerFixture, tmp_path: Path
167+
) -> None:
168+
external_a, external_b, child, old_snapshots, current_models, selector = (
169+
_external_schema_refresh_case(mocker, tmp_path)
170+
)
171+
changed_a = create_external_model("db.external_a", columns={"id": "int", "new": "text"})
172+
current_models.update({changed_a.fqn: changed_a})
173+
174+
models, selected_fqns = selector.select_models([external_a.fqn], "prod")
175+
current = _current_snapshots(models)
176+
177+
assert external_a.fqn in selected_fqns
178+
assert current[external_a.fqn].is_directly_modified(old_snapshots[external_a.fqn])
179+
assert not current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
180+
assert current[child.fqn].is_indirectly_modified(old_snapshots[child.fqn])
181+
182+
23183
@pytest.mark.parametrize(
24184
"default_catalog",
25185
[

0 commit comments

Comments
 (0)