Commit the migration run instead of trusting MySQL to

SQLAlchemy 2.0 connections do not autocommit, and on MySQL alembic reports
"non-transactional DDL", so context.begin_transaction() is a no-op. Nothing in
a migration run committed.

It looked like it worked because MySQL implicitly commits on DDL: every
ALTER/CREATE flushed whatever was queued before it, including the PREVIOUS
migration's version stamp. The LAST migration of a run has no DDL after it, so
its stamp was rolled back when the connection closed. `flask db upgrade` then
exited 0 with the schema change really applied and alembic_version one revision
behind, and re-ran that same migration on the next deploy. A migration that is
not idempotent applies twice.

Found while adding a core migration: the column default really changed and
alembic_version still named its parent, with UPDATE alembic_version followed
immediately by ROLLBACK in the log.

Existing databases upgraded before this are one revision behind their real
schema. Compare `flask db current` against `flask db heads`; re-running the
upgrade re-applies the final migration once and lands the stamp.
This commit is contained in:
cproudlock
2026-08-13 09:28:11 -04:00
parent 5de3594425
commit 9336577abe

View File

@@ -147,6 +147,21 @@ def run_migrations_online():
with context.begin_transaction():
context.run_migrations()
# COMMIT THE RUN. SQLAlchemy 2.0 connections do not autocommit, and on
# MySQL alembic reports "non-transactional DDL" so begin_transaction()
# above is a no-op - nothing here commits on its own.
#
# It looked like it worked because MySQL implicitly commits on DDL: each
# ALTER/CREATE flushed everything queued before it, including the
# PREVIOUS migration's version stamp. The LAST migration of every run
# has no DDL after it, so its stamp was rolled back at close. The column
# changes survived (already committed by their own DDL) while
# alembic_version stayed one revision behind, so `flask db upgrade`
# exited 0 having silently re-run the final migration, and re-ran it
# again on the next deploy. A migration that is not idempotent would
# apply twice.
connection.commit()
if context.is_offline_mode():
run_migrations_offline()