Skip to content

DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension#770

Draft
nikagra wants to merge 2 commits into
scylladb:masterfrom
nikagra:driver-153-scylla-use-metadata-id
Draft

DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension#770
nikagra wants to merge 2 commits into
scylladb:masterfrom
nikagra:driver-153-scylla-use-metadata-id

Conversation

@nikagra

@nikagra nikagra commented Mar 26, 2026

Copy link
Copy Markdown

Summary

Implements the SCYLLA_USE_METADATA_ID Scylla CQL protocol extension (DRIVER-153), which backports the prepared-statement metadata-ID mechanism from CQL v5 to earlier protocol versions.

When the extension is negotiated:

  • The server includes a result metadata hash in the PREPARE response
  • The driver sends that hash back with every EXECUTE request, allowing the server to skip sending full result metadata on every response (skip_meta=True)
  • If the result schema has changed, the server sets the METADATA_CHANGED flag and includes the new metadata ID + new column metadata in the response — the driver picks this up and updates its cached metadata automatically

Rebased on #934 (merged groundwork that threads each connection's negotiated ProtocolFeatures into message serialization) and reworked to resolve the review discussion below.

Design change from the previous version of this PR

Previously, ExecuteMessage carried a use_metadata_id flag and ResponseFuture._query() mutated skip_meta/result_metadata_id on the shared message after borrowing a connection. Per @Lorak-mmk's review (#770 (comment) and the surrounding thread), that design is gone:

  • ExecuteMessage is immutable once constructed — skip_meta and result_metadata_id are set from the prepared statement's cached metadata at construction time (in _create_response_future), same as before this extension existed.
  • send_body decides what actually reaches the wire from (protocol_version, protocol_features) — the connection's negotiated features, supplied by protocol: pass negotiated ProtocolFeatures to message serialization #934's plumbing. The metadata-id field is written whenever the serving connection speaks CQL v5+ or negotiated the extension — always, not conditionally — with an empty b'' sentinel when the statement has no id yet (mixed cluster / rolling upgrade). _SKIP_METADATA_FLAG is only set when that same condition holds, so a statement executed against a connection without the extension never asks the server to skip metadata it has no way to recover.

This fixes two problems the old design had:

  • Speculative-execution race: the old _query() mutated the one shared ExecuteMessage per connection borrowed; with speculative retries or a mixed-feature cluster, two connections could interleave mutation and encoding of the same message. Immutability removes the race entirely.
  • Control-connection fallback gap (@dkropachev, DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment)): _query_control_connection never went through the mutation block and would omit the id field on an extension-enabled control connection. Every send path now serializes correctly with zero extra code, because the decision is made in send_body from the connection actually being used.

Other review threads resolved

Changes

cassandra/protocol_features.py

  • Add USE_METADATA_ID = "SCYLLA_USE_METADATA_ID" constant and use_metadata_id field to ProtocolFeatures
  • Parse the extension from the SUPPORTED frame; include it in STARTUP when present

cassandra/protocol.py

  • Bug fix: _SKIP_METADATA_FLAG is now actually written to the wire — it was stored on _QueryMessage but never sent (effectively dead code upstream)
  • recv_results_prepared: read result_metadata_id for the Scylla extension (pre-v5) in addition to standard CQL v5+
  • ExecuteMessage.send_body: decides both the metadata-id field and the skip-metadata flag from (protocol_version, protocol_features) at serialization time; writes the b'' sentinel when the statement has no id

cassandra/query.py

  • PreparedStatement.result_metadata/result_metadata_id become properties over a single (result_metadata, result_metadata_id) tuple; add update_result_metadata() for atomic pair replacement

cassandra/cluster.py

  • _create_response_future: snapshot the metadata pair once, construct ExecuteMessage immutably with skip_meta/result_metadata_id set from that snapshot
  • _set_result: on METADATA_CHANGED, replace the cached pair atomically; ignore (with a warning) a response that carries a new id without column metadata
  • _execute_after_prepare: same atomic update on reprepare, keeping the previous id when the response carries none

docs/scylla-specific.rst

  • Document the extension and its behaviour; corrected rolling-upgrade description and spec reference link

Test plan

  • Unit tests across test_protocol_features.py, test_protocol.py, test_query.py, test_response_future.py covering: feature negotiation, STARTUP options, wire-format assertions for the metadata-id field and skip-metadata flag (present/suppressed, sentinel for None, v4/v5), PREPARE response decoding with/without the extension, atomic metadata-pair replacement (constructor, update_result_metadata, compatibility setters), _create_response_future construction gating (id present/absent, LWT/NO_METADATA, zero-column), a regression proving _query sends the message unmutated (the old speculative-execution race), _set_result METADATA_CHANGED and the anomalous-response warning path (nothing cached), and _execute_after_prepare reprepare handling
  • Full unit test suite passes (698 passed, 103 skipped)
  • Cython build (build_ext --inplace) compiles the reworked signatures; tests pass against the compiled modules
  • Integration tests against a Scylla node with the extension: verify that schema changes after PREPARE are detected and metadata is updated without re-preparation, and that the empty-id sentinel is accepted as a mismatch (the spec doc doesn't say explicitly — this needs live-server confirmation)

Pre-review checklist

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I have provided docstrings for the public items that I want to introduce.
  • I have adjusted the documentation in ./docs/.
  • I added appropriate Fixes: annotations to PR description.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements negotiation and support for Scylla’s SCYLLA_USE_METADATA_ID protocol extension to enable metadata-id based skip_meta behavior (backporting CQL v5 prepared-statement metadata-id semantics to earlier protocol versions).

Changes:

  • Adds SCYLLA_USE_METADATA_ID parsing from SUPPORTED and includes it in STARTUP when negotiated.
  • Extends protocol encode/decode to read/write result_metadata_id for PREPARE/EXECUTE on pre-v5 when the extension is used, and fixes on-wire encoding of _SKIP_METADATA_FLAG.
  • Updates execution/result handling to conditionally use skip_meta and to refresh cached prepared metadata when the server reports metadata changes.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
cassandra/protocol_features.py Adds the SCYLLA_USE_METADATA_ID feature flag and includes it in negotiated STARTUP options.
cassandra/protocol.py Writes _SKIP_METADATA_FLAG in query params; adds pre-v5 extension handling for result_metadata_id in PREPARE/EXECUTE.
cassandra/cluster.py Adjusts when skip_meta is enabled and updates cached prepared metadata/id on METADATA_CHANGED responses.
tests/unit/test_protocol_features.py Adds unit tests for feature parsing and STARTUP option inclusion.
tests/unit/test_protocol.py Adds unit tests for skip-meta flag encoding and metadata-id handling in pre-v5 PREPARE/EXECUTE paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/protocol.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cassandra/cluster.py Outdated
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from ade35d8 to f42e225 Compare March 27, 2026 12:32
@mykaul

mykaul commented Mar 29, 2026

Copy link
Copy Markdown

I'm not sure where, but we should document this - with reference mainly to the scylladb docs about this feature.

@nikagra

nikagra commented Mar 30, 2026

Copy link
Copy Markdown
Author

@mykaul Documentation I'm aware of is MetadataId extension in CQLv4 Requirement Document

@nikagra
nikagra requested a review from sylwiaszunejko April 9, 2026 11:12
@nikagra
nikagra marked this pull request as ready for review April 9, 2026 21:30

@dkropachev dkropachev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocking correctness issue below: skip_meta is being enabled for prepared statements that can still have empty/absent cached result metadata.

Comment thread cassandra/cluster.py Outdated
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 6eea397 to a86fd53 Compare April 15, 2026 09:09
@nikagra
nikagra requested a review from dkropachev April 15, 2026 09:12
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 7ba5835 to a86fd53 Compare April 15, 2026 11:12
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from a86fd53 to 8880f03 Compare April 22, 2026 12:34
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 170fd31 to 5fe1902 Compare May 14, 2026 14:45
@nikagra
nikagra requested a review from Lorak-mmk May 14, 2026 14:45
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from fcd3eba to 5fe1902 Compare May 15, 2026 09:22
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 5fe1902 to 251b1a8 Compare May 28, 2026 20:32
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds SCYLLA_USE_METADATA_ID negotiation, protocol metadata-id serialization and parsing, atomic prepared-statement metadata caching, and cache refresh handling for changed schemas. Execute messages conditionally send skip_meta and result_metadata_id; responses update cached metadata when column metadata is returned. Documentation and unit tests cover protocol versions, feature negotiation, reprepare behavior, cache consistency, and warning paths.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResponseFuture
  participant Server
  participant PreparedStatement
  Client->>ResponseFuture: execute prepared statement
  ResponseFuture->>PreparedStatement: snapshot metadata and id
  ResponseFuture->>Server: send ExecuteMessage
  Server-->>ResponseFuture: return result metadata id and optional columns
  ResponseFuture->>PreparedStatement: update cached metadata pair
  ResponseFuture-->>Client: return rows
Loading

Possibly related PRs

Suggested labels: area/Driver_-_python-driver

Suggested reviewers: dkropachev, sylwiaszunejko

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly states the main change: negotiating and implementing the SCYLLA_USE_METADATA_ID extension.
Description check ✅ Passed The description covers summary, changes, tests, and checklist items, with only non-critical items like Fixes annotations left incomplete.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch 2 times, most recently from de8d3fc to bfc9760 Compare June 2, 2026 20:42
@nikagra

nikagra commented Jun 2, 2026

Copy link
Copy Markdown
Author

🤖: All review issues (1–9) have been addressed and the branch has been squashed to the required 2-commit shape. Requesting re-review from @Lorak-mmk and @dkropachev.

What changed since last review:

Production commit (8accdb5a) — no functional changes, two cleanups squashed in:

  • Issue 8 (docs scope claim): corrected docs/scylla-specific.rst to say the extension applies to EXECUTE requests, not PREPARE
  • Issue 9 (GIL comment): added a note in _set_result explaining why the metadata update write ordering is safe under the GIL

Test commit (bfc97602) — all 6 fix/addition commits squashed in, commit message fully replaced to enumerate every test:

  • Issue 1: test_query_no_skip_meta_without_extension fixture corrected (result_metadata=[] was falsy, defeating the assertion)
  • Issue 2: test_execute_after_prepare_updates_result_metadata_id and test_execute_after_prepare_no_metadata_id_in_response added to cover the _execute_after_prepare reprepare path
  • Issue 3: test_recv_results_metadata_no_metadata_flag_skips_metadata_id tightened — now asserts not hasattr(result, 'result_metadata_id') rather than is None, and checks column_metadata not result_metadata
  • Issue 4: test_recv_results_prepared_v5_reads_metadata_id added — covers the v5 native uses_prepared_metadata() decode path
  • Issue 5: test_execute_message_v5_skip_meta_sets_flag added — confirms _SKIP_METADATA_FLAG is correctly written into the 4-byte v5 flags word (this flag was dead code in upstream before this PR)
  • Issue 6: test_repeat_orig_query_after_succesful_reprepare fixed — result_metadata_id value changed from str to bytes; assertion that value is stored on prepared_statement added
  • Issue 7: test_set_result_warns_when_metadata_id_but_column_metadata_is_none added — covers the column_metadata=None (absent) variant of the METADATA_CHANGED warning path

CI on the 8-commit pre-squash branch: 18/19 passed; test libev (3.14t) was cancelled after a 6-hour runner stall (infrastructure timeout — test asyncio (3.14t) on the same commit passed cleanly).

@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from bfc9760 to d3300e2 Compare June 19, 2026 08:54
Comment thread cassandra/protocol.py Outdated
Comment on lines +628 to +636
paging_state=None, timestamp=None, skip_meta=False,
continuous_paging_options=None, result_metadata_id=None):
continuous_paging_options=None, result_metadata_id=None,
use_metadata_id=False):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here ExecuteMessage got a new parameter, use_metadata_id, in addition to existing result_metadata_id

Comment thread cassandra/cluster.py
Comment on lines 3060 to 3066
elif isinstance(query, BoundStatement):
prepared_statement = query.prepared_statement
message = ExecuteMessage(
prepared_statement.query_id, query.values, cl,
serial_cl, fetch_size, paging_state, timestamp,
skip_meta=bool(prepared_statement.result_metadata),
continuous_paging_options=continuous_paging_options,
result_metadata_id=prepared_statement.result_metadata_id)
continuous_paging_options=continuous_paging_options)
elif isinstance(query, BatchStatement):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But here you remove result_metadata_id and skip_meta from this call to ExecuteMessage. Why?

Comment thread cassandra/cluster.py Outdated
Comment on lines +5009 to +5019
if self.prepared_statement and isinstance(message, ExecuteMessage):
has_result_metadata_id = self.prepared_statement.result_metadata_id is not None
has_result_metadata = bool(self.prepared_statement.result_metadata)
can_skip_meta = has_result_metadata_id and has_result_metadata and (
ProtocolVersion.uses_prepared_metadata(connection.protocol_version)
or connection.features.use_metadata_id
)
message.skip_meta = can_skip_meta
message.result_metadata_id = self.prepared_statement.result_metadata_id if can_skip_meta else None
message.use_metadata_id = connection.features.use_metadata_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so instead of setting those when creating ExecuteMessage you started to set them here.
This seems a bit inelegant. Why did you decide on this approach? Can't we still pass those params to the constructor? And if we are going with your approach, why do we need those params in the constructor?

Comment thread cassandra/protocol.py Outdated
Comment on lines 646 to 650
def send_body(self, f, protocol_version):
write_string(f, self.query_id)
if ProtocolVersion.uses_prepared_metadata(protocol_version):
write_string(f, self.result_metadata_id)
if ProtocolVersion.uses_prepared_metadata(protocol_version) or self.use_metadata_id:
write_string(f, self.result_metadata_id if self.result_metadata_id is not None else b'')
self._write_query_params(f, protocol_version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more elegant approach, it seems to me, is to not have use_metadata_id in ExecuteMessage. Instead, pass ProtocolFeatures to this method, use it and version to decide wheter to send an id, and use it from ExecuteMessage

Comment thread cassandra/cluster.py Outdated
Comment on lines +5183 to +5203
new_result_metadata_id = getattr(response, 'result_metadata_id', None)
if self.prepared_statement and new_result_metadata_id is not None:
if response.column_metadata:
# Write result_metadata before result_metadata_id intentionally:
# a concurrent reader that still sees the old metadata_id will
# ask the server for full metadata and recover safely; a reader
# that sees the new metadata_id together with the new metadata
# is immediately correct. The opposite write order could expose
# a window where a reader uses a new metadata_id with stale metadata.
# Note: correctness of this ordering relies on CPython's GIL making
# individual attribute reads/writes effectively atomic. Other Python
# implementations (PyPy, Jython, etc.) may not provide this guarantee.
self.prepared_statement.result_metadata = response.column_metadata
else:
log.warning(
"Server sent a new result_metadata_id but no column metadata "
"for prepared statement %r. The cached column metadata will not "
"be updated; only result_metadata_id is refreshed.",
getattr(self.prepared_statement, 'query_id', None)
)
self.prepared_statement.result_metadata_id = new_result_metadata_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the concurrency model here? I don't think this code works if its possible for it to execute concurrently.
Consider the following:

  • We start with schema X and perform two schema changes, to version Y and then Z.
  • We issue two requests
  • First request receives Y_id and Y, sets result_metadata to Y, and then execution switches to second request
  • Second request receives Z_id and Z, sets result_metadata to Z, then result_metadata_id to Z_id.
  • Execution switches back to first request, it sets result_metadata_id to Y_id.

Now we have result_metadata = Z, and result_metadata_id - Y_id. Any request executed won't receive new metadata (because id is the newest) but will try to deserialize results using older metadata.

Comment thread docs/scylla-specific.rst
Comment on lines +190 to +193
- Statements prepared before the extension was negotiated (e.g., during a rolling
upgrade) retain ``result_metadata_id=None`` and fall back to always requesting
full metadata, which is the safest option.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but at some point we'll receive the metadata and id, and then it will be skipped.

I'm saying that because this point may suggest that clients need to be restarted after such rolling upgrade, but they don't

Comment thread docs/scylla-specific.rst Outdated
directly from the data.

For full protocol details see the ScyllaDB CQL extensions documentation:
https://opensource.docs.scylladb.com/stable/cql/cql-extensions.html

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this link? Opensource is LONG deprecated, and this link is about CQL language extension, not CQL protocol extensions - totally irrelevant here.

Comment thread cassandra/cluster.py Outdated
Comment on lines +5009 to +5013
if self.prepared_statement and isinstance(message, ExecuteMessage):
has_result_metadata_id = self.prepared_statement.result_metadata_id is not None
has_result_metadata = bool(self.prepared_statement.result_metadata)
can_skip_meta = has_result_metadata_id and has_result_metadata and (
ProtocolVersion.uses_prepared_metadata(connection.protocol_version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is bool(self.prepared_statement.result_metadata) true?
Is it true if server sent result metadata with 0 columns?

@dkropachev dkropachev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two non-duplicate correctness issues found in the current diff. I did not add a new skip_meta cached-metadata comment because that issue is already covered in the existing review thread.

Comment thread cassandra/cluster.py Outdated
self._connection = connection
result_meta = self.prepared_statement.result_metadata if self.prepared_statement else []

if self.prepared_statement and isinstance(message, ExecuteMessage):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This per-connection setup only runs in _query(), but _query_control_connection() can also send the original prepared ExecuteMessage during control-connection fallback. In that path the message keeps the constructor defaults (use_metadata_id=False, skip_meta=False, result_metadata_id=None). If the control connection is v4 and negotiated SCYLLA_USE_METADATA_ID, ExecuteMessage.send_body() will omit the required metadata-id field, shifting the frame layout and causing a protocol error.

Please share this setup logic with _query_control_connection() before its send_msg() call so both send paths decide use_metadata_id, skip_meta, and result_metadata_id from the actual connection being used.

Comment thread cassandra/cluster.py Outdated
or connection.features.use_metadata_id
)
message.skip_meta = can_skip_meta
message.result_metadata_id = self.prepared_statement.result_metadata_id if can_skip_meta else None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can pair a fresh metadata id with a stale metadata snapshot. _set_result() writes prepared_statement.result_metadata first and result_metadata_id second, but _query() snapshots result_meta before reading result_metadata_id. A concurrent execute can capture old metadata, then read the new id here, send skip_meta=True, and decode a no-metadata response using stale result_meta.

Read result_metadata_id first, then snapshot result_metadata, and use those two local values consistently for both can_skip_meta and send_msg(..., result_metadata=...). That matches the write-ordering comment in _set_result() and avoids pairing a fresh id with stale cached metadata.

@dawmd

dawmd commented Jul 13, 2026

Copy link
Copy Markdown

This PR seems to be solving similar problems to what I've been facing while working on TABLETS_ROUTING_V2 (cf. #913). In my work, I also need to pass per-request information and encode it in the message body (tablet_version_block), similarly to what you seem to be doing with result_metadata_id.

I approached at least one problem in a bit different way (which turns out to be what @Lorak-mmk suggested in his comment here: https://github.com/scylladb/python-driver/pull/770/changes#r3442046951). Unfortunately, I broke the public API this way (I had to extend the argument list for _ProtocolHandler::encode_message, which is part of the contract; cf.:

class _ProtocolHandler(object):
"""
_ProtocolHander handles encoding and decoding messages.
This class can be specialized to compose Handlers which implement alternative
result decoding or type deserialization. Class definitions are passed to :class:`cassandra.cluster.Cluster`
on initialization.
Contracted class methods are :meth:`_ProtocolHandler.encode_message` and :meth:`_ProtocolHandler.decode_message`.
"""
).

Since we're working on the same code and solving similar problems, maybe it would be a good idea to discuss our solutions and agree on a unified approach. This would prevent doubling the effort and, what's even more important, introducing two different solutions to the same issue.

I'm explaining this here for visibility, but let's move the conversation to Slack.

nikagra added a commit to nikagra/python-driver that referenced this pull request Jul 16, 2026
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 (scylladb#770) and TABLETS_ROUTING_V2 (scylladb#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>
nikagra added a commit to nikagra/python-driver that referenced this pull request Jul 16, 2026
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 (scylladb#770) and TABLETS_ROUTING_V2 (scylladb#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>
dkropachev pushed a commit that referenced this pull request Jul 16, 2026
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>
nikagra added 2 commits July 16, 2026 21:51
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 only written when the metadata-id mechanism is
  active on the connection; without it, a schema change after PREPARE
  would leave the driver decoding rows with stale cached metadata.
  (This also revives the flag itself, which was dead code upstream:
  _write_query_params never wrote it.)

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.

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).
_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. _execute_after_prepare refreshes the
pair the same way, keeping the previous id when the reprepare response
carries none.
Unit tests for the extension across its layers:

test_protocol_features.py: SCYLLA_USE_METADATA_ID parsed from SUPPORTED
and echoed in STARTUP options; absent by default.

test_protocol.py (wire format):
- metadata-id field written on v4 iff the connection negotiated the
  extension, with the exact bytes asserted; empty sentinel (b'') when
  the statement has no id, on both the extension path (v4) and the v5
  native path (previously a TypeError);
- _SKIP_METADATA_FLAG written when skip_meta is requested and the
  metadata-id mechanism is active (extension on v4, native v5), and
  suppressed - together with the id field - on a v4 connection without
  the extension, even when the statement carries an id;
- PREPARED response decoding reads result_metadata_id iff the extension
  was negotiated (or v5); METADATA_CHANGED/NO_METADATA flag handling.

test_query.py: PreparedStatement stores the (result_metadata,
result_metadata_id) pair atomically - constructor, update_result_metadata,
and the backwards-compatible single-attribute setters all replace the
pair as one unit, and previously-taken snapshots stay internally
consistent.

test_response_future.py:
- _create_response_future builds ExecuteMessage from a single pair
  snapshot: skip_meta only with both an id and usable cached metadata;
  disabled for id-less statements, NO_METADATA/LWT statements
  (result_metadata None) and zero-column statements (result_metadata []),
  while the id still rides on the message;
- _query sends the message exactly as constructed (no per-connection
  mutation - regression test for the speculative-execution race);
- _set_result METADATA_CHANGED path replaces the cached pair atomically;
  a response with a new id but no column metadata (empty or absent) is
  ignored with a warning, leaving the cached pair unchanged - adopting
  the id alone would poison the cache with a stale-metadata/current-id
  pair the server would never refresh;
- _execute_after_prepare refreshes the pair from the reprepare response
  and keeps the previous id when the response carries none.
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from d3300e2 to 8aecb92 Compare July 20, 2026 13:54
@nikagra
nikagra marked this pull request as draft July 20, 2026 13:55
@coderabbitai
coderabbitai Bot requested a review from dkropachev July 20, 2026 13:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/unit/test_protocol.py (1)

473-489: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider ClassVar annotation for the read-only frame-hex lookup table.

Ruff RUF012 flags EXPECTED_FRAMES as a mutable class attribute. It's only read here, so risk is low, but annotating with typing.ClassVar[dict] (or moving it to module scope) documents the intent and silences the lint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_protocol.py` around lines 473 - 489, Annotate the read-only
EXPECTED_FRAMES class attribute with typing.ClassVar[dict] so Ruff RUF012
recognizes it as intentional class-level state. Preserve the existing frame
mappings and lookup behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/test_protocol.py`:
- Around line 260-282: Add a standalone test method definition before the
`_METADATA_ID_FLAG` ROWS-decoding test body, using the surrounding test class’s
naming and `self` conventions. Keep its existing docstring, buffer setup,
`recv_results_metadata` call, and assertions inside the new method so pytest
discovers it independently from
`test_recv_results_prepared_v5_reads_metadata_id`.

---

Nitpick comments:
In `@tests/unit/test_protocol.py`:
- Around line 473-489: Annotate the read-only EXPECTED_FRAMES class attribute
with typing.ClassVar[dict] so Ruff RUF012 recognizes it as intentional
class-level state. Preserve the existing frame mappings and lookup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: edb6a82d-1220-476f-9fe6-b25c6d7b238e

📥 Commits

Reviewing files that changed from the base of the PR and between d3300e2 and 8aecb92.

📒 Files selected for processing (9)
  • cassandra/cluster.py
  • cassandra/protocol.py
  • cassandra/protocol_features.py
  • cassandra/query.py
  • docs/scylla-specific.rst
  • tests/unit/test_protocol.py
  • tests/unit/test_protocol_features.py
  • tests/unit/test_query.py
  • tests/unit/test_response_future.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/unit/test_protocol_features.py
  • cassandra/protocol_features.py
  • docs/scylla-specific.rst

Comment on lines +260 to +282


"""
When _METADATA_ID_FLAG (0x0008) is set in a ROWS result,
recv_results_metadata must read and store the new result_metadata_id
sent by the server (METADATA_CHANGED signal), and still populate
column_metadata normally.
"""
# Wire layout for a ROWS result with METADATA_CHANGED:
# flags: int(0x0008) = _METADATA_ID_FLAG
# colcount: int(0)
# result_metadata_id: short(4) + b'new1'
# (no columns — colcount=0 — to keep the buffer minimal)
buf = io.BytesIO(
struct.pack('>i', 0x0008) # flags: METADATA_ID_FLAG
+ struct.pack('>i', 0) # colcount = 0
+ struct.pack('>H', 4) + b'new1' # result_metadata_id = b'new1'
)
msg = ResultMessage(kind=RESULT_KIND_ROWS)
msg.recv_results_metadata(buf, user_type_map={})
assert msg.result_metadata_id == b'new1'
assert msg.column_metadata == []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Missing def line silently merges this test into the previous method.

Lines 262-281 (the _METADATA_ID_FLAG ROWS-decoding test) have no preceding def test_...(self):. Since the docstring at line 262 sits at the same 8-space indentation as the body of test_recv_results_prepared_v5_reads_metadata_id (ending line 259), Python treats it as a continuation of that method's body rather than a syntax error. This test never runs as its own unit — its assertions execute (and reuse/reassign buf/msg) inside the unrelated prepared-statement test, so the intended standalone coverage for _METADATA_ID_FLAG on ROWS results doesn't actually exist as a discoverable pytest test.

🐛 Proposed fix: restore the missing method signature
         assert msg.query_id == b'ab'
         assert msg.result_metadata_id == b'xyz'
 
+    def test_recv_results_metadata_metadata_id_flag_reads_metadata_id(self):
         """
         When _METADATA_ID_FLAG (0x0008) is set in a ROWS result,
         recv_results_metadata must read and store the new result_metadata_id
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""
When _METADATA_ID_FLAG (0x0008) is set in a ROWS result,
recv_results_metadata must read and store the new result_metadata_id
sent by the server (METADATA_CHANGED signal), and still populate
column_metadata normally.
"""
# Wire layout for a ROWS result with METADATA_CHANGED:
# flags: int(0x0008) = _METADATA_ID_FLAG
# colcount: int(0)
# result_metadata_id: short(4) + b'new1'
# (no columns — colcount=0 — to keep the buffer minimal)
buf = io.BytesIO(
struct.pack('>i', 0x0008) # flags: METADATA_ID_FLAG
+ struct.pack('>i', 0) # colcount = 0
+ struct.pack('>H', 4) + b'new1' # result_metadata_id = b'new1'
)
msg = ResultMessage(kind=RESULT_KIND_ROWS)
msg.recv_results_metadata(buf, user_type_map={})
assert msg.result_metadata_id == b'new1'
assert msg.column_metadata == []
def test_recv_results_metadata_metadata_id_flag_reads_metadata_id(self):
"""
When _METADATA_ID_FLAG (0x0008) is set in a ROWS result,
recv_results_metadata must read and store the new result_metadata_id
sent by the server (METADATA_CHANGED signal), and still populate
column_metadata normally.
"""
# Wire layout for a ROWS result with METADATA_CHANGED:
# flags: int(0x0008) = _METADATA_ID_FLAG
# colcount: int(0)
# result_metadata_id: short(4) + b'new1'
# (no columns — colcount=0 — to keep the buffer minimal)
buf = io.BytesIO(
struct.pack('>i', 0x0008) # flags: METADATA_ID_FLAG
struct.pack('>i', 0) # colcount = 0
struct.pack('>H', 4) + b'new1' # result_metadata_id = b'new1'
)
msg = ResultMessage(kind=RESULT_KIND_ROWS)
msg.recv_results_metadata(buf, user_type_map={})
assert msg.result_metadata_id == b'new1'
assert msg.column_metadata == []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_protocol.py` around lines 260 - 282, Add a standalone test
method definition before the `_METADATA_ID_FLAG` ROWS-decoding test body, using
the surrounding test class’s naming and `self` conventions. Keep its existing
docstring, buffer setup, `recv_results_metadata` call, and assertions inside the
new method so pytest discovers it independently from
`test_recv_results_prepared_v5_reads_metadata_id`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants