Skip to content

Commit 6731332

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

2 files changed

Lines changed: 177 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: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,172 @@
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. This
48+
# synthetic mismatch does not imply that an upstream migration produces this state.
49+
stored_b = t.cast(ExternalModel, external_b.copy(update={"optimize_query": False}))
50+
stored_b._data_hash = external_b.data_hash
51+
stored_b._metadata_hash = external_b.metadata_hash
52+
53+
old_nodes: t.Dict[str, Node] = {
54+
external_a.fqn: external_a,
55+
stored_b.fqn: stored_b,
56+
child.fqn: child,
57+
}
58+
old_snapshots = {
59+
name: Snapshot.from_node(node, nodes=old_nodes) for name, node in old_nodes.items()
60+
}
61+
for snapshot in old_snapshots.values():
62+
snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
63+
64+
current_models: UniqueKeyDict[str, Model] = UniqueKeyDict("models")
65+
current_models[external_a.fqn] = external_a
66+
current_models[external_b.fqn] = external_b
67+
current_models[child.fqn] = child
68+
69+
# Simulate the process worker's fresh fingerprint calculation synchronously. This avoids
70+
# forking from pytest while still exercising the schema worker's hash recomputation.
71+
mocker.patch("sqlmesh.core.constants.MAX_FORK_WORKERS", 1)
72+
original_load = schema_module.load_optimized_query_and_mapping
73+
74+
def load_with_fresh_hashes(model: Model, mapping: t.Dict) -> t.Any:
75+
model._data_hash = None
76+
model._metadata_hash = None
77+
return original_load(model, mapping)
78+
79+
mocker.patch(
80+
"sqlmesh.core.model.schema.load_optimized_query_and_mapping",
81+
side_effect=load_with_fresh_hashes,
82+
)
83+
84+
state_reader = mocker.Mock()
85+
state_reader.get_environment.return_value = Environment(
86+
name="prod",
87+
snapshots=[snapshot.table_info for snapshot in old_snapshots.values()],
88+
start_at="2023-01-01",
89+
end_at="2023-01-02",
90+
plan_id="test_plan",
91+
)
92+
93+
snapshot_cache = SnapshotCache(tmp_path / "snapshot_cache")
94+
95+
def load_snapshots(infos):
96+
snapshot_ids = {info.snapshot_id for info in infos}
97+
snapshots, _ = snapshot_cache.get_or_load(
98+
snapshot_ids,
99+
lambda ids: [
100+
snapshot.copy(deep=True)
101+
for snapshot in old_snapshots.values()
102+
if snapshot.snapshot_id in ids
103+
],
104+
)
105+
return snapshots
106+
107+
state_reader.get_snapshots.side_effect = load_snapshots
108+
109+
selector = NativeSelector(
110+
state_reader,
111+
current_models,
112+
context_path=tmp_path,
113+
cache_dir=tmp_path,
114+
)
115+
return external_a, external_b, child, old_snapshots, current_models, selector
116+
117+
118+
def _current_snapshots(models: UniqueKeyDict[str, Model]) -> t.Dict[str, Snapshot]:
119+
nodes = t.cast(t.Dict[str, Node], dict(models))
120+
return {model.fqn: Snapshot.from_node(model, nodes=nodes) for model in models.values()}
121+
122+
123+
def test_unselected_external_coparent_is_not_directly_modified_by_schema_refresh(
124+
mocker: MockerFixture, tmp_path: Path
125+
) -> None:
126+
external_a, external_b, child, old_snapshots, _, selector = _external_schema_refresh_case(
127+
mocker, tmp_path
128+
)
129+
130+
schema_refresh = mocker.spy(selector_module, "update_model_schemas")
131+
models, selected_fqns = selector.select_models([external_a.fqn], "prod")
132+
current = _current_snapshots(models)
133+
134+
assert external_a.fqn in selected_fqns
135+
schema_refresh.assert_called_once()
136+
assert not current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
137+
assert (
138+
current[external_b.fqn].fingerprint.metadata_hash
139+
== old_snapshots[external_b.fqn].fingerprint.metadata_hash
140+
)
141+
assert not current[child.fqn].is_indirectly_modified(old_snapshots[child.fqn])
142+
143+
144+
def test_selected_external_change_remains_directly_modified(
145+
mocker: MockerFixture, tmp_path: Path
146+
) -> None:
147+
_, external_b, _, old_snapshots, current_models, selector = _external_schema_refresh_case(
148+
mocker, tmp_path
149+
)
150+
changed_b = create_external_model("db.external_b", columns={"id": "int", "new": "text"})
151+
current_models.update({changed_b.fqn: changed_b})
152+
153+
models, selected_fqns = selector.select_models([external_b.fqn], "prod")
154+
current = _current_snapshots(models)
155+
156+
assert external_b.fqn in selected_fqns
157+
assert current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
158+
159+
160+
def test_selected_dependency_schema_change_remains_visible(
161+
mocker: MockerFixture, tmp_path: Path
162+
) -> None:
163+
external_a, external_b, child, old_snapshots, current_models, selector = (
164+
_external_schema_refresh_case(mocker, tmp_path)
165+
)
166+
changed_a = create_external_model("db.external_a", columns={"id": "int", "new": "text"})
167+
current_models.update({changed_a.fqn: changed_a})
168+
169+
models, selected_fqns = selector.select_models([external_a.fqn], "prod")
170+
current = _current_snapshots(models)
171+
172+
assert external_a.fqn in selected_fqns
173+
assert current[external_a.fqn].is_directly_modified(old_snapshots[external_a.fqn])
174+
assert not current[external_b.fqn].is_directly_modified(old_snapshots[external_b.fqn])
175+
assert current[child.fqn].is_indirectly_modified(old_snapshots[child.fqn])
176+
177+
23178
@pytest.mark.parametrize(
24179
"default_catalog",
25180
[

0 commit comments

Comments
 (0)