Skip to content

Commit da654b7

Browse files
Dispatch decode_value via a precomputed table indexed by type code
The prior shape walked a 6-arm ``if value_type == ValueType.X`` IntEnum-equality chain per cell (~500 ns) where every comparison paid an attribute load, an int comparison, and a branch decision. On a 100k-row × 16-column fetch (1.6M cells) that ladder cost ~0.8 s of pure-Python decoder overhead on the loop thread. Extract each arm into a module-level ``_decode_value_<type>`` helper, build a precomputed ``_VALUE_TYPE_DECODERS`` dict keyed by the integer type code, and dispatch with one C-level lookup plus one function call (~100 ns). The arm-specific context comments (BOOLEAN truthiness rationale, UNIXTIME raw-vs-datetime split, NULL permissive-decode parity, ISO8601 label) carry over verbatim into the helpers. Mirrors the precomputed-table precedent already established at ``tuples.py``'s ``_NIBBLE_TO_VALUETYPE`` — same pattern, one layer up the call stack. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bfb5c56 commit da654b7

2 files changed

Lines changed: 319 additions & 115 deletions

File tree

‎src/dqlitewire/types.py‎

Lines changed: 162 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import datetime
1414
import mmap
1515
import struct
16+
from collections.abc import Callable
1617
from typing import Final, cast
1718

1819
from dqlitewire.constants import WORD_SIZE, ValueType
@@ -1004,6 +1005,159 @@ def encode_value(value: WireInput, value_type: ValueType | None = None) -> tuple
10041005
raise EncodeError(f"Unknown value type: {value_type}")
10051006

10061007

1008+
def _decode_value_integer(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1009+
return decode_int64(data, label="INTEGER cell"), 8
1010+
1011+
1012+
def _decode_value_float(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1013+
return decode_double(data, label="FLOAT cell"), 8
1014+
1015+
1016+
def _decode_value_text(data: bytes | memoryview, text_errors: str) -> tuple[WireValue, int]:
1017+
return decode_text(data, label="TEXT", errors=text_errors)
1018+
1019+
1020+
def _decode_value_iso8601(data: bytes | memoryview, text_errors: str) -> tuple[WireValue, int]:
1021+
# ISO8601 is treated as text at the wire level — the C reference
1022+
# uses text__encode / text__decode for DQLITE_ISO8601 (see dqlite
1023+
# src/tuple.c) and Go returns the raw string from the codec.
1024+
# Parsing to datetime belongs in the driver/DBAPI layer.
1025+
# The ``"ISO8601"`` label is forwarded so truncation / not-null-
1026+
# terminated diagnostics name the actual cell type rather than
1027+
# the generic ``"Text ..."`` default — symmetric with
1028+
# ``encode_value``.
1029+
return decode_text(data, label="ISO8601", errors=text_errors)
1030+
1031+
1032+
def _decode_value_blob(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1033+
return decode_blob(data)
1034+
1035+
1036+
def _decode_value_null(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1037+
# Permissive decode matching Go's
1038+
# ``r.message.getUint64()`` discard
1039+
# (``internal/protocol/message.go:539``) and C's
1040+
# ``uint64__decode(... &value->null)`` (``tuple.c:147``) where
1041+
# the value is read into a union member that is never inspected.
1042+
# Delegating to ``decode_uint64`` (instead of rolling an inline
1043+
# length check) gives the same wording discipline as the
1044+
# sibling INTEGER / UNIXTIME / FLOAT / BOOLEAN arms — operators
1045+
# grepping logs for "wire decode short-read" see a uniform
1046+
# ``"Need 8 bytes for <type> cell"`` phrasing across every
1047+
# short-read case. Our encoder still writes 8 zero bytes
1048+
# (``encode_null``), but a peer / future server / mock that
1049+
# emits non-zero NULL bytes is wire-conformant per upstream's
1050+
# tuple_decoder semantics. Strict-rejection provided no
1051+
# defensive value (the field is documented as unused) and was
1052+
# an interop hazard.
1053+
#
1054+
# Round-trip identity caveat: ``encode_value(None)`` writes 8
1055+
# zero bytes regardless of what the decoder consumed, so a
1056+
# mock-server / proxy / fuzzer that captured a non-zero NULL
1057+
# payload cannot recover byte-identity through the public
1058+
# encode path. The public encoder is uniformly zero-emitting;
1059+
# callers needing byte-identity must emit the captured 8 bytes
1060+
# plus the NULL type tag directly. The asymmetry mirrors the
1061+
# sibling permissive-read patterns at
1062+
# :meth:`DbResponse.decode_body` and
1063+
# :meth:`EmptyResponse.decode_body`, which document the same
1064+
# round-trip lossiness for their reserved fields.
1065+
decode_uint64(data, label="NULL cell")
1066+
return None, NULL_CELL_WIDTH
1067+
1068+
1069+
def _decode_value_unixtime(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1070+
# Returns raw int64 (NOT datetime). Contrast with the BOOLEAN
1071+
# arm, which DOES collapse to ``bool`` because ``bool`` is a
1072+
# wire primitive per the module charter. UNIXTIME's primitive is
1073+
# the epoch-seconds int; the ``datetime`` semantic is driver-
1074+
# layer (see ``dqlitedbapi``).
1075+
#
1076+
# Higher-level clients (``dqlitedbapi``) turn this into a
1077+
# UTC-aware ``datetime``; Go's ``Rows.Next()`` does the
1078+
# conversion at the ``database/sql`` driver layer with
1079+
# ``time.Unix(...)``. Bare wire-layer consumers must convert
1080+
# themselves; the wire layer's parity bar is "raw bytes →
1081+
# primitive", not "raw bytes → semantic".
1082+
#
1083+
# NOTE: This arm is intentionally context-blind. UNIXTIME is
1084+
# legitimate in row cells (the C row-encode side at
1085+
# ``dqlite-upstream/src/query.c`` does emit it) but malformed
1086+
# in params (``dqlite-upstream/src/tuple.c::tuple_decoder__next``
1087+
# has no DQLITE_UNIXTIME case in the params switch; the default
1088+
# returns DQLITE_PARSE). The params-vs-row rejection lives at
1089+
# the caller's type-code switch in
1090+
# ``tuples.py::decode_params_tuple``, NOT here — a refactor that
1091+
# moved the rejection into this arm would also break the row
1092+
# decoder, which legitimately needs to accept UNIXTIME.
1093+
return decode_int64(data, label="UNIXTIME cell"), 8
1094+
1095+
1096+
def _decode_value_boolean(data: bytes | memoryview, _text_errors: str) -> tuple[WireValue, int]:
1097+
# Returns ``bool`` (NOT raw int). Justified by the module
1098+
# charter at the top of this file, which enumerates ``bool`` as
1099+
# a wire primitive — not by raw preservation. Contrast with the
1100+
# UNIXTIME arm, which DOES preserve the raw int64 because
1101+
# epoch-seconds is the primitive and ``datetime`` is the
1102+
# driver-layer semantic. The principled split: a type is
1103+
# "raw"-preserved at the wire layer iff the wire primitive is
1104+
# itself the canonical representation; ``bool`` is canonical for
1105+
# BOOLEAN, ``int`` is canonical for UNIXTIME, and the datetime
1106+
# conversion is deferred to the driver (``dqlitedbapi``). A
1107+
# peer-emitted BOOLEAN cell carrying a raw uint64 outside
1108+
# ``{0, 1}`` loses the original integer through this collapse —
1109+
# see the encode-side asymmetry note on ``encode_value``'s
1110+
# BOOLEAN arm.
1111+
#
1112+
# Permissive truthiness decode: read the wire cell as an
1113+
# unsigned uint64 (matching C's ``uint64__decode(d->cursor,
1114+
# &value->boolean)`` in ``dqlite-upstream/src/tuple.c``,
1115+
# ``DQLITE_BOOLEAN`` case in ``tuple_decoder__next``) and coerce
1116+
# to ``bool``. Go's reference decoder uses signed
1117+
# ``getInt64() != 0`` instead
1118+
# (``internal/protocol/message.go:566``), which agrees on
1119+
# truthiness for every bit pattern but diverges on the raw int
1120+
# for the upper half of the range (``[2**63, 2**64 - 1]``);
1121+
# Python tracks the C side so a hypothetical future
1122+
# "keep_raw=True" debug knob would yield C-shaped values, not
1123+
# Go-shaped.
1124+
#
1125+
# The dqlite C server emits a ``DQLITE_BOOLEAN`` cell whenever
1126+
# the column's decltype is ``BOOLEAN`` and the value comes from
1127+
# ``sqlite3_column_int64`` (``encodeColumn`` in
1128+
# ``dqlite-upstream/src/query.c``, ``DQLITE_BOOLEAN`` case) —
1129+
# i.e. any int the column actually contains. SQLite typing is
1130+
# dynamic, so a row inserted as
1131+
# ``INSERT INTO t(b BOOLEAN) VALUES (5)`` arrives with raw=5 on
1132+
# the wire. Strict-rejection would deterministically poison the
1133+
# decoder for legitimate cluster data while Go and C clients
1134+
# work fine.
1135+
raw = decode_uint64(data, label="BOOLEAN cell")
1136+
return bool(raw), 8
1137+
1138+
1139+
# Dispatch table indexed by the integer value-type code. Replaces
1140+
# the prior 6-arm ``if value_type == ValueType.X`` chain (~500 ns
1141+
# per cell on CPython) with one C-level dict lookup plus one
1142+
# function call (~100 ns per cell). Mirrors the precomputed-table
1143+
# precedent at ``tuples.py``'s ``_NIBBLE_TO_VALUETYPE`` — same
1144+
# pattern, one layer up the call stack. For a 100k-row × 16-col
1145+
# fetch (1.6M cells) the saving is several hundred ms of
1146+
# loop-thread CPU.
1147+
_VALUE_TYPE_DECODERS: Final[
1148+
dict[int, Callable[[bytes | memoryview, str], tuple[WireValue, int]]]
1149+
] = {
1150+
int(ValueType.INTEGER): _decode_value_integer,
1151+
int(ValueType.FLOAT): _decode_value_float,
1152+
int(ValueType.TEXT): _decode_value_text,
1153+
int(ValueType.BLOB): _decode_value_blob,
1154+
int(ValueType.NULL): _decode_value_null,
1155+
int(ValueType.UNIXTIME): _decode_value_unixtime,
1156+
int(ValueType.ISO8601): _decode_value_iso8601,
1157+
int(ValueType.BOOLEAN): _decode_value_boolean,
1158+
}
1159+
1160+
10071161
def decode_value(
10081162
data: bytes | memoryview,
10091163
value_type: ValueType,
@@ -1028,120 +1182,13 @@ def decode_value(
10281182
See :func:`decode_text`'s WARNING about ``"replace"`` /
10291183
``"surrogateescape"`` bypassing :func:`sanitize_for_log` and
10301184
failing re-encode before adopting a non-strict mode.
1185+
1186+
Per-value-type dispatch goes through ``_VALUE_TYPE_DECODERS``
1187+
(defined above) — see each ``_decode_value_*`` helper for
1188+
arm-specific context (BOOLEAN truthiness rationale, UNIXTIME
1189+
raw-vs-datetime split, NULL permissive-decode parity, etc.).
10311190
"""
1032-
if value_type == ValueType.BOOLEAN:
1033-
# Returns ``bool`` (NOT raw int). Justified by the module
1034-
# charter at the top of this file, which enumerates ``bool``
1035-
# as a wire primitive — not by raw preservation. Contrast with
1036-
# the UNIXTIME arm below, which DOES preserve the raw int64
1037-
# because epoch-seconds is the primitive and ``datetime`` is
1038-
# the driver-layer semantic. The principled split: a type is
1039-
# "raw"-preserved at the wire layer iff the wire primitive is
1040-
# itself the canonical representation; ``bool`` is canonical
1041-
# for BOOLEAN, ``int`` is canonical for UNIXTIME, and the
1042-
# datetime conversion is deferred to the driver
1043-
# (``dqlitedbapi``). A peer-emitted BOOLEAN cell carrying a
1044-
# raw uint64 outside ``{0, 1}`` loses the original integer
1045-
# through this collapse — see the encode-side asymmetry note
1046-
# on ``encode_value``'s BOOLEAN arm.
1047-
#
1048-
# Permissive truthiness decode: read the wire cell as an
1049-
# unsigned uint64 (matching C's ``uint64__decode(d->cursor,
1050-
# &value->boolean)`` in ``dqlite-upstream/src/tuple.c``,
1051-
# ``DQLITE_BOOLEAN`` case in ``tuple_decoder__next``) and
1052-
# coerce to ``bool``. Go's reference decoder uses signed
1053-
# ``getInt64() != 0`` instead
1054-
# (``internal/protocol/message.go:566``), which agrees on
1055-
# truthiness for every bit pattern but diverges on the raw
1056-
# int for the upper half of the range
1057-
# (``[2**63, 2**64 - 1]``); Python tracks the C side so a
1058-
# hypothetical future "keep_raw=True" debug knob would yield
1059-
# C-shaped values, not Go-shaped.
1060-
#
1061-
# The dqlite C server emits a ``DQLITE_BOOLEAN`` cell whenever
1062-
# the column's decltype is ``BOOLEAN`` and the value comes
1063-
# from ``sqlite3_column_int64`` (``encodeColumn`` in
1064-
# ``dqlite-upstream/src/query.c``, ``DQLITE_BOOLEAN`` case) —
1065-
# i.e. any int the column actually contains. SQLite typing is
1066-
# dynamic, so a row inserted as
1067-
# ``INSERT INTO t(b BOOLEAN) VALUES (5)`` arrives with raw=5
1068-
# on the wire. Strict-rejection would deterministically poison
1069-
# the decoder for legitimate cluster data while Go and C
1070-
# clients work fine.
1071-
raw = decode_uint64(data, label="BOOLEAN cell")
1072-
return bool(raw), 8
1073-
elif value_type == ValueType.INTEGER:
1074-
return decode_int64(data, label="INTEGER cell"), 8
1075-
elif value_type == ValueType.UNIXTIME:
1076-
# Returns raw int64 (NOT datetime). Contrast with the BOOLEAN
1077-
# arm above, which DOES collapse to ``bool`` because ``bool``
1078-
# is a wire primitive per the module charter. UNIXTIME's
1079-
# primitive is the epoch-seconds int; the ``datetime``
1080-
# semantic is driver-layer (see ``dqlitedbapi``).
1081-
#
1082-
# Higher-level clients (``dqlitedbapi``) turn this into a
1083-
# UTC-aware ``datetime``; Go's ``Rows.Next()`` does the
1084-
# conversion at the ``database/sql`` driver layer with
1085-
# ``time.Unix(...)``. Bare wire-layer consumers must convert
1086-
# themselves; the wire layer's parity bar is "raw bytes →
1087-
# primitive", not "raw bytes → semantic".
1088-
#
1089-
# NOTE: This arm is intentionally context-blind. UNIXTIME is
1090-
# legitimate in row cells (the C row-encode side at
1091-
# ``dqlite-upstream/src/query.c`` does emit it) but malformed
1092-
# in params (``dqlite-upstream/src/tuple.c::tuple_decoder__next``
1093-
# has no DQLITE_UNIXTIME case in the params switch; the
1094-
# default returns DQLITE_PARSE). The params-vs-row rejection
1095-
# lives at the caller's type-code switch in
1096-
# ``tuples.py::decode_params_tuple``, NOT here — a refactor
1097-
# that moved the rejection into this arm would also break the
1098-
# row decoder, which legitimately needs to accept UNIXTIME.
1099-
return decode_int64(data, label="UNIXTIME cell"), 8
1100-
elif value_type == ValueType.FLOAT:
1101-
return decode_double(data, label="FLOAT cell"), 8
1102-
elif value_type in (ValueType.TEXT, ValueType.ISO8601):
1103-
# ISO8601 is treated as text at the wire level — the C reference
1104-
# uses text__encode / text__decode for DQLITE_ISO8601 (see dqlite
1105-
# src/tuple.c) and Go returns the raw string from the codec.
1106-
# Parsing to datetime belongs in the driver/DBAPI layer.
1107-
# Forward ``value_type.name`` as the label so truncation / not-
1108-
# null-terminated diagnostics name the actual cell type
1109-
# (``"ISO8601 not null-terminated"``) rather than the generic
1110-
# ``"Text ..."`` default — symmetric with ``encode_value``.
1111-
return decode_text(data, label=value_type.name, errors=text_errors)
1112-
elif value_type == ValueType.BLOB:
1113-
return decode_blob(data)
1114-
elif value_type == ValueType.NULL:
1115-
# Permissive decode matching Go's
1116-
# ``r.message.getUint64()`` discard
1117-
# (``internal/protocol/message.go:539``) and C's
1118-
# ``uint64__decode(... &value->null)`` (``tuple.c:147``) where
1119-
# the value is read into a union member that is never
1120-
# inspected. Delegating to ``decode_uint64`` (instead of
1121-
# rolling an inline length check) gives the same wording
1122-
# discipline as the sibling INTEGER / UNIXTIME / FLOAT /
1123-
# BOOLEAN arms — operators grepping logs for "wire decode
1124-
# short-read" see a uniform ``"Need 8 bytes for <type> cell"``
1125-
# phrasing across every short-read case. Our encoder still
1126-
# writes 8 zero bytes
1127-
# (``encode_null``), but a peer / future server / mock that
1128-
# emits non-zero NULL bytes is wire-conformant per upstream's
1129-
# tuple_decoder semantics. Strict-rejection provided no
1130-
# defensive value (the field is documented as unused) and
1131-
# was an interop hazard.
1132-
#
1133-
# Round-trip identity caveat: ``encode_value(None)`` writes 8
1134-
# zero bytes regardless of what the decoder consumed, so a
1135-
# mock-server / proxy / fuzzer that captured a non-zero NULL
1136-
# payload cannot recover byte-identity through the public
1137-
# encode path. The public encoder is uniformly zero-emitting;
1138-
# callers needing byte-identity must emit the captured 8 bytes
1139-
# plus the NULL type tag directly. The asymmetry mirrors the
1140-
# sibling permissive-read patterns at
1141-
# :meth:`DbResponse.decode_body` and
1142-
# :meth:`EmptyResponse.decode_body`, which document the same
1143-
# round-trip lossiness for their reserved fields.
1144-
decode_uint64(data, label="NULL cell")
1145-
return None, NULL_CELL_WIDTH
1146-
else:
1191+
decoder = _VALUE_TYPE_DECODERS.get(int(value_type))
1192+
if decoder is None:
11471193
raise DecodeError(f"Unknown value type: {value_type}")
1194+
return decoder(data, text_errors)

0 commit comments

Comments
 (0)