Skip to content

alembic/env.py: an upgrade's exception ends it as itself, on one connection - #31

Merged
ChrisHuie merged 1 commit into
mainfrom
feature/upgrade-one-connection
Oct 22, 2026
Merged

ChrisHuie merged 1 commit into
mainfrom
feature/upgrade-one-connection

Conversation

@ChrisHuie

Copy link
Copy Markdown
Contributor

An upgrade's exception now ends it as itself, and an upgrade runs on one driver connection (redesign plan gap 111).

What was wrong

  • An interrupt, a SystemExit, or an error SQLAlchemy reads as a disconnect, raised inside a statement of an upgrade, makes SQLAlchemy invalidate the connection. alembic/env.py's handler then wrote connection.info[TRANSACTION_STATE] = "failed". On an invalidated connection, reading Connection.info revalidates it and raises PendingRollbackError: Can't reconnect until invalid transaction is rolled back in place of the original exception.
  • After that invalidation, a revision's rollback() let its next statement open a second driver connection, outside the upgrade's transaction and outside the state the authorizer reads, and the upgrade ran on to its end without an error. A revision could also ask the engine for a second connection outright.

What changes

  • The handler records the failure on the connection's mapping, held before the upgrade runs, so the exception propagates as itself.
  • Once the upgrade's connection is open, a connect listener closes and refuses any other connection before anything runs on it. A revision that catches that refusal and goes on still fails the upgrade before the commit. The file keeps its revision and contents.
  • docs/database.md states the one-connection rule; the plan records it as gap 111.

Tests (tests/db/test_migrations.py, TestOneTransaction)

Test Input Asserts
test_an_interrupt_after_the_second_revision_applies_none[KeyboardInterrupt, SystemExit] an after_cursor_execute listener raises at the real chain's UPDATE alembic_version for the second revision after the first schema the raised instance itself; one pool invalidation, by it; the file unchanged at the first schema's head
test_an_error_read_as_a_disconnect_applies_none a test-only revision closes its driver connection, then runs SELECT 1 ProgrammingError with connection_invalidated; one invalidation, by its orig; the file unchanged
test_no_statement_reconnects_after_the_connection_is_invalidated[the revision goes on, the revision raises] the same, then rollback() and a CREATE TABLE StatementError whose orig is the refusal; both driver connections closed; the file unchanged
test_a_revision_gets_no_second_connection a revision calls engine.connect() the refusal; both driver connections closed; the file unchanged
test_a_revision_that_catches_the_refusal_still_fails_the_upgrade a revision catches that refusal and runs a CREATE TABLE MigrationRefused with its exact message; the file unchanged
test_an_upgrade_runs_on_one_driver_connection the chain from the first schema to head one driver connection, closed; the file at head

Against main's env.py, the first seven cases fail. The KeyboardInterrupt, SystemExit, and disconnect cases end with PendingRollbackError. The upgrades in the reconnect case (the revision going on), the second-connection case, and the caught-refusal case end without an error, and the reconnect case whose revision raises ends with that RuntimeError after reconnecting. The last test holds on main too: it is the passing side of the one-connection rule.


Ran, at 58ad137894343e6924309fa81b9bb2000f7256e7: the full suite (2066 tests, 0 failed, 0 errored, 0 skipped, scripts/check_junit.py --max-skipped 0); pre-commit run --all-files; the new tests against main's env.py.
Not run: a real SIGINT sent to alembic upgrade from a shell; a disconnect from a file deleted under the connection.

…ection

An interrupt, a SystemExit, or an error SQLAlchemy reads as a
disconnect, inside a statement of an upgrade, makes SQLAlchemy
invalidate the connection. env.py's handler then read connection.info,
which on an invalidated connection raises PendingRollbackError in place
of the exception. And after the invalidation, a revision's rollback()
let the next statement reconnect on a second driver connection, outside
the upgrade's transaction and the state the authorizer reads.

- The handler records the failure on the connection's mapping, held
  before the upgrade runs.
- Once the upgrade's connection is open, a connect listener closes and
  refuses any other; a revision that catches the refusal and goes on
  still fails the upgrade before the commit.
- Tests: an interrupt and a SystemExit at a set point, a closed driver
  connection read as a disconnect, a reconnect after the invalidation
  (going on, and raising), a second connection a revision asks for (and
  catches), and an upgrade from the first schema on one connection.
@ChrisHuie
ChrisHuie merged commit 9f0259f into main Oct 22, 2026
4 checks passed
@ChrisHuie
ChrisHuie deleted the feature/upgrade-one-connection branch October 22, 2026 21:08
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.

1 participant