Skip to content

Commit 34658f1

Browse files
authored
Merge branch 'main' into fix/limit-duckdb
2 parents 98fbaa0 + 2026750 commit 34658f1

18 files changed

Lines changed: 843 additions & 294 deletions

File tree

‎pnpm-lock.yaml‎

Lines changed: 153 additions & 222 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎sqlmesh/core/context.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,25 @@ def load(
748748
prod = self.state_reader.get_environment(c.PROD)
749749

750750
if prod:
751-
for snapshot in self.state_reader.get_snapshots(prod.snapshots).values():
751+
# Environment snapshot infos already contain the names we need to distinguish
752+
# local nodes from nodes owned by another project. Hydrating local snapshots
753+
# here is wasteful: their payloads are not used, and a large remote state can
754+
# spend most of Context.load() decoding those model object graphs.
755+
#
756+
# Only hydrate names which are absent locally. These can be deleted nodes from
757+
# this project or nodes from another project which must be merged into the
758+
# context. The project field on the hydrated node disambiguates the two cases.
759+
remote_snapshot_infos = []
760+
for snapshot_info in prod.snapshots:
761+
local_store = (
762+
self._standalone_audits if snapshot_info.is_audit else self._models
763+
)
764+
if snapshot_info.name in local_store:
765+
uncached.add(snapshot_info.name)
766+
else:
767+
remote_snapshot_infos.append(snapshot_info)
768+
769+
for snapshot in self.state_reader.get_snapshots(remote_snapshot_infos).values():
752770
if snapshot.node.project in self._projects:
753771
uncached.add(snapshot.name)
754772
else:

‎sqlmesh/core/model/cache.py‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,48 @@ def with_optimized_query(self, model: Model, name: t.Optional[str] = None) -> bo
112112
cache_entry = self._file_cache.get(name)
113113
if cache_entry:
114114
try:
115-
# If the optimized rendered query is None, then there are likely adapter calls in the query
116-
# that prevent us from rendering it at load time. This means that we can safely set the
117-
# unoptimized cache to None as well to prevent attempts to render it downstream.
118-
optimized = cache_entry.optimized_rendered_query is not None
119-
model._query_renderer.update_cache(
120-
cache_entry.optimized_rendered_query,
121-
cache_entry.renderer_violations,
122-
optimized=optimized,
123-
)
115+
self.with_optimized_query_entry(model, cache_entry)
124116
return True
125117
except Exception as ex:
126118
logger.warning("Failed to load a cache entry '%s': %s", name, ex)
127119

128120
self._put(name, model)
129121
return False
130122

123+
@staticmethod
124+
def with_optimized_query_entry(model: Model, cache_entry: OptimizedQueryCacheEntry) -> None:
125+
"""Adds an already-decoded optimized query cache entry to a model."""
126+
if not isinstance(model, SqlModel):
127+
return
128+
129+
# If the optimized rendered query is None, then there are likely adapter calls in the
130+
# query that prevent us from rendering it at load time. This means that we can safely
131+
# set the unoptimized cache to None as well to prevent attempts to render it downstream.
132+
optimized = cache_entry.optimized_rendered_query is not None
133+
model._query_renderer.update_cache(
134+
cache_entry.optimized_rendered_query,
135+
cache_entry.renderer_violations,
136+
optimized=optimized,
137+
)
138+
139+
def get_or_create_entry(
140+
self, model: Model, name: t.Optional[str] = None
141+
) -> t.Optional[OptimizedQueryCacheEntry]:
142+
"""Returns one decoded cache entry, creating it when necessary."""
143+
if not isinstance(model, SqlModel):
144+
return None
145+
146+
name = self._entry_name(model) if name is None else name
147+
cache_entry = self._file_cache.get(name)
148+
if cache_entry:
149+
try:
150+
self.with_optimized_query_entry(model, cache_entry)
151+
return cache_entry
152+
except Exception as ex:
153+
logger.warning("Failed to load a cache entry '%s': %s", name, ex)
154+
155+
return self._put(name, model)
156+
131157
def put(self, model: Model) -> t.Optional[str]:
132158
if not isinstance(model, SqlModel):
133159
return None
@@ -140,14 +166,15 @@ def put(self, model: Model) -> t.Optional[str]:
140166
self._put(name, model)
141167
return name
142168

143-
def _put(self, name: str, model: SqlModel) -> None:
169+
def _put(self, name: str, model: SqlModel) -> OptimizedQueryCacheEntry:
144170
optimized_query = model.render_query()
145171

146172
new_entry = OptimizedQueryCacheEntry(
147173
optimized_rendered_query=optimized_query,
148174
renderer_violations=model.violated_rules_for_query,
149175
)
150176
self._file_cache.put(name, value=new_entry)
177+
return new_entry
151178

152179
@staticmethod
153180
def _entry_name(model: SqlModel) -> str:
@@ -197,7 +224,13 @@ def load_optimized_query(
197224

198225
def load_optimized_query_and_mapping(
199226
model: Model, mapping: t.Dict
200-
) -> t.Tuple[str, t.Optional[str], str, str, t.Dict]:
227+
) -> t.Tuple[
228+
str,
229+
t.Optional[OptimizedQueryCacheEntry],
230+
str,
231+
str,
232+
t.Dict,
233+
]:
201234
assert _optimized_query_cache
202235

203236
schema = MappingSchema(normalize=False)
@@ -207,13 +240,13 @@ def load_optimized_query_and_mapping(
207240

208241
if isinstance(model, SqlModel):
209242
entry_name = _optimized_query_cache._entry_name(model)
210-
_optimized_query_cache.with_optimized_query(model, entry_name)
243+
cache_entry = _optimized_query_cache.get_or_create_entry(model, entry_name)
211244
else:
212-
entry_name = None
245+
cache_entry = None
213246

214247
return (
215248
model.fqn,
216-
entry_name,
249+
cache_entry,
217250
model.data_hash,
218251
model.metadata_hash,
219252
model.mapping_schema,

‎sqlmesh/core/model/schema.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,16 @@ def process_models(completed_model: t.Optional[Model] = None) -> None:
8787
for future in as_completed(futures):
8888
try:
8989
futures.remove(future)
90-
fqn, entry_name, data_hash, metadata_hash, mapping_schema = future.result()
90+
fqn, cache_entry, data_hash, metadata_hash, mapping_schema = future.result()
9191
model = models[fqn]
9292
model._data_hash = data_hash
9393
model._metadata_hash = metadata_hash
9494
if model.mapping_schema != mapping_schema:
9595
model.set_mapping_schema(mapping_schema)
96-
optimized_query_cache.with_optimized_query(model, entry_name)
96+
if cache_entry is not None:
97+
# The worker has already decompressed and decoded this entry. Passing
98+
# it back avoids a second gzip/pickle read in the parent process.
99+
optimized_query_cache.with_optimized_query_entry(model, cache_entry)
97100
_update_schema_with_model(schema, model)
98101
process_models(completed_model=model)
99102
except Exception as ex:

‎sqlmesh/core/plan/explainer.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from rich.console import Console as RichConsole
1010
from rich.tree import Tree
11+
from sqlglot import exp
1112
from sqlglot.dialects.dialect import DialectType
1213
from sqlmesh.core import constants as c
1314
from sqlmesh.core.console import Console, TerminalConsole, get_console
15+
from sqlmesh.core.dialect import schema_
1416
from sqlmesh.core.environment import EnvironmentNamingInfo
1517
from sqlmesh.core.plan.common import (
1618
SnapshotIntervalClearRequest,
@@ -145,6 +147,29 @@ def visit_before_all_stage(self, stage: stages.BeforeAllStage) -> Tree:
145147
def visit_after_all_stage(self, stage: stages.AfterAllStage) -> Tree:
146148
return Tree("[bold]Execute after all statements[/bold]")
147149

150+
def visit_physical_layer_schema_creation_stage(
151+
self, stage: stages.PhysicalLayerSchemaCreationStage
152+
) -> t.Optional[Tree]:
153+
tables = [
154+
exp.to_table(
155+
snapshot.table_name(is_deployable=stage.deployability_index.is_deployable(snapshot))
156+
)
157+
for snapshot in stage.snapshots
158+
if snapshot.is_model and not snapshot.is_symbolic
159+
]
160+
schemas = {
161+
schema_(table.args["db"], table.args.get("catalog")).sql(dialect=self.dialect)
162+
for table in tables
163+
if table.db
164+
}
165+
if not schemas:
166+
return None
167+
168+
tree = Tree("[bold]Create physical schemas if they do not exist[/bold]")
169+
for schema in sorted(schemas):
170+
tree.add(schema)
171+
return tree
172+
148173
def visit_physical_layer_update_stage(self, stage: stages.PhysicalLayerUpdateStage) -> Tree:
149174
snapshots = [
150175
s for s in stage.snapshots if s.snapshot_id in stage.snapshots_with_missing_intervals

‎sqlmesh/core/test/definition.py‎

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import pandas as pd
3535

3636
from sqlglot.dialects.dialect import DialectType
37+
from sqlglot.generator import Generator
3738

3839
Row = t.Dict[str, t.Any]
3940

@@ -48,6 +49,54 @@
4849
}
4950

5051

52+
_FROZEN_TIME_GENERATORS: t.Dict[
53+
t.Tuple[t.Type[Generator], str, t.Optional[str]], t.Type[Generator]
54+
] = {}
55+
_FROZEN_TIME_GENERATORS_LOCK = threading.Lock()
56+
57+
58+
def _frozen_time_generator_class(
59+
generator_class: t.Type[Generator], execution_time: str, dialect: t.Optional[str]
60+
) -> t.Type[Generator]:
61+
"""Returns a subclass of `generator_class` whose CURRENT_* transforms render `execution_time`.
62+
63+
SQLGlot caches one dispatch table per generator class, so a subclass gets its own table and
64+
the dialect's shared generator class is never modified. Subclasses are cached per
65+
(generator, execution time, dialect) so tests that share an execution time share a table.
66+
"""
67+
key = (generator_class, execution_time, dialect)
68+
with _FROZEN_TIME_GENERATORS_LOCK:
69+
klass = _FROZEN_TIME_GENERATORS.get(key)
70+
if klass is None:
71+
exec_time = exp.Literal.string(execution_time)
72+
klass = t.cast(
73+
t.Type["Generator"],
74+
type(
75+
f"{generator_class.__name__}FrozenTime",
76+
(generator_class,),
77+
{
78+
"TRANSFORMS": {
79+
**generator_class.TRANSFORMS,
80+
exp.CurrentDate: lambda self, _: self.sql(
81+
exp.cast(exec_time, "date", dialect=dialect)
82+
),
83+
exp.CurrentDatetime: lambda self, _: self.sql(
84+
exp.cast(exec_time, "datetime", dialect=dialect)
85+
),
86+
exp.CurrentTime: lambda self, _: self.sql(
87+
exp.cast(exec_time, "time", dialect=dialect)
88+
),
89+
exp.CurrentTimestamp: lambda self, _: self.sql(
90+
exp.cast(exec_time, "timestamp", dialect=dialect)
91+
),
92+
}
93+
},
94+
),
95+
)
96+
_FROZEN_TIME_GENERATORS[key] = klass
97+
return klass
98+
99+
51100
class ModelTest(unittest.TestCase):
52101
__test__ = False
53102

@@ -116,31 +165,22 @@ def __init__(
116165
)
117166
self._qualified_fixture_schema = schema_(self._fixture_schema, self._fixture_catalog)
118167

119-
self._transforms = self._test_adapter_dialect.generator_class.TRANSFORMS
120168
self._execution_time = str(self.body.get("vars", {}).get("execution_time") or "")
121169

122170
if self._execution_time:
123171
# Normalizes the execution time by converting it into UTC timezone
124172
self._execution_time = str(to_datetime(self._execution_time))
125173

126-
# When execution_time is set, we mock the CURRENT_* SQL expressions so they always return it
174+
# When execution_time is set, the CURRENT_* SQL expressions must render as that time. The
175+
# overrides live on a per-test generator subclass rather than on the dialect's shared
176+
# generator class: SQLGlot caches one dispatch table per generator class, so patching the
177+
# shared one is visible to every other thread that renders SQL (e.g. a concurrent test
178+
# creating its fixture views) and races with the patch's restoration.
179+
self._generator_class = self._test_adapter_dialect.generator_class
127180
if self._execution_time:
128-
exec_time = exp.Literal.string(self._execution_time)
129-
self._transforms = {
130-
**self._transforms,
131-
exp.CurrentDate: lambda self, _: self.sql(
132-
exp.cast(exec_time, "date", dialect=dialect)
133-
),
134-
exp.CurrentDatetime: lambda self, _: self.sql(
135-
exp.cast(exec_time, "datetime", dialect=dialect)
136-
),
137-
exp.CurrentTime: lambda self, _: self.sql(
138-
exp.cast(exec_time, "time", dialect=dialect)
139-
),
140-
exp.CurrentTimestamp: lambda self, _: self.sql(
141-
exp.cast(exec_time, "timestamp", dialect=dialect)
142-
),
143-
}
181+
self._generator_class = _frozen_time_generator_class(
182+
self._generator_class, self._execution_time, dialect
183+
)
144184

145185
super().__init__()
146186

@@ -600,15 +640,18 @@ def _normalize_column_name(self, name: str) -> str:
600640
return normalized_name
601641

602642
@contextmanager
603-
def _concurrent_render_context(self) -> t.Iterator[None]:
643+
def _concurrent_render_context(self, patch_shared_dialect: bool = False) -> t.Iterator[None]:
604644
"""
605645
Context manager that ensures that the tests are executed safely in a concurrent environment.
606-
This is needed in case `execution_time` is set, as we'd then have to:
607-
- Freeze time through `time_machine` (not thread safe)
608-
- Globally patch the SQLGlot dialect so that any date/time nodes are evaluated at the `execution_time` during generation
646+
This is needed in case `execution_time` is set, as we'd then have to freeze time through
647+
`time_machine`, which is not thread safe.
648+
649+
SQL model tests render through `self._generator_class`, so the shared dialect is never
650+
modified. Python model tests may run arbitrary SQL through the engine adapter, whose
651+
generator cannot be swapped per test, so they additionally patch the shared generator's
652+
transforms (`patch_shared_dialect=True`) while holding the lock.
609653
"""
610654
import time_machine
611-
from sqlglot.generator import _DISPATCH_CACHE
612655

613656
lock_ctx: AbstractContextManager = (
614657
self.CONCURRENT_RENDER_LOCK if self.concurrency else nullcontext()
@@ -618,19 +661,30 @@ def _concurrent_render_context(self) -> t.Iterator[None]:
618661
dispatch_patch_ctx: AbstractContextManager = nullcontext()
619662

620663
if self._execution_time:
621-
generator_class = self._test_adapter_dialect.generator_class
622664
time_ctx = time_machine.travel(self._execution_time, tick=False)
623-
dialect_patch_ctx = patch.dict(generator_class.TRANSFORMS, self._transforms)
665+
666+
if self._execution_time and patch_shared_dialect:
667+
from sqlglot.generator import _DISPATCH_CACHE
668+
669+
generator_class = self._test_adapter_dialect.generator_class
670+
transforms = self._generator_class.TRANSFORMS
671+
dialect_patch_ctx = patch.dict(generator_class.TRANSFORMS, transforms)
624672

625673
# sqlglot caches a dispatch table per generator class, so we need to patch
626674
# it as well to ensure the overridden transforms are actually used
627675
dispatch = _DISPATCH_CACHE.get(generator_class)
628676
if dispatch is not None:
629-
dispatch_patch_ctx = patch.dict(dispatch, self._transforms)
677+
dispatch_patch_ctx = patch.dict(dispatch, transforms)
630678

631679
with lock_ctx, time_ctx, dialect_patch_ctx, dispatch_patch_ctx:
632680
yield
633681

682+
def _generate_sql(self, expression: exp.Expr) -> str:
683+
"""Generates SQL for the testing engine, rendering CURRENT_* at `execution_time` when set."""
684+
return self._generator_class(
685+
dialect=self._test_adapter_dialect, pretty=self.engine_adapter._pretty_sql
686+
).generate(expression)
687+
634688
def _execute(self, query: exp.Query | str) -> pd.DataFrame:
635689
"""Executes the given query using the testing engine adapter and returns a DataFrame."""
636690
return self.engine_adapter.fetchdf(query)
@@ -698,9 +752,7 @@ def test_ctes(self, ctes: t.Dict[str, exp.Expr], recursive: bool = False) -> Non
698752
with self._concurrent_render_context():
699753
# Similar to the model's query, we render the CTE query under the locked context
700754
# so that the execution (fetchdf) can continue concurrently between the threads
701-
sql = cte_query.sql(
702-
self._test_adapter_dialect, pretty=self.engine_adapter._pretty_sql
703-
)
755+
sql = self._generate_sql(cte_query)
704756

705757
actual = self._execute(sql)
706758
expected = self._create_df(values, columns=cte_query.named_selects, partial=partial)
@@ -712,7 +764,7 @@ def runTest(self) -> None:
712764
# Render the model's query and generate the SQL under the locked context so that
713765
# execution (fetchdf) can continue concurrently between the threads
714766
query = self._render_model_query()
715-
sql = query.sql(self._test_adapter_dialect, pretty=self.engine_adapter._pretty_sql)
767+
sql = self._generate_sql(query)
716768

717769
with_clause = query.args.get("with_")
718770

@@ -817,7 +869,7 @@ def _execute_model(self) -> pd.DataFrame:
817869
"""Executes the python model and returns a DataFrame."""
818870
import pandas as pd
819871

820-
with self._concurrent_render_context():
872+
with self._concurrent_render_context(patch_shared_dialect=True):
821873
variables = self.body.get("vars", {}).copy()
822874
time_kwargs = {key: variables.pop(key) for key in TIME_KWARG_KEYS if key in variables}
823875
df = next(self.model.render(context=self.context, variables=variables, **time_kwargs))

‎sqlmesh/migrations/v0071_add_dev_version_to_intervals.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def _migrate_intervals(
106106
continue
107107

108108
dev_version = snapshot_ids_to_dev_versions.get((name, identifier))
109-
if dev_version not in used_dev_versions and is_dev:
109+
if (name, dev_version) not in used_dev_versions and is_dev:
110110
# If the interval's dev version is no longer used and this is a dev interval, we can safely delete it
111111
continue
112112

0 commit comments

Comments
 (0)