Skip to content

Commit fa14e80

Browse files
committed
DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension
Implement the SCYLLA_USE_METADATA_ID protocol extension, which backports the CQL v5 prepared-statement metadata-id mechanism to earlier protocol versions. When negotiated, the server includes a hash of the result metadata in the PREPARE response; the driver sends it back with every EXECUTE, allowing the server to omit result metadata from responses (skip_meta) and to report schema changes with METADATA_CHANGED plus fresh metadata, which the driver adopts automatically. protocol_features.py: parse the extension from SUPPORTED, echo it in STARTUP, expose it as ProtocolFeatures.use_metadata_id. protocol.py: ExecuteMessage carries connection-independent request data (skip_meta, result_metadata_id) fixed at construction; serialization decides the wire format from the (protocol_version, protocol_features) that Connection.send_msg supplies for the serving connection: - The metadata-id field is written iff the connection speaks CQL v5+ or negotiated the extension - always, on such connections. An empty sentinel (b'') is written when the statement has no id (prepared before the extension was active, e.g. during a rolling upgrade, or an LWT statement): the sentinel mismatch makes the server respond with METADATA_CHANGED plus the current id and metadata, so such statements acquire an id on their first execution. This also fixes a TypeError on v5 when result_metadata_id was None. - _SKIP_METADATA_FLAG is written only when the SCYLLA_USE_METADATA_ID extension is negotiated on the connection; without the metadata-id mechanism a schema change after PREPARE would leave the driver decoding rows with stale cached metadata. This is deliberately narrower than the metadata-id field above: on native CQL v5 the field is part of the frame layout, but the driver does not request skip there. Upstream never emitted _SKIP_METADATA_FLAG on any version (_write_query_params never wrote it), and enabling the skip optimization for native v5 is a separate change kept out of scope for this Scylla extension. Because messages are immutable after construction, every send path is correct without per-path setup - including the control-connection fallback - and concurrent sends of the same message (speculative executions) cannot race on per-connection state. query.py: PreparedStatement stores (result_metadata, result_metadata_id) as one tuple replaced in a single attribute assignment, read through compatibility properties and updated via update_result_metadata(). Response callbacks update statements while request threads read them; a torn pair (fresh id + stale metadata) would make the server skip sending metadata while rows are decoded against the wrong columns, with no recovery. The compatibility setters are documented as non-atomic relative to each other - update_result_metadata() is the atomic path; the setters exist only for callers assigning the old individual attributes. cluster.py: _create_response_future snapshots the pair once and requests skip_meta only when the statement has both an id and usable cached metadata (result_metadata is None for NO_METADATA/LWT statements and [] for zero-column statements; neither can nor needs to skip metadata). The same snapshot is handed to the ResponseFuture, so a skip_meta response is decoded against the metadata that pairs with the id the message sent - not a later re-read of the statement cache, which a concurrent METADATA_CHANGED could have replaced between construction and send (and which also keeps speculative sends of one message internally consistent). _set_result adopts a METADATA_CHANGED response by replacing the pair atomically; a response carrying a new id without column metadata is ignored with a warning, since adopting the id alone would create the unrecoverable stale-decode state. skip_meta additionally stays off for continuous paging (@dkropachev): Connection.process_msg hardcodes result_metadata=None for every page after the first, since it isn't threaded through the paging session - a skip_meta response has nothing to decode page 2+ against, and would crash on it. _execute_after_prepare refreshes the pair from exactly what the reprepare response carries, including the id (@dkropachev): falling back to the previously cached id when the response has none risks pairing it with metadata from a different schema version than the one that id was computed for - e.g. if the schema changed and then reverted between the two PREPAREs, the old id can become valid again for the current schema while paired locally with an intermediate version's metadata, with no server-side mismatch to catch it. Dropping it instead lets the next id-aware execute re-acquire a correctly paired id through the same b'' sentinel self-healing path a never-prepared statement uses. docs/scylla-specific.rst: documents the extension and its behaviour, worded so the skip_meta optimization reads as conditional on the extension being negotiated rather than pre-existing default behaviour. CHANGELOG.rst: add a Features entry for the extension.
1 parent bcc2d3d commit fa14e80

6 files changed

Lines changed: 247 additions & 19 deletions

File tree

CHANGELOG.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
Unreleased
22
==========
33

4+
Features
5+
--------
6+
* Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared
7+
statements skip re-sending result metadata on EXECUTE, and the driver automatically
8+
refreshes cached metadata when the server detects a schema change (DRIVER-153)
9+
410
Others
511
------
12+
* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are
13+
now read-only. They are replaced together by
14+
``PreparedStatement.update_result_metadata()``, so a request can never observe a metadata
15+
id paired with result metadata from a different schema version. Code that assigned either
16+
attribute directly must call ``update_result_metadata()`` instead.
617
* Message serialization now receives the connection's negotiated ``ProtocolFeatures``:
718
``Connection.send_msg`` passes ``protocol_features`` to the encoder, and
819
``_ProtocolHandler.encode_message`` forwards it to each message's ``send_body``.

cassandra/cluster.py

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3047,6 +3047,10 @@ def _create_response_future(self, query, parameters, trace, custom_payload,
30473047
else:
30483048
timestamp = None
30493049

3050+
# Snapshot passed to the ResponseFuture for decoding skip_meta responses; only
3051+
# bound statements carry cached result metadata (set in the BoundStatement branch).
3052+
bound_result_metadata = _NOT_SET
3053+
30503054
if isinstance(query, SimpleStatement):
30513055
query_string = query.query_string
30523056
statement_keyspace = query.keyspace if ProtocolVersion.uses_keyspace_flag(self._protocol_version) else None
@@ -3058,12 +3062,27 @@ def _create_response_future(self, query, parameters, trace, custom_payload,
30583062
continuous_paging_options, statement_keyspace)
30593063
elif isinstance(query, BoundStatement):
30603064
prepared_statement = query.prepared_statement
3065+
# Snapshot metadata and its id as one atomic pair so the message never
3066+
# carries the id of one schema version alongside a skip_meta decision
3067+
# made for another. skip_meta is requested only when there is both an
3068+
# id to validate it with and cached metadata to decode against: while
3069+
# a statement has no cached metadata there is nothing to decode a
3070+
# metadata-less response with, so the server must send it.
3071+
# Whether skip_meta and the id actually reach the wire is decided per
3072+
# connection at serialization time (see ExecuteMessage.send_body).
3073+
# Continuous paging sessions are excluded: Connection.process_msg hardcodes
3074+
# result_metadata=None for every page after the first (it isn't threaded
3075+
# through the paging session), so a skip_meta response has nothing to
3076+
# decode page 2+ against.
3077+
result_metadata, result_metadata_id = prepared_statement.result_metadata_and_id
3078+
bound_result_metadata = result_metadata
30613079
message = ExecuteMessage(
30623080
prepared_statement.query_id, query.values, cl,
30633081
serial_cl, fetch_size, paging_state, timestamp,
3064-
skip_meta=bool(prepared_statement.result_metadata),
3082+
skip_meta=bool(result_metadata) and result_metadata_id is not None
3083+
and continuous_paging_options is None,
30653084
continuous_paging_options=continuous_paging_options,
3066-
result_metadata_id=prepared_statement.result_metadata_id)
3085+
result_metadata_id=result_metadata_id)
30673086
elif isinstance(query, BatchStatement):
30683087
if self._protocol_version < 2:
30693088
raise UnsupportedOperation(
@@ -3090,7 +3109,7 @@ def _create_response_future(self, query, parameters, trace, custom_payload,
30903109
self, message, query, timeout, metrics=self._metrics,
30913110
prepared_statement=prepared_statement, retry_policy=retry_policy, row_factory=row_factory,
30923111
load_balancer=load_balancing_policy, start_time=start_time, speculative_execution_plan=spec_exec_plan,
3093-
continuous_paging_state=None, host=host)
3112+
continuous_paging_state=None, host=host, bound_result_metadata=bound_result_metadata)
30943113

30953114
def get_execution_profile(self, name):
30963115
"""
@@ -4717,12 +4736,14 @@ class ResponseFuture(object):
47174736
_host = None
47184737
_control_connection_query_attempted = False
47194738
_TABLET_ROUTING_CTYPE = None
4739+
_bound_result_metadata = []
47204740

47214741
_warned_timeout = False
47224742

47234743
def __init__(self, session, message, query, timeout, metrics=None, prepared_statement=None,
47244744
retry_policy=RetryPolicy(), row_factory=None, load_balancer=None, start_time=None,
4725-
speculative_execution_plan=None, continuous_paging_state=None, host=None):
4745+
speculative_execution_plan=None, continuous_paging_state=None, host=None,
4746+
bound_result_metadata=_NOT_SET):
47264747
self.session = session
47274748
# TODO: normalize handling of retry policy and row factory
47284749
self.row_factory = row_factory or session.row_factory
@@ -4733,6 +4754,12 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat
47334754
self._retry_policy = retry_policy
47344755
self._metrics = metrics
47354756
self.prepared_statement = prepared_statement
4757+
# Metadata snapshotted alongside the message's result_metadata_id at construction
4758+
# time (see Session._create_response_future). Decoding a skip_meta response uses
4759+
# this so the metadata decoded-with always pairs with the id the message sent,
4760+
# even if a concurrent METADATA_CHANGED replaces the prepared statement's cache in
4761+
# between. Defaults to [] for unprepared statements (no cached metadata).
4762+
self._bound_result_metadata = [] if bound_result_metadata is _NOT_SET else bound_result_metadata
47364763
self._callback_lock = Lock()
47374764
self._start_time = start_time or time.time()
47384765
self._host = host
@@ -4956,7 +4983,7 @@ def _query_control_connection(self, message=None, cb=None, connection=None, host
49564983
try:
49574984
request_id = self._borrow_control_connection(connection)
49584985
self._connection = connection
4959-
result_meta = self.prepared_statement.result_metadata if self.prepared_statement else []
4986+
result_meta = self._bound_result_metadata
49604987
if cb is None:
49614988
cb = partial(self._set_result, host, connection, None)
49624989
cb = partial(self._handle_control_connection_response, connection, cb)
@@ -5010,7 +5037,7 @@ def _query(self, host, message=None, cb=None):
50105037
else:
50115038
connection, request_id = pool.borrow_connection(timeout=2.0)
50125039
self._connection = connection
5013-
result_meta = self.prepared_statement.result_metadata if self.prepared_statement else []
5040+
result_meta = self._bound_result_metadata
50145041

50155042
if cb is None:
50165043
cb = partial(self._set_result, host, connection, pool)
@@ -5175,6 +5202,33 @@ def _set_result(self, host, connection, pool, response):
51755202
self._paging_state = response.paging_state
51765203
self._col_names = response.column_names
51775204
self._col_types = response.column_types
5205+
new_result_metadata_id = getattr(response, 'result_metadata_id', None)
5206+
if self.prepared_statement and new_result_metadata_id is not None:
5207+
if response.column_metadata:
5208+
# METADATA_CHANGED: replace metadata and its id as one
5209+
# atomic pair so a concurrent reader can never pair the
5210+
# new id with the old metadata (the server would then
5211+
# skip sending metadata and rows would be decoded
5212+
# against stale columns, with no recovery).
5213+
# (this also re-arms the anomaly warning below)
5214+
self.prepared_statement.update_result_metadata(
5215+
response.column_metadata, new_result_metadata_id)
5216+
elif not self.prepared_statement._warned_missing_column_metadata:
5217+
# Anomalous response: a new id without the metadata it
5218+
# describes. Cache neither — adopting the id alone would
5219+
# create exactly the stale-metadata/fresh-id state
5220+
# described above. Keeping the old pair means the next
5221+
# EXECUTE sends the old id, the server detects the
5222+
# mismatch, and the driver recovers with full metadata.
5223+
# Log once per statement (not per execute) while the
5224+
# anomaly persists.
5225+
self.prepared_statement._warned_missing_column_metadata = True
5226+
log.warning(
5227+
"Server sent a new result_metadata_id but no column metadata "
5228+
"for prepared statement %r. Ignoring both; the cached metadata "
5229+
"and id are left unchanged.",
5230+
getattr(self.prepared_statement, 'query_id', None)
5231+
)
51785232
if getattr(self.message, 'continuous_paging_options', None):
51795233
self._handle_continuous_paging_first_response(connection, response)
51805234
else:
@@ -5325,10 +5379,17 @@ def _execute_after_prepare(self, host, connection, pool, response):
53255379
expected=hexlify(self.prepared_statement.query_id), got=hexlify(response.query_id)
53265380
)
53275381
))
5328-
self.prepared_statement.result_metadata = response.column_metadata
5329-
new_metadata_id = response.result_metadata_id
5330-
if new_metadata_id is not None:
5331-
self.prepared_statement.result_metadata_id = new_metadata_id
5382+
# Update the metadata/id pair atomically from exactly what this
5383+
# reprepare response carries. Falling back to the previously
5384+
# cached id when this response has none would risk pairing it
5385+
# with metadata from a different schema version than the one the
5386+
# old id was computed for (e.g. schema changed and reverted
5387+
# between the two PREPAREs) - a stale-but-plausible id a later
5388+
# id-aware execute could send without the server detecting the
5389+
# mismatch. Dropping it instead triggers the same self-healing
5390+
# b'' sentinel path a never-prepared id would.
5391+
self.prepared_statement.update_result_metadata(
5392+
response.column_metadata, response.result_metadata_id)
53325393

53335394
# use self._query to re-use the same host and
53345395
# at the same time properly borrow the connection

cassandra/protocol.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,14 @@ def __init__(self, query_params, consistency_level,
558558
self.skip_meta = skip_meta
559559
self.keyspace = keyspace
560560

561+
def _should_skip_metadata(self, protocol_version, protocol_features):
562+
"""Whether to set ``_SKIP_METADATA_FLAG`` on this message.
563+
564+
The base is unconditional (the message's own ``skip_meta``); subclasses
565+
narrow it based on the connection's negotiated features.
566+
"""
567+
return self.skip_meta
568+
561569
def _write_query_params(self, f, protocol_version, protocol_features=None):
562570
write_consistency_level(f, self.consistency_level)
563571
flags = 0x00
@@ -576,6 +584,9 @@ def _write_query_params(self, f, protocol_version, protocol_features=None):
576584
if self.timestamp is not None:
577585
flags |= _PROTOCOL_TIMESTAMP_FLAG
578586

587+
if self._should_skip_metadata(protocol_version, protocol_features):
588+
flags |= _SKIP_METADATA_FLAG
589+
579590
if self.keyspace is not None:
580591
if ProtocolVersion.uses_keyspace_flag(protocol_version):
581592
flags |= _WITH_KEYSPACE_FLAG
@@ -625,6 +636,17 @@ def send_body(self, f, protocol_version, protocol_features=None):
625636
self._write_query_params(f, protocol_version, protocol_features)
626637

627638

639+
def _metadata_id_negotiated(protocol_version, protocol_features):
640+
"""Whether the result-metadata-id field is part of the frame layout.
641+
642+
It is part of the layout of EXECUTE requests and PREPARE responses whenever
643+
the connection speaks CQL v5+ natively or negotiated SCYLLA_USE_METADATA_ID,
644+
so on such connections it must always be written and always be read.
645+
"""
646+
return (ProtocolVersion.uses_prepared_metadata(protocol_version)
647+
or (protocol_features is not None and protocol_features.use_metadata_id))
648+
649+
628650
class ExecuteMessage(_QueryMessage):
629651
opcode = 0x0A
630652
name = 'EXECUTE'
@@ -638,13 +660,34 @@ def __init__(self, query_id, query_params, consistency_level,
638660
super(ExecuteMessage, self).__init__(query_params, consistency_level, serial_consistency_level, fetch_size,
639661
paging_state, timestamp, skip_meta, continuous_paging_options)
640662

663+
def _should_skip_metadata(self, protocol_version, protocol_features):
664+
"""Whether to ask the server to skip sending result metadata.
665+
666+
Only when the SCYLLA_USE_METADATA_ID extension is negotiated on this
667+
connection. Without the metadata-id mechanism a schema change after
668+
PREPARE would leave the driver decoding rows with stale cached metadata.
669+
670+
This is deliberately narrower than :func:`_metadata_id_negotiated`: on
671+
native CQL v5 the metadata-id field is part of the frame layout, but we
672+
do NOT emit ``_SKIP_METADATA_FLAG`` there. Upstream never emitted it on
673+
any version, and turning the skip optimization on for native v5 is a
674+
separate behavior change out of scope for this Scylla extension.
675+
"""
676+
return (self.skip_meta
677+
and protocol_features is not None
678+
and protocol_features.use_metadata_id)
679+
641680
def _write_query_params(self, f, protocol_version, protocol_features=None):
642681
super(ExecuteMessage, self)._write_query_params(f, protocol_version, protocol_features)
643682

644683
def send_body(self, f, protocol_version, protocol_features=None):
645684
write_string(f, self.query_id)
646-
if ProtocolVersion.uses_prepared_metadata(protocol_version):
647-
write_string(f, self.result_metadata_id)
685+
if _metadata_id_negotiated(protocol_version, protocol_features):
686+
# An empty id is written when the statement has no cached metadata id
687+
# (prepared before the extension was negotiated, e.g. in a mixed
688+
# cluster): the server treats the mismatch as METADATA_CHANGED and
689+
# responds with full metadata plus the current id.
690+
write_string(f, self.result_metadata_id if self.result_metadata_id is not None else b'')
648691
self._write_query_params(f, protocol_version, protocol_features)
649692

650693

@@ -748,7 +791,7 @@ def decode_row(row):
748791

749792
def recv_results_prepared(self, f, protocol_version, protocol_features, user_type_map):
750793
self.query_id = read_binary_string(f)
751-
if ProtocolVersion.uses_prepared_metadata(protocol_version):
794+
if _metadata_id_negotiated(protocol_version, protocol_features):
752795
self.result_metadata_id = read_binary_string(f)
753796
else:
754797
self.result_metadata_id = None

cassandra/protocol_features.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,37 @@
1010
LWT_OPTIMIZATION_META_BIT_MASK = "LWT_OPTIMIZATION_META_BIT_MASK"
1111
RATE_LIMIT_ERROR_EXTENSION = "SCYLLA_RATE_LIMIT_ERROR"
1212
TABLETS_ROUTING_V1 = "TABLETS_ROUTING_V1"
13+
USE_METADATA_ID = "SCYLLA_USE_METADATA_ID"
1314

1415
class ProtocolFeatures(object):
1516
rate_limit_error = None
1617
shard_id = 0
1718
sharding_info = None
1819
tablets_routing_v1 = False
1920
lwt_info = None
21+
use_metadata_id = False
2022

2123
# Keyword-only so that independently developed protocol extensions can add
2224
# new fields without conflicting over positional-argument order.
23-
def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None):
25+
def __init__(self, *, rate_limit_error=None, shard_id=0, sharding_info=None, tablets_routing_v1=False, lwt_info=None,
26+
use_metadata_id=False):
2427
self.rate_limit_error = rate_limit_error
2528
self.shard_id = shard_id
2629
self.sharding_info = sharding_info
2730
self.tablets_routing_v1 = tablets_routing_v1
2831
self.lwt_info = lwt_info
32+
self.use_metadata_id = use_metadata_id
2933

3034
@staticmethod
3135
def parse_from_supported(supported):
3236
rate_limit_error = ProtocolFeatures.maybe_parse_rate_limit_error(supported)
3337
shard_id, sharding_info = ProtocolFeatures.parse_sharding_info(supported)
3438
tablets_routing_v1 = ProtocolFeatures.parse_tablets_info(supported)
3539
lwt_info = ProtocolFeatures.parse_lwt_info(supported)
40+
use_metadata_id = ProtocolFeatures.parse_use_metadata_id(supported)
3641
return ProtocolFeatures(rate_limit_error=rate_limit_error, shard_id=shard_id, sharding_info=sharding_info,
37-
tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info)
42+
tablets_routing_v1=tablets_routing_v1, lwt_info=lwt_info,
43+
use_metadata_id=use_metadata_id)
3844

3945
@staticmethod
4046
def maybe_parse_rate_limit_error(supported):
@@ -60,6 +66,8 @@ def add_startup_options(self, options):
6066
options[TABLETS_ROUTING_V1] = ""
6167
if self.lwt_info is not None:
6268
options[LWT_ADD_METADATA_MARK] = str(self.lwt_info.lwt_meta_bit_mask)
69+
if self.use_metadata_id:
70+
options[USE_METADATA_ID] = ""
6371

6472
@staticmethod
6573
def parse_sharding_info(options):
@@ -84,6 +92,11 @@ def parse_sharding_info(options):
8492
def parse_tablets_info(options):
8593
return TABLETS_ROUTING_V1 in options
8694

95+
@staticmethod
96+
def parse_use_metadata_id(options):
97+
"""Return True if the ``SCYLLA_USE_METADATA_ID`` extension is advertised in ``options``."""
98+
return USE_METADATA_ID in options
99+
87100
@staticmethod
88101
def parse_lwt_info(options):
89102
value_list = options.get(LWT_ADD_METADATA_MARK, [None])

0 commit comments

Comments
 (0)