Skip to content

Commit ea1ff33

Browse files
nikagradawmd
authored andcommitted
protocol: pass negotiated ProtocolFeatures to message serialization
Connection.send_msg now passes the connection's negotiated ProtocolFeatures to the encoder; _ProtocolHandler.encode_message accepts it as a new required protocol_features argument (passed by keyword from send_msg) and forwards it to every message's send_body, which gains the same parameter. Messages carry connection-independent request data; send_body decides the wire format from (protocol_version, protocol_features), so fields belonging to a negotiated protocol extension are emitted exactly on the connections that negotiated it — on every send path, including the control-connection fallback, and without mutating shared message objects per attempt. This is pure plumbing: no message consumes the parameter yet, so no bytes on the wire change. It is groundwork for the SCYLLA_USE_METADATA_ID (#770) and TABLETS_ROUTING_V2 (#913) extensions, which must serialize extension fields based on what the serving connection negotiated. The encode side becomes symmetric with decode_message, which already receives protocol_features. This changes the contracted signature of encode_message: custom protocol handlers overriding it must accept the protocol_features keyword argument. The argument is deliberately required, with no default and no fallback for old-style encoders: extensions are negotiated per connection at STARTUP before the per-request handler is known, so an encoder unaware of protocol_features could silently omit fields a negotiated extension requires; omitting it fails fast with TypeError instead. Tests: send_msg hands the connection's features to the encoder; encode_message forwards them into send_body (plain and compressed paths) and raises TypeError when the argument is omitted; a byte-identity suite pins frames for representative messages (v3/v4/v5) to the exact bytes produced before this change, both without features and with all-default features. Co-authored-by: Dawid Mędrek <dawid.medrek@scylladb.com>
1 parent c7f5c98 commit ea1ff33

4 files changed

Lines changed: 167 additions & 21 deletions

File tree

cassandra/connection.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1222,7 +1222,8 @@ def send_msg(self, msg, request_id, cb, encoder=ProtocolHandler.encode_message,
12221222
# this allows us to inject custom functions per request to encode, decode messages
12231223
self._requests[request_id] = (cb, decoder, result_metadata)
12241224
msg = encoder(msg, request_id, self.protocol_version, compressor=self.compressor,
1225-
allow_beta_protocol_version=self.allow_beta_protocol_version)
1225+
allow_beta_protocol_version=self.allow_beta_protocol_version,
1226+
protocol_features=self.features)
12261227

12271228
if self._is_checksumming_enabled:
12281229
buffer = io.BytesIO()

cassandra/protocol.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ def __init__(self, cqlversion, options):
424424
self.cqlversion = cqlversion
425425
self.options = options
426426

427-
def send_body(self, f, protocol_version):
427+
def send_body(self, f, protocol_version, protocol_features=None):
428428
optmap = self.options.copy()
429429
optmap['CQL_VERSION'] = self.cqlversion
430430
write_stringmap(f, optmap)
@@ -459,7 +459,7 @@ class CredentialsMessage(_MessageType):
459459
def __init__(self, creds):
460460
self.creds = creds
461461

462-
def send_body(self, f, protocol_version):
462+
def send_body(self, f, protocol_version, protocol_features=None):
463463
if protocol_version > 1:
464464
raise UnsupportedOperation(
465465
"Credentials-based authentication is not supported with "
@@ -490,7 +490,7 @@ class AuthResponseMessage(_MessageType):
490490
def __init__(self, response):
491491
self.response = response
492492

493-
def send_body(self, f, protocol_version):
493+
def send_body(self, f, protocol_version, protocol_features=None):
494494
write_longstring(f, self.response)
495495

496496

@@ -510,7 +510,7 @@ class OptionsMessage(_MessageType):
510510
opcode = 0x05
511511
name = 'OPTIONS'
512512

513-
def send_body(self, f, protocol_version):
513+
def send_body(self, f, protocol_version, protocol_features=None):
514514
pass
515515

516516

@@ -558,7 +558,7 @@ def __init__(self, query_params, consistency_level,
558558
self.skip_meta = skip_meta
559559
self.keyspace = keyspace
560560

561-
def _write_query_params(self, f, protocol_version):
561+
def _write_query_params(self, f, protocol_version, protocol_features=None):
562562
write_consistency_level(f, self.consistency_level)
563563
flags = 0x00
564564
if self.query_params is not None:
@@ -620,9 +620,9 @@ def __init__(self, query, consistency_level, serial_consistency_level=None,
620620
super(QueryMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size,
621621
paging_state, timestamp, False, continuous_paging_options, keyspace)
622622

623-
def send_body(self, f, protocol_version):
623+
def send_body(self, f, protocol_version, protocol_features=None):
624624
write_longstring(f, self.query)
625-
self._write_query_params(f, protocol_version)
625+
self._write_query_params(f, protocol_version, protocol_features)
626626

627627

628628
class ExecuteMessage(_QueryMessage):
@@ -638,14 +638,14 @@ def __init__(self, query_id, query_params, consistency_level,
638638
super(ExecuteMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size,
639639
paging_state, timestamp, skip_meta, continuous_paging_options)
640640

641-
def _write_query_params(self, f, protocol_version):
642-
super(ExecuteMessage, self)._write_query_params(f, protocol_version)
641+
def _write_query_params(self, f, protocol_version, protocol_features=None):
642+
super(ExecuteMessage, self)._write_query_params(f, protocol_version, protocol_features)
643643

644-
def send_body(self, f, protocol_version):
644+
def send_body(self, f, protocol_version, protocol_features=None):
645645
write_string(f, self.query_id)
646646
if ProtocolVersion.uses_prepared_metadata(protocol_version):
647647
write_string(f, self.result_metadata_id)
648-
self._write_query_params(f, protocol_version)
648+
self._write_query_params(f, protocol_version, protocol_features)
649649

650650

651651
CUSTOM_TYPE = object()
@@ -870,7 +870,7 @@ def __init__(self, query, keyspace=None):
870870
self.query = query
871871
self.keyspace = keyspace
872872

873-
def send_body(self, f, protocol_version):
873+
def send_body(self, f, protocol_version, protocol_features=None):
874874
write_longstring(f, self.query)
875875

876876
flags = 0x00
@@ -914,7 +914,7 @@ def __init__(self, batch_type, queries, consistency_level,
914914
self.timestamp = timestamp
915915
self.keyspace = keyspace
916916

917-
def send_body(self, f, protocol_version):
917+
def send_body(self, f, protocol_version, protocol_features=None):
918918
write_byte(f, self.batch_type.value)
919919
write_short(f, len(self.queries))
920920
for prepared, string_or_query_id, params in self.queries:
@@ -972,7 +972,7 @@ class RegisterMessage(_MessageType):
972972
def __init__(self, event_list):
973973
self.event_list = event_list
974974

975-
def send_body(self, f, protocol_version):
975+
def send_body(self, f, protocol_version, protocol_features=None):
976976
write_stringlist(f, self.event_list)
977977

978978

@@ -1046,7 +1046,7 @@ def __init__(self, op_type, op_id, next_pages=0):
10461046
self.op_id = op_id
10471047
self.next_pages = next_pages
10481048

1049-
def send_body(self, f, protocol_version):
1049+
def send_body(self, f, protocol_version, protocol_features=None):
10501050
write_int(f, self.op_type)
10511051
write_int(f, self.op_id)
10521052
if self.op_type == ReviseRequestMessage.RevisionType.PAGING_BACKPRESSURE:
@@ -1079,14 +1079,20 @@ class _ProtocolHandler(object):
10791079
"""Instance of :class:`cassandra.policies.ColumnEncryptionPolicy` in use by this handler"""
10801080

10811081
@classmethod
1082-
def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta_protocol_version):
1082+
def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta_protocol_version,
1083+
protocol_features):
10831084
"""
10841085
Encodes a message using the specified frame parameters, and compressor
10851086
10861087
:param msg: the message, typically of cassandra.protocol._MessageType, generated by the driver
10871088
:param stream_id: protocol stream id for the frame header
10881089
:param protocol_version: version for the frame header, and used encoding contents
10891090
:param compressor: optional compression function to be used on the body
1091+
:param protocol_features: :class:`~cassandra.protocol_features.ProtocolFeatures` negotiated on the connection
1092+
this message is sent over, forwarded to ``send_body``. Messages carry
1093+
connection-independent request data; ``send_body`` decides the wire format from
1094+
``(protocol_version, protocol_features)``, so fields belonging to a negotiated
1095+
protocol extension are emitted exactly on the connections that negotiated it.
10901096
"""
10911097
flags = 0
10921098
if msg.custom_payload:
@@ -1108,7 +1114,7 @@ def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta
11081114
body = io.BytesIO()
11091115
if msg.custom_payload:
11101116
write_bytesmap(body, msg.custom_payload)
1111-
msg.send_body(body, protocol_version)
1117+
msg.send_body(body, protocol_version, protocol_features)
11121118
body = body.getvalue()
11131119

11141120
if len(body) > 0:
@@ -1120,7 +1126,7 @@ def encode_message(cls, msg, stream_id, protocol_version, compressor, allow_beta
11201126
else:
11211127
if msg.custom_payload:
11221128
write_bytesmap(buff, msg.custom_payload)
1123-
msg.send_body(buff, protocol_version)
1129+
msg.send_body(buff, protocol_version, protocol_features)
11241130

11251131
length = buff.tell() - 9
11261132

tests/unit/test_connection.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,26 @@ def test_set_keyspace_async_escapes_quotes(self):
291291
assert query_msg.query == 'USE "my""ks"', (
292292
"Double quotes in keyspace name must be escaped as double-double quotes")
293293

294+
def test_send_msg_passes_negotiated_features_to_encoder(self):
295+
"""
296+
send_msg must hand the connection's negotiated ProtocolFeatures to the
297+
encoder, so message serialization can emit fields belonging to protocol
298+
extensions exactly on the connections that negotiated them.
299+
"""
300+
c = self.make_connection()
301+
c.push = Mock()
302+
captured = {}
303+
304+
def encoder(msg, stream_id, protocol_version, compressor, allow_beta_protocol_version,
305+
protocol_features=None):
306+
captured['protocol_features'] = protocol_features
307+
return b'encoded-frame'
308+
309+
c.send_msg(Mock(), 1, cb=Mock(), encoder=encoder, decoder=Mock())
310+
311+
assert captured['protocol_features'] is c.features
312+
c.push.assert_called_once_with(b'encoded-frame')
313+
294314
def test_set_connection_class(self):
295315
cluster = Cluster(connection_class='test')
296316
assert 'test' == cluster.connection_class

tests/unit/test_protocol.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
from unittest.mock import Mock
1818

19-
from cassandra import ProtocolVersion, UnsupportedOperation
19+
from cassandra import ConsistencyLevel, ProtocolVersion, UnsupportedOperation
2020
from cassandra.protocol import (
2121
PrepareMessage, QueryMessage, ExecuteMessage, UnsupportedOperation,
22-
BatchMessage
22+
BatchMessage, StartupMessage, OptionsMessage, RegisterMessage,
23+
AuthResponseMessage, ProtocolHandler, _MessageType
2324
)
25+
from cassandra.protocol_features import ProtocolFeatures
2426
from cassandra.query import BatchType
2527
import pytest
2628

@@ -185,3 +187,120 @@ def test_batch_message_with_keyspace(self):
185187
(b'\x00\x03',),
186188
(b'\x00\x00\x00\x80',), (b'\x00\x02',), (b'ks',))
187189
)
190+
191+
192+
class ProtocolFeaturesPlumbingTest(unittest.TestCase):
193+
"""
194+
The negotiated ProtocolFeatures must flow from encode_message into each
195+
message's send_body, so serialization can emit fields belonging to
196+
protocol extensions exactly on the connections that negotiated them.
197+
"""
198+
199+
class CapturingMessage(_MessageType):
200+
opcode = 0x00
201+
name = 'CAPTURE'
202+
203+
def __init__(self):
204+
self.seen_features = []
205+
206+
def send_body(self, f, protocol_version, protocol_features=None):
207+
self.seen_features.append(protocol_features)
208+
209+
def test_encode_message_forwards_protocol_features_to_send_body(self):
210+
features = ProtocolFeatures()
211+
msg = self.CapturingMessage()
212+
ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=None,
213+
allow_beta_protocol_version=False, protocol_features=features)
214+
assert msg.seen_features == [features]
215+
assert msg.seen_features[0] is features
216+
217+
def test_encode_message_forwards_protocol_features_when_compressing(self):
218+
features = ProtocolFeatures()
219+
msg = self.CapturingMessage()
220+
ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=lambda body: body,
221+
allow_beta_protocol_version=False, protocol_features=features)
222+
assert msg.seen_features[0] is features
223+
224+
def test_encode_message_fails_without_protocol_features(self):
225+
msg = self.CapturingMessage()
226+
227+
with pytest.raises(TypeError, match='positional argument'):
228+
ProtocolHandler.encode_message(msg, stream_id=0, protocol_version=4, compressor=None,
229+
allow_beta_protocol_version=False)
230+
231+
232+
class FrameByteIdentityTest(unittest.TestCase):
233+
"""
234+
Threading ProtocolFeatures into serialization is pure plumbing: with no
235+
extension consuming it (and for all-default features), every frame must be
236+
byte-identical to what the driver produced before the parameter existed.
237+
The expected frames below were captured from the pre-change encoder.
238+
"""
239+
240+
EXPECTED_FRAMES = {
241+
'startup_v4': '0400000701000000160001000b43514c5f56455253494f4e0005332e342e35',
242+
'options_v4': '040000070500000000',
243+
'register_v4': '040000070b000000220002000f544f504f4c4f47595f4348414e4745000d5354415455535f4348414e4745',
244+
'auth_response_v4': '040000070f0000000e0000000a00757365720070617373',
245+
'prepare_v4': '0400000709000000220000001e53454c454354202a2046524f4d206b732e74205748455245206b203d203f',
246+
'prepare_v5_keyspace': '0500000709000000270000001b53454c454354202a2046524f4d2074205748455245206b203d203f0000000100026b73',
247+
'query_v3': '0300000707000000270000001253454c454354202a2046524f4d206b732e74000434000013880008000462d53c8abac0',
248+
'execute_v3': '030000070a00000033000412345678000a2d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1',
249+
'batch_v3': '030000070d00000043000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001200000000006a11e3d',
250+
'query_v4': '0400000707000000270000001253454c454354202a2046524f4d206b732e74000434000013880008000462d53c8abac0',
251+
'execute_v4': '040000070a00000033000412345678000a2d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1',
252+
'batch_v4': '040000070d00000043000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001200000000006a11e3d',
253+
'query_v5': '05000007070000002a0000001253454c454354202a2046524f4d206b732e74000400000034000013880008000462d53c8abac0',
254+
'execute_v5': '050000070a0000003c0004123456780004aabbccdd000a0000002d000200000002000100000003616263000000640000000b504147494e475354415445000000003ade68b1',
255+
'batch_v5': '050000070d00000046000002000000001f494e5345525420494e544f206b732e7420286b292056414c5545532028312900000100041234567800010000000200020001000000200000000006a11e3d',
256+
}
257+
258+
@staticmethod
259+
def _make_cases():
260+
cases = [
261+
('startup_v4', StartupMessage(cqlversion="3.4.5", options={}), 4),
262+
('options_v4', OptionsMessage(), 4),
263+
('register_v4', RegisterMessage(["TOPOLOGY_CHANGE", "STATUS_CHANGE"]), 4),
264+
('auth_response_v4', AuthResponseMessage(b"\x00user\x00pass"), 4),
265+
('prepare_v4', PrepareMessage("SELECT * FROM ks.t WHERE k = ?"), 4),
266+
('prepare_v5_keyspace', PrepareMessage("SELECT * FROM t WHERE k = ?", keyspace="ks"), 5),
267+
]
268+
for pv in (3, 4, 5):
269+
cases.append((
270+
'query_v%d' % pv,
271+
QueryMessage("SELECT * FROM ks.t", ConsistencyLevel.QUORUM,
272+
serial_consistency_level=ConsistencyLevel.SERIAL,
273+
fetch_size=5000, timestamp=1234567890123456),
274+
pv,
275+
))
276+
cases.append((
277+
'execute_v%d' % pv,
278+
ExecuteMessage(b"\x12\x34\x56\x78", [b"\x00\x01", b"abc"],
279+
ConsistencyLevel.LOCAL_ONE, fetch_size=100,
280+
paging_state=b"PAGINGSTATE",
281+
result_metadata_id=b"\xaa\xbb\xcc\xdd" if pv >= 5 else None,
282+
timestamp=987654321),
283+
pv,
284+
))
285+
cases.append((
286+
'batch_v%d' % pv,
287+
BatchMessage(BatchType.LOGGED,
288+
[(False, "INSERT INTO ks.t (k) VALUES (1)", []),
289+
(True, b"\x12\x34\x56\x78", [b"\x00\x02"])],
290+
ConsistencyLevel.ONE, timestamp=111222333),
291+
pv,
292+
))
293+
return cases
294+
295+
def _assert_frames(self, protocol_features):
296+
for name, msg, pv in self._make_cases():
297+
frame = ProtocolHandler.encode_message(
298+
msg, stream_id=7, protocol_version=pv, compressor=None,
299+
allow_beta_protocol_version=False, protocol_features=protocol_features)
300+
assert frame.hex() == self.EXPECTED_FRAMES[name], name
301+
302+
def test_frames_without_features(self):
303+
self._assert_frames(None)
304+
305+
def test_frames_with_default_features(self):
306+
self._assert_frames(ProtocolFeatures())

0 commit comments

Comments
 (0)