test_migration_error_classification_2949.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """Migration idempotency must not depend on the server's message language (#2949).
  2. ``_safe_execute`` used to decide whether a failed DDL statement had simply
  3. already been applied by looking for ``"already exists"`` in the error text.
  4. PostgreSQL renders its messages in the server's ``lc_messages`` locale, so a
  5. Russian-locale server answered a duplicate ``ADD COLUMN`` with
  6. ``столбец … уже существует`` — no English substring, so the statement was
  7. re-raised and startup aborted.
  8. That was not a corner case: ``create_all()`` runs before ``run_migrations()``,
  9. so on a fresh database essentially every ``ADD COLUMN`` in the migration list is
  10. *expected* to come back as a duplicate. The reporter's install died on the very
  11. first one, and no PostgreSQL server outside an English locale could start at all.
  12. The classifier now reads the SQLSTATE, which PostgreSQL never translates. These
  13. tests pin both halves: the codes it accepts, the codes it must still let through,
  14. and the SQLite fallback for a DBAPI that has no SQLSTATE to offer.
  15. """
  16. from __future__ import annotations
  17. import pytest
  18. from sqlalchemy import text
  19. from sqlalchemy.exc import OperationalError, ProgrammingError
  20. from backend.app.core.database import _is_already_applied, _safe_execute, _sqlstate
  21. # Verbatim from a PostgreSQL 15 server running lc_messages=ru_RU.utf8 — the exact
  22. # text the reporter pasted into the issue. Nothing in the classifier may read it.
  23. RU_DUPLICATE_COLUMN = 'столбец "parent_run_id" отношения "pipeline_runs" уже существует'
  24. RU_DUPLICATE_TABLE = 'отношение "t_dup" уже существует'
  25. RU_DUPLICATE_OBJECT = 'ограничение-проверка "ck_a" для отношения "t_ck" уже существует'
  26. RU_UNDEFINED_COLUMN = 'столбец "nope" не существует'
  27. RU_UNDEFINED_TABLE = 'отношение "no_such_table" не существует'
  28. ADD_COLUMN = (
  29. "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL"
  30. )
  31. RENAME_COLUMN = "ALTER TABLE t_rn RENAME COLUMN nope TO other"
  32. CREATE_INDEX = "CREATE INDEX ix_t ON t (nope)"
  33. class _FakeOrig(Exception):
  34. """Stand-in for asyncpg's DBAPI wrapper, which exposes ``sqlstate``."""
  35. def __init__(self, sqlstate: str, message: str):
  36. super().__init__(message)
  37. self.sqlstate = sqlstate
  38. def _pg_error(sqlstate: str, message: str, sql: str) -> ProgrammingError:
  39. return ProgrammingError(sql, {}, _FakeOrig(sqlstate, message))
  40. class TestSqlstateIsPreferredOverTheMessage:
  41. @pytest.mark.parametrize(
  42. "sqlstate,message,sql",
  43. [
  44. ("42701", RU_DUPLICATE_COLUMN, ADD_COLUMN), # duplicate_column — the reported failure
  45. ("42P07", RU_DUPLICATE_TABLE, "CREATE TABLE t_dup (id INTEGER)"), # duplicate_table
  46. ("42P07", RU_DUPLICATE_TABLE, "CREATE INDEX ix_t_idx ON t_idx (a)"), # duplicate index
  47. ("42710", RU_DUPLICATE_OBJECT, "ALTER TABLE t_ck ADD CONSTRAINT ck_a CHECK (a > 0)"), # duplicate_object
  48. ],
  49. )
  50. def test_a_russian_language_duplicate_is_recognised(self, sqlstate, message, sql):
  51. assert _is_already_applied(_pg_error(sqlstate, message, sql), sql) is True
  52. def test_undefined_column_is_idempotency_only_for_rename(self):
  53. """The rename already ran. On any other statement a missing column means
  54. the schema is broken, and swallowing it would hide the corruption."""
  55. rename = _pg_error("42703", RU_UNDEFINED_COLUMN, RENAME_COLUMN)
  56. assert _is_already_applied(rename, RENAME_COLUMN) is True
  57. index = _pg_error("42703", RU_UNDEFINED_COLUMN, CREATE_INDEX)
  58. assert _is_already_applied(index, CREATE_INDEX) is False
  59. @pytest.mark.parametrize(
  60. "sqlstate,message",
  61. [
  62. ("42P01", RU_UNDEFINED_TABLE), # undefined_table — migrating a table that isn't there
  63. ("42704", 'тип "notatype" не существует'), # undefined_object — a typo'd column type
  64. ("42601", "ошибка синтаксиса"), # syntax_error
  65. ],
  66. )
  67. def test_real_failures_still_abort_startup(self, sqlstate, message):
  68. exc = _pg_error(sqlstate, message, ADD_COLUMN)
  69. assert _is_already_applied(exc, ADD_COLUMN) is False
  70. def test_an_english_message_saying_already_exists_cannot_rescue_a_fatal_sqlstate(self):
  71. """Once a SQLSTATE is present it is the whole answer. A server whose text
  72. happens to contain the old keyword must not talk us out of a real error.
  73. """
  74. exc = _pg_error("42P01", 'relation "printers" does not exist, table already exists', ADD_COLUMN)
  75. assert _is_already_applied(exc, ADD_COLUMN) is False
  76. class TestSqlstateExtraction:
  77. def test_reads_sqlstate_from_the_dbapi_error(self):
  78. assert _sqlstate(_pg_error("42701", RU_DUPLICATE_COLUMN, ADD_COLUMN)) == "42701"
  79. def test_falls_back_to_pgcode(self):
  80. """psycopg spells the same value ``pgcode``."""
  81. class _Psycopg(Exception):
  82. pgcode = "42P07"
  83. exc = ProgrammingError("CREATE TABLE t (a INTEGER)", {}, _Psycopg())
  84. assert _sqlstate(exc) == "42P07"
  85. def test_none_when_the_driver_offers_no_code(self):
  86. """SQLite. Callers fall back to matching the message, which SQLite —
  87. unlike PostgreSQL — never translates."""
  88. exc = OperationalError("ALTER TABLE t ADD COLUMN a INTEGER", {}, Exception("duplicate column name: a"))
  89. assert _sqlstate(exc) is None
  90. class TestSqliteFallback:
  91. """No SQLSTATE, so the message keywords still decide — unchanged behaviour."""
  92. @pytest.mark.parametrize(
  93. "message,sql,expected",
  94. [
  95. ("duplicate column name: a", ADD_COLUMN, True),
  96. ("table t already exists", "CREATE TABLE t (a INTEGER)", True),
  97. ("no such column: nope", RENAME_COLUMN, True),
  98. ("no such table: printers", ADD_COLUMN, False),
  99. ('near "GARBAGE": syntax error', ADD_COLUMN, False),
  100. ],
  101. )
  102. def test_message_keywords(self, message, sql, expected):
  103. exc = OperationalError(sql, {}, Exception(message))
  104. assert _is_already_applied(exc, sql) is expected
  105. @pytest.mark.asyncio
  106. class TestSafeExecuteAgainstRealSqlite:
  107. async def test_a_duplicate_add_column_is_swallowed(self, db_session):
  108. conn = await db_session.connection()
  109. await conn.execute(text("CREATE TABLE t_2949 (id INTEGER PRIMARY KEY)"))
  110. await _safe_execute(conn, "ALTER TABLE t_2949 ADD COLUMN extra INTEGER")
  111. await _safe_execute(conn, "ALTER TABLE t_2949 ADD COLUMN extra INTEGER")
  112. cols = {row[1] for row in await conn.execute(text("PRAGMA table_info(t_2949)"))}
  113. assert cols == {"id", "extra"}
  114. async def test_a_genuine_failure_is_re_raised(self, db_session):
  115. conn = await db_session.connection()
  116. with pytest.raises(OperationalError):
  117. await _safe_execute(conn, "ALTER TABLE table_that_does_not_exist ADD COLUMN x INTEGER")