Skip to content

feat(cli): report state backend versions on rollback and info - #6088

Merged
cmgoffena13 merged 6 commits into
SQLMesh:mainfrom
tripleaceme:rollback-version-output
Sep 24, 2026
Merged

cmgoffena13 merged 6 commits into
SQLMesh:mainfrom
tripleaceme:rollback-version-output

Conversation

@tripleaceme

Copy link
Copy Markdown
Contributor

Description

Closes #6045.

sqlmesh rollback printed nothing at all, so there was no way to tell what it had done. That is worst exactly when it matters most: part way through a bad upgrade, with no command anywhere that shows the version state of the state backend.

Rollback now reports the schema, SQLGlot and SQLMesh versions it moved from and to:

$ sqlmesh rollback

State backend versions:
Schema version: 84 -> 83
SQLGlot version: 9999.0.0 -> 30.8.0
SQLMesh version: 9999.0.0 -> 0.214.1

And sqlmesh info -v reports the backend's current versions, so there is a way to inspect them without performing a rollback.

Both go through one helper, so the two commands cannot drift apart, and info logs an error rather than failing outright if the versions can't be read. The versions are read with validate=False, since the whole point is to show what is actually there — including the mismatched state that prompts a rollback in the first place.

Two defaults I picked, both flagged on the issue and neither answered, so say the word if you'd rather have them the other way:

  • Rollback prints the pair after the rollback succeeds, not unconditionally. A failed rollback prints nothing.
  • info shows the versions behind -v, as suggested in the issue, rather than at default verbosity.

Test Plan

Three tests in tests/cli/test_cli.py:

  • test_info_state_versions — plain info does not print them; info -v does, and the values match SCHEMA_VERSION / the installed SQLGlot / the installed SQLMesh.
  • test_rollback_state_versions — backs up state, fakes a migration to a newer version, then asserts the rollback prints 84 -> 83 style before/after pairs for all three.
  • test_rollback_without_backup_does_not_print_state_versions — a rollback that fails with "There are no prior migrations to roll back to." prints no versions. This one guards the ordering: it fails if the print is ever moved ahead of the rollback call.

I checked the first two fail without the implementation, so they aren't passing for free.

pytest tests/cli/test_cli.py tests/core/test_test.py
175 passed

The test_dlt_* and test_pyspark_python_model failures are pre-existing on main in my environment (ModuleNotFoundError).

Checklist

  • I have run make style and fixed any issues
  • I have added tests for my changes (if applicable)
  • All existing tests pass (make fast-test)
  • My commits are signed off (git commit -s) per the DCO

`sqlmesh rollback` printed nothing at all, so there was no way to tell
what it had actually done. That is worst precisely when it matters: part
way through a bad upgrade, with no command anywhere that shows the
version state of the state backend.

Rollback now reports the schema, SQLGlot and SQLMesh versions it moved
from and to, once the rollback has succeeded, and `sqlmesh info -v`
reports the backend's current versions. Both read through one helper so
the two commands cannot drift apart, and `info` degrades to an error
message rather than failing when the versions can't be read.

The versions are read with validate=False, since the point is to show
what is there, including the mismatched state that prompts a rollback in
the first place.

Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
@tripleaceme

Copy link
Copy Markdown
Contributor Author

@cmgoffena13 — closes #6045.

Two calls I had to make without an answer, both noted on the issue and both easy to flip:

  1. Rollback prints the before/after pair after it succeeds, so a failed rollback prints nothing.
  2. info shows the state versions behind -v, as the issue suggested, rather than always.

Say if you'd rather have either the other way round.

@cmgoffena13

Copy link
Copy Markdown
Collaborator

@tripleaceme -- this was missed in the issue, but could you apply this logic to sqlmesh migrate as well so the version changes are clear upon a successful migration.

I'm good with only showing on success. Any error will produce proper output.

Lets keep the versions in -v for info still, its in-depth debug information


One edge case, migrate looks at MAJOR.MINOR and right now does not update versions, so the output could be wrong if someone runs migrate after a PATCH bump.

Easy fix in sqlmesh/core/state_sync/db/migrator.py - lines 98-103

Before:

if not migrate_rows and major_minor(SQLMESH_VERSION) == versions.minor_sqlmesh_version:
    return

if migrate_rows:
    self._migrate_rows(promoted_snapshots_only)
self.version_state.update_versions()

After:

if not migrate_rows and major_minor(SQLMESH_VERSION) == versions.minor_sqlmesh_version:
    if (
        versions.sqlmesh_version != SQLMESH_VERSION
        or versions.sqlglot_version != SQLGLOT_VERSION
    ):
        self.version_state.update_versions()
    return

if migrate_rows:
    self._migrate_rows(promoted_snapshots_only)
self.version_state.update_versions()

Review feedback on SQLMesh#6088. `migrate` was as silent as `rollback` was, so
it now reports the same before and after versions once it succeeds.

It also fixes a case where that output would have been wrong. The early
return in StateSyncMigrator.migrate compares only MAJOR.MINOR, so a
patch-level upgrade left the recorded versions behind what was actually
running and `migrate` reported no change. The recorded versions are now
brought up to date in that branch when they differ from the running
ones.

Both are covered: one test asserts the before and after pair is printed,
and one pins the patch bump specifically. The second sets both recorded
minor versions equal to the installed ones on purpose — with a differing
minor, `_apply_migrations` reports rows to migrate and the early return
under test is never reached.

Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
@tripleaceme

Copy link
Copy Markdown
Contributor Author

Thanks @cmgoffena13 — both done in 8005529, and noted on the two defaults staying as they are.

migrate now reports the same pair, after it succeeds, the same way rollback does:

$ sqlmesh migrate

State backend versions:
Schema version: 84 -> 84
SQLGlot version: 30.8.dev0 -> 30.8.0
SQLMesh version: 0.214.dev0 -> 0.214.1

Your patch-bump fix is in, applied as you wrote it, with a comment saying why the branch exists.

One thing worth flagging about the test for it, because my first attempt was wrong and passed for the wrong reason. I initially seeded sqlglot_version="0.0.1", but a differing minor makes _apply_migrations report rows to migrate, so execution falls through to update_versions() anyway and the early return under test is never reached — the test passed with the fix reverted. It now sets both recorded minor versions equal to the installed ones and varies only the patch, which does reach that branch. Verified it fails without your fix and passes with it.

Tests added to tests/cli/test_cli.py:

  • test_migrate_state_versions — the before/after pair is printed on a successful migrate.
  • test_migrate_updates_versions_after_a_patch_bump — after a patch-only bump the recorded versions actually move to the running ones.
pytest tests/cli/test_cli.py tests/core/state_sync
190 passed

The three test_dlt_* failures are pre-existing on main in my environment (ModuleNotFoundError: dlt).

@cmgoffena13

Copy link
Copy Markdown
Collaborator

@tripleaceme -- okay, one last small fix: for the migrator we should actually call the version update with the same schema_version. Like this: self.version_state.update_versions(schema_version=versions.schema_version) -- that way people can't introduce a weird scenario where they re-trigger a migration somehow. So we only update the SQLMesh version and SQLGlot version in the patch bump / no migration scenario.

Review feedback on SQLMesh#6088. The patch-bump branch called update_versions
with no arguments, and schema_version defaults to the current
SCHEMA_VERSION, so a run with nothing to migrate still moved it. The
recorded value is now carried over, since a migration that is genuinely
still needed must not be masked by a version bump that skipped it.

The guarantee is pinned in tests/core/state_sync, not through the CLI:
a state whose schema version differs makes _apply_migrations report rows
to migrate, so the branch is unreachable end to end. The test forces
_apply_migrations to report nothing, which is the only way to reach it,
and that is also why the change is defensive rather than a fix for
something observable today.

Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
@tripleaceme

Copy link
Copy Markdown
Contributor Author

Done in 8882ae8update_versions(schema_version=versions.schema_version), as you described.

Worth recording why, since it took me a moment to see it: schema_version defaults to the current SCHEMA_VERSION, so the bare call was moving it even though no migration had run. Carrying it over means a migration that is still genuinely needed can't be masked by a version bump that skipped it.

Where the test had to go. I first tried to pin this through the CLI by seeding an older schema version, and it failed — a differing schema version makes _apply_migrations report rows to migrate, so the run falls through to the full update_versions() at the bottom and the patch-bump branch is never reached. That is exactly why this is defensive rather than a fix for something observable today: no real state can get into the shape it guards.

So the guarantee is pinned in tests/core/state_sync/test_state_sync.py instead, with _apply_migrations forced to report nothing so the branch is reachable at all:

def test_migrate_patch_bump_preserves_schema_version(state_sync, mocker):
    ...
    mocker.patch(
        "sqlmesh.core.state_sync.db.migrator.StateMigrator._apply_migrations",
        return_value=False,
    )
    state_sync.migrate()
    assert versions.schema_version == stale_schema_version

Verified it fails without the change (assert 102 == 101) and passes with it.

pytest tests/cli/test_cli.py tests/core/state_sync
191 passed

The three test_dlt_* failures are pre-existing on main in my environment.

tripleaceme added a commit to tripleaceme/sqlmesh that referenced this pull request Sep 24, 2026
SQLMesh#6088 makes rollback report the versions it moved between, so the note
would go stale as soon as that merges.

Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
@cmgoffena13

Copy link
Copy Markdown
Collaborator

@tripleaceme -- looks like tests/integration/jupyter/test_magics.py is failing on test_info and test_migrate -- they'll need adjusted as well

info -v and migrate now print the state backend versions, so the magics
tests see four more lines each.

The migrate test builds the expected values from the running versions
rather than hard-coding them. The sushi state is an in-memory DuckDB
database, so the state sync migrate opens starts empty and the versions
move from the defaults.

Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
@tripleaceme

Copy link
Copy Markdown
Contributor Author

Fixed in cbfa9c0. Both tests now expect the four extra lines.

  • test_info: info --verbose now ends with the state backend versions, so the test expects 10 outputs instead of 6.
  • test_migrate: the expected versions come from SCHEMA_VERSION, SQLGLOT_VERSION and SQLMESH_VERSION instead of literals, since the running versions change with every build. Rich highlights the numbers in those lines differently depending on the version string, so the HTML check only covers the heading and the Migration complete line. The text check covers every line.

One thing worth knowing about what these tests show. Both print the versions moving from the defaults (0, 0.0.0), even though loaded_sushi_context has applied a plan. The sushi state is an in-memory DuckDB database with no separate state_connection, so the fresh state sync from _new_state_sync() opens a new, empty database. Checked on the fixture: context.state_sync.get_versions() gives schema 102, while context._new_state_sync().get_versions(validate=False) gives the defaults. That was already the case on main, since migrate has always gone through _new_state_sync(), so the old test_migrate was also migrating an empty database. File-backed and remote state backends aren't affected, and the CLI tests use a file-backed DuckDB, so the versions they assert are the real ones.

pytest tests/integrations/jupyter/test_magics.py
23 passed

@cmgoffena13
cmgoffena13 merged commit 9bd35b7 into SQLMesh:main Sep 24, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show version details on state backend for sqlmesh migrate, sqlmesh rollback and sqlmesh info commands

2 participants