Skip to content

Commit 9e5f9f4

Browse files
Pin three client-package defensive branches that lacked direct tests
- ``DqliteProtocol.is_wire_coherent`` is a one-line forwarder over ``self._decoder.is_poisoned``. Existing pool-reset tests stubbed the property as a boolean attribute on a mock; the real getter was never exercised against a real ``Decoder`` instance. A future refactor that swapped the underlying field name would silently break every short-circuit caller. - ``_starts_with_tx_verb`` and ``Connection._sql_is_outermost_release_or_commit`` both strip leading SQL comments / whitespace and return ``False`` if the result is empty. The empty-after-strip case was exercised indirectly via ``_split_top_level_statements``; pin it directly so a refactor of either predicate is caught at the predicate level. - ``ConnectionPool._reset_connection`` distinguishes leader-flip ROLLBACK failures (DEBUG; routine cluster churn) from genuine server faults (WARNING + traceback). The branching was untested, so a future refactor that flattened the two arms to the same level would silently degrade the operational signal.
1 parent 1722813 commit 9e5f9f4

3 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Pin: ``ConnectionPool._reset_connection`` distinguishes leader-flip
2+
ROLLBACK failures (DEBUG) from genuine server faults (WARNING).
3+
4+
A code in ``LEADER_ERROR_CODES`` represents routine cluster churn —
5+
the new leader has no record of our transaction, so the ROLLBACK
6+
fails predictably. Operators should not get a WARNING-level log line
7+
per leader flip on a busy pool. Codes outside that set indicate an
8+
actual fault and DO warrant the WARNING + traceback so the operator
9+
can investigate.
10+
11+
Without this test, a future refactor that flattened the two arms to
12+
the same level (or swapped them) would silently degrade the
13+
operational signal.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import logging
19+
from typing import Any
20+
from unittest.mock import AsyncMock, MagicMock
21+
22+
import pytest
23+
24+
from dqliteclient.cluster import ClusterClient
25+
from dqliteclient.exceptions import OperationalError
26+
from dqliteclient.pool import ConnectionPool
27+
from dqlitewire import LEADER_ERROR_CODES
28+
29+
30+
def _make_pool() -> ConnectionPool:
31+
cluster = MagicMock(spec=ClusterClient)
32+
return ConnectionPool(
33+
addresses=["localhost:9001"],
34+
min_size=0,
35+
max_size=1,
36+
timeout=1.0,
37+
cluster=cluster,
38+
)
39+
40+
41+
def _conn_in_tx(execute_side_effect: BaseException) -> Any:
42+
"""Build a fake conn that ``_reset_connection`` will route through
43+
the ROLLBACK arm. ``_in_transaction=True`` selects the arm; the
44+
socket-liveness check is bypassed by leaving the protocol stub
45+
looking healthy."""
46+
conn = MagicMock()
47+
conn._address = "localhost:9001"
48+
conn._in_transaction = True
49+
conn._tx_owner = None
50+
conn._savepoint_stack = []
51+
conn._savepoint_implicit_begin = False
52+
conn._has_untracked_savepoint = False
53+
# Look-alive for the cheap pre-write liveness check.
54+
conn._protocol = MagicMock()
55+
conn._protocol._writer = MagicMock()
56+
conn._protocol._writer.transport = MagicMock()
57+
conn._protocol._writer.transport.is_closing = lambda: False
58+
conn._protocol._reader = MagicMock()
59+
conn._protocol._reader.at_eof = lambda: False
60+
conn._protocol.is_wire_coherent = True
61+
conn.execute = AsyncMock(side_effect=execute_side_effect)
62+
return conn
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_reset_leader_class_rollback_failure_logs_debug(
67+
caplog: pytest.LogCaptureFixture,
68+
) -> None:
69+
"""A ROLLBACK failure with a ``LEADER_ERROR_CODES`` code logs at
70+
DEBUG, not WARNING — routine cluster churn shouldn't trigger
71+
operator alerting."""
72+
pool = _make_pool()
73+
leader_code = next(iter(LEADER_ERROR_CODES))
74+
err = OperationalError(leader_code, "not leader")
75+
conn = _conn_in_tx(err)
76+
77+
with caplog.at_level(logging.DEBUG, logger="dqliteclient.pool"):
78+
result = await pool._reset_connection(conn)
79+
80+
assert result is False, "leader-class ROLLBACK failure must drop the conn"
81+
debug_records = [
82+
r
83+
for r in caplog.records
84+
if r.levelno == logging.DEBUG and "leader-class ROLLBACK failure" in r.message
85+
]
86+
warning_records = [r for r in caplog.records if r.levelno == logging.WARNING]
87+
assert debug_records, (
88+
f"expected a DEBUG record about leader-class ROLLBACK failure; "
89+
f"got {[(r.levelno, r.message) for r in caplog.records]!r}"
90+
)
91+
assert not warning_records, (
92+
"leader-class ROLLBACK failure must NOT produce a WARNING — "
93+
"routine cluster churn shouldn't trigger operator alerting; "
94+
f"got {[(r.levelno, r.message) for r in warning_records]!r}"
95+
)
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_reset_non_leader_rollback_failure_logs_warning(
100+
caplog: pytest.LogCaptureFixture,
101+
) -> None:
102+
"""A ROLLBACK failure with a code OUTSIDE ``LEADER_ERROR_CODES``
103+
is a genuine server fault and logs at WARNING with traceback so
104+
the operator can investigate."""
105+
pool = _make_pool()
106+
# Pick a code that is NOT in LEADER_ERROR_CODES.
107+
non_leader_code = 1
108+
assert non_leader_code not in LEADER_ERROR_CODES
109+
err = OperationalError(non_leader_code, "disk I/O error")
110+
conn = _conn_in_tx(err)
111+
112+
with caplog.at_level(logging.WARNING, logger="dqliteclient.pool"):
113+
result = await pool._reset_connection(conn)
114+
115+
assert result is False
116+
warning_records = [
117+
r
118+
for r in caplog.records
119+
if r.levelno == logging.WARNING and "ROLLBACK failure" in r.message
120+
]
121+
assert warning_records, (
122+
f"expected a WARNING record about ROLLBACK failure; "
123+
f"got {[(r.levelno, r.message) for r in caplog.records]!r}"
124+
)
125+
# WARNING path must include exc_info for traceback so the operator
126+
# can investigate the genuine fault.
127+
assert warning_records[0].exc_info is not None, (
128+
"non-leader ROLLBACK failure WARNING must include exc_info=True"
129+
)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Pin: ``DqliteProtocol.is_wire_coherent`` routes through the
2+
underlying decoder's ``is_poisoned`` flag.
3+
4+
Existing pool-reset tests stub ``protocol.is_wire_coherent`` as a
5+
boolean attribute on a mock; the real one-line forwarder
6+
(``return not self._decoder.is_poisoned``) at
7+
``protocol.py:190`` was never exercised against a real ``Decoder``
8+
instance. Without a direct test, a future refactor that swapped the
9+
underlying field name (e.g. ``is_poisoned`` → ``is_corrupt``) would
10+
silently break every pool-reset short-circuit caller.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
from unittest.mock import MagicMock
17+
18+
from dqliteclient.protocol import DqliteProtocol
19+
from dqlitewire.exceptions import DecodeError
20+
21+
22+
def _make_protocol() -> DqliteProtocol:
23+
"""Construct a ``DqliteProtocol`` against mock reader/writer.
24+
25+
The protocol's ``is_wire_coherent`` property reads from
26+
``self._decoder``, which is a real ``MessageDecoder`` instance —
27+
so the test exercises the real getter.
28+
"""
29+
reader = MagicMock(spec=asyncio.StreamReader)
30+
writer = MagicMock(spec=asyncio.StreamWriter)
31+
return DqliteProtocol(reader=reader, writer=writer)
32+
33+
34+
def test_is_wire_coherent_true_when_decoder_not_poisoned() -> None:
35+
proto = _make_protocol()
36+
# Fresh decoder: not poisoned. The forwarder returns True.
37+
assert proto._decoder.is_poisoned is False
38+
assert proto.is_wire_coherent is True
39+
40+
41+
def test_is_wire_coherent_false_when_decoder_poisoned() -> None:
42+
proto = _make_protocol()
43+
# Poison the underlying decoder. The forwarder returns False.
44+
proto._decoder._buffer.poison(DecodeError("forced poison for test"))
45+
assert proto._decoder.is_poisoned is True
46+
assert proto.is_wire_coherent is False
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Pin: ``_starts_with_tx_verb`` and
2+
``_sql_is_outermost_release_or_commit`` return ``False`` on
3+
all-comment / whitespace-only input.
4+
5+
Both predicates strip leading SQL comments and whitespace then
6+
return ``False`` if the result is empty. This is currently exercised
7+
indirectly via ``_split_top_level_statements`` (see
8+
``test_split_top_level_statements_all_comment_input.py``), but the
9+
predicates themselves have no direct pin. A future refactor that
10+
tightened the empty-after-strip case to an exception or to ``True``
11+
would ripple silently through both call sites.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from dqliteclient import DqliteConnection
17+
from dqliteclient.connection import _starts_with_tx_verb
18+
19+
20+
def test_starts_with_tx_verb_returns_false_for_pure_dash_dash_comment() -> None:
21+
assert _starts_with_tx_verb("-- comment\n") is False
22+
23+
24+
def test_starts_with_tx_verb_returns_false_for_pure_block_comment() -> None:
25+
assert _starts_with_tx_verb("/* x */") is False
26+
27+
28+
def test_starts_with_tx_verb_returns_false_for_whitespace_only() -> None:
29+
assert _starts_with_tx_verb(" ") is False
30+
31+
32+
def test_starts_with_tx_verb_returns_false_for_empty_string() -> None:
33+
assert _starts_with_tx_verb("") is False
34+
35+
36+
def test_starts_with_tx_verb_recognises_begin_after_comments() -> None:
37+
"""Sanity: the strip-then-match path must still recognise a verb
38+
when one survives the strip."""
39+
assert _starts_with_tx_verb("/* hint */ BEGIN") is True
40+
41+
42+
def _make_connection() -> DqliteConnection:
43+
"""Connection for direct method invocation. We never call
44+
``connect()``, so the underlying socket is never opened — only
45+
the predicate's pure-string path is exercised."""
46+
conn = DqliteConnection("127.0.0.1:9001")
47+
return conn
48+
49+
50+
def test_sql_is_outermost_release_or_commit_false_for_pure_dash_dash_comment() -> None:
51+
conn = _make_connection()
52+
assert conn._sql_is_outermost_release_or_commit("-- comment\n") is False
53+
54+
55+
def test_sql_is_outermost_release_or_commit_false_for_pure_block_comment() -> None:
56+
conn = _make_connection()
57+
assert conn._sql_is_outermost_release_or_commit("/* x */") is False
58+
59+
60+
def test_sql_is_outermost_release_or_commit_false_for_whitespace_only() -> None:
61+
conn = _make_connection()
62+
assert conn._sql_is_outermost_release_or_commit(" ") is False
63+
64+
65+
def test_sql_is_outermost_release_or_commit_false_for_empty_string() -> None:
66+
conn = _make_connection()
67+
assert conn._sql_is_outermost_release_or_commit("") is False

0 commit comments

Comments
 (0)