DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension#770
DRIVER-153: negotiate and implement SCYLLA_USE_METADATA_ID extension#770nikagra wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
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_IDparsing fromSUPPORTEDand includes it inSTARTUPwhen negotiated. - Extends protocol encode/decode to read/write
result_metadata_idfor 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_metaand 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.
There was a problem hiding this comment.
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.
ade35d8 to
f42e225
Compare
|
I'm not sure where, but we should document this - with reference mainly to the scylladb docs about this feature. |
|
@mykaul Documentation I'm aware of is MetadataId extension in CQLv4 Requirement Document |
dkropachev
left a comment
There was a problem hiding this comment.
One blocking correctness issue below: skip_meta is being enabled for prepared statements that can still have empty/absent cached result metadata.
6eea397 to
a86fd53
Compare
7ba5835 to
a86fd53
Compare
a86fd53 to
8880f03
Compare
170fd31 to
5fe1902
Compare
fcd3eba to
5fe1902
Compare
5fe1902 to
251b1a8
Compare
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis 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 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
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
|
Actionable comments posted: 0 |
de8d3fc to
bfc9760
Compare
|
🤖: 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 (
Test commit (
CI on the 8-commit pre-squash branch: 18/19 passed; |
bfc9760 to
d3300e2
Compare
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>
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>
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>
d3300e2 to
8aecb92
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/unit/test_protocol.py (1)
473-489: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider
ClassVarannotation for the read-only frame-hex lookup table.Ruff RUF012 flags
EXPECTED_FRAMESas a mutable class attribute. It's only read here, so risk is low, but annotating withtyping.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
📒 Files selected for processing (9)
cassandra/cluster.pycassandra/protocol.pycassandra/protocol_features.pycassandra/query.pydocs/scylla-specific.rsttests/unit/test_protocol.pytests/unit/test_protocol_features.pytests/unit/test_query.pytests/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
8aecb92 to
a620293
Compare
a620293 to
e636095
Compare
e636095 to
7208531
Compare
7208531 to
cea992c
Compare
cea992c to
2295d2d
Compare
2295d2d to
7a49fbc
Compare
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.
7a49fbc to
4292f0d
Compare
Summary
Implements the
SCYLLA_USE_METADATA_IDScylla 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:
skip_meta=True)METADATA_CHANGEDflag and includes the new metadata ID + new column metadata in the response — the driver picks this up and updates its cached metadata automaticallyRebased on #934 (merged groundwork that threads each connection's negotiated
ProtocolFeaturesinto message serialization) and reworked to resolve the review discussion below.Design change from the previous version of this PR
Previously,
ExecuteMessagecarried ause_metadata_idflag andResponseFuture._query()mutatedskip_meta/result_metadata_idon the shared message after borrowing a connection. Per @Lorak-mmk's review (#770 (comment) and the surrounding thread), that design is gone:ExecuteMessageis immutable once constructed —skip_metaandresult_metadata_idare set from the prepared statement's cached metadata at construction time (in_create_response_future), same as before this extension existed.send_bodydecides 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 emptyb''sentinel when the statement has no id yet (mixed cluster / rolling upgrade)._SKIP_METADATA_FLAGis 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:
_query()mutated the one sharedExecuteMessageper 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._query_control_connectionnever 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 insend_bodyfrom the connection actually being used.Other review threads resolved
PreparedStatementnow stores(result_metadata, result_metadata_id)as one tuple replaced in a single attribute assignment (update_result_metadata()), withresult_metadata/result_metadata_idas 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 atupdate_result_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.METADATA_CHANGEDresponse; no client restart needed. Also reworded the section's opening paragraph soskip_metareads as conditional on the extension, not pre-existing default behavior.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_metadataisNonefor statements withNO_METADATAin their PREPARE response (LWT/conditional statements) and[]for statements returning zero columns (plain INSERT/UPDATE/DELETE). Both are falsy, and both correctly keepskip_metaoff — there's nothing to decode against in either case. Documented in a code comment at the construction site.Changes
cassandra/protocol_features.pyUSE_METADATA_ID = "SCYLLA_USE_METADATA_ID"constant anduse_metadata_idfield toProtocolFeaturescassandra/protocol.py_SKIP_METADATA_FLAGis now actually written to the wire — it was stored on_QueryMessagebut never sent (effectively dead code upstream)recv_results_prepared: readresult_metadata_idfor 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 theb''sentinel when the statement has no idcassandra/query.pyPreparedStatement.result_metadata/result_metadata_idbecome properties over a single(result_metadata, result_metadata_id)tuple; addupdate_result_metadata()for atomic pair replacementcassandra/cluster.py_create_response_future: snapshot the metadata pair once, constructExecuteMessageimmutably withskip_meta/result_metadata_idset from that snapshot_set_result: onMETADATA_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 nonedocs/scylla-specific.rstCHANGELOG.rstFeaturesentry for the extensionLive-server verification of the
b''sentinelThe 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#758addsPreparedStatementIT.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 — itsTest_framer_writeExecuteFrameunit test asserts a nil id serializes to a zero-length short-bytes field, the same convention as this PR'sb''. 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:test_empty_sentinel_id_triggers_metadata_changedis the direct check: prepare a statement, force itsresult_metadata_idback toNoneviaupdate_result_metadata()(simulating a statement prepared before the extension was known), execute it, and confirm both that no error is raised and that a freshresult_metadata_idcomes back afterward — proving Scylla treats the sentinel asMETADATA_CHANGED, not a protocol error.Test plan
test_protocol_features.py,test_protocol.py,test_query.py,test_response_future.pycovering: feature negotiation, STARTUP options, wire-format assertions for the metadata-id field and skip-metadata flag (present/suppressed, sentinel forNone, v4/v5), PREPARE response decoding with/without the extension, atomic metadata-pair replacement (constructor,update_result_metadata, compatibility setters),_create_response_futureconstruction gating (id present/absent, LWT/NO_METADATA, zero-column), a regression proving_querysends the message unmutated (the old speculative-execution race),_set_resultMETADATA_CHANGED and the anomalous-response warning path (nothing cached), and_execute_after_preparereprepare handlingbuild_ext --inplace) compiles the reworked signatures; tests pass against the compiled modulesPre-review checklist
./docs/.Fixes:annotations to PR description.