Skip to content

Commit 243a658

Browse files
Cache ClusterClient per (seed, governors); thread governors through _resolve_leader
Two compounding problems in the dbapi leader-discovery probe path (connection._resolve_leader, called by _build_and_connect): 1. Configuration silently dropped at the first hop. _resolve_leader constructed ClusterClient(store, timeout=timeout) with no other governors, so an operator who set trust_server_heartbeat=True saw the FIRST round-trip — leader discovery — running with the default opt-out. Likewise max_total_rows / max_continuation_frames were dropped for admin paths reachable through the resolved client. 2. Single-flight collapse / leader-tracker fast-path dead at this layer. Each _resolve_leader call constructed a brand-new ClusterClient and discarded it on return; the _find_leader_tasks slot map and _last_known_leader cache went with it. Under N concurrent SA pool checkouts after a leader flip, the cluster sees N independent leader-discovery sweeps where one would suffice. Thread the full governor set through _resolve_leader, and add a process-wide ClusterClient cache keyed by (address, timeout, max_total_rows, max_continuation_frames, trust_server_heartbeat). The cache is wholesale-invalidated on fork via the same _current_pid token DqliteConnection uses for fork-safety; bounded at 32 entries (LRU-ish drop) so adversarial governor-fragmentation cannot leak unbounded. The existing cluster-level fixes (ISSUE-1402 single-flight, ISSUE-1403 last-known-leader) presupposed ClusterClient reuse; without this fix those benefits were largely defeated at the dbapi layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1ecfbea commit 243a658

8 files changed

Lines changed: 306 additions & 9 deletions

src/dqlitedbapi/connection.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,97 @@ def _validate_close_timeout(close_timeout: float) -> None:
147147
)
148148

149149

150-
async def _resolve_leader(address: str, *, timeout: float) -> str:
150+
# Process-wide ``ClusterClient`` cache for the leader-discovery probe.
151+
# Keyed by the full ``(address, governor)`` tuple so two configurations
152+
# never share state. Without the cache, every dbapi ``connect()`` /
153+
# every SA pool slot warm-up constructs a fresh ``ClusterClient`` —
154+
# discarding both the single-flight ``_find_leader_tasks`` slot map
155+
# AND the ``_last_known_leader`` fast-path cache. Under N concurrent
156+
# SA pool checkouts after a leader flip, the cluster sees N
157+
# independent leader-discovery sweeps where one would suffice.
158+
#
159+
# Fork-safety: the cache is wholesale-invalidated on fork via the same
160+
# ``_current_pid`` token that ``DqliteConnection`` uses (see
161+
# ``dqliteclient.connection`` lines 45-78). The first ``_resolve_leader``
162+
# call in a child process observes the pid mismatch and clears the
163+
# inherited cache; the parent's ``ClusterClient`` instances would
164+
# otherwise carry parent-allocated ``asyncio.Lock`` / ``asyncio.Task``
165+
# references that the child's event loop cannot make progress on.
166+
#
167+
# Strong-reference (``dict``, not ``WeakValueDictionary``): the
168+
# ClusterClient must outlive a single ``find_leader`` call so the
169+
# fast-path cache survives across calls; nothing else holds a
170+
# reference. The cap (``_RESOLVE_LEADER_CACHE_MAX``) bounds the worst
171+
# case to a single distinct configuration per dbapi ``connect()`` call;
172+
# typical SA deployments use one config per Engine, so the cap is
173+
# only reached by adversarial / highly-fragmented usage.
174+
_RESOLVE_LEADER_CACHE: dict[tuple[object, ...], ClusterClient] = {}
175+
_RESOLVE_LEADER_CACHE_PID: int = os.getpid()
176+
_RESOLVE_LEADER_CACHE_MAX: Final[int] = 32
177+
178+
179+
def _get_resolve_leader_cluster(
180+
*,
181+
address: str,
182+
timeout: float,
183+
max_total_rows: int | None,
184+
max_continuation_frames: int | None,
185+
trust_server_heartbeat: bool,
186+
) -> ClusterClient:
187+
"""Return a process-shared :class:`ClusterClient` for the
188+
leader-discovery probe, keyed by the (address, governor) tuple.
189+
190+
The single-flight collapse and ``_last_known_leader`` fast-path
191+
inside ``ClusterClient`` only amortise across callers of the
192+
*same* instance. Constructing a fresh client per ``connect()``
193+
defeats both. A process-wide cache restores the invariant.
194+
195+
Cleared wholesale on fork: ``ClusterClient`` instances inherit
196+
parent ``asyncio.Lock`` / pending ``asyncio.Task`` references
197+
that are bound to the parent's event loop and cannot make
198+
progress in the child. The pid check is cheap (Python int
199+
equality) and runs only on the cache-lookup path.
200+
"""
201+
global _RESOLVE_LEADER_CACHE_PID
202+
pid = _client_conn_mod._current_pid
203+
if pid != _RESOLVE_LEADER_CACHE_PID:
204+
_RESOLVE_LEADER_CACHE.clear()
205+
_RESOLVE_LEADER_CACHE_PID = pid
206+
207+
key: tuple[object, ...] = (
208+
address,
209+
timeout,
210+
max_total_rows,
211+
max_continuation_frames,
212+
trust_server_heartbeat,
213+
)
214+
cluster = _RESOLVE_LEADER_CACHE.get(key)
215+
if cluster is None:
216+
if len(_RESOLVE_LEADER_CACHE) >= _RESOLVE_LEADER_CACHE_MAX:
217+
# Evict an arbitrary oldest entry. Worst case: the
218+
# evicted client's fast-path cache is lost; the next
219+
# ``find_leader`` against that key rediscovers in one
220+
# sweep. Acceptable bound on the cache size.
221+
_RESOLVE_LEADER_CACHE.pop(next(iter(_RESOLVE_LEADER_CACHE)))
222+
cluster = ClusterClient(
223+
MemoryNodeStore([address]),
224+
timeout=timeout,
225+
max_total_rows=max_total_rows,
226+
max_continuation_frames=max_continuation_frames,
227+
trust_server_heartbeat=trust_server_heartbeat,
228+
)
229+
_RESOLVE_LEADER_CACHE[key] = cluster
230+
return cluster
231+
232+
233+
async def _resolve_leader(
234+
address: str,
235+
*,
236+
timeout: float,
237+
max_total_rows: int | None = _DEFAULT_MAX_TOTAL_ROWS,
238+
max_continuation_frames: int | None = _DEFAULT_MAX_CONTINUATION_FRAMES,
239+
trust_server_heartbeat: bool = False,
240+
) -> str:
151241
"""Resolve the cluster's current leader address from a seed.
152242
153243
Bootstraps from the user-supplied ``address`` (the URL host:port
@@ -160,14 +250,29 @@ async def _resolve_leader(address: str, *, timeout: float) -> str:
160250
cluster has a healthy leader at a different address; the SA
161251
pool's reconnect-after-pre-ping path cannot recover.
162252
253+
Threads the governor set used by the subsequent
254+
:class:`DqliteConnection` so the leader-discovery probe runs
255+
with the same configuration as the eventual data session. Without
256+
forwarding, an operator who set ``trust_server_heartbeat=True``
257+
finds the *first* round-trip — leader discovery — running with
258+
the default opt-out, defeating the very setting they enabled.
259+
Likewise ``max_total_rows`` / ``max_continuation_frames`` matter
260+
for admin paths (``cluster_info`` / ``dump``) reachable through
261+
the resolved client.
262+
163263
Wraps the seed in a single-node :class:`MemoryNodeStore` and
164264
delegates to :meth:`ClusterClient.find_leader`. Returns the
165265
leader's address on success; raises the underlying
166266
``ClusterError`` / ``ClusterPolicyError`` for the surrounding
167267
error-translation arms in :func:`_build_and_connect` to handle.
168268
"""
169-
store = MemoryNodeStore([address])
170-
cluster = ClusterClient(store, timeout=timeout)
269+
cluster = _get_resolve_leader_cluster(
270+
address=address,
271+
timeout=timeout,
272+
max_total_rows=max_total_rows,
273+
max_continuation_frames=max_continuation_frames,
274+
trust_server_heartbeat=trust_server_heartbeat,
275+
)
171276
return await cluster.find_leader()
172277

173278

@@ -202,7 +307,13 @@ async def _build_and_connect(
202307
connections; the dbapi handles the redirect transparently.
203308
"""
204309
try:
205-
leader_address = await _resolve_leader(address, timeout=timeout)
310+
leader_address = await _resolve_leader(
311+
address,
312+
timeout=timeout,
313+
max_total_rows=max_total_rows,
314+
max_continuation_frames=max_continuation_frames,
315+
trust_server_heartbeat=trust_server_heartbeat,
316+
)
206317
except _client_exc.ClusterPolicyError as e:
207318
# Operator allowlist rejected a redirect target. Surface as
208319
# InterfaceError with the canonical prefix — symmetric with

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
"""Pytest configuration for dqlite-dbapi tests."""
22

33
import sys
4+
from collections.abc import Iterator
45
from pathlib import Path
56

7+
import pytest
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def _clear_resolve_leader_cache() -> Iterator[None]:
12+
"""Clear the process-wide ``_resolve_leader`` ClusterClient cache
13+
between tests. The cache is keyed by (address, governors) so two
14+
tests that mock ``ClusterClient`` against the same seed address
15+
would otherwise share a stale cached instance from the first
16+
test's patch context.
17+
"""
18+
from dqlitedbapi import connection as _conn_mod
19+
20+
_conn_mod._RESOLVE_LEADER_CACHE.clear()
21+
yield
22+
_conn_mod._RESOLVE_LEADER_CACHE.clear()
23+
24+
625
# Add python-dqlite-dev's testlib to sys.path so tests (in particular
726
# the leader-redirect integration suite) can import shared utilities
827
# from ``dqlitetestlib``. ``python-dqlite-dev`` is expected as a

tests/test_async_close_race.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ async def fake_query_raw_typed(_sql: str, _params: object) -> tuple[list, list,
3434
with (
3535
patch(
3636
"dqlitedbapi.connection._resolve_leader",
37-
new=AsyncMock(side_effect=lambda address, *, timeout: address),
37+
new=AsyncMock(side_effect=lambda address, *, timeout, **_kw: address),
3838
),
3939
patch("dqlitedbapi.connection.DqliteConnection") as MockDqliteConn,
4040
):

tests/test_close_timeout_plumbing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def test_propagates_to_underlying_dqlite_connection(self) -> None:
8383
with (
8484
patch(
8585
"dqlitedbapi.connection._resolve_leader",
86-
new=AsyncMock(side_effect=lambda address, *, timeout: address),
86+
new=AsyncMock(side_effect=lambda address, *, timeout, **_kw: address),
8787
),
8888
patch("dqlitedbapi.connection.DqliteConnection") as MockConn,
8989
):

tests/test_connection_closed_checks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ async def _raise_policy(*args: object, **kwargs: object) -> None:
195195
# ``DqliteConnection.connect`` arm. Then patch
196196
# ``DqliteConnection.connect`` to raise the policy error this
197197
# test pins.
198-
async def _identity_resolve(address: str, *, timeout: float) -> str:
198+
async def _identity_resolve(address: str, *, timeout: float, **_kw: object) -> str:
199199
return address
200200

201201
monkeypatch.setattr(

tests/test_leader_redirect_on_connect.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,40 @@ async def test_build_and_connect_mid_flip_leader_change_propagates() -> None:
222222
)
223223

224224

225+
@pytest.mark.asyncio
226+
async def test_resolve_leader_threads_governors_to_cluster_client() -> None:
227+
"""``_resolve_leader`` must forward ``trust_server_heartbeat`` /
228+
``max_total_rows`` / ``max_continuation_frames`` to the
229+
``ClusterClient`` it constructs. Without forwarding, the FIRST
230+
round-trip — leader discovery — runs with default governors
231+
even when the operator opted into different ones, defeating
232+
the per-connection setting at exactly the path the security
233+
audit flagged.
234+
"""
235+
captured: dict[str, object] = {}
236+
237+
def fake_cluster_client(store: object, **kwargs: object) -> object:
238+
captured.update(kwargs)
239+
client = MagicMock()
240+
client.find_leader = AsyncMock(return_value="leader:9999")
241+
return client
242+
243+
with patch("dqlitedbapi.connection.ClusterClient", fake_cluster_client):
244+
result = await _resolve_leader(
245+
"seed:9001",
246+
timeout=5.0,
247+
max_total_rows=None,
248+
max_continuation_frames=42,
249+
trust_server_heartbeat=True,
250+
)
251+
252+
assert result == "leader:9999"
253+
assert captured["timeout"] == 5.0
254+
assert captured["max_total_rows"] is None
255+
assert captured["max_continuation_frames"] == 42
256+
assert captured["trust_server_heartbeat"] is True
257+
258+
225259
# --- end-to-end via Connection (sync surface) ---
226260

227261

@@ -231,7 +265,7 @@ def test_connection_connect_uses_leader_address(monkeypatch: pytest.MonkeyPatch)
231265
inner ``DqliteConnection``."""
232266
captured_addresses: list[str] = []
233267

234-
async def fake_resolve(address: str, *, timeout: float) -> str:
268+
async def fake_resolve(address: str, *, timeout: float, **_kw: object) -> str:
235269
return "leader:9999"
236270

237271
def capture_dqlite_connection(address: str, *args: object, **kwargs: object) -> AsyncMock:

tests/test_max_total_rows_plumbing.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ def test_propagates_to_underlying_dqlite_connection(self) -> None:
4747
with (
4848
patch(
4949
"dqlitedbapi.connection._resolve_leader",
50-
new=AsyncMock(side_effect=lambda address, *, timeout: address),
50+
new=AsyncMock(
51+
side_effect=lambda address, *, timeout, **_kw: address,
52+
),
5153
),
5254
patch("dqlitedbapi.connection.DqliteConnection") as MockConn,
5355
):
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""``_resolve_leader`` shares a process-wide ``ClusterClient`` per
2+
``(address, governors)`` tuple.
3+
4+
Without sharing, every dbapi ``connect()`` / SA pool slot warm-up
5+
constructs a fresh ``ClusterClient`` and discards it on return —
6+
defeating the single-flight ``_find_leader_tasks`` collapse and the
7+
``_last_known_leader`` fast-path that ``ClusterClient`` already
8+
implements. Under N concurrent SA pool checkouts after a leader flip,
9+
the cluster sees N independent leader-discovery sweeps where one
10+
would suffice.
11+
12+
This test pins the per-key reuse and the per-governor isolation, and
13+
validates wholesale invalidation on the fork-pid token used by the
14+
underlying ``DqliteConnection`` fork-safety machinery.
15+
"""
16+
17+
import os
18+
from unittest.mock import AsyncMock, MagicMock, patch
19+
20+
import pytest
21+
22+
from dqliteclient import connection as _client_conn_mod
23+
from dqlitedbapi import connection as _conn_mod
24+
from dqlitedbapi.connection import _resolve_leader
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_resolve_leader_reuses_cluster_client_for_same_key() -> None:
29+
"""Two ``_resolve_leader`` calls with the same address+governors
30+
must share a single ``ClusterClient`` instance — that is what
31+
keeps the leader-tracker fast-path effective at the dbapi layer."""
32+
construct_count = 0
33+
34+
def fake_cluster_client(_store: object, **_kwargs: object) -> MagicMock:
35+
nonlocal construct_count
36+
construct_count += 1
37+
client = MagicMock()
38+
client.find_leader = AsyncMock(return_value="leader:9999")
39+
return client
40+
41+
with patch("dqlitedbapi.connection.ClusterClient", fake_cluster_client):
42+
await _resolve_leader("seed:9001", timeout=5.0)
43+
await _resolve_leader("seed:9001", timeout=5.0)
44+
await _resolve_leader("seed:9001", timeout=5.0)
45+
46+
# Three calls but only ONE ClusterClient construction.
47+
assert construct_count == 1
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_resolve_leader_isolates_distinct_governors() -> None:
52+
"""Different governor tuples must produce distinct
53+
ClusterClient instances — sharing would cross-contaminate
54+
the trust_server_heartbeat opt-in."""
55+
constructed_kwargs: list[dict[str, object]] = []
56+
57+
def fake_cluster_client(_store: object, **kwargs: object) -> MagicMock:
58+
constructed_kwargs.append(kwargs)
59+
client = MagicMock()
60+
client.find_leader = AsyncMock(return_value="leader:9999")
61+
return client
62+
63+
with patch("dqlitedbapi.connection.ClusterClient", fake_cluster_client):
64+
await _resolve_leader("seed:9001", timeout=5.0, trust_server_heartbeat=False)
65+
await _resolve_leader("seed:9001", timeout=5.0, trust_server_heartbeat=True)
66+
# Repeat each — cache hits.
67+
await _resolve_leader("seed:9001", timeout=5.0, trust_server_heartbeat=False)
68+
await _resolve_leader("seed:9001", timeout=5.0, trust_server_heartbeat=True)
69+
70+
# Two distinct keys → two constructions; the repeats are cached.
71+
assert len(constructed_kwargs) == 2
72+
heartbeat_settings = {kw["trust_server_heartbeat"] for kw in constructed_kwargs}
73+
assert heartbeat_settings == {False, True}
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_resolve_leader_cache_invalidates_on_fork_pid_change() -> None:
78+
"""Fork in a child process must wholesale-clear the cache:
79+
a ClusterClient inherited from the parent carries
80+
parent-allocated asyncio.Lock / Task references that the
81+
child's event loop cannot make progress on. The fork-pid
82+
token that DqliteConnection uses for fork-safety is the
83+
sentinel."""
84+
construct_count = 0
85+
86+
def fake_cluster_client(_store: object, **_kwargs: object) -> MagicMock:
87+
nonlocal construct_count
88+
construct_count += 1
89+
client = MagicMock()
90+
client.find_leader = AsyncMock(return_value="leader:9999")
91+
return client
92+
93+
with patch("dqlitedbapi.connection.ClusterClient", fake_cluster_client):
94+
await _resolve_leader("seed:9001", timeout=5.0)
95+
# Simulate fork: the client-side _current_pid is what
96+
# _refresh_pid_cache writes after_in_child. Bump it to a
97+
# value that cannot collide with the cache's recorded pid.
98+
with patch.object(_client_conn_mod, "_current_pid", os.getpid() + 1):
99+
await _resolve_leader("seed:9001", timeout=5.0)
100+
101+
# Two constructions: one pre-fork, one post-fork.
102+
assert construct_count == 2
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_resolve_leader_cache_evicts_at_max_size() -> None:
107+
"""LRU-ish eviction: a cache that grew past the cap must drop
108+
its oldest entry rather than leak unbounded under adversarial
109+
governor-fragmentation."""
110+
construct_count = 0
111+
112+
def fake_cluster_client(_store: object, **_kwargs: object) -> MagicMock:
113+
nonlocal construct_count
114+
construct_count += 1
115+
client = MagicMock()
116+
client.find_leader = AsyncMock(return_value="leader:9999")
117+
return client
118+
119+
cap = _conn_mod._RESOLVE_LEADER_CACHE_MAX
120+
with patch("dqlitedbapi.connection.ClusterClient", fake_cluster_client):
121+
# Fill to cap with distinct timeouts.
122+
for i in range(cap):
123+
await _resolve_leader("seed:9001", timeout=float(i + 1))
124+
assert len(_conn_mod._RESOLVE_LEADER_CACHE) == cap
125+
126+
# One more entry must NOT push the cache past the cap.
127+
await _resolve_leader("seed:9001", timeout=float(cap + 100))
128+
assert len(_conn_mod._RESOLVE_LEADER_CACHE) == cap
129+
130+
# Exactly cap+1 constructions occurred.
131+
assert construct_count == cap + 1

0 commit comments

Comments
 (0)