Skip to content

Commit 8cb48d2

Browse files
authored
Feat: Introduce plan explain mode (#4642)
1 parent b9c9a57 commit 8cb48d2

File tree

14 files changed

+1556
-66
lines changed

14 files changed

+1556
-66
lines changed

docs/reference/cli.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@ Options:
343343
Default: prod.
344344
--skip-tests Skip tests prior to generating the plan if
345345
they are defined.
346+
--skip-linter Skip linting prior to generating the plan if
347+
the linter is enabled.
346348
-r, --restate-model TEXT Restate data for specified models and models
347349
downstream from the one specified. For
348350
production environment, all related model
@@ -383,9 +385,12 @@ Options:
383385
application (prod environment only).
384386
--enable-preview Enable preview for forward-only models when
385387
targeting a development environment.
386-
--diff-rendered Output text differences for rendered versions
387-
of models and standalone audits
388-
-v, --verbose Verbose output.
388+
--diff-rendered Output text differences for the rendered
389+
versions of the models and standalone
390+
audits.
391+
--explain Explain the plan instead of applying it.
392+
-v, --verbose Verbose output. Use -vv for very verbose
393+
output.
389394
--help Show this message and exit.
390395
```
391396

sqlmesh/cli/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,13 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None:
457457
@click.option(
458458
"--diff-rendered",
459459
is_flag=True,
460-
help="Output text differences for the rendered versions of the models and standalone audits",
460+
help="Output text differences for the rendered versions of the models and standalone audits.",
461+
default=None,
462+
)
463+
@click.option(
464+
"--explain",
465+
is_flag=True,
466+
help="Explain the plan instead of applying it.",
461467
default=None,
462468
)
463469
@opt.verbose

sqlmesh/core/context.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
NotificationTarget,
8989
NotificationTargetManager,
9090
)
91-
from sqlmesh.core.plan import Plan, PlanBuilder, SnapshotIntervals
91+
from sqlmesh.core.plan import Plan, PlanBuilder, SnapshotIntervals, PlanExplainer
9292
from sqlmesh.core.plan.definition import UserProvidedFlags
9393
from sqlmesh.core.reference import ReferenceGraph
9494
from sqlmesh.core.scheduler import Scheduler, CompletionStatus
@@ -1211,6 +1211,7 @@ def plan(
12111211
run: t.Optional[bool] = None,
12121212
diff_rendered: t.Optional[bool] = None,
12131213
skip_linter: t.Optional[bool] = None,
1214+
explain: t.Optional[bool] = None,
12141215
) -> Plan:
12151216
"""Interactively creates a plan.
12161217
@@ -1256,6 +1257,7 @@ def plan(
12561257
run: Whether to run latest intervals as part of the plan application.
12571258
diff_rendered: Whether the diff should compare raw vs rendered models
12581259
skip_linter: Linter runs by default so this will skip it if enabled
1260+
explain: Whether to explain the plan instead of applying it.
12591261
12601262
Returns:
12611263
The populated Plan object.
@@ -1283,6 +1285,7 @@ def plan(
12831285
run=run,
12841286
diff_rendered=diff_rendered,
12851287
skip_linter=skip_linter,
1288+
explain=explain,
12861289
)
12871290

12881291
plan = plan_builder.build()
@@ -1292,6 +1295,9 @@ def plan(
12921295
# or if there are any uncategorized snapshots in the plan
12931296
no_prompts = False
12941297

1298+
if explain:
1299+
auto_apply = True
1300+
12951301
self.console.plan(
12961302
plan_builder,
12971303
auto_apply if auto_apply is not None else self.config.plan.auto_apply,
@@ -1328,6 +1334,7 @@ def plan_builder(
13281334
run: t.Optional[bool] = None,
13291335
diff_rendered: t.Optional[bool] = None,
13301336
skip_linter: t.Optional[bool] = None,
1337+
explain: t.Optional[bool] = None,
13311338
) -> PlanBuilder:
13321339
"""Creates a plan builder.
13331340
@@ -1544,6 +1551,7 @@ def plan_builder(
15441551
interval_end_per_model=max_interval_end_per_model,
15451552
console=self.console,
15461553
user_provided_flags=user_provided_flags,
1554+
explain=explain or False,
15471555
)
15481556

15491557
def apply(
@@ -1568,6 +1576,16 @@ def apply(
15681576
return
15691577
if plan.uncategorized:
15701578
raise UncategorizedPlanError("Can't apply a plan with uncategorized changes.")
1579+
1580+
if plan.explain:
1581+
explainer = PlanExplainer(
1582+
state_reader=self.state_reader,
1583+
default_catalog=self.default_catalog,
1584+
console=self.console,
1585+
)
1586+
explainer.evaluate(plan.to_evaluatable())
1587+
return
1588+
15711589
self.notification_target_manager.notify(
15721590
NotificationEvent.APPLY_START,
15731591
environment=plan.environment_naming_info.name,

sqlmesh/core/environment.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,19 @@ def summary(self) -> EnvironmentSummary:
229229
finalized_ts=self.finalized_ts,
230230
)
231231

232+
def can_partially_promote(self, existing_environment: Environment) -> bool:
233+
"""Returns True if the existing environment can be partially promoted to the current environment.
234+
235+
Partial promotion means that we don't need to re-create views for snapshots that are already promoted in the
236+
target environment.
237+
"""
238+
return (
239+
bool(existing_environment.finalized_ts)
240+
and not existing_environment.expired
241+
and existing_environment.gateway_managed == self.gateway_managed
242+
and existing_environment.name == c.PROD
243+
)
244+
232245
def _convert_list_to_models_and_store(
233246
self, field: str, type_: t.Type[PydanticType]
234247
) -> t.Optional[t.List[PydanticType]]:

sqlmesh/core/plan/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
PlanEvaluator as PlanEvaluator,
1111
update_intervals_for_new_snapshots as update_intervals_for_new_snapshots,
1212
)
13+
from sqlmesh.core.plan.explainer import PlanExplainer as PlanExplainer

sqlmesh/core/plan/builder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class PlanBuilder:
8383
environment state, or to use whatever snapshots are in the current environment state even if
8484
the environment is not finalized.
8585
interval_end_per_model: The mapping from model FQNs to target end dates.
86+
explain: Whether to explain the plan instead of applying it.
8687
"""
8788

8889
def __init__(
@@ -112,6 +113,7 @@ def __init__(
112113
enable_preview: bool = False,
113114
end_bounded: bool = False,
114115
ensure_finalized_snapshots: bool = False,
116+
explain: bool = False,
115117
interval_end_per_model: t.Optional[t.Dict[str, int]] = None,
116118
console: t.Optional[PlanBuilderConsole] = None,
117119
user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None,
@@ -142,6 +144,7 @@ def __init__(
142144
self._console = console or get_console()
143145
self._choices: t.Dict[SnapshotId, SnapshotChangeCategory] = {}
144146
self._user_provided_flags = user_provided_flags
147+
self._explain = explain
145148

146149
self._start = start
147150
if not self._start and (
@@ -273,6 +276,7 @@ def build(self) -> Plan:
273276
empty_backfill=self._empty_backfill,
274277
no_gaps=self._no_gaps,
275278
forward_only=self._forward_only,
279+
explain=self._explain,
276280
allow_destructive_models=t.cast(t.Set, self._allow_destructive_models),
277281
include_unmodified=self._include_unmodified,
278282
environment_ttl=self._environment_ttl,

sqlmesh/core/plan/definition.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class Plan(PydanticModel, frozen=True):
4646
include_unmodified: bool
4747
end_bounded: bool
4848
ensure_finalized_snapshots: bool
49+
explain: bool
4950

5051
environment_ttl: t.Optional[str] = None
5152
environment_naming_info: EnvironmentNamingInfo

sqlmesh/core/plan/evaluator.py

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
SnapshotCreationFailedError,
3737
)
3838
from sqlmesh.utils import CompletionStatus
39-
from sqlmesh.core.state_sync import StateSync
39+
from sqlmesh.core.state_sync import StateSync, StateReader
4040
from sqlmesh.core.state_sync.base import PromotionResult
4141
from sqlmesh.utils.concurrency import NodeExecutionFailedError
4242
from sqlmesh.utils.errors import PlanError
@@ -284,23 +284,7 @@ def _push(
284284
new_snapshots=plan.new_snapshots, plan_id=plan.plan_id
285285
)
286286

287-
promoted_snapshot_ids = (
288-
set(plan.environment.promoted_snapshot_ids)
289-
if plan.environment.promoted_snapshot_ids is not None
290-
else None
291-
)
292-
293-
def _should_create(s: Snapshot) -> bool:
294-
if not s.is_model or s.is_symbolic:
295-
return False
296-
# Only create tables for snapshots that we're planning to promote or that were selected for backfill
297-
return (
298-
plan.is_selected_for_backfill(s.name)
299-
or promoted_snapshot_ids is None
300-
or s.snapshot_id in promoted_snapshot_ids
301-
)
302-
303-
snapshots_to_create = [s for s in snapshots.values() if _should_create(s)]
287+
snapshots_to_create = get_snapshots_to_create(plan, snapshots)
304288

305289
completion_status = None
306290
progress_stopped = False
@@ -573,32 +557,7 @@ def _run_audits_for_metadata_snapshots(
573557
plan: EvaluatablePlan,
574558
new_snapshots: t.Dict[SnapshotId, Snapshot],
575559
) -> None:
576-
# Filter out snapshots that are not categorized as metadata changes on models
577-
metadata_snapshots = []
578-
for snapshot in new_snapshots.values():
579-
if not snapshot.is_metadata or not snapshot.is_model or not snapshot.evaluatable:
580-
continue
581-
582-
metadata_snapshots.append(snapshot)
583-
584-
# Bulk load all the previous snapshots
585-
previous_snapshots = self.state_sync.get_snapshots(
586-
[
587-
s.previous_version.snapshot_id(s.name)
588-
for s in metadata_snapshots
589-
if s.previous_version
590-
]
591-
).values()
592-
593-
# Check if any of the snapshots have modifications to the audits field by comparing the hashes
594-
audit_snapshots = {}
595-
for snapshot, previous_snapshot in zip(metadata_snapshots, previous_snapshots):
596-
new_audits_hash = snapshot.model.audit_metadata_hash()
597-
previous_audit_hash = previous_snapshot.model.audit_metadata_hash()
598-
599-
if snapshot.model.audits and previous_audit_hash != new_audits_hash:
600-
audit_snapshots[snapshot.snapshot_id] = snapshot
601-
560+
audit_snapshots = get_audit_only_snapshots(new_snapshots, self.state_sync)
602561
if not audit_snapshots:
603562
return
604563

@@ -636,3 +595,52 @@ def update_intervals_for_new_snapshots(
636595

637596
if snapshots_intervals:
638597
state_sync.add_snapshots_intervals(snapshots_intervals)
598+
599+
600+
def get_audit_only_snapshots(
601+
new_snapshots: t.Dict[SnapshotId, Snapshot], state_reader: StateReader
602+
) -> t.Dict[SnapshotId, Snapshot]:
603+
metadata_snapshots = []
604+
for snapshot in new_snapshots.values():
605+
if not snapshot.is_metadata or not snapshot.is_model or not snapshot.evaluatable:
606+
continue
607+
608+
metadata_snapshots.append(snapshot)
609+
610+
# Bulk load all the previous snapshots
611+
previous_snapshots = state_reader.get_snapshots(
612+
[s.previous_version.snapshot_id(s.name) for s in metadata_snapshots if s.previous_version]
613+
).values()
614+
615+
# Check if any of the snapshots have modifications to the audits field by comparing the hashes
616+
audit_snapshots = {}
617+
for snapshot, previous_snapshot in zip(metadata_snapshots, previous_snapshots):
618+
new_audits_hash = snapshot.model.audit_metadata_hash()
619+
previous_audit_hash = previous_snapshot.model.audit_metadata_hash()
620+
621+
if snapshot.model.audits and previous_audit_hash != new_audits_hash:
622+
audit_snapshots[snapshot.snapshot_id] = snapshot
623+
624+
return audit_snapshots
625+
626+
627+
def get_snapshots_to_create(
628+
plan: EvaluatablePlan, snapshots: t.Dict[SnapshotId, Snapshot]
629+
) -> t.List[Snapshot]:
630+
promoted_snapshot_ids = (
631+
set(plan.environment.promoted_snapshot_ids)
632+
if plan.environment.promoted_snapshot_ids is not None
633+
else None
634+
)
635+
636+
def _should_create(s: Snapshot) -> bool:
637+
if not s.is_model or s.is_symbolic:
638+
return False
639+
# Only create tables for snapshots that we're planning to promote or that were selected for backfill
640+
return (
641+
plan.is_selected_for_backfill(s.name)
642+
or promoted_snapshot_ids is None
643+
or s.snapshot_id in promoted_snapshot_ids
644+
)
645+
646+
return [s for s in snapshots.values() if _should_create(s)]

0 commit comments

Comments
 (0)