| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- """Migration idempotency must not depend on the server's message language (#2949).
- ``_safe_execute`` used to decide whether a failed DDL statement had simply
- already been applied by looking for ``"already exists"`` in the error text.
- PostgreSQL renders its messages in the server's ``lc_messages`` locale, so a
- Russian-locale server answered a duplicate ``ADD COLUMN`` with
- ``столбец … уже существует`` — no English substring, so the statement was
- re-raised and startup aborted.
- That was not a corner case: ``create_all()`` runs before ``run_migrations()``,
- so on a fresh database essentially every ``ADD COLUMN`` in the migration list is
- *expected* to come back as a duplicate. The reporter's install died on the very
- first one, and no PostgreSQL server outside an English locale could start at all.
- The classifier now reads the SQLSTATE, which PostgreSQL never translates. These
- tests pin both halves: the codes it accepts, the codes it must still let through,
- and the SQLite fallback for a DBAPI that has no SQLSTATE to offer.
- """
- from __future__ import annotations
- import pytest
- from sqlalchemy import text
- from sqlalchemy.exc import OperationalError, ProgrammingError
- from backend.app.core.database import _is_already_applied, _safe_execute, _sqlstate
- # Verbatim from a PostgreSQL 15 server running lc_messages=ru_RU.utf8 — the exact
- # text the reporter pasted into the issue. Nothing in the classifier may read it.
- RU_DUPLICATE_COLUMN = 'столбец "parent_run_id" отношения "pipeline_runs" уже существует'
- RU_DUPLICATE_TABLE = 'отношение "t_dup" уже существует'
- RU_DUPLICATE_OBJECT = 'ограничение-проверка "ck_a" для отношения "t_ck" уже существует'
- RU_UNDEFINED_COLUMN = 'столбец "nope" не существует'
- RU_UNDEFINED_TABLE = 'отношение "no_such_table" не существует'
- ADD_COLUMN = (
- "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL"
- )
- RENAME_COLUMN = "ALTER TABLE t_rn RENAME COLUMN nope TO other"
- CREATE_INDEX = "CREATE INDEX ix_t ON t (nope)"
- class _FakeOrig(Exception):
- """Stand-in for asyncpg's DBAPI wrapper, which exposes ``sqlstate``."""
- def __init__(self, sqlstate: str, message: str):
- super().__init__(message)
- self.sqlstate = sqlstate
- def _pg_error(sqlstate: str, message: str, sql: str) -> ProgrammingError:
- return ProgrammingError(sql, {}, _FakeOrig(sqlstate, message))
- class TestSqlstateIsPreferredOverTheMessage:
- @pytest.mark.parametrize(
- "sqlstate,message,sql",
- [
- ("42701", RU_DUPLICATE_COLUMN, ADD_COLUMN), # duplicate_column — the reported failure
- ("42P07", RU_DUPLICATE_TABLE, "CREATE TABLE t_dup (id INTEGER)"), # duplicate_table
- ("42P07", RU_DUPLICATE_TABLE, "CREATE INDEX ix_t_idx ON t_idx (a)"), # duplicate index
- ("42710", RU_DUPLICATE_OBJECT, "ALTER TABLE t_ck ADD CONSTRAINT ck_a CHECK (a > 0)"), # duplicate_object
- ],
- )
- def test_a_russian_language_duplicate_is_recognised(self, sqlstate, message, sql):
- assert _is_already_applied(_pg_error(sqlstate, message, sql), sql) is True
- def test_undefined_column_is_idempotency_only_for_rename(self):
- """The rename already ran. On any other statement a missing column means
- the schema is broken, and swallowing it would hide the corruption."""
- rename = _pg_error("42703", RU_UNDEFINED_COLUMN, RENAME_COLUMN)
- assert _is_already_applied(rename, RENAME_COLUMN) is True
- index = _pg_error("42703", RU_UNDEFINED_COLUMN, CREATE_INDEX)
- assert _is_already_applied(index, CREATE_INDEX) is False
- @pytest.mark.parametrize(
- "sqlstate,message",
- [
- ("42P01", RU_UNDEFINED_TABLE), # undefined_table — migrating a table that isn't there
- ("42704", 'тип "notatype" не существует'), # undefined_object — a typo'd column type
- ("42601", "ошибка синтаксиса"), # syntax_error
- ],
- )
- def test_real_failures_still_abort_startup(self, sqlstate, message):
- exc = _pg_error(sqlstate, message, ADD_COLUMN)
- assert _is_already_applied(exc, ADD_COLUMN) is False
- def test_an_english_message_saying_already_exists_cannot_rescue_a_fatal_sqlstate(self):
- """Once a SQLSTATE is present it is the whole answer. A server whose text
- happens to contain the old keyword must not talk us out of a real error.
- """
- exc = _pg_error("42P01", 'relation "printers" does not exist, table already exists', ADD_COLUMN)
- assert _is_already_applied(exc, ADD_COLUMN) is False
- class TestSqlstateExtraction:
- def test_reads_sqlstate_from_the_dbapi_error(self):
- assert _sqlstate(_pg_error("42701", RU_DUPLICATE_COLUMN, ADD_COLUMN)) == "42701"
- def test_falls_back_to_pgcode(self):
- """psycopg spells the same value ``pgcode``."""
- class _Psycopg(Exception):
- pgcode = "42P07"
- exc = ProgrammingError("CREATE TABLE t (a INTEGER)", {}, _Psycopg())
- assert _sqlstate(exc) == "42P07"
- def test_none_when_the_driver_offers_no_code(self):
- """SQLite. Callers fall back to matching the message, which SQLite —
- unlike PostgreSQL — never translates."""
- exc = OperationalError("ALTER TABLE t ADD COLUMN a INTEGER", {}, Exception("duplicate column name: a"))
- assert _sqlstate(exc) is None
- class TestSqliteFallback:
- """No SQLSTATE, so the message keywords still decide — unchanged behaviour."""
- @pytest.mark.parametrize(
- "message,sql,expected",
- [
- ("duplicate column name: a", ADD_COLUMN, True),
- ("table t already exists", "CREATE TABLE t (a INTEGER)", True),
- ("no such column: nope", RENAME_COLUMN, True),
- ("no such table: printers", ADD_COLUMN, False),
- ('near "GARBAGE": syntax error', ADD_COLUMN, False),
- ],
- )
- def test_message_keywords(self, message, sql, expected):
- exc = OperationalError(sql, {}, Exception(message))
- assert _is_already_applied(exc, sql) is expected
- @pytest.mark.asyncio
- class TestSafeExecuteAgainstRealSqlite:
- async def test_a_duplicate_add_column_is_swallowed(self, db_session):
- conn = await db_session.connection()
- await conn.execute(text("CREATE TABLE t_2949 (id INTEGER PRIMARY KEY)"))
- await _safe_execute(conn, "ALTER TABLE t_2949 ADD COLUMN extra INTEGER")
- await _safe_execute(conn, "ALTER TABLE t_2949 ADD COLUMN extra INTEGER")
- cols = {row[1] for row in await conn.execute(text("PRAGMA table_info(t_2949)"))}
- assert cols == {"id", "extra"}
- async def test_a_genuine_failure_is_re_raised(self, db_session):
- conn = await db_session.connection()
- with pytest.raises(OperationalError):
- await _safe_execute(conn, "ALTER TABLE table_that_does_not_exist ADD COLUMN x INTEGER")
|