Skip to content

Commit 6eea397

Browse files
committed
DRIVER-153: add tests, defensive warning and docs for SCYLLA_USE_METADATA_ID
- Add unit tests for the _METADATA_ID_FLAG path in recv_results_metadata (ROWS result with METADATA_CHANGED signal) - Add unit tests for _set_result metadata cache update on METADATA_CHANGED: update both result_metadata and result_metadata_id, no-op when id absent, warning when id present but column_metadata empty - Add unit tests for _query per-connection feature gating: skip_meta and result_metadata_id are set only when the connection negotiated SCYLLA_USE_METADATA_ID (or protocol v5) and the prepared statement carries a result_metadata_id - Add defensive log.warning in _set_result when server sends a new result_metadata_id without column_metadata (protocol violation) - Add write-order comment explaining thread-safety rationale for the two assignments to prepared_statement.result_metadata / result_metadata_id - Add SCYLLA_USE_METADATA_ID section to docs/scylla-specific.rst
1 parent f42e225 commit 6eea397

4 files changed

Lines changed: 344 additions & 1 deletion

File tree

cassandra/cluster.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4784,7 +4784,20 @@ def _set_result(self, host, connection, pool, response):
47844784
new_result_metadata_id = getattr(response, 'result_metadata_id', None)
47854785
if self.prepared_statement and new_result_metadata_id is not None:
47864786
if response.column_metadata:
4787+
# Write result_metadata before result_metadata_id intentionally:
4788+
# a concurrent reader that still sees the old metadata_id will
4789+
# ask the server for full metadata and recover safely; a reader
4790+
# that sees the new metadata_id together with the new metadata
4791+
# is immediately correct. The opposite write order could expose
4792+
# a window where a reader uses a new metadata_id with stale metadata.
47874793
self.prepared_statement.result_metadata = response.column_metadata
4794+
else:
4795+
log.warning(
4796+
"Server sent a new result_metadata_id but no column metadata "
4797+
"for prepared statement %r. The cached column metadata will not "
4798+
"be updated; only result_metadata_id is refreshed.",
4799+
getattr(self.prepared_statement, 'query_id', None)
4800+
)
47884801
self.prepared_statement.result_metadata_id = new_result_metadata_id
47894802
if getattr(self.message, 'continuous_paging_options', None):
47904803
self._handle_continuous_paging_first_response(connection, response)

docs/scylla-specific.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,43 @@ https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md
156156

157157
Details on the sending tablet information to the drivers
158158
https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md#sending-tablet-info-to-the-drivers
159+
160+
161+
Prepared Statement Metadata Caching (``SCYLLA_USE_METADATA_ID``)
162+
----------------------------------------------------------------
163+
164+
When executing prepared SELECT statements, the driver normally requests the server
165+
to skip sending full result metadata with each response (``skip_meta`` optimization),
166+
relying on the metadata cached from the initial ``PREPARE`` call. However, if the
167+
table schema changes after a statement is prepared (e.g., a column is added, removed,
168+
or its type is altered), this cached metadata becomes stale — leading to decoding
169+
errors or incorrect data.
170+
171+
ScyllaDB solves this by backporting the ``metadata_id`` mechanism from CQL native
172+
protocol v5 as a v4 extension: ``SCYLLA_USE_METADATA_ID``. When this extension is
173+
negotiated, the server includes a hash of the result metadata in the ``PREPARE``
174+
response. The driver sends this hash back with every ``EXECUTE`` request. If the
175+
schema has changed, the server sets the ``METADATA_CHANGED`` flag and returns the
176+
new metadata hash together with the updated column definitions. The driver
177+
automatically updates its cache and uses the new metadata to decode the current
178+
response — all transparently, with no application code change required.
179+
180+
**Behaviour summary:**
181+
182+
- Automatically negotiated at connection time when the ScyllaDB node supports it.
183+
- ``skip_meta`` is enabled (metadata omitted from EXECUTE responses) only when it
184+
is safe: the connection must have negotiated ``SCYLLA_USE_METADATA_ID`` (or use
185+
CQL v5), *and* the prepared statement must carry a ``result_metadata_id`` obtained
186+
from PREPARE.
187+
- When a schema change is detected by the server, the driver refreshes both the
188+
cached column metadata and the metadata hash for that prepared statement so that
189+
all subsequent executions benefit immediately.
190+
- Statements prepared before the extension was negotiated (e.g., during a rolling
191+
upgrade) retain ``result_metadata_id=None`` and fall back to always requesting
192+
full metadata, which is the safest option.
193+
194+
**Current scope:** schema-change detection is implemented for SELECT statements.
195+
UPDATE/INSERT coverage is planned in a separate effort.
196+
197+
For full protocol details see the ScyllaDB CQL extensions documentation:
198+
https://opensource.docs.scylladb.com/stable/cql/cql-extensions.html

tests/unit/test_protocol.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
_PAGING_OPTIONS_FLAG, _WITH_SERIAL_CONSISTENCY_FLAG,
2525
_PAGE_SIZE_FLAG, _WITH_PAGING_STATE_FLAG,
2626
_SKIP_METADATA_FLAG,
27-
BatchMessage, ResultMessage
27+
BatchMessage, ResultMessage,
28+
RESULT_KIND_ROWS
2829
)
2930
from cassandra.protocol_features import ProtocolFeatures
3031
from cassandra.query import BatchType
@@ -153,6 +154,44 @@ def test_recv_results_prepared_no_extension_skips_metadata_id(self):
153154
assert msg.query_id == b'ab'
154155
assert msg.result_metadata_id is None
155156

157+
def test_recv_results_metadata_changed_flag(self):
158+
"""
159+
When _METADATA_ID_FLAG (0x0008) is set in a ROWS result,
160+
recv_results_metadata must read and store the new result_metadata_id
161+
sent by the server (METADATA_CHANGED signal), and still populate
162+
column_metadata normally.
163+
"""
164+
# Wire layout for a ROWS result with METADATA_CHANGED:
165+
# flags: int(0x0008) = _METADATA_ID_FLAG
166+
# colcount: int(0)
167+
# result_metadata_id: short(4) + b'new1'
168+
# (no columns — colcount=0 — to keep the buffer minimal)
169+
buf = io.BytesIO(
170+
struct.pack('>i', 0x0008) # flags: METADATA_ID_FLAG
171+
+ struct.pack('>i', 0) # colcount = 0
172+
+ struct.pack('>H', 4) + b'new1' # result_metadata_id = b'new1'
173+
)
174+
msg = ResultMessage(kind=RESULT_KIND_ROWS)
175+
msg.recv_results_metadata(buf, user_type_map={})
176+
assert msg.result_metadata_id == b'new1'
177+
assert msg.column_metadata == []
178+
179+
def test_recv_results_metadata_no_metadata_flag_skips_metadata_id(self):
180+
"""
181+
When _NO_METADATA_FLAG (0x0004) is set, recv_results_metadata returns
182+
early and must NOT read or set result_metadata_id, even if the caller
183+
mistakenly sets _METADATA_ID_FLAG alongside it.
184+
"""
185+
# flags = _NO_METADATA_FLAG (0x0004), colcount = 0
186+
buf = io.BytesIO(
187+
struct.pack('>i', 0x0004) # flags: NO_METADATA
188+
+ struct.pack('>i', 0) # colcount = 0
189+
)
190+
msg = ResultMessage(kind=RESULT_KIND_ROWS)
191+
msg.recv_results_metadata(buf, user_type_map={})
192+
assert not hasattr(msg, 'result_metadata_id') or msg.result_metadata_id is None
193+
assert not hasattr(msg, 'column_metadata') or msg.column_metadata is None
194+
156195
def test_query_message(self):
157196
"""
158197
Test to check the appropriate calls are made

tests/unit/test_response_future.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from cassandra.connection import Connection, ConnectionException
2424
from cassandra.protocol import (ReadTimeoutErrorMessage, WriteTimeoutErrorMessage,
2525
UnavailableErrorMessage, ResultMessage, QueryMessage,
26+
ExecuteMessage,
2627
OverloadedErrorMessage, IsBootstrappingErrorMessage,
2728
PreparedQueryNotFound, PrepareMessage, ServerError,
2829
RESULT_KIND_ROWS, RESULT_KIND_SET_KEYSPACE,
@@ -723,3 +724,253 @@ def test_single_host_query_plan_exhausted_after_one_retry(self):
723724
# Instead, it should set a NoHostAvailable exception
724725
assert rf._final_exception is not None
725726
assert isinstance(rf._final_exception, NoHostAvailable)
727+
728+
# -------------------------------------------------------------------------
729+
# Helpers for SCYLLA_USE_METADATA_ID tests
730+
# -------------------------------------------------------------------------
731+
732+
def _make_rows_response(self, result_metadata_id=None, column_metadata=None):
733+
"""
734+
Return a real ResultMessage(kind=RESULT_KIND_ROWS) with all attributes
735+
that _set_result accesses pre-set, so it passes isinstance checks and
736+
doesn't trigger unexpected code paths.
737+
"""
738+
response = ResultMessage(kind=RESULT_KIND_ROWS)
739+
response.paging_state = None
740+
response.column_names = ['col']
741+
response.parsed_rows = []
742+
response.column_types = []
743+
response.column_metadata = column_metadata
744+
response.result_metadata_id = result_metadata_id
745+
response.trace_id = None
746+
response.warnings = None
747+
response.custom_payload = None
748+
return response
749+
750+
def _make_execute_response_future(self, session, connection, prepared_statement):
751+
"""
752+
Return a ResponseFuture whose message is an ExecuteMessage and which
753+
has a prepared_statement set so that _query()'s feature-gating logic
754+
is exercised.
755+
"""
756+
execute_msg = ExecuteMessage(b'qid', [], ConsistencyLevel.ONE)
757+
query = SimpleStatement("SELECT * FROM foo")
758+
rf = ResponseFuture(
759+
session, execute_msg, query, timeout=1,
760+
prepared_statement=prepared_statement,
761+
)
762+
pool = session._pools.get.return_value
763+
pool.borrow_connection.return_value = (connection, 1)
764+
return rf
765+
766+
# -------------------------------------------------------------------------
767+
# _set_result: METADATA_CHANGED update path
768+
# -------------------------------------------------------------------------
769+
770+
def test_set_result_updates_metadata_when_metadata_changed(self):
771+
"""
772+
When the EXECUTE response carries a new result_metadata_id (server
773+
detected a schema change), _set_result must update both
774+
prepared_statement.result_metadata and prepared_statement.result_metadata_id.
775+
"""
776+
session = self.make_session()
777+
pool = session._pools.get.return_value
778+
connection = Mock(spec=Connection)
779+
connection.protocol_version = 4
780+
connection.features = Mock()
781+
connection.features.use_metadata_id = False
782+
pool.borrow_connection.return_value = (connection, 1)
783+
784+
old_meta = [('ks', 'tb', 'old_col', Mock())]
785+
new_meta = [('ks', 'tb', 'new_col', Mock())]
786+
ps = Mock()
787+
ps.result_metadata = old_meta
788+
ps.result_metadata_id = b'old_id'
789+
790+
rf = self.make_response_future(session)
791+
rf.prepared_statement = ps
792+
rf.send_request()
793+
794+
response = self._make_rows_response(
795+
result_metadata_id=b'new_id',
796+
column_metadata=new_meta,
797+
)
798+
rf._set_result(None, None, None, response)
799+
800+
assert ps.result_metadata is new_meta
801+
assert ps.result_metadata_id == b'new_id'
802+
803+
def test_set_result_does_not_update_metadata_when_metadata_id_absent(self):
804+
"""
805+
When the EXECUTE response has no result_metadata_id (normal skip-meta
806+
path — server metadata unchanged), _set_result must leave the
807+
prepared_statement's cached metadata untouched.
808+
"""
809+
session = self.make_session()
810+
pool = session._pools.get.return_value
811+
connection = Mock(spec=Connection)
812+
connection.protocol_version = 4
813+
connection.features = Mock()
814+
connection.features.use_metadata_id = False
815+
pool.borrow_connection.return_value = (connection, 1)
816+
817+
old_meta = [('ks', 'tb', 'col', Mock())]
818+
ps = Mock()
819+
ps.result_metadata = old_meta
820+
ps.result_metadata_id = b'old_id'
821+
822+
rf = self.make_response_future(session)
823+
rf.prepared_statement = ps
824+
rf.send_request()
825+
826+
# result_metadata_id is None → server sent full metadata, no hash update
827+
response = self._make_rows_response(
828+
result_metadata_id=None,
829+
column_metadata=old_meta,
830+
)
831+
rf._set_result(None, None, None, response)
832+
833+
assert ps.result_metadata is old_meta
834+
assert ps.result_metadata_id == b'old_id'
835+
836+
def test_set_result_warns_when_metadata_id_but_no_column_metadata(self):
837+
"""
838+
If the server sends a new result_metadata_id but no column metadata
839+
(protocol violation), _set_result must still update result_metadata_id
840+
and emit a WARNING log so the inconsistency is visible in logs.
841+
"""
842+
session = self.make_session()
843+
pool = session._pools.get.return_value
844+
connection = Mock(spec=Connection)
845+
connection.protocol_version = 4
846+
connection.features = Mock()
847+
connection.features.use_metadata_id = False
848+
pool.borrow_connection.return_value = (connection, 1)
849+
850+
ps = Mock()
851+
ps.result_metadata = [('ks', 'tb', 'col', Mock())]
852+
ps.result_metadata_id = b'old_id'
853+
854+
rf = self.make_response_future(session)
855+
rf.prepared_statement = ps
856+
rf.send_request()
857+
858+
# column_metadata is falsy (empty list) but result_metadata_id is set
859+
response = self._make_rows_response(
860+
result_metadata_id=b'new_id',
861+
column_metadata=[],
862+
)
863+
864+
with self.assertLogs('cassandra.cluster', level='WARNING') as log_ctx:
865+
rf._set_result(None, None, None, response)
866+
867+
assert any('result_metadata_id' in msg for msg in log_ctx.output)
868+
# metadata_id is still updated even without column metadata
869+
assert ps.result_metadata_id == b'new_id'
870+
871+
# -------------------------------------------------------------------------
872+
# _query: per-connection feature gating for skip_meta / result_metadata_id
873+
# -------------------------------------------------------------------------
874+
875+
def test_query_sets_skip_meta_with_scylla_extension(self):
876+
"""
877+
When the borrowed connection has negotiated SCYLLA_USE_METADATA_ID and
878+
the prepared statement carries a result_metadata_id, _query() must set
879+
skip_meta=True and attach the metadata_id to the ExecuteMessage.
880+
"""
881+
session = self.make_basic_session()
882+
session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1']
883+
session._pools.get.return_value = self.make_pool()
884+
885+
connection = Mock(spec=Connection)
886+
connection.protocol_version = 4
887+
connection.features = Mock()
888+
connection.features.use_metadata_id = True
889+
session._pools.get.return_value.borrow_connection.return_value = (connection, 1)
890+
891+
ps = Mock()
892+
ps.result_metadata = []
893+
ps.result_metadata_id = b'meta_hash'
894+
895+
rf = self._make_execute_response_future(session, connection, ps)
896+
rf.send_request()
897+
898+
assert rf.message.skip_meta is True
899+
assert rf.message.result_metadata_id == b'meta_hash'
900+
901+
def test_query_no_skip_meta_without_extension(self):
902+
"""
903+
When the connection does NOT have SCYLLA_USE_METADATA_ID (and protocol
904+
is v4), _query() must leave skip_meta=False and result_metadata_id=None.
905+
"""
906+
session = self.make_basic_session()
907+
session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1']
908+
session._pools.get.return_value = self.make_pool()
909+
910+
connection = Mock(spec=Connection)
911+
connection.protocol_version = 4
912+
connection.features = Mock()
913+
connection.features.use_metadata_id = False
914+
session._pools.get.return_value.borrow_connection.return_value = (connection, 1)
915+
916+
ps = Mock()
917+
ps.result_metadata = []
918+
ps.result_metadata_id = b'meta_hash'
919+
920+
rf = self._make_execute_response_future(session, connection, ps)
921+
rf.send_request()
922+
923+
assert rf.message.skip_meta is False
924+
assert rf.message.result_metadata_id is None
925+
926+
def test_query_no_skip_meta_when_prepared_statement_has_no_metadata_id(self):
927+
"""
928+
Even if the connection supports SCYLLA_USE_METADATA_ID, if the prepared
929+
statement was created before the extension was active (result_metadata_id
930+
is None), _query() must NOT set skip_meta — the driver has no hash to send.
931+
"""
932+
session = self.make_basic_session()
933+
session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1']
934+
session._pools.get.return_value = self.make_pool()
935+
936+
connection = Mock(spec=Connection)
937+
connection.protocol_version = 4
938+
connection.features = Mock()
939+
connection.features.use_metadata_id = True # extension active on this connection
940+
session._pools.get.return_value.borrow_connection.return_value = (connection, 1)
941+
942+
ps = Mock()
943+
ps.result_metadata = []
944+
ps.result_metadata_id = None # statement prepared without extension
945+
946+
rf = self._make_execute_response_future(session, connection, ps)
947+
rf.send_request()
948+
949+
assert rf.message.skip_meta is False
950+
assert rf.message.result_metadata_id is None
951+
952+
def test_query_sets_skip_meta_for_protocol_v5(self):
953+
"""
954+
On a protocol-v5 connection (ProtocolVersion.uses_prepared_metadata),
955+
_query() must set skip_meta=True and send result_metadata_id even if
956+
the Scylla-specific use_metadata_id flag is False.
957+
"""
958+
session = self.make_basic_session()
959+
session.cluster._default_load_balancing_policy.make_query_plan.return_value = ['ip1']
960+
session._pools.get.return_value = self.make_pool()
961+
962+
connection = Mock(spec=Connection)
963+
connection.protocol_version = 5 # CQL v5 — uses_prepared_metadata() is True
964+
connection.features = Mock()
965+
connection.features.use_metadata_id = False # Scylla extension not needed
966+
session._pools.get.return_value.borrow_connection.return_value = (connection, 1)
967+
968+
ps = Mock()
969+
ps.result_metadata = []
970+
ps.result_metadata_id = b'v5_hash'
971+
972+
rf = self._make_execute_response_future(session, connection, ps)
973+
rf.send_request()
974+
975+
assert rf.message.skip_meta is True
976+
assert rf.message.result_metadata_id == b'v5_hash'

0 commit comments

Comments
 (0)