Skip to content

Commit 9c6e72b

Browse files
Override DqliteError.__reduce__ to preserve code and raw_message on pickle
cycle-27 commits XP2 ("Thread code and raw_message through leader- change connect rewrap") and XP3 ("Hoist raw_message to DqliteError base") added optional ``code`` and ``raw_message`` fields to the exception hierarchy. The default ``Exception.__reduce__`` returns ``(cls, self.args)`` and reconstructs via ``cls(*args)`` — losing every field set on the instance after ``Exception.__init__``. Round-trip a leader-change DqliteConnectionError(code=10250, raw_message="not leader") through ``pickle.dumps`` / ``pickle.loads`` (or through ``multiprocessing.Queue``, ``ProcessPoolExecutor`` task results, Celery, SA's multiprocess pool) and you get back ``code=None``, ``raw_message=None``: SA's ``is_disconnect`` code-based classifier on the connect path falls back to the substring scan that XP2 was added to escape, defeating the cross-process motivation cited in XP2's commit message. Override ``__reduce__`` on the base ``DqliteError`` to return the 3-tuple ``(callable, args, state)`` form so pickle reconstructs via ``cls(*args)`` then applies ``self.__dict__`` as state. Every subclass inherits the discipline automatically. ``OperationalError`` remains pickle-lossless via its existing ``args = (code, message)`` discipline plus the new state-dict overlay. Pin: lossless round-trip across pickle protocols 2..HIGHEST and ``copy.deepcopy`` for every subclass that accepts ``raw_message=``, plus the leader-change ``code=10250`` shape for DqliteConnectionError, plus an end-to-end ``multiprocessing.Queue`` crossing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b0f6dd commit 9c6e72b

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

src/dqliteclient/exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ def __init__(self, *args: object, raw_message: str | None = None) -> None:
4040
super().__init__(*args)
4141
self.raw_message = raw_message
4242

43+
def __reduce__(
44+
self,
45+
) -> tuple[type["DqliteError"], tuple[object, ...], dict[str, object]]:
46+
# Default ``Exception.__reduce__`` returns ``(cls, self.args)``,
47+
# which reconstructs via ``cls(*args)``. That loses every field
48+
# set on the instance after ``Exception.__init__`` — most
49+
# notably ``raw_message`` (set on the ``DqliteError`` base) and
50+
# the ``code`` carried by ``DqliteConnectionError``. The
51+
# carriers were added in cycle 27 (XP2 / XP3) precisely so the
52+
# wire-level signal would survive cross-process pickling
53+
# (``ProcessPoolExecutor``, ``multiprocessing.Queue``, Celery
54+
# task results, SA's multiprocess pool); without overriding
55+
# ``__reduce__`` the round-trip silently drops them.
56+
#
57+
# Return the 3-tuple ``(callable, args, state)`` form so pickle
58+
# reconstructs via ``cls(*args)`` then applies state via
59+
# ``self.__dict__.update(state)`` — preserving every attribute
60+
# we set on the instance (``raw_message`` on the base, ``code``
61+
# on subclasses, the truncated ``message`` on
62+
# ``OperationalError``). All subclasses of ``DqliteError``
63+
# inherit this discipline automatically.
64+
return (self.__class__, self.args, self.__dict__.copy())
65+
4366

4467
class DqliteConnectionError(DqliteError):
4568
"""Error establishing or maintaining connection.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Pin: every ``DqliteError`` subclass round-trips through pickle /
2+
deepcopy without losing the ``raw_message`` (base) or ``code``
3+
(``DqliteConnectionError``) fields.
4+
5+
The fields were added in cycle 27 (XP2 / XP3) so the wire-level
6+
signal would survive cross-process boundaries — ``ProcessPoolExecutor``,
7+
``multiprocessing.Queue.put(exception)``, Celery task results, SA's
8+
multiprocess pool. Without overriding ``__reduce__`` the default
9+
``Exception`` pickle path (``(cls, self.args)``) silently drops every
10+
attribute set on the instance after ``Exception.__init__``.
11+
12+
Pin lossless round-trip on every subclass that takes ``raw_message=``
13+
or ``code=`` so a future regression on the ``__reduce__`` discipline
14+
fails this test.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import copy
20+
import pickle
21+
22+
import pytest
23+
24+
from dqliteclient.exceptions import (
25+
ClusterError,
26+
ClusterPolicyError,
27+
DataError,
28+
DqliteConnectionError,
29+
DqliteError,
30+
InterfaceError,
31+
OperationalError,
32+
ProtocolError,
33+
)
34+
35+
36+
@pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1))
37+
def test_dqlite_connection_error_pickle_preserves_code(protocol: int) -> None:
38+
e = DqliteConnectionError(
39+
"Node host:9001 is no longer leader: not leader",
40+
code=10250,
41+
raw_message="not leader",
42+
)
43+
restored = pickle.loads(pickle.dumps(e, protocol=protocol))
44+
assert restored.code == 10250
45+
assert restored.raw_message == "not leader"
46+
assert "Node host:9001" in str(restored)
47+
48+
49+
@pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1))
50+
def test_dqlite_connection_error_deepcopy_preserves_code(protocol: int) -> None:
51+
e = DqliteConnectionError("leader-flip", code=10506, raw_message="leadership lost")
52+
restored = copy.deepcopy(e)
53+
assert restored.code == 10506
54+
assert restored.raw_message == "leadership lost"
55+
56+
57+
def test_dqlite_connection_error_default_construction_pickle_round_trip() -> None:
58+
"""No-arg / message-only constructions still round-trip cleanly."""
59+
e = DqliteConnectionError("Connection refused")
60+
restored = pickle.loads(pickle.dumps(e))
61+
assert restored.code is None
62+
assert restored.raw_message is None
63+
assert str(restored) == "Connection refused"
64+
65+
66+
@pytest.mark.parametrize(
67+
"cls",
68+
[DqliteError, DataError, InterfaceError, ClusterError, ClusterPolicyError, ProtocolError],
69+
)
70+
def test_subclass_raw_message_round_trips_through_pickle(cls: type) -> None:
71+
e = cls("msg", raw_message="server text")
72+
restored = pickle.loads(pickle.dumps(e))
73+
assert restored.raw_message == "server text"
74+
assert str(restored) == "msg"
75+
76+
77+
@pytest.mark.parametrize(
78+
"cls",
79+
[DqliteError, DataError, InterfaceError, ClusterError, ClusterPolicyError, ProtocolError],
80+
)
81+
def test_subclass_raw_message_round_trips_through_deepcopy(cls: type) -> None:
82+
e = cls("msg", raw_message="server text")
83+
restored = copy.deepcopy(e)
84+
assert restored.raw_message == "server text"
85+
86+
87+
def test_operational_error_pickle_still_lossless_after_dqlite_error_reduce() -> None:
88+
"""Defence pin: the existing OperationalError pickle contract
89+
(cycle-22 truncation+raw_message) must not regress when the base
90+
DqliteError implements __reduce__."""
91+
payload = "y" * 5000
92+
e = OperationalError(19, payload)
93+
restored = pickle.loads(pickle.dumps(e))
94+
assert restored.raw_message == payload
95+
assert restored.code == 19
96+
assert len(restored.message) < 1200
97+
assert "truncated" in restored.message
98+
99+
100+
def test_pickle_round_trip_through_multiprocessing_queue() -> None:
101+
"""End-to-end: an exception sent through ``multiprocessing.Queue``
102+
survives with code and raw_message intact. This is the canonical
103+
cross-process surface XP2 was added to plumb."""
104+
import multiprocessing
105+
106+
ctx = multiprocessing.get_context("spawn")
107+
q = ctx.Queue()
108+
e = DqliteConnectionError(
109+
"node failover mid-handshake",
110+
code=10250,
111+
raw_message="not leader",
112+
)
113+
q.put(e)
114+
restored = q.get(timeout=5)
115+
assert isinstance(restored, DqliteConnectionError)
116+
assert restored.code == 10250
117+
assert restored.raw_message == "not leader"

0 commit comments

Comments
 (0)