|
|
@@ -0,0 +1,149 @@
|
|
|
+"""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")
|