Skip to content

Commit 8bf6b85

Browse files
Offload large RowsResponse decode off the loop thread
``RowsResponse.decode_body`` walks every row × cell of a single frame synchronously. A 100k-row × 32-col result (~25 MiB body) pinned the loop for hundreds of ms — long enough to miss a 100 ms heartbeat deadline. The wire layer is sync by design, so inserting an explicit cooperative yield inside the decode would mean a two-tier sync-generator API + matching client-side ``await asyncio.sleep(0)`` between chunks. A simpler shape achieves the same end effect: peek the pending message header to size-gate the decode, then call the stateful ``decoder.decode()`` / ``decoder.decode_continuation()`` via ``asyncio.to_thread`` when the projected frame exceeds ``_DECODE_OFFLOAD_THRESHOLD`` (256 KiB — matches the encode threshold). CPython's GIL release every ``sys.setswitchinterval`` (~5 ms default) inside the worker gives the loop thread natural scheduling slices, comparable to a cooperative yield every ~5 ms of decode work. The protocol's ``_lock`` discipline guarantees no concurrent decoder access from a sibling coroutine while the worker holds it, so calling the stateful ``decode`` family from the worker is safe. Mirrors the in-tree dump-decode + send-encode offload pattern. The latency guarantee is coarser than an explicit per-K-rows generator yield (5 ms vs. sub-ms); callers needing tighter scheduling SLA would still benefit from the two-tier API. The threshold comment forward-points this for future work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8926a48 commit 8bf6b85

2 files changed

Lines changed: 284 additions & 27 deletions

File tree

‎src/dqliteclient/protocol.py‎

Lines changed: 113 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@
107107
# under this threshold.
108108
_ENCODE_OFFLOAD_THRESHOLD: Final[int] = 256 * 1024
109109

110+
# Decode-offload threshold for ``_read_response``. Frames whose
111+
# total wire size meets or exceeds this value are decoded on a
112+
# worker thread via ``asyncio.to_thread`` so the per-row + per-cell
113+
# walk in ``RowsResponse.decode_body`` does not freeze the loop.
114+
# A 100k-row × 32-col RowsResponse (~25 MiB body) otherwise pins
115+
# the loop for hundreds of ms. The threshold matches
116+
# ``_ENCODE_OFFLOAD_THRESHOLD`` so both directions amortise the
117+
# ~50 µs thread-hop overhead at the same payload boundary.
118+
# CPython's GIL release every ``sys.setswitchinterval`` (~5 ms by
119+
# default) inside the worker thread gives the loop thread natural
120+
# scheduling slices during the off-loop decode — comparable to a
121+
# cooperative ``await asyncio.sleep(0)`` every ~5 ms of decode
122+
# work without the API redesign a two-tier sync-generator
123+
# approach would require. Mirrors the in-tree dump-decode +
124+
# send-encode offload pattern. NOTE: the latency guarantee is
125+
# coarser than an in-loop generator (5 ms granularity vs.
126+
# sub-ms with explicit yields); callers needing sub-5-ms
127+
# scheduling SLA would still benefit from a two-tier sync
128+
# generator + per-K-rows ``await asyncio.sleep(0)`` shape.
129+
_DECODE_OFFLOAD_THRESHOLD: Final[int] = 256 * 1024
130+
110131

111132
def _decode_dump_response_sync(frame_bytes: bytes, decoder: MessageDecoder) -> Message:
112133
"""Sync body of the ``dump`` off-loop decode hop.
@@ -1339,23 +1360,32 @@ async def query_sql(
13391360

13401361
return column_names, all_rows
13411362

1342-
async def _read_message_bytes(self, deadline: float | None = None) -> bytes:
1363+
async def _read_message_bytes(
1364+
self,
1365+
deadline: float | None = None,
1366+
*,
1367+
allow_trailing: bool = False,
1368+
) -> bytes:
13431369
"""Read the next complete frame as raw bytes WITHOUT
13441370
type-dispatched decode.
13451371
13461372
Used by the dump path so the multi-MiB
13471373
``FilesResponse.decode_body`` per-file memcpy chain can run
13481374
off-loop via ``asyncio.to_thread``. The wire-receive half
13491375
stays on the loop thread; only the bytes-to-message
1350-
materialise hops off.
1376+
materialise hops off. Also used by :meth:`_read_response`
1377+
to threshold-gate the response decode.
13511378
13521379
Preserves the hostile-server trailing-frames hardening from
1353-
:meth:`_read_response`: ``FilesResponse`` / ``FailureResponse``
1354-
are both terminal per the wire spec, so a trailing buffered
1355-
frame after either is a protocol violation. The check fires
1356-
on the loop thread BEFORE the off-loop decode begins so the
1357-
hostile-server vector (a peer coalescing a fake follow-on
1358-
frame after ``FilesResponse``) is rejected pre-offload.
1380+
the prior in-line shape: every dqlite response other than
1381+
``RowsResponse`` is terminal per the wire spec, so a
1382+
trailing buffered frame after a non-Rows response is a
1383+
protocol violation. The check fires on the loop thread
1384+
BEFORE the off-loop decode begins so the hostile-server
1385+
vector (a peer coalescing a fake follow-on frame) is
1386+
rejected pre-offload. ``allow_trailing=True`` opts out
1387+
(the INTERRUPT-drain caller deliberately expects two
1388+
frames in sequence).
13591389
"""
13601390
from dqlitewire.constants import ResponseType
13611391

@@ -1375,10 +1405,10 @@ async def _read_message_bytes(self, deadline: float | None = None) -> bytes:
13751405
# 5-byte layout (size_words[0:4] + msg_type[4] +
13761406
# schema_version[5]). ROWS is the only response type with
13771407
# legitimate trailing-frame semantics; everything else is
1378-
# terminal. Mirrors the ``_read_response`` post-decode
1408+
# terminal. Mirrors the prior ``_read_response`` post-decode
13791409
# hostile-server check at the same call surface.
13801410
msg_type = frame_bytes[4]
1381-
if msg_type != ResponseType.ROWS and self._decoder.has_message():
1411+
if not allow_trailing and msg_type != ResponseType.ROWS and self._decoder.has_message():
13821412
raise ProtocolError(
13831413
f"Server emitted extra response after message type "
13841414
f"{msg_type}{self._addr_suffix()} — protocol "
@@ -1666,9 +1696,40 @@ async def _read_continuation(self, deadline: float | None = None) -> RowsRespons
16661696
# using ``read_continuation`` directly; the fallback
16671697
# picks up the connection's operation timeout.
16681698
deadline = self._operation_deadline()
1699+
from dqlitewire.constants import WORD_SIZE
1700+
16691701
try:
16701702
while True:
1671-
result = self._decoder.decode_continuation()
1703+
# Threshold-gated decode offload (mirror of
1704+
# ``_read_response``). Peek the pending header to
1705+
# decide whether to offload via
1706+
# ``asyncio.to_thread``. ``decode_continuation``
1707+
# is stateful (consumes from the buffer, updates
1708+
# per-stream counters), so it MUST be called as a
1709+
# unit — the protocol's ``_lock`` guarantees no
1710+
# concurrent decoder access from a sibling
1711+
# coroutine while the worker thread holds it.
1712+
# Best-effort header peek to decide on offload.
1713+
# Wrapped in try/except so stub decoders /
1714+
# MagicMock fakes used by tests (which don't
1715+
# implement the wire-layer buffer API) fall back
1716+
# cleanly to the in-loop decode path.
1717+
try:
1718+
pending = self._decoder._buffer.peek_header()
1719+
pending_size = 8 + pending[0] * WORD_SIZE if pending is not None else 0
1720+
# MagicMock arithmetic does NOT raise — it
1721+
# returns another MagicMock that then breaks
1722+
# the ``>=`` comparison below. Coerce non-int
1723+
# values to 0 so stub-decoder tests fall
1724+
# through to the in-loop path.
1725+
if not isinstance(pending_size, int):
1726+
pending_size = 0
1727+
except (AttributeError, TypeError, IndexError):
1728+
pending_size = 0
1729+
if pending_size >= _DECODE_OFFLOAD_THRESHOLD:
1730+
result = await asyncio.to_thread(self._decoder.decode_continuation)
1731+
else:
1732+
result = self._decoder.decode_continuation()
16721733
if isinstance(result, EmptyResponse):
16731734
raise ProtocolError(
16741735
"Unexpected EmptyResponse during ROWS continuation"
@@ -1772,7 +1833,43 @@ async def _read_response(
17721833
# under a fast-burst server / buffer-limit bump.
17731834
await asyncio.sleep(0)
17741835

1775-
message = self._decoder.decode()
1836+
# Threshold-gated decode offload. ``_decoder.decode``
1837+
# is stateful (sets ``_continuation_expected``,
1838+
# per-stream counters, column-name snapshots), so it
1839+
# MUST be called as a unit — splitting into
1840+
# ``read_message`` + ``decode_bytes`` skips the
1841+
# continuation-state setup the streaming path needs.
1842+
# Peek the header to size-gate the offload: large
1843+
# RowsResponse frames decode on a worker thread so
1844+
# the per-row + per-cell walk does not freeze the
1845+
# loop. CPython's GIL release every
1846+
# ``sys.setswitchinterval`` (~5 ms by default) inside
1847+
# the worker gives the loop thread natural scheduling
1848+
# slices during the off-loop decode. The protocol's
1849+
# ``_lock`` ensures only one coroutine touches the
1850+
# decoder at a time, so calling ``decode`` from a
1851+
# worker thread is safe.
1852+
from dqlitewire.constants import WORD_SIZE
1853+
1854+
# Best-effort header peek to decide on offload. Wrapped
1855+
# in try/except so stub decoders / MagicMock fakes used
1856+
# by tests (which don't implement the wire-layer buffer
1857+
# API) fall back cleanly to the in-loop decode path.
1858+
try:
1859+
pending = self._decoder._buffer.peek_header()
1860+
pending_size = 8 + pending[0] * WORD_SIZE if pending is not None else 0
1861+
# MagicMock arithmetic does NOT raise — it returns
1862+
# another MagicMock that then breaks the ``>=``
1863+
# comparison below. Coerce non-int values to 0 so
1864+
# stub-decoder tests fall through to the in-loop path.
1865+
if not isinstance(pending_size, int):
1866+
pending_size = 0
1867+
except (AttributeError, TypeError, IndexError):
1868+
pending_size = 0
1869+
if pending_size >= _DECODE_OFFLOAD_THRESHOLD:
1870+
message = await asyncio.to_thread(self._decoder.decode)
1871+
else:
1872+
message = self._decoder.decode()
17761873
except _WireProtocolError as e:
17771874
raise ProtocolError(f"{WIRE_DECODE_FAILED_PREFIX}{self._addr_suffix()}: {e}") from e
17781875

@@ -1790,24 +1887,13 @@ async def _read_response(
17901887
# one response. If the decoder still has another frame
17911888
# buffered after extracting a non-RowsResponse, the server
17921889
# emitted extra bytes (or two coalesced replies arrived in
1793-
# one TCP segment). Without this check the leftover frame
1794-
# would be consumed as the response to the NEXT user request,
1795-
# producing a misleading ``OperationalError`` against an
1796-
# unrelated operation. Raise ``ProtocolError`` here so
1797-
# ``_run_protocol`` invalidates the connection and the pool
1798-
# discards the slot.
1890+
# one TCP segment). Raise ``ProtocolError`` here so
1891+
# ``_run_protocol`` invalidates the connection.
17991892
#
18001893
# ``RowsResponse`` is the sole exception: continuation frames
18011894
# for a single Rows result legitimately carry across multiple
1802-
# decode steps. The continuation drain path does its own
1803-
# has_message-aware iteration.
1804-
#
1805-
# ``allow_trailing=True`` is the INTERRUPT-drain caller's
1806-
# explicit opt-out (see docstring): the drain context
1807-
# legitimately expects multiple frames in sequence (RESULT
1808-
# from the just-completed EXEC, then EMPTY for the INTERRUPT
1809-
# ack — gateway.c re-dispatches via ``handle_interrupt`` when
1810-
# ``g->req == NULL`` at the time the INTERRUPT lands).
1895+
# decode steps. ``allow_trailing=True`` is the INTERRUPT
1896+
# drain caller's explicit opt-out.
18111897
if (
18121898
not allow_trailing
18131899
and not isinstance(message, RowsResponse)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Pin: ``DqliteProtocol._read_response`` offloads the
2+
multi-MiB wire decode to a worker thread when the pending
3+
message exceeds ``_DECODE_OFFLOAD_THRESHOLD``.
4+
5+
The prior shape ran ``self._decoder.decode()`` synchronously on
6+
the loop thread. For a single large ``RowsResponse`` frame
7+
(100k rows × 32 cols ≈ 25 MiB body), the per-row + per-cell
8+
decode pinned the loop for hundreds of ms — long enough to miss
9+
a 100 ms heartbeat deadline.
10+
11+
Mirrors the dump-decode offload + send-encode offload pattern
12+
already in this file: read raw frame bytes on-loop, peek the
13+
header to size-gate, decode via ``asyncio.to_thread`` when the
14+
projected payload exceeds the threshold. The hostile-server
15+
trailing-frame check still fires on the loop thread before the
16+
off-loop decode.
17+
18+
The threshold (256 KiB, shared with the encode-offload constant)
19+
sits comfortably above the per-frame heartbeat / admin RPC size
20+
so the common case stays in-loop.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import asyncio
26+
import threading
27+
from typing import Any
28+
from unittest.mock import AsyncMock, MagicMock
29+
30+
import pytest
31+
32+
from dqliteclient.protocol import DqliteProtocol
33+
from dqlitewire import MessageDecoder, MessageEncoder
34+
from dqlitewire.constants import ValueType
35+
from dqlitewire.messages.responses import EmptyResponse, FilesResponse, RowsResponse
36+
37+
pytestmark = pytest.mark.asyncio
38+
39+
40+
def _build_rows_response_bytes(n_rows: int, n_cols: int) -> bytes:
41+
encoder = MessageEncoder()
42+
column_names = [f"col_{i}" for i in range(n_cols)]
43+
column_types = [ValueType.INTEGER] * n_cols
44+
rows = [[i + j for j in range(n_cols)] for i in range(n_rows)]
45+
row_types = [list(column_types) for _ in range(n_rows)]
46+
# The ints in ``rows`` are statically typed as ``list[list[int]]``
47+
# but ``RowsResponse.rows`` is ``list[list[WireValue]]``; the
48+
# cast is widening-safe (int is a member of WireValue).
49+
response = RowsResponse(
50+
column_names=column_names,
51+
column_types=column_types,
52+
row_types=row_types,
53+
rows=rows, # type: ignore[arg-type]
54+
has_more=False,
55+
)
56+
return encoder.encode(response)
57+
58+
59+
def _build_empty_response_bytes() -> bytes:
60+
encoder = MessageEncoder()
61+
return encoder.encode(EmptyResponse())
62+
63+
64+
def _make_protocol_with_buffered_response(frame_bytes: bytes) -> DqliteProtocol:
65+
proto = DqliteProtocol.__new__(DqliteProtocol)
66+
proto._writer = MagicMock()
67+
proto._writer.write = MagicMock()
68+
proto._writer.drain = AsyncMock()
69+
proto._timeout = 5.0
70+
proto._read_timeout = 5.0
71+
proto._encoder = MessageEncoder()
72+
proto._client_id = 1
73+
proto._decoder = MessageDecoder(is_request=False)
74+
proto._decoder.feed(frame_bytes)
75+
proto._lock = asyncio.Lock()
76+
proto._addr_suffix = lambda: ""
77+
return proto
78+
79+
80+
async def test_read_response_small_message_stays_in_loop() -> None:
81+
"""A small response (empty / admin / heartbeat-class) must
82+
NOT offload — the ~50 µs thread-hop cost is wasteful for
83+
sub-threshold payloads.
84+
"""
85+
frame_bytes = _build_empty_response_bytes()
86+
proto = _make_protocol_with_buffered_response(frame_bytes)
87+
88+
loop_thread_id = threading.get_ident()
89+
decode_thread_ids: list[int] = []
90+
real_decode_bytes = proto._decoder.decode_bytes
91+
92+
def _tracking_decode_bytes(data: Any) -> Any:
93+
decode_thread_ids.append(threading.get_ident())
94+
return real_decode_bytes(data)
95+
96+
proto._decoder.decode_bytes = _tracking_decode_bytes
97+
98+
await proto._read_response()
99+
assert decode_thread_ids, "decode_bytes was never called"
100+
for tid in decode_thread_ids:
101+
assert tid == loop_thread_id, (
102+
f"small response unexpectedly offloaded to thread {tid}; "
103+
f"expected in-loop decode on {loop_thread_id}"
104+
)
105+
106+
107+
async def test_read_response_large_message_offloads_decode() -> None:
108+
"""A large RowsResponse frame (well above 256 KiB) MUST
109+
decode on a worker thread so the per-row + per-cell decode
110+
chain does not freeze the loop.
111+
"""
112+
# ~40k rows × 4 cols ≈ ~1.3 MiB encoded — comfortably above
113+
# the 256 KiB threshold.
114+
frame_bytes = _build_rows_response_bytes(n_rows=40_000, n_cols=4)
115+
assert len(frame_bytes) > 256 * 1024, (
116+
f"test fixture too small ({len(frame_bytes)} bytes); should be >256 KiB"
117+
)
118+
proto = _make_protocol_with_buffered_response(frame_bytes)
119+
120+
loop_thread_id = threading.get_ident()
121+
decode_thread_ids: list[int] = []
122+
real_decode_bytes = proto._decoder.decode_bytes
123+
124+
def _tracking_decode_bytes(data: Any) -> Any:
125+
decode_thread_ids.append(threading.get_ident())
126+
return real_decode_bytes(data)
127+
128+
proto._decoder.decode_bytes = _tracking_decode_bytes
129+
130+
response = await proto._read_response()
131+
assert isinstance(response, RowsResponse)
132+
assert len(response.rows) == 40_000
133+
134+
assert decode_thread_ids, "decode_bytes was never called"
135+
for tid in decode_thread_ids:
136+
assert tid != loop_thread_id, (
137+
f"large RowsResponse decode ran on loop thread ({tid}); "
138+
f"must offload via asyncio.to_thread for multi-MiB payloads"
139+
)
140+
141+
142+
async def test_read_response_threshold_is_documented_constant() -> None:
143+
"""The decode threshold lives as a module-level ``Final``
144+
constant so operators can grep for it and a future tuning
145+
lands at a single site.
146+
"""
147+
from dqliteclient import protocol as protocol_mod
148+
149+
assert hasattr(protocol_mod, "_DECODE_OFFLOAD_THRESHOLD")
150+
threshold = protocol_mod._DECODE_OFFLOAD_THRESHOLD
151+
# Sanity bound: between 64 KiB and 1 MiB.
152+
assert 64 * 1024 <= threshold <= 1024 * 1024
153+
154+
155+
async def test_read_response_rejects_trailing_frame_after_terminal() -> None:
156+
"""Hostile-server hardening: a trailing buffered frame after
157+
a terminal-type (non-Rows) response is a protocol violation.
158+
The check still fires under the offload-based decode path
159+
(the check looks at ``self._decoder.has_message()`` after the
160+
decode returns; both the in-loop and off-loop decode paths
161+
leave the trailing frame visible to that probe).
162+
"""
163+
files_bytes = MessageEncoder().encode(FilesResponse(files={"main.db": b"\x00" * 64}))
164+
trailing = _build_empty_response_bytes()
165+
poisoned = files_bytes + trailing
166+
proto = _make_protocol_with_buffered_response(poisoned)
167+
168+
from dqliteclient.exceptions import ProtocolError
169+
170+
with pytest.raises(ProtocolError, match="extra response"):
171+
await proto._read_response()

0 commit comments

Comments
 (0)