Skip to content

DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension - #770

Merged
nikagra merged 2 commits into
scylladb:masterfrom
nikagra:driver-153-scylla-use-metadata-id
Jul 29, 2026
Merged

DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension#770
nikagra merged 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

Fixes: https://scylladb.atlassian.net/browse/DRIVER-153

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

  • Torn metadata/id pair (@Lorak-mmk DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment), @dkropachev DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment)): PreparedStatement now stores (result_metadata, result_metadata_id) as one tuple replaced in a single attribute assignment (update_result_metadata()), with result_metadata/result_metadata_id as compatibility properties over it. A reader can no longer observe the metadata of one schema version paired with the id of another — the previous per-field write ordering (and its GIL-dependent correctness comment) is gone. The compatibility setters are documented as non-atomic relative to each other, pointing callers at update_result_metadata().
  • Behavioral fix beyond what was asked: when the server sends a new metadata id without column metadata (_set_result), the old code adopted the id anyway. That manufactures the exact unrecoverable state the pair-atomicity fix is meant to prevent: the server would then match the id and stop sending metadata, while the driver decodes against stale cached columns forever. Now that response is logged as a warning and nothing is cached — the next EXECUTE resends the old id, the server detects the mismatch, and the driver recovers with full metadata.
  • Rolling-upgrade docs (@Lorak-mmk DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment)): reworded to describe the actual self-healing behavior — a statement prepared before the extension was negotiated sends the empty sentinel on its first execute over an extension-enabled connection and acquires an id from the resulting METADATA_CHANGED response; no client restart needed. Also reworded the section's opening paragraph so skip_meta reads as conditional on the extension, not pre-existing default behavior.
  • Wrong spec link (@Lorak-mmk DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment)): replaced the opensource-docs CQL-language-extensions link with the scylladb repo's docs/dev/protocol-extensions.md, which actually documents this extension.
  • bool(result_metadata) question (@Lorak-mmk DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension #770 (comment)): result_metadata is None for statements with NO_METADATA in their PREPARE response (LWT/conditional statements) and [] for statements returning zero columns (plain INSERT/UPDATE/DELETE). Both are falsy, and both correctly keep skip_meta off — there's nothing to decode against in either case. Documented in a code comment at the construction site.

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

CHANGELOG.rst

  • Added a Features entry for the extension

Live-server verification of the b'' sentinel

The one open question from review was whether Scylla actually treats the empty b'' metadata-id sentinel — sent by a statement that was prepared before the extension was negotiated, e.g. mid rolling-upgrade — as a mismatch, versus rejecting it as a malformed frame. Resolved two ways:

Cross-driver precedent. The identical scenario is already covered by a merged, live-server-tested case in the Java driver: scylladb/java-driver#758 adds PreparedStatementIT.should_handle_empty_metadata_id_when_executing_statement_when_supported, which nulls a prepared statement's cached id, executes, and asserts the id comes back non-null against a real Scylla node. The gocql implementation (scylladb/gocql#590) independently reaches the identical wire convention — its Test_framer_writeExecuteFrame unit test asserts a nil id serializes to a zero-length short-bytes field, the same convention as this PR's b''. All three drivers trace back to the same server-side fix (scylladb/scylladb#23292).

Live run against this driver. Added tests/integration/standard/test_scylla_metadata_id.py (3 tests) and ran it against a real Scylla node via CCM:

source ~/Envs/scylla/bin/activate   # venv with ccm + a Scylla build already cached locally, no download needed
cd python-driver
SCYLLA_VERSION=release:2026.1.9 PROTOCOL_VERSION=4 pytest tests/integration/standard/test_scylla_metadata_id.py -v
tests/integration/standard/test_scylla_metadata_id.py::ScyllaMetadataIdTests::test_empty_sentinel_id_triggers_metadata_changed PASSED [ 33%]
tests/integration/standard/test_scylla_metadata_id.py::ScyllaMetadataIdTests::test_extension_is_negotiated PASSED [ 66%]
tests/integration/standard/test_scylla_metadata_id.py::ScyllaMetadataIdTests::test_metadata_changed_recovers_after_schema_change PASSED [100%]
======================== 3 passed, 3 warnings in 25.09s ========================

test_empty_sentinel_id_triggers_metadata_changed is the direct check: prepare a statement, force its result_metadata_id back to None via update_result_metadata() (simulating a statement prepared before the extension was known), execute it, and confirm both that no error is raised and that a fresh result_metadata_id comes back afterward — proving Scylla treats the sentinel as METADATA_CHANGED, not a protocol error.

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 (693 passed, 111 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: verified live via CCM (Scylla 2026.1.9) 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, not rejected — see "Live-server verification" above

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

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 names the main change: negotiating and implementing the SCYLLA_USE_METADATA_ID extension.
Description check ✅ Passed The description matches the template well and includes the required checklist items, summary, Fixes annotation, tests, and docs updates.

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

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 9 out of 9 changed files in this pull request and generated no new comments.

Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py Outdated
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch 3 times, most recently from 4292f0d to 80590f4 Compare July 23, 2026 12:32
@nikagra
nikagra requested a review from dkropachev July 23, 2026 15:04
@github-actions github-actions Bot added the P2 label Jul 23, 2026
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 80590f4 to 8a25e77 Compare July 23, 2026 19:29
Comment thread docs/scylla-specific.rst Outdated
Comment thread tests/integration/standard/test_scylla_metadata_id.py
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 8a25e77 to 8bc97cf Compare July 28, 2026 16:19
@nikagra

nikagra commented Jul 28, 2026

Copy link
Copy Markdown
Author

🤖: Both of @dkropachev's latest comments are addressed, and the branch keeps the 2-commit shape: 8f3c3f50 (impl) + 8bc97cf8 (tests).

Review comments

  • docs (or speak CQL v5) claim — removed; added a bullet stating the native-v5 behaviour explicitly so the claim can't creep back.
  • setUpClass resource leak — the probe is now wrapped so an unsupported build or a probe failure tears down the cluster/keyspace before raising, since unittest skips tearDownClass when setUpClass raises.

Four fixes from a self-review pass, folded into the same two commits

  1. update_result_metadata() now re-arms _warned_missing_column_metadata, so recovery via the reprepare path re-arms it too — previously only the _set_result METADATA_CHANGED branch did, contradicting the flag's own comment. Duplicate reset in cluster.py removed.
  2. The ROWS-response cache update is now gated on prepared_statement.result_metadata is not None, so a statement prepared with NO_METADATA (conditional/LWT, PYTHON-847) can never acquire cached metadata and become skip_meta-eligible. Its result shape varies per response (applied vs not-applied), so caching one shape could have led to decoding the other against the wrong columns. This was previously unreachable only if the server sets METADATA_CHANGED solely in reply to a skip request — now it is enforced driver-side instead of assumed. New unit test covers it.
  3. test_empty_sentinel_id_triggers_metadata_changed now spies on ResponseFuture._reprepare and asserts zero calls, so it provably exercises the METADATA_CHANGED-on-ROWS path rather than the UNPREPARED/reprepare fallback.
  4. The feature probe no longer borrows a connection (borrow_connection() pops a stream id that only process_msg gives back, so it leaked one per call); it reads features off the pool's existing connection.

Testing: unit suite 696 passed / 111 skipped. Both new production changes were verified non-vacuous by reverting each in turn and confirming the corresponding test fails. Not yet re-run against a live cluster — test_scylla_metadata_id.py and test_prepared_statements.py -k conditional are the two worth watching in CI, the latter confirming fix 2 left LWT behaviour untouched.

@nikagra

nikagra commented Jul 28, 2026

Copy link
Copy Markdown
Author

@dawmd Agreed on not shipping two mechanisms for the same problem — and I think that is now settled in a way that works for both of us: #934 ("protocol: pass negotiated ProtocolFeatures to message serialization") merged on 2026-07-16 and is exactly the shared plumbing.

What it gives us:

  • _ProtocolHandler.encode_message and decode_message both receive the ProtocolFeatures negotiated on the connection carrying the message (encode_message as a required protocol_features parameter), and forward it to send_body.
  • Messages therefore stay connection-independent — they carry request data only — and send_body decides the wire format from (protocol_version, protocol_features). A field belonging to a negotiated extension is emitted on exactly the connections that negotiated it.
  • That is the shape Lorak-mmk suggested in https://github.com/scylladb/python-driver/pull/770/changes#r3442046951, and this PR is rebased on it: result_metadata_id and the skip-metadata flag are now decided inside ExecuteMessage.send_body from the serving connection's features, with no per-request mutation of the message.

On the public API concern: the encode_message signature extension was taken once, in #934, rather than separately in each feature PR. It is documented as a contract change in docs/api/cassandra/protocol.rst (overrides must keep the protocol_features parameter name) with a CHANGELOG entry, so custom _ProtocolHandler subclasses have one documented break instead of two.

So tablet_version_block in #913 should be able to ride the same path — encode it in send_body off protocol_features, no extra parameter needed. If anything in #913 still needs something #934 does not cover, happy to go through it on Slack and fold the difference in here.

@nikagra

nikagra commented Jul 28, 2026

Copy link
Copy Markdown
Author

@mykaul Following up on this — the feature is now documented in-tree, with the ScyllaDB reference you asked for.

docs/scylla-specific.rst has a "Prepared Statement Metadata Caching (SCYLLA_USE_METADATA_ID)" section covering:

  • what the extension does and why change detection is needed (stale cached metadata after a schema change),
  • that it is negotiated automatically when the node supports it, and the exact conditions under which the driver asks the server to omit result metadata from EXECUTE responses,
  • how a METADATA_CHANGED response refreshes both the cached column metadata and the hash, with no application change,
  • what happens to statements prepared before the extension was negotiated (rolling upgrade): they acquire a hash automatically on their first execute, no re-prepare or restart,
  • which statements the optimization applies to in practice (anything whose PREPARE returns result columns, i.e. SELECTs).

It closes with the link to the ScyllaDB protocol extensions doc: https://github.com/scylladb/scylladb/blob/master/docs/dev/protocol-extensions.md — plus the internal requirement document I linked above for the design rationale.

@dkropachev

Copy link
Copy Markdown

@Lorak-mmk , can you please check it, or just tell us it is good to go

Comment thread cassandra/cluster.py
Comment thread cassandra/protocol.py Outdated
Comment thread cassandra/query.py Outdated
nikagra added 2 commits July 29, 2026 14:50
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.
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
  SCYLLA_USE_METADATA_ID extension is negotiated (v4 or v5), and NOT set
  on a native v5 connection without the extension (the id field is still
  written there, but the driver does not request skip); also 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) and
  decodes a skip_meta response against the metadata snapshotted when the
  message was built, not a later read of the statement cache (regression
  for a concurrent METADATA_CHANGED racing the send);
- _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 exactly what the
  reprepare response carries, including the id, and no longer keeps the
  previous id when the response has none (@dkropachev: doing so risked
  pairing a stale id with metadata from a different schema version -
  test_execute_after_prepare_no_metadata_id_in_response_clears_id);
- a statement with valid cached metadata+id must still get skip_meta=False
  when continuous_paging_options is set (@dkropachev: Connection.process_msg
  hardcodes result_metadata=None for paging-session pages after the first,
  so a skip_meta response would crash decoding them -
  test_create_execute_message_continuous_paging_disables_skip_meta).

tests/integration/standard/test_scylla_metadata_id.py: live-server
coverage against a real Scylla node via CCM, closing the one gap unit
tests can't - whether Scylla actually treats the empty result_metadata_id
sentinel as a mismatch rather than a protocol error. Confirms extension
negotiation, the normal METADATA_CHANGED-after-ALTER-TABLE path, and the
sentinel round trip: a statement forced back to result_metadata_id=None
(simulating one prepared before the extension was known, e.g. mid
rolling-upgrade) executes without error and comes back with a fresh id.
Mirrors the equivalent live test already merged in the Java driver
(scylladb/java-driver#758,
should_handle_empty_metadata_id_when_executing_statement_when_supported).
Run locally against Scylla 2026.1.9 via CCM; see PR description for setup
and log excerpt.
@nikagra
nikagra force-pushed the driver-153-scylla-use-metadata-id branch from 8bc97cf to 7b21c61 Compare July 29, 2026 12:51
@nikagra
nikagra removed the request for review from sylwiaszunejko July 29, 2026 14:50
@nikagra
nikagra merged commit b8b714c into scylladb:master Jul 29, 2026
21 checks passed
@nikagra
nikagra deleted the driver-153-scylla-use-metadata-id branch July 29, 2026 16:28
nikagra added a commit to nikagra/gocql that referenced this pull request Aug 3, 2026
…ension

DisableSkipMetadata defaults to true in this fork, and not because skipping is
generally unwanted: upstream leaves it false. It was flipped in f292aaf
("Disable skipping metadata by default", Ref: scylladb/scylladb#20860) because
prepared-statement result metadata could not be invalidated safely. That is the
exact bug SCYLLA_USE_METADATA_ID plus scylladb/scylladb#23292 fix, so under the
extension the flag is a workaround for a problem that no longer exists and the
driver overrides it. The previous commit already did this; what was missing was
saying so, and bounding when it is safe.

Both sibling drivers resolve it the same way, which is worth recording since
gocql is the only one of the three exposing a plain bool for it:

- python-driver (scylladb/python-driver#770) has no user-facing knob at all;
  ExecuteMessage._should_skip_metadata() emits the flag only when the connection
  negotiated the extension, and deliberately not on native v5.
- java-driver (scylladb/java-driver#599 and follow-ups) has a three-valued
  option, advanced.prepared-statements.skip-cql4-metadata-resolve-method, and
  DefaultPreparedStatement.resolveSkipMetadata() returns true before ever reading
  it once resultMetadataId is non-empty.

Documentation:

- Rewrite the DisableSkipMetadata doc comment. It said the driver "may still"
  send skip_metadata when the extension is negotiated, which understates it: the
  flag defaults to true, so the override is the normal case rather than an
  exception, and "Default: true" is misleading on its own. Say plainly that the
  flag is ignored, why the extension makes that safe, and that the override stays
  scoped to the extension (native v5 still honours it, as in python-driver).
- Reword the Validate() warning added in 5e07ffd. It fires when
  !DisableSkipMetadata, so after the override the population that actually gets
  skipping — the default config against Scylla — is unwarned, while those who
  opted in explicitly still are. Negotiation is per connection and happens long
  after Validate, so the trigger cannot be narrowed; instead name the case that
  is still risky, a server without the extension.
- Extend the NoSkipMetadata doc: it is now the only way to force metadata under
  the extension, and it matters most for conditional statements, whose response
  column set depends on whether the condition applied — something a metadata ID
  cannot express, as it describes the statement and not the outcome. ScanCAS and
  MapScanCAS set it internally and are unaffected.

Require an ID before skipping (parity with both siblings):

skipMeta was gated only on the connection having negotiated the extension, where
both siblings additionally require a non-empty result metadata ID on the statement
being executed. The distinction is reachable: the prepared cache is keyed
(hostID, keyspace, statement) and is evicted only on prepare failure or
UNPREPARED, never on connection close, so a statement prepared before the
extension was negotiated survives a reconnect to a now-extension-enabled
connection. The driver would then request skip_metadata while sending an empty
ID. Add metadataIDTracked() requiring both, so there is no window in which the
driver skips metadata it cannot recover. Such a statement now asks for metadata
for one more round trip, acquires an ID from the resulting METADATA_CHANGED
response, and skips from then on.

Also guard the METADATA_CHANGED cache update against a response carrying a new ID
with no column metadata. METADATA_CHANGED obliges the server to include the new
metadata, so that is malformed, but adopting the ID while keeping the old columns
is unrecoverable: the server would match the ID from then on and stop sending
metadata, leaving the driver decoding against stale columns indefinitely. Log and
cache nothing, so the next execute resends the old ID and recovers with full
metadata. python-driver guards this identically.

Rework TestShouldSkipResultMetadata to compose metadataIDTracked with
shouldSkipResultMetadata the way the EXECUTE path does, covering the nil and
zero-length ID cases, an ID without the extension, and an explicit opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
nikagra added a commit to nikagra/gocql that referenced this pull request Aug 3, 2026
…etadata under it

Prepared-statement result metadata could not be invalidated safely: after an
ALTER the server kept answering with the old column set, so a driver reusing the
metadata it cached at prepare time decoded rows against columns that no longer
described the response (scylladb/scylladb#20860). gocql's answer was to stop
reusing it — f292aaf ("Disable skipping metadata by default") flipped
DisableSkipMetadata to default true — at the cost of carrying result metadata on
every response.

scylladb/scylladb#23292 fixes the underlying problem for SELECT statements. A
server advertising SCYLLA_USE_METADATA_ID hands out a result metadata ID at
prepare time; the driver returns that ID with every EXECUTE, and a stale ID is
answered with the METADATA_CHANGED flag plus fresh metadata and a fresh ID. That
is native protocol v5's mechanism made available on v4, which is what Scylla
negotiates. Implement the driver half.

Negotiation and plumbing.

scyllaUseMetadataIDExt implements cqlProtocolExtension and is registered in
parseCQLProtocolExtensions, so it is sent in STARTUP whenever SUPPORTED lists the
key. The v5 metadata-ID gates in frame.go widen from `proto > protoVersion4` to
`proto > protoVersion4 || scyllaUseMetadataID`: the read in parseResultPrepared,
the read in parseResultMetadata behind METADATA_CHANGED, and the write in
writeExecuteFrame. A v4 connection that negotiated the extension therefore drives
the same primitives the v5 port already built, rather than a second
implementation of them.

Detection is consolidated onto the extension. parseSupported already keyed
isMetadataIDSupported — and through it the isScylla heuristic and the
IsMetadataIDSupported() getter — off a function-local SCYLLA_USE_METADATA_ID
const. That const moves to package scope and both the detector and the extension
read it, leaving one spelling and one detector for the capability.

One source of truth for the negotiated flag.

Two independently derived booleans governing the two halves of one wire contract
is a bug waiting to happen: a Conn that believed the extension was on while its
framers did not would ask the server to skip result metadata while writing no ID
for it to compare against, and the driver would then decode rows against whatever
metadata it had cached. So the negotiated state lives in framerConfig, populated
by connFramers.initCache during connection setup before any query can run, and
Conn reads it through the usesMetadataID() accessor. There is no Conn-level copy
to diverge from it.

newFramerWithExts derives the same flag, and its cast-miss branch logs and carries
on instead of returning early — the early return in the tabletsRoutingV1 block
above it would otherwise skip this block, and the next one added after it. That
constructor has no non-test callers; it is documented as test-only, with a note
that an extension handled there needs handling in initCache too.

Skipping result metadata.

shouldSkipResultMetadata replaces the inline skipMeta expression in
executeQueryWithMetrics. Under the extension the session-level
DisableSkipMetadata is ignored: the flag is a workaround for the bug the extension
fixes, so once the server reports metadata changes there is nothing left to work
around. The override stays deliberately scoped to the extension — native protocol
v5 carries metadata IDs too, but there the flag is still honoured, matching the
python-driver's choice. Query.NoSkipMetadata wins in every case, and is now the
only way to force metadata under the extension; that matters for conditional
statements, whose response column set depends on whether the condition applied —
something a result metadata ID cannot express, since it describes the statement
and not the outcome. ScanCAS and MapScanCAS set it internally and are unaffected.

metadataIDTracked gates on both halves: the connection negotiated the extension
*and* the prepared statement carries a non-empty result metadata ID. The
distinction is reachable — the prepared cache is keyed (hostID, keyspace,
statement) and is evicted only on prepare failure or UNPREPARED, never on
connection close, so a statement prepared before the extension was negotiated
survives a reconnect onto a now-extension-enabled connection. Without the second
condition the driver would request skip_metadata while sending an empty ID. With
it, such a statement asks for metadata for one more round trip, acquires an ID
from the resulting METADATA_CHANGED response, and skips from then on — leaving no
window in which the driver skips metadata it cannot recover. Both sibling drivers
gate on the same two conditions (scylladb/python-driver#770,
scylladb/java-driver#599 and follow-ups); gocql is the only one of the three
exposing a plain bool for it, which is why the override needs saying out loud.

A RESULT/Rows carrying a new metadata ID but no column metadata is now ignored
rather than cached. METADATA_CHANGED obliges the server to include the new
metadata, so that response is malformed, but adopting the ID while keeping the old
columns is unrecoverable: the server would match the ID from then on and stop
sending metadata, leaving the driver decoding against stale columns indefinitely.
Log and cache nothing, so the next execute resends the old ID and recovers with
full metadata. The python-driver guards this identically.

Record and replay.

The record/replay dialers hash EXECUTE frames at fixed offsets, and skipped the
resultMetadataID field only for protocol v5+. Under this extension that field also
appears on v4 EXECUTE frames, which the frame bytes alone cannot reveal, so the
negotiated state is plumbed through instead: StartupNegotiatesMetadataID detects
the opt-in on both the record and the replay path, the recorder latches it and
stamps each Record with UseMetadataID, and GetFrameHash takes it as an argument.

Documentation.

The DisableSkipMetadata comment said the driver "may still" send skip_metadata
under the extension, which understates it: the flag defaults to true, so the
override is the normal case rather than an exception, and "Default: true" is
misleading on its own. It now says plainly that the flag is ignored, why the
extension makes that safe, and that the scope is the extension. The Validate()
warning fires on !DisableSkipMetadata, so after the override the population that
actually gets skipping — the default config against Scylla — is unwarned, while
those who opted in explicitly still are. Negotiation is per connection and happens
long after Validate runs, so the trigger cannot be narrowed; the message instead
names the case that is still risky, a server without the extension.

Tests.

Unit coverage for the extension's negotiation, registration and serialization
(scylla_test.go); for initFramerCache and usesMetadataID against the framer
config, with a negative counterpart for the not-negotiated case; for
shouldSkipResultMetadata composed with metadataIDTracked the way the EXECUTE path
composes them, over the nil and zero-length ID cases, an ID without the extension,
and an explicit opt-in; and for the v4-plus-extension GetFrameHash skip. A
regression test pins that a truncated resultMetadataID in a RESULT/Prepared frame
is reported as an error through parseFrame's recover rather than panicking the
serve goroutine — the extension makes that short-bytes read live on protocol v4.

The integration test stops skipping unconditionally: it skips only when the server
does not advertise the capability, and fails when the server advertises it but
negotiation did not happen. It is the only end-to-end coverage of the feature, so
a negotiation regression must not be able to turn it green.

One occurrence of the Id spelling is deliberate, in frame_test.go's transcription
of Cassandra's ResultSet$ResultMetadata$Codec.encode — a verbatim quote of Java
source, which keeps its original naming.

Fixes: https://scylladb.atlassian.net/browse/DRIVER-152
Fixes: scylladb#527
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
nikagra added a commit to nikagra/gocql that referenced this pull request Aug 3, 2026
…etadata under it

Prepared-statement result metadata could not be invalidated safely: after an
ALTER the server kept answering with the old column set, so a driver reusing the
metadata it cached at prepare time decoded rows against columns that no longer
described the response (scylladb/scylladb#20860). gocql's answer was to stop
reusing it — f292aaf ("Disable skipping metadata by default") flipped
DisableSkipMetadata to default true — at the cost of carrying result metadata on
every response.

scylladb/scylladb#23292 fixes the underlying problem for SELECT statements. A
server advertising SCYLLA_USE_METADATA_ID hands out a result metadata ID at
prepare time; the driver returns that ID with every EXECUTE, and a stale ID is
answered with the METADATA_CHANGED flag plus fresh metadata and a fresh ID. That
is native protocol v5's mechanism made available on v4, which is what Scylla
negotiates. Implement the driver half.

Negotiation and plumbing.

scyllaUseMetadataIDExt implements cqlProtocolExtension and is registered in
parseCQLProtocolExtensions, so it is sent in STARTUP whenever SUPPORTED lists the
key. The v5 metadata-ID gates in frame.go widen from `proto > protoVersion4` to
`proto > protoVersion4 || scyllaUseMetadataID`: the read in parseResultPrepared,
the read in parseResultMetadata behind METADATA_CHANGED, and the write in
writeExecuteFrame. A v4 connection that negotiated the extension therefore drives
the same primitives the v5 port already built, rather than a second
implementation of them.

Detection is consolidated onto the extension. parseSupported already keyed
isMetadataIDSupported — and through it the isScylla heuristic and the
IsMetadataIDSupported() getter — off a function-local SCYLLA_USE_METADATA_ID
const. That const moves to package scope and both the detector and the extension
read it, leaving one spelling and one detector for the capability.

One source of truth for the negotiated flag.

Two independently derived booleans governing the two halves of one wire contract
is a bug waiting to happen: a Conn that believed the extension was on while its
framers did not would ask the server to skip result metadata while writing no ID
for it to compare against, and the driver would then decode rows against whatever
metadata it had cached. So the negotiated state lives in framerConfig, populated
by connFramers.initCache during connection setup before any query can run, and
Conn reads it through the usesMetadataID() and tracksResultMetadataID()
accessors. There is no Conn-level copy to diverge from it.

newFramerWithExts derived the same flags a second time and is deleted. It had no
non-test callers — production framers come from the per-Conn pool — so keeping it
meant every future extension had to be handled in two places, with only a comment
to say so. Its call sites in scylla_test.go now go through initFramerCache and
getWriteFramer, i.e. the path production uses, which is what makes the claim above
true rather than aspirational.

Skipping result metadata.

shouldSkipResultMetadata replaces the inline skipMeta expression in
executeQueryWithMetrics, and metadataIDTracked gates it on both halves of the
mechanism: the connection exchanges result metadata IDs *and* the prepared
statement carries a non-empty one. Where both hold, the session-level
DisableSkipMetadata is ignored, including when it was set to true explicitly — the
flag is a workaround for the bug this mechanism fixes, so once the server reports
metadata changes there is nothing left to work around. Upstream gocql skips by
default on every protocol version, and there is deliberately no session-level knob
to force metadata back on; the java-driver's
skip-cql4-metadata-resolve-method has no equivalent here.

The ID exchange is active on native protocol v5, where the field is mandatory, as
well as on v4 with the extension, and Conn.tracksResultMetadataID reports either.
Scoping the override to the extension alone would leave gocql with opposite
defaults for two encodings of one mechanism, and the losing one would be the one
where the ID is guaranteed by the protocol rather than negotiated: a v5 connection
would carry full result metadata on every response for no reason.
scylladb/scylla-drivers#81 states the rule as "if SCYLLA_USE_METADATA_ID was
negotiated or CQL v5 is used", and the java-driver reaches it from the other
direction — DefaultPreparedStatement.resolveSkipMetadata returns true for any
non-empty result metadata ID, which v5 always supplies. The python-driver
implements the extension half only.

The second condition, a non-empty ID, is reachable and matters. The prepared cache
is keyed (hostID, keyspace, statement) and is evicted only on prepare failure or
UNPREPARED, never on connection close, so a statement prepared before the
extension was negotiated survives a reconnect onto a now-extension-enabled
connection. Without the gate the driver would request skip_metadata while sending
an empty ID. With it, such a statement asks for metadata for one more round trip,
acquires an ID from the resulting METADATA_CHANGED response, and skips from then
on — leaving no window in which the driver skips metadata it cannot recover. Both
sibling drivers gate on the same condition (scylladb/python-driver#770,
scylladb/java-driver#599 and follow-ups).

The remaining gate, a non-empty cached column set, is not an optimization either.
A statement whose RESULT/Prepared carries no result metadata is handed an ID
hashed from empty metadata; current Scylla compares the returned ID against that
same empty-metadata ID, always matches, and so never sets METADATA_CHANGED,
leaving a driver that asked to skip with a response it has no columns to decode.
LIST ROLES OF is the motivating case. The server-side fixes,
scylladb/scylladb#29233 and scylladb/scylladb#29275, are both closed unmerged, so
this gate is what keeps such statements working; document and test it as such.

Query.NoSkipMetadata wins in every case, and is now the only way to force metadata
where the ID exchange is active. Conditional statements are the case to keep in
mind, since their response column set depends on whether the condition applied —
something a result metadata ID cannot express, as it describes the statement and
not the outcome. In practice the column-set gate already covers them, because a
prepared conditional statement's result metadata is empty, and ScanCAS and
MapScanCAS set NoSkipMetadata internally regardless.

A RESULT/Rows carrying a new metadata ID but no column metadata is now ignored
rather than cached. METADATA_CHANGED obliges the server to include the new
metadata, so that response is malformed, but adopting the ID while keeping the old
columns is unrecoverable: the server would match the ID from then on and stop
sending metadata, leaving the driver decoding against stale columns indefinitely.
Log and cache nothing, so the next execute resends the old ID and recovers with
full metadata. The python-driver guards this identically.

Record and replay.

The record/replay dialers hash EXECUTE frames at fixed offsets, and skipped the
resultMetadataID field only for protocol v5+. Under this extension that field also
appears on v4 EXECUTE frames, which the frame bytes alone cannot reveal, so the
negotiated state is plumbed through instead: StartupNegotiatesMetadataID detects
the opt-in on both the record and the replay path, the recorder latches it and
stamps each Record with UseMetadataID, and GetFrameHash takes it as an argument.

Documentation.

The DisableSkipMetadata comment said the driver "may still" send skip_metadata
under the extension, which understates it: the flag defaults to true, so the
override is the normal case rather than an exception, and "Default: true" is
misleading on its own. It now says plainly that the flag is ignored — explicit
values included — which connections that applies to, and why that is safe. The
Validate() warning fires on !DisableSkipMetadata, so after the override the
population that actually gets skipping is unwarned while those who opted in
explicitly still are. The protocol version is negotiated per connection, and the
extension long after Validate runs, so the trigger cannot be narrowed; the message
instead names the case that is still risky — a connection that exchanges no result
metadata ID at all.

Tests.

Unit coverage for the extension's negotiation, registration and serialization
(scylla_test.go); for initFramerCache and usesMetadataID against the framer
config, with a negative counterpart for the not-negotiated case; for
tracksResultMetadataID over both mechanisms and both protocol versions, including
that it masks the request/response direction bit, and that usesMetadataID stays
narrower so a v5 connection cannot pass for a negotiated extension; for
shouldSkipResultMetadata composed with metadataIDTracked the way the EXECUTE path
composes them, over the nil and zero-length ID cases, an ID without an ID
exchange, an explicit opt-in, and the empty-column-set gate; and for the
v4-plus-extension GetFrameHash skip. A regression test pins that a truncated
resultMetadataID in a RESULT/Prepared frame is reported as an error through
parseFrame's recover rather than panicking the serve goroutine — the extension
makes that short-bytes read live on protocol v4.

TestPrepareExecuteMetadataChangedFlag becomes table-driven over both ways the ID
exchange can be active, rather than growing a second near-verbatim copy of its
~150-line flow for the extension. It also drops and recreates its table instead of
CREATE IF NOT EXISTS, because the flow ALTERs that table and one left over from an
earlier run already has the added column; and it asserts the no-change case by
pointer identity on the cache entry, since comparing the entry's fields compares it
with itself and can never fail. The extension case stops skipping unconditionally:
it skips only when the server does not advertise the capability, and fails when the
server advertises it but negotiation did not happen, since it is the only
end-to-end coverage of the feature and a negotiation regression must not be able to
turn it green. The v5 case cannot run in CI at all — TEST_CQL_PROTOCOL is pinned to
4 and no workflow overrides it — so exercising the v5 behaviour takes an explicit
TEST_CQL_PROTOCOL=5 run against Cassandra.

One occurrence of the Id spelling is deliberate, in frame_test.go's transcription
of Cassandra's ResultSet$ResultMetadata$Codec.encode — a verbatim quote of Java
source, which keeps its original naming.

Fixes: https://scylladb.atlassian.net/browse/DRIVER-152
Fixes: scylladb#527
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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