Skip to content

Commit 9bd35b7

Browse files
authored
feat(cli): report state backend versions on rollback and info (#6088)
Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
1 parent df6e3a5 commit 9bd35b7

5 files changed

Lines changed: 217 additions & 16 deletions

File tree

‎sqlmesh/core/context.py‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
CachingStateSync,
109109
StateReader,
110110
StateSync,
111+
Versions,
111112
)
112113
from sqlmesh.core.janitor import cleanup_expired_views, delete_expired_snapshots
113114
from sqlmesh.core.table_diff import TableDiff
@@ -2610,15 +2611,18 @@ def migrate(self) -> None:
26102611
"""
26112612
self.notification_target_manager.notify(NotificationEvent.MIGRATION_START)
26122613
self._load_materializations()
2614+
state_sync = self._new_state_sync()
2615+
previous_versions = self._state_versions(state_sync)
26132616
try:
2614-
self._new_state_sync().migrate(
2617+
state_sync.migrate(
26152618
promoted_snapshots_only=self.config.migration.promoted_snapshots_only,
26162619
)
26172620
except Exception as e:
26182621
self.notification_target_manager.notify(
26192622
NotificationEvent.MIGRATION_FAILURE, traceback.format_exc()
26202623
)
26212624
raise e
2625+
self._print_state_versions(self._state_versions(state_sync), previous_versions)
26222626
self.notification_target_manager.notify(NotificationEvent.MIGRATION_END)
26232627

26242628
@python_api_analytics
@@ -2627,7 +2631,10 @@ def rollback(self) -> None:
26272631
26282632
Please contact your SQLMesh administrator before doing this. This action cannot be undone.
26292633
"""
2630-
self._new_state_sync().rollback()
2634+
state_sync = self._new_state_sync()
2635+
previous_versions = self._state_versions(state_sync)
2636+
state_sync.rollback()
2637+
self._print_state_versions(self._state_versions(state_sync), previous_versions)
26312638

26322639
@python_api_analytics
26332640
def create_external_models(self, strict: bool = False) -> None:
@@ -2701,6 +2708,12 @@ def print_info(
27012708
if state_connection:
27022709
self._try_connection("state backend", state_connection.connection_validator())
27032710

2711+
if verbosity >= Verbosity.VERBOSE:
2712+
try:
2713+
self._print_state_versions(self._state_versions())
2714+
except Exception as ex:
2715+
self.console.log_error(f"Failed to fetch the state backend versions. {ex}")
2716+
27042717
@python_api_analytics
27052718
def print_environment_names(self) -> None:
27062719
"""Prints all environment names along with expiry datetime."""
@@ -3290,6 +3303,25 @@ def _try_connection(self, connection_name: str, validator: t.Callable[[], None])
32903303
except Exception as ex:
32913304
self.console.log_error(f"{connection_name} connection failed. {ex}")
32923305

3306+
def _state_versions(self, state_sync: t.Optional[StateSync] = None) -> Versions:
3307+
"""Returns the versions recorded in the state backend without validating them."""
3308+
return (state_sync or self._new_state_sync()).get_versions(validate=False)
3309+
3310+
def _print_state_versions(
3311+
self, versions: Versions, previous_versions: t.Optional[Versions] = None
3312+
) -> None:
3313+
"""Prints the state backend versions, optionally alongside the ones they replaced."""
3314+
self.console.log_status_update("\nState backend versions:")
3315+
for label, attribute in (
3316+
("Schema version", "schema_version"),
3317+
("SQLGlot version", "sqlglot_version"),
3318+
("SQLMesh version", "sqlmesh_version"),
3319+
):
3320+
version = getattr(versions, attribute)
3321+
if previous_versions is not None:
3322+
version = f"{getattr(previous_versions, attribute)} -> {version}"
3323+
self.console.log_status_update(f"{label}: {version}")
3324+
32933325
def _new_state_sync(self) -> StateSync:
32943326
return self._provided_state_sync or self._scheduler.create_state_sync(self)
32953327

‎sqlmesh/core/state_sync/db/migrator.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ def migrate(
9696
migrate_rows = self._apply_migrations(schema, skip_backup)
9797

9898
if not migrate_rows and major_minor(SQLMESH_VERSION) == versions.minor_sqlmesh_version:
99+
# Nothing to migrate, but a patch-level bump still leaves the recorded versions
100+
# behind what is actually running, so they are brought up to date here. The
101+
# schema version is carried over rather than defaulted, since no migration ran
102+
# and moving it could hide one that is genuinely needed later.
103+
if (
104+
versions.sqlmesh_version != SQLMESH_VERSION
105+
or versions.sqlglot_version != SQLGLOT_VERSION
106+
):
107+
self.version_state.update_versions(schema_version=versions.schema_version)
99108
return
100109

101110
if migrate_rows:

‎tests/cli/test_cli.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@
1010

1111
from click import ClickException
1212
from click.testing import CliRunner
13+
from sqlglot import __version__ as SQLGLOT_VERSION
1314
from sqlmesh import RuntimeEnv
15+
from sqlmesh._version import __version__ as SQLMESH_VERSION
1416
from sqlmesh.cli.project_init import ProjectTemplate, init_example_project
1517
from sqlmesh.cli.main import cli
1618
from sqlmesh.core.context import Context
19+
from sqlmesh.core.state_sync.base import SCHEMA_VERSION
1720
from sqlmesh.integrations.dlt import generate_dlt_models
21+
from sqlmesh.utils import major_minor
1822
from sqlmesh.utils.date import now_ds, time_like_to_str, timedelta, to_datetime, yesterday_ds
1923
from sqlmesh.core.config.connection import DIALECT_TO_TYPE
2024

@@ -1024,6 +1028,107 @@ def test_info_on_new_project_does_not_create_state_sync(runner, tmp_path):
10241028
assert not context.engine_adapter.table_exists("sqlmesh._versions")
10251029

10261030

1031+
def test_info_state_versions(runner, tmp_path):
1032+
create_example_project(tmp_path)
1033+
init_prod_and_backfill(runner, tmp_path)
1034+
1035+
result = runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "info"])
1036+
assert result.exit_code == 0
1037+
assert "State backend versions" not in result.output
1038+
1039+
result = runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "info", "-v"])
1040+
assert result.exit_code == 0
1041+
assert "State backend versions" in result.output
1042+
assert f"Schema version: {SCHEMA_VERSION}" in result.output
1043+
assert f"SQLGlot version: {SQLGLOT_VERSION}" in result.output
1044+
assert f"SQLMesh version: {SQLMESH_VERSION}" in result.output
1045+
1046+
1047+
def test_rollback_state_versions(runner, tmp_path):
1048+
create_example_project(tmp_path)
1049+
init_prod_and_backfill(runner, tmp_path)
1050+
1051+
context = Context(paths=tmp_path)
1052+
state_sync = context._new_state_sync()
1053+
# Back up the current state, then pretend the state was migrated by a newer SQLMesh.
1054+
state_sync.migrator._backup_state()
1055+
state_sync.version_state.update_versions(
1056+
schema_version=SCHEMA_VERSION + 1,
1057+
sqlglot_version="9999.0.0",
1058+
sqlmesh_version="9999.0.0",
1059+
)
1060+
context.close()
1061+
1062+
result = runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "rollback"])
1063+
assert result.exit_code == 0
1064+
assert "State backend versions" in result.output
1065+
assert f"Schema version: {SCHEMA_VERSION + 1} -> {SCHEMA_VERSION}" in result.output
1066+
assert f"SQLGlot version: 9999.0.0 -> {SQLGLOT_VERSION}" in result.output
1067+
assert f"SQLMesh version: 9999.0.0 -> {SQLMESH_VERSION}" in result.output
1068+
1069+
1070+
def test_migrate_state_versions(runner, tmp_path):
1071+
create_example_project(tmp_path)
1072+
init_prod_and_backfill(runner, tmp_path)
1073+
1074+
context = Context(paths=tmp_path)
1075+
# Pretend the state was written by an older patch release of the same minor version, which
1076+
# is the case `migrate` used to leave untouched.
1077+
major, minor = major_minor(SQLMESH_VERSION)
1078+
older_sqlmesh = f"{major}.{minor}.dev0"
1079+
context._new_state_sync().version_state.update_versions(
1080+
sqlglot_version="0.0.1",
1081+
sqlmesh_version=older_sqlmesh,
1082+
)
1083+
context.close()
1084+
1085+
result = runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "migrate"])
1086+
assert result.exit_code == 0
1087+
assert "State backend versions" in result.output
1088+
assert f"SQLGlot version: 0.0.1 -> {SQLGLOT_VERSION}" in result.output
1089+
assert f"SQLMesh version: {older_sqlmesh} -> {SQLMESH_VERSION}" in result.output
1090+
1091+
1092+
def test_migrate_updates_versions_after_a_patch_bump(runner, tmp_path):
1093+
"""A patch bump leaves the minor version equal, but the recorded versions must still move.
1094+
1095+
Both minor versions have to match the installed ones, otherwise `_apply_migrations` reports
1096+
rows to migrate and the early return this covers is never reached.
1097+
"""
1098+
create_example_project(tmp_path)
1099+
init_prod_and_backfill(runner, tmp_path)
1100+
1101+
sqlmesh_major, sqlmesh_minor = major_minor(SQLMESH_VERSION)
1102+
sqlglot_major, sqlglot_minor = major_minor(SQLGLOT_VERSION)
1103+
context = Context(paths=tmp_path)
1104+
context._new_state_sync().version_state.update_versions(
1105+
sqlglot_version=f"{sqlglot_major}.{sqlglot_minor}.dev0",
1106+
sqlmesh_version=f"{sqlmesh_major}.{sqlmesh_minor}.dev0",
1107+
)
1108+
context.close()
1109+
1110+
assert (
1111+
runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "migrate"]).exit_code
1112+
== 0
1113+
)
1114+
1115+
context = Context(paths=tmp_path)
1116+
versions = context._new_state_sync().get_versions(validate=False)
1117+
context.close()
1118+
assert versions.sqlmesh_version == SQLMESH_VERSION
1119+
assert versions.sqlglot_version == SQLGLOT_VERSION
1120+
1121+
1122+
def test_rollback_without_backup_does_not_print_state_versions(runner, tmp_path):
1123+
create_example_project(tmp_path)
1124+
init_prod_and_backfill(runner, tmp_path)
1125+
1126+
result = runner.invoke(cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "rollback"])
1127+
assert result.exit_code == 1
1128+
assert "There are no prior migrations to roll back to." in result.output
1129+
assert "State backend versions" not in result.output
1130+
1131+
10271132
def test_dlt_pipeline_errors(runner, tmp_path):
10281133
# Error if no pipeline is provided
10291134
result = runner.invoke(cli, ["--paths", tmp_path, "init", "-t", "dlt", "duckdb"])

‎tests/core/state_sync/test_state_sync.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4254,3 +4254,35 @@ def test_state_version_is_too_old(
42544254
match="The current state belongs to an old version of SQLMesh that is no longer supported. Please upgrade to 0.134.0 first before upgrading to.*",
42554255
):
42564256
state_sync.migrate(skip_backup=True)
4257+
4258+
4259+
def test_migrate_patch_bump_preserves_schema_version(
4260+
state_sync: EngineAdapterStateSync, mocker: MockerFixture
4261+
) -> None:
4262+
"""A run with nothing to migrate must not move the recorded schema version.
4263+
4264+
`_apply_migrations` is forced to report no rows so the patch-bump branch is reached with a
4265+
schema version that differs from the current one. A real state can't get into that shape,
4266+
which is the point: the schema version is carried over rather than defaulted so that a
4267+
migration which is genuinely still needed can't be masked.
4268+
"""
4269+
from sqlmesh import __version__ as SQLMESH_VERSION
4270+
from sqlmesh.core.state_sync.base import SCHEMA_VERSION
4271+
4272+
stale_schema_version = SCHEMA_VERSION - 1
4273+
state_sync.version_state.update_versions(
4274+
schema_version=stale_schema_version,
4275+
sqlglot_version="0.0.1",
4276+
sqlmesh_version=SQLMESH_VERSION,
4277+
)
4278+
4279+
mocker.patch(
4280+
"sqlmesh.core.state_sync.db.migrator.StateMigrator._apply_migrations",
4281+
return_value=False,
4282+
)
4283+
4284+
state_sync.migrate()
4285+
4286+
versions = state_sync.get_versions(validate=False)
4287+
assert versions.schema_version == stale_schema_version
4288+
assert versions.sqlglot_version == SQLGLOT_VERSION

‎tests/integrations/jupyter/test_magics.py‎

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
from IPython.utils.capture import CapturedIO, capture_output
1313
from pytest_mock.plugin import MockerFixture
1414
from rich.console import Console as RichConsole
15+
from sqlglot import __version__ as SQLGLOT_VERSION
1516

1617
from sqlmesh import Context, RuntimeEnv
18+
from sqlmesh._version import __version__ as SQLMESH_VERSION
19+
from sqlmesh.core.state_sync.base import SCHEMA_VERSION, Versions
1720
from sqlmesh.magics import register_magics
1821
from pathlib import Path
1922

@@ -740,14 +743,19 @@ def test_info(notebook, sushi_context, convert_all_html_output_to_text, get_all_
740743

741744
assert not output.stdout
742745
assert not output.stderr
743-
assert len(output.outputs) == 6
746+
assert len(output.outputs) == 10
747+
# No plan has been applied, so the state backend is still empty and reports the defaults.
744748
assert convert_all_html_output_to_text(output) == [
745749
"Models: 20",
746750
"Macros: 8",
747751
"",
748752
"Connection:\n type: duckdb\n concurrent_tasks: 1\n register_comments: true\n pre_ping: false\n pretty_sql: false\n extensions: []\n connector_config: {}\n secrets: None\n filesystems: []\n shared_connection: true",
749753
"Test Connection:\n type: duckdb\n concurrent_tasks: 1\n register_comments: true\n pre_ping: false\n pretty_sql: false\n extensions: []\n connector_config: {}\n secrets: None\n filesystems: []\n shared_connection: true",
750754
"Data warehouse connection succeeded",
755+
"State backend versions:",
756+
"Schema version: 0",
757+
"SQLGlot version: 0.0.0",
758+
"SQLMesh version: 0.0.0",
751759
]
752760
assert get_all_html_output(output) == [
753761
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">Models: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">20</span></pre>",
@@ -756,6 +764,10 @@ def test_info(notebook, sushi_context, convert_all_html_output_to_text, get_all_
756764
'<pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace">Connection: type: duckdb concurrent_tasks: <span style="color: #008080; text-decoration-color: #008080; font-weight: bold">1</span> register_comments: true pre_ping: false pretty_sql: false extensions: <span style="font-weight: bold">[]</span> connector_config: <span style="font-weight: bold">{}</span> secrets: <span style="color: #800080; text-decoration-color: #800080; font-style: italic">None</span> filesystems: <span style="font-weight: bold">[]</span> shared_connection: true</pre>',
757765
'<pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace">Test Connection: type: duckdb concurrent_tasks: <span style="color: #008080; text-decoration-color: #008080; font-weight: bold">1</span> register_comments: true pre_ping: false pretty_sql: false extensions: <span style="font-weight: bold">[]</span> connector_config: <span style="font-weight: bold">{}</span> secrets: <span style="color: #800080; text-decoration-color: #800080; font-style: italic">None</span> filesystems: <span style="font-weight: bold">[]</span> shared_connection: true</pre>',
758766
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">Data warehouse connection <span style=\"color: #008000; text-decoration-color: #008000\">succeeded</span></pre>",
767+
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">State backend versions:</pre>",
768+
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">Schema version: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span></pre>",
769+
'<pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace">SQLGlot version: <span style="color: #008080; text-decoration-color: #008080; font-weight: bold">0.0</span>.<span style="color: #008080; text-decoration-color: #008080; font-weight: bold">0</span></pre>',
770+
'<pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace">SQLMesh version: <span style="color: #008080; text-decoration-color: #008080; font-weight: bold">0.0</span>.<span style="color: #008080; text-decoration-color: #008080; font-weight: bold">0</span></pre>',
759771
]
760772

761773

@@ -768,25 +780,36 @@ def test_migrate(
768780

769781
assert not output.stdout
770782
assert not output.stderr
771-
assert len(output.outputs) == 1
783+
assert len(output.outputs) == 5
784+
# The sushi state lives in an in-memory DuckDB database, so the state sync that `migrate`
785+
# opens starts empty and the versions move from the defaults to the running ones.
786+
empty = Versions()
772787
assert convert_all_html_output_to_text(output) == [
788+
"State backend versions:",
789+
f"Schema version: {empty.schema_version} -> {SCHEMA_VERSION}",
790+
f"SQLGlot version: {empty.sqlglot_version} -> {SQLGLOT_VERSION}",
791+
f"SQLMesh version: {empty.sqlmesh_version} -> {SQLMESH_VERSION}",
773792
"Migration complete",
774793
]
775-
assert get_all_html_output(output) == [
776-
str(
794+
# Rich highlights the numbers inside the version lines, and that markup depends on the
795+
# running versions, so only the fixed lines are compared as HTML.
796+
html_output = get_all_html_output(output)
797+
assert html_output[0] == str(
798+
h("pre", {"style": RICH_PRE_STYLE}, "State backend versions:", autoescape=False)
799+
)
800+
assert html_output[-1] == str(
801+
h(
802+
"pre",
803+
{"style": RICH_PRE_STYLE},
777804
h(
778-
"pre",
779-
{"style": RICH_PRE_STYLE},
780-
h(
781-
"span",
782-
{"style": SUCCESS_STYLE},
783-
"Migration complete",
784-
autoescape=False,
785-
),
805+
"span",
806+
{"style": SUCCESS_STYLE},
807+
"Migration complete",
786808
autoescape=False,
787-
)
809+
),
810+
autoescape=False,
788811
)
789-
]
812+
)
790813

791814

792815
# TODO: Add test for rollback

0 commit comments

Comments
 (0)