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
111132def _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 )
0 commit comments