|
| 1 | +"""Pin: ``DqliteConnection.transaction()`` does NOT invalidate the |
| 2 | +connection on a COMMIT-arm failure whose SQLite primary code is a |
| 3 | +deterministic-rollback shape (``_TX_AUTO_ROLLBACK_PRIMARY_CODES`` or |
| 4 | +``SQLITE_CONSTRAINT`` = 19). |
| 5 | +
|
| 6 | +Both code classes leave the server in a known no-tx state — the |
| 7 | +local tx flags were already cleared by ``execute`` / |
| 8 | +``_run_protocol`` before the exception propagated. Invalidating |
| 9 | +discards a healthy connection, forcing a fresh-connect round-trip |
| 10 | +under retry storms. Mirrors the rollback-arm discrimination already |
| 11 | +in place at the sibling no-tx-rollback path (done/tx-013). |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +from unittest.mock import MagicMock |
| 17 | + |
| 18 | +import pytest |
| 19 | + |
| 20 | +from dqliteclient.connection import DqliteConnection |
| 21 | +from dqliteclient.exceptions import OperationalError |
| 22 | + |
| 23 | + |
| 24 | +def _make_connection_in_transaction() -> DqliteConnection: |
| 25 | + from dqliteclient import connection as _conn_mod |
| 26 | + |
| 27 | + conn = DqliteConnection.__new__(DqliteConnection) |
| 28 | + conn._address = "host:9001" |
| 29 | + conn._in_use = False |
| 30 | + conn._in_transaction = False |
| 31 | + conn._tx_owner = None |
| 32 | + conn._savepoint_stack = [] |
| 33 | + conn._savepoint_implicit_begin = False |
| 34 | + conn._has_untracked_savepoint = False |
| 35 | + conn._invalidation_cause = None |
| 36 | + conn._bound_loop = None |
| 37 | + conn._pending_drain = None |
| 38 | + conn._creator_pid = _conn_mod._current_pid |
| 39 | + conn._pool_released = False |
| 40 | + conn._database = "main" |
| 41 | + # Mark as connected so _check_connected does not raise. |
| 42 | + conn._protocol = MagicMock() |
| 43 | + conn._db_id = 1 |
| 44 | + # Suppress thread/loop binding by short-circuiting _check_in_use. |
| 45 | + conn._check_in_use = lambda: None |
| 46 | + return conn |
| 47 | + |
| 48 | + |
| 49 | +@pytest.mark.asyncio |
| 50 | +@pytest.mark.parametrize( |
| 51 | + "code", |
| 52 | + [ |
| 53 | + 7, # SQLITE_NOMEM |
| 54 | + 10, # SQLITE_IOERR |
| 55 | + 9, # SQLITE_INTERRUPT |
| 56 | + 11, # SQLITE_CORRUPT |
| 57 | + 13, # SQLITE_FULL |
| 58 | + 4, # SQLITE_ABORT |
| 59 | + 19, # SQLITE_CONSTRAINT (deferred-FK on COMMIT) |
| 60 | + ], |
| 61 | +) |
| 62 | +async def test_transaction_commit_arm_does_not_invalidate_on_deterministic_rollback( |
| 63 | + code: int, |
| 64 | +) -> None: |
| 65 | + """Drive the transaction ctxmgr to the commit-arm failure path |
| 66 | + with a deterministic-rollback OperationalError code. The |
| 67 | + invalidate path must NOT fire.""" |
| 68 | + conn = _make_connection_in_transaction() |
| 69 | + invalidate_called = MagicMock() |
| 70 | + conn._invalidate = invalidate_called |
| 71 | + |
| 72 | + # Mock execute so BEGIN succeeds, COMMIT raises with the code. |
| 73 | + async def _execute(sql: str) -> None: |
| 74 | + if "COMMIT" in sql.upper() or sql.strip().upper() == "END": |
| 75 | + raise OperationalError(code, "auto-rollback shape") |
| 76 | + |
| 77 | + conn.execute = _execute # type: ignore[assignment] |
| 78 | + |
| 79 | + with pytest.raises(OperationalError): |
| 80 | + async with conn.transaction(): |
| 81 | + pass |
| 82 | + |
| 83 | + invalidate_called.assert_not_called() |
| 84 | + |
| 85 | + |
| 86 | +@pytest.mark.asyncio |
| 87 | +async def test_transaction_commit_arm_invalidates_on_ambiguous_failure() -> None: |
| 88 | + """Defence pin: a commit-arm failure with an ambiguous shape |
| 89 | + (e.g. CancelledError, transport error, non-rollback OperationalError) |
| 90 | + MUST still invalidate. The discrimination is narrow.""" |
| 91 | + conn = _make_connection_in_transaction() |
| 92 | + invalidate_called = MagicMock() |
| 93 | + conn._invalidate = invalidate_called |
| 94 | + |
| 95 | + async def _execute(sql: str) -> None: |
| 96 | + if "COMMIT" in sql.upper() or sql.strip().upper() == "END": |
| 97 | + # Code 1 (SQLITE_ERROR) is NOT in the deterministic- |
| 98 | + # rollback set; server-side state is ambiguous. |
| 99 | + raise OperationalError(1, "ambiguous error") |
| 100 | + |
| 101 | + conn.execute = _execute # type: ignore[assignment] |
| 102 | + |
| 103 | + with pytest.raises(OperationalError): |
| 104 | + async with conn.transaction(): |
| 105 | + pass |
| 106 | + |
| 107 | + invalidate_called.assert_called_once() |
| 108 | + |
| 109 | + |
| 110 | +@pytest.mark.asyncio |
| 111 | +async def test_transaction_commit_arm_invalidates_on_cancelled_error() -> None: |
| 112 | + """CancelledError mid-COMMIT keeps server-side state ambiguous — |
| 113 | + invalidation is still mandatory.""" |
| 114 | + import asyncio |
| 115 | + |
| 116 | + conn = _make_connection_in_transaction() |
| 117 | + invalidate_called = MagicMock() |
| 118 | + conn._invalidate = invalidate_called |
| 119 | + |
| 120 | + async def _execute(sql: str) -> None: |
| 121 | + if "COMMIT" in sql.upper() or sql.strip().upper() == "END": |
| 122 | + raise asyncio.CancelledError() |
| 123 | + |
| 124 | + conn.execute = _execute # type: ignore[assignment] |
| 125 | + |
| 126 | + with pytest.raises(asyncio.CancelledError): |
| 127 | + async with conn.transaction(): |
| 128 | + pass |
| 129 | + |
| 130 | + invalidate_called.assert_called_once() |
| 131 | + |
| 132 | + |
| 133 | +@pytest.mark.asyncio |
| 134 | +async def test_transaction_commit_arm_invalidates_on_extended_ioerr() -> None: |
| 135 | + """An extended IOERR code (10250 = SQLITE_IOERR_NOT_LEADER) maps |
| 136 | + to primary IOERR (10) which IS in the rollback set; pin that the |
| 137 | + extended-code arithmetic doesn't trip the discrimination.""" |
| 138 | + conn = _make_connection_in_transaction() |
| 139 | + invalidate_called = MagicMock() |
| 140 | + conn._invalidate = invalidate_called |
| 141 | + |
| 142 | + async def _execute(sql: str) -> None: |
| 143 | + if "COMMIT" in sql.upper() or sql.strip().upper() == "END": |
| 144 | + raise OperationalError(10250, "leader changed mid-commit") |
| 145 | + |
| 146 | + conn.execute = _execute # type: ignore[assignment] |
| 147 | + |
| 148 | + with pytest.raises(OperationalError): |
| 149 | + async with conn.transaction(): |
| 150 | + pass |
| 151 | + |
| 152 | + # IOERR's primary is in the deterministic-rollback set; the |
| 153 | + # connection should NOT be invalidated. |
| 154 | + invalidate_called.assert_not_called() |
0 commit comments