Skip to content

Commit 7c75136

Browse files
Close client-side test gaps on cancel guard, pool reset, shuffle, and SAVEPOINT
Five untested or under-tested branches that protect load-bearing invariants on the client: * ``test_connect_cancelling_guard_branches`` — pins the ``Task.cancelling() > 0`` guard on connect()'s pending-drain arm. The existing test covered the outer-cancel-only case; this adds the inner-cancel-only case (must consume + proceed) and the no-pending-drain / already-done branches. * ``test_pool_reset_short_circuits_on_poisoned_decoder`` — pins the only consumer of ``protocol.is_wire_coherent``: when the decoder buffer is poisoned, ``_socket_looks_dead`` returns True and ``_reset_connection`` skips ROLLBACK. Includes a coherent- decoder positive control so the short-circuit's narrowness is also pinned. * ``test_raft_busy_message_fragments_pinned`` — imports the tuple by symbol and asserts canonical membership / non-emptiness / lowercase + no-padding invariants. Without this pin a future addition added in source could go un-pinned in tests because every existing test references the bare ``"checkpoint in progress"`` string directly. * ``test_cluster.py::test_find_leader_calls_random_shuffle_on_candidate_list`` — deterministic complement to the probabilistic shuffle test. Patches ``random.shuffle`` with ``wraps=`` so the call is observed without breaking find_leader's enumeration. Catches a refactor that replaces the shuffle with another deterministic permutation that still produces ≥ 2 distinct firsts. * ``tests/integration/test_savepoint_untracked_then_tracked`` (cluster-required) — drives the full ``SAVEPOINT "Foo"`` → ``SAVEPOINT inner`` → ``RELEASE inner`` sequence and pins the post-fix client-side invariants at each step against the real server. Closes the gap between the unit-level mocks and the upstream auto-begin-on-quoted-SAVEPOINT behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 534799b commit 7c75136

5 files changed

Lines changed: 652 additions & 0 deletions
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Integration test for the SAVEPOINT untracked-then-tracked sequence
2+
against the real dqlite cluster.
3+
4+
The client-side parser deliberately rejects quoted / backtick /
5+
square-bracket / unicode SAVEPOINT names — the case-sensitivity
6+
trade-off in ``_parse_savepoint_name`` is documented to ensure
7+
identifier handling stays consistent across the codebase. The
8+
upstream server, by contrast, has no such restriction: it forwards
9+
the SQL to the embedded SQLite engine, which auto-begins a
10+
transaction on ANY well-formed ``SAVEPOINT`` statement.
11+
12+
The mismatch is the load-bearing case for the post-fix invariants:
13+
14+
1. ``_has_untracked_savepoint`` flips True (parser couldn't
15+
represent the name).
16+
2. ``_savepoint_implicit_begin`` STAYS False (the auto-begin was
17+
driven by the outer untracked SAVEPOINT, not by the inner
18+
tracked one — there is no implicit begin to record).
19+
3. ``in_transaction`` returns True via the property's OR over
20+
``_in_transaction or _has_untracked_savepoint``.
21+
4. After the cleanup ROLLBACK clears the autobegun tx, both
22+
flags drop and a follow-up ``transaction()`` ctxmgr can enter
23+
without raising.
24+
25+
The unit-level tests against mocks pin (1)-(3) but cannot verify
26+
that the upstream server actually auto-begins on a quoted
27+
SAVEPOINT — only the live cluster confirms that. This integration
28+
test closes the loop end-to-end.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import pytest
34+
35+
from dqliteclient import DqliteConnection
36+
37+
38+
@pytest.mark.integration
39+
@pytest.mark.asyncio
40+
async def test_quoted_savepoint_then_tracked_savepoint_then_release(
41+
cluster_address: str,
42+
) -> None:
43+
"""Drive the full ``SAVEPOINT "Foo"`` → ``SAVEPOINT inner`` →
44+
``RELEASE inner`` sequence and pin the post-fix client-side
45+
invariants at every step."""
46+
conn = DqliteConnection(cluster_address)
47+
try:
48+
await conn.connect()
49+
50+
# Baseline: clean connection, no transaction.
51+
assert conn._in_transaction is False
52+
assert conn._has_untracked_savepoint is False
53+
assert conn._savepoint_implicit_begin is False
54+
assert conn.in_transaction is False
55+
56+
# Quoted SAVEPOINT — the client parser refuses to extract
57+
# the name (case-sensitive identifier rule), so the tracker
58+
# marks the connection as having an untracked savepoint and
59+
# leaves the explicit-tx flag alone. The server, however,
60+
# auto-begins a transaction on the wire.
61+
await conn.execute('SAVEPOINT "Foo"')
62+
assert conn._has_untracked_savepoint is True, (
63+
"quoted SAVEPOINT name must trip the untracked flag — the parser cannot represent it"
64+
)
65+
assert conn._in_transaction is False, (
66+
"the parser-rejected name leaves the explicit-tx flag "
67+
"untouched (no BEGIN was issued, only the SAVEPOINT)"
68+
)
69+
assert conn.in_transaction is True, (
70+
"in_transaction property must OR in the untracked flag — "
71+
"the server-side autobegun tx is real and the property "
72+
"is the load-bearing read"
73+
)
74+
75+
# Tracked inner SAVEPOINT. POST-FIX INVARIANT:
76+
# ``_savepoint_implicit_begin`` stays False. Pre-fix it
77+
# would be set on the inner SAVEPOINT because the tracker
78+
# didn't know the outer untracked one had already
79+
# auto-begun the transaction. Setting it would lead to an
80+
# incorrect ROLLBACK target on cleanup.
81+
await conn.execute("SAVEPOINT inner")
82+
assert conn._savepoint_implicit_begin is False, (
83+
"inner tracked SAVEPOINT must NOT record an implicit "
84+
"begin — the outer untracked SAVEPOINT already auto-began"
85+
)
86+
assert conn._has_untracked_savepoint is True, (
87+
"untracked flag survives an inner tracked SAVEPOINT"
88+
)
89+
90+
# Release the inner. Server-side: outer "Foo" still alive,
91+
# so the autobegun tx is still alive too.
92+
await conn.execute("RELEASE inner")
93+
assert conn.in_transaction is True, (
94+
"outer SAVEPOINT still holds the autobegun tx after releasing the inner one"
95+
)
96+
assert conn._has_untracked_savepoint is True
97+
98+
# Cleanup ROLLBACK clears the autobegun tx and the
99+
# untracked flag.
100+
await conn.execute("ROLLBACK")
101+
assert conn._in_transaction is False
102+
assert conn._has_untracked_savepoint is False
103+
assert conn._savepoint_implicit_begin is False
104+
assert conn.in_transaction is False
105+
finally:
106+
await conn.close()
107+
108+
109+
@pytest.mark.integration
110+
@pytest.mark.asyncio
111+
async def test_transaction_ctxmgr_enters_cleanly_after_untracked_cleanup(
112+
cluster_address: str,
113+
) -> None:
114+
"""Pin the follow-up: after the cleanup ROLLBACK clears
115+
``_has_untracked_savepoint``, a fresh ``transaction()`` ctxmgr
116+
can enter without raising the "SAVEPOINT outside an explicit
117+
BEGIN" InterfaceError. Catches a regression that forgets to
118+
clear the flag on ROLLBACK and leaves the slot poisoned for
119+
every subsequent caller."""
120+
conn = DqliteConnection(cluster_address)
121+
try:
122+
await conn.connect()
123+
await conn.execute('SAVEPOINT "Bar"')
124+
assert conn._has_untracked_savepoint is True
125+
126+
await conn.execute("ROLLBACK")
127+
assert conn._has_untracked_savepoint is False, (
128+
"ROLLBACK must clear ``_has_untracked_savepoint``; "
129+
"without this clear, the next transaction() ctxmgr "
130+
"would see the stale flag and refuse to enter"
131+
)
132+
133+
# Fresh transaction() ctxmgr enters cleanly — no
134+
# InterfaceError because the flag was cleared above.
135+
async with conn.transaction():
136+
assert conn._in_transaction is True
137+
assert conn._in_transaction is False
138+
finally:
139+
await conn.close()

tests/test_cluster.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,47 @@ async def track(address: str, **_kwargs: object) -> str | None:
458458
counts = Counter(firsts)
459459
assert len(counts) >= 2, f"find_leader always probed the same node first: {counts}"
460460

461+
async def test_find_leader_calls_random_shuffle_on_candidate_list(self) -> None:
462+
"""Deterministic pin complementing the probabilistic test above:
463+
``find_leader`` invokes ``random.shuffle`` on its candidate list.
464+
465+
The probabilistic test catches "shuffle disabled entirely"
466+
(``len(counts) == 1``) but not "shuffle replaced by some other
467+
deterministic permutation that still produces ≥ 2 distinct
468+
firsts" — a regression vector that would silently slip past.
469+
Patching ``random.shuffle`` directly lets us assert the call
470+
happens, and ``wraps=`` keeps the real shuffle behavior so
471+
``find_leader`` still terminates cleanly.
472+
"""
473+
import random as _random
474+
475+
store = MemoryNodeStore(["n1:9001", "n2:9001", "n3:9001", "n4:9001"])
476+
client = ClusterClient(store, timeout=0.2)
477+
478+
async def fail_query(address: str, **_kwargs: object) -> str | None:
479+
raise DqliteConnectionError("not leader")
480+
481+
with (
482+
patch(
483+
"dqliteclient.cluster.random.shuffle",
484+
wraps=_random.shuffle,
485+
) as mock_shuffle,
486+
patch.object(client, "_query_leader", side_effect=fail_query),
487+
contextlib.suppress(ClusterError),
488+
):
489+
await client.find_leader()
490+
491+
assert mock_shuffle.call_count >= 1, (
492+
"find_leader must invoke random.shuffle on its candidate list — "
493+
"without the shuffle, parallel callers stampede the first-listed node"
494+
)
495+
# The first positional arg is the list being shuffled in place.
496+
shuffled_arg = mock_shuffle.call_args[0][0]
497+
assert isinstance(shuffled_arg, list), (
498+
"shuffle must be called on a list (the per-role candidate "
499+
f"slice), got {type(shuffled_arg).__name__}"
500+
)
501+
461502
async def test_find_leader_probes_voters_before_non_voters(self) -> None:
462503
"""Non-voter nodes (standby/spare) cannot become leader; probing them
463504
first wastes an RTT. find_leader must prefer voters.

0 commit comments

Comments
 (0)