Skip to content

Commit 634b2d4

Browse files
nikagraclaude
andcommitted
scylla,conn,frame: negotiate SCYLLA_USE_METADATA_ID and skip result metadata 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. One framer is still not built from framerConfig: framerPool.get falls back to newFramer when the pool is disabled, which yields scyllaUseMetadataID false whatever was negotiated. That is correct during the handshake, and unreachable from the request path afterwards, because execInternal takes its framer before addCall rejects a closed connection — so a framer taken after the pool closed belongs to a call that never writes a frame. The accessor's doc comment says so rather than claiming an invariant that holds by call ordering rather than by construction; scylladb#982 tracks the fix, which also covers flagLWT and tabletsRoutingV1. 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 that sets METADATA_CHANGED while also setting NO_METADATA is rejected. The combination is malformed — METADATA_CHANGED obliges the server to include the new metadata — and both ways of continuing are unrecoverable. Adopting the ID while keeping the old columns lets the server match it from then on and stop sending metadata, leaving the driver decoding against stale columns indefinitely; the python-driver guards that identically. But returning the response's own rows is the same misdecode one execute earlier: the server has just declared those columns stale, and the skip-metadata path below would decode against them anyway. So do neither, and fail the query with the old ID left in the cache — a retry resends it and the server gets another chance to answer with the metadata it owes. 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 does not run at the suite's default protocol, since TEST_CQL_PROTOCOL is pinned to 4; the tests commit later in this series gives it an explicit run against Cassandra 5. 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) <noreply@anthropic.com>
1 parent 4bee517 commit 634b2d4

15 files changed

Lines changed: 928 additions & 189 deletions

cassandra_test.go

Lines changed: 126 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3657,30 +3657,119 @@ func TestQueryCompressionNotWorthIt(t *testing.T) {
36573657
require.Equal(t, str, result)
36583658
}
36593659

3660-
// This test ensures that the whole Metadata_changed flow
3661-
// is handled properly.
3660+
// This test ensures that the whole Metadata_changed flow is handled properly.
36623661
//
3663-
// To trigger C* to return Metadata_changed we should do:
3662+
// To trigger the server to return Metadata_changed we should do:
36643663
// 1. Create a table
36653664
// 2. Prepare stmt which uses the created table
36663665
// 3. Change the table schema in order to affect prepared stmt (e.g. add a column)
3667-
// 4. Execute prepared stmt. As a result C* should return RESULT/ROWS response with
3668-
// Metadata_changed flag, new metadata id and updated metadata resultset.
3666+
// 4. Execute prepared stmt. As a result the server should return RESULT/ROWS
3667+
// response with Metadata_changed flag, new metadata id and updated metadata
3668+
// resultset.
36693669
//
36703670
// The driver should handle this by updating its prepared statement inside the cache
3671-
// when it receives RESULT/ROWS with Metadata_changed flag
3671+
// when it receives RESULT/ROWS with Metadata_changed flag.
3672+
//
3673+
// It runs for both ways the result-metadata-ID exchange can be active on a
3674+
// connection: native protocol v5, where the field is mandatory, and protocol v4
3675+
// with Scylla's SCYLLA_USE_METADATA_ID extension, which backports it. The two share
3676+
// the whole read and cache-update path (framer.parseResultMetadata and the
3677+
// RESULT/Rows case in Conn.executeQueryWithMetrics), so they are driven through one
3678+
// flow rather than two copies of it.
3679+
//
3680+
// Exactly one case runs per invocation, because the protocol version is fixed for
3681+
// the whole suite by the -proto flag. Note that the v5 case never runs in CI:
3682+
// TEST_CQL_PROTOCOL is pinned to 4 in the Makefile and no workflow overrides it, so
3683+
// reaching it takes an explicit `TEST_CQL_PROTOCOL=5 make test-integration-cassandra`.
36723684
func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
3673-
session := createSession(t)
3674-
defer session.Close()
3685+
for _, tc := range []struct {
3686+
name string
3687+
table string
3688+
// gate returns a reason to skip, or "" to run. It may also fail outright, for
3689+
// a state that must not be allowed to pass as a skip.
3690+
gate func(t *testing.T, session *Session, conn *Conn) string
3691+
}{
3692+
{
3693+
name: "native protocol v5",
3694+
table: "metadata_changed",
3695+
gate: func(t *testing.T, session *Session, conn *Conn) string {
3696+
if session.cfg.ProtoVersion < protoVersion5 {
3697+
return "Metadata_changed mechanism is only available in proto > 4"
3698+
}
3699+
if *flagDistribution == "scylla" && flagCassVersion.Before(2025, 3, 0) {
3700+
return "ScyllaDB before 2025.3 does not exchange result metadata ids"
3701+
}
3702+
return ""
3703+
},
3704+
},
3705+
{
3706+
name: "protocol v4 with SCYLLA_USE_METADATA_ID",
3707+
table: "scylla_metadata_changed",
3708+
gate: func(t *testing.T, session *Session, conn *Conn) string {
3709+
// Skip only for a server that cannot do this at all. If the server
3710+
// advertised SCYLLA_USE_METADATA_ID and the driver still failed to
3711+
// negotiate it, that is a regression — and since this is the only
3712+
// end-to-end coverage of the extension, skipping would turn that
3713+
// regression green. Fail instead.
3714+
if !conn.scyllaSupported.IsMetadataIDSupported() {
3715+
return "server does not advertise SCYLLA_USE_METADATA_ID"
3716+
}
3717+
if !conn.usesMetadataID() {
3718+
t.Fatal("server advertises SCYLLA_USE_METADATA_ID but the driver did not negotiate it")
3719+
}
3720+
return ""
3721+
},
3722+
},
3723+
} {
3724+
t.Run(tc.name, func(t *testing.T) {
3725+
session := createSession(t)
3726+
defer session.Close()
36753727

3676-
if session.cfg.ProtoVersion < protoVersion5 || (*flagDistribution == "scylla" && flagCassVersion.Before(2025, 3, 0)) {
3677-
t.Skip("Metadata_changed mechanism is only available in proto > 4")
3728+
// We have to specify conn for all queries to ensure that
3729+
// all queries are running on the same node
3730+
conn := session.getConn()
3731+
if conn == nil {
3732+
t.Skip("no connection available")
3733+
}
3734+
if reason := tc.gate(t, session, conn); reason != "" {
3735+
t.Skip(reason)
3736+
}
3737+
3738+
runMetadataChangedFlow(t, session, conn, tc.table)
3739+
})
36783740
}
3741+
}
3742+
3743+
// runMetadataChangedFlow drives the METADATA_CHANGED flow against
3744+
// gocql_test.<table>, with every statement pinned to conn so they all land on the
3745+
// node whose prepared-statement cache entry is being inspected.
3746+
func runMetadataChangedFlow(t *testing.T, session *Session, conn *Conn, table string) {
3747+
t.Helper()
3748+
3749+
qualified := "gocql_test." + table
36793750

3680-
if err := createTable(session, "CREATE TABLE IF NOT EXISTS gocql_test.metadata_changed(id int, PRIMARY KEY (id))"); err != nil {
3751+
// Drop rather than CREATE IF NOT EXISTS: the flow ALTERs the table, so a table
3752+
// left behind by an earlier run already has new_col and the ALTER below would
3753+
// fail before anything is exercised.
3754+
if err := createTable(session, "DROP TABLE IF EXISTS "+qualified); err != nil {
3755+
t.Fatal(err)
3756+
}
3757+
if err := createTable(session, "CREATE TABLE "+qualified+"(id int, PRIMARY KEY (id))"); err != nil {
36813758
t.Fatal(err)
36823759
}
36833760

3761+
// Bound every query, not only the ones after the schema change: the first
3762+
// response after it is the one that actually exercises METADATA_CHANGED, so it is
3763+
// the most likely to hang and the least useful to leave to the package timeout.
3764+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
3765+
defer cancel()
3766+
3767+
pinned := func(stmt string, values ...any) *Query {
3768+
q := session.Query(stmt, values...).WithContext(ctx)
3769+
q.conn = conn
3770+
return q
3771+
}
3772+
36843773
type record struct {
36853774
id int
36863775
newCol int
@@ -3689,21 +3778,14 @@ func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
36893778
firstRecord := record{
36903779
id: 1,
36913780
}
3692-
err := session.Query("INSERT INTO gocql_test.metadata_changed (id) VALUES (?)", firstRecord.id).Exec()
3693-
if err != nil {
3781+
if err := pinned("INSERT INTO "+qualified+" (id) VALUES (?)", firstRecord.id).Exec(); err != nil {
36943782
t.Fatal(err)
36953783
}
36963784

3697-
// We have to specify conn for all queries to ensure that
3698-
// all queries are running on the same node
3699-
conn := session.getConn()
3700-
3701-
const selectStmt = "SELECT * FROM gocql_test.metadata_changed"
3702-
queryBeforeTableAltering := session.Query(selectStmt)
3703-
queryBeforeTableAltering.conn = conn
3785+
selectStmt := "SELECT * FROM " + qualified
3786+
queryBeforeTableAltering := pinned(selectStmt)
37043787
row := make(map[string]interface{})
3705-
err = queryBeforeTableAltering.MapScan(row)
3706-
if err != nil {
3788+
if err := queryBeforeTableAltering.MapScan(row); err != nil {
37073789
t.Fatal(err)
37083790
}
37093791

@@ -3714,34 +3796,28 @@ func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
37143796
inflight, _ := session.stmtsLRU.get(stmtCacheKey)
37153797
preparedStatementBeforeTableAltering := inflight.preparedStatment
37163798

3717-
// Changing table schema in order to cause C* to return RESULT/ROWS Metadata_changed
3718-
alteringTableQuery := session.Query("ALTER TABLE gocql_test.metadata_changed ADD new_col int")
3719-
alteringTableQuery.conn = conn
3720-
err = alteringTableQuery.Exec()
3721-
if err != nil {
3799+
// Change the table schema so the server returns RESULT/Rows with METADATA_CHANGED.
3800+
if err := pinned("ALTER TABLE " + qualified + " ADD new_col int").Exec(); err != nil {
37223801
t.Fatal(err)
37233802
}
37243803

37253804
secondRecord := record{
37263805
id: 2,
37273806
newCol: 10,
37283807
}
3729-
err = session.Query("INSERT INTO gocql_test.metadata_changed (id, new_col) VALUES (?, ?)", secondRecord.id, secondRecord.newCol).
3730-
Exec()
3731-
if err != nil {
3808+
if err := pinned("INSERT INTO "+qualified+" (id, new_col) VALUES (?, ?)", secondRecord.id, secondRecord.newCol).Exec(); err != nil {
37323809
t.Fatal(err)
37333810
}
37343811

3735-
// Handles result from iter and ensures integrity of the result,
3736-
// closes iter and handles error
3812+
// handleRows scans all rows from the iterator and verifies the values.
37373813
handleRows := func(iter *Iter) {
37383814
t.Helper()
37393815

37403816
var scannedID int
3741-
var scannedNewCol *int // to perform null values
3817+
var scannedNewCol *int // to capture null values
37423818

3743-
// when the driver handling null values during unmarshalling
3744-
// it sets to dest type its zero value, which is (*int)(nil) for this case
3819+
// When the driver handles null values during unmarshalling it sets the
3820+
// destination to its zero value, which is (*int)(nil) for this case.
37453821
var nilIntPtr *int
37463822

37473823
// Collect all rows into a map to avoid order-dependent assertions.
@@ -3759,26 +3835,24 @@ func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
37593835
err := iter.Close()
37603836
if err != nil {
37613837
if errors.Is(err, context.DeadlineExceeded) {
3762-
t.Fatal("It is likely failed due deadlock")
3838+
t.Fatal("It is likely failed due to a deadlock")
37633839
}
37643840
t.Fatal(err)
37653841
}
37663842
}
37673843

3768-
// Expecting C* will return RESULT/ROWS Metadata_changed
3769-
// and it will be properly handled
3770-
queryAfterTableAltering := session.Query(selectStmt)
3771-
queryAfterTableAltering.conn = conn
3772-
iter := queryAfterTableAltering.Iter()
3773-
handleRows(iter)
3844+
// The first query after the schema change should trigger METADATA_CHANGED.
3845+
handleRows(pinned(selectStmt).Iter())
37743846

3775-
// Ensuring if cache contains updated prepared statement
3847+
// The prepared statement cache must have been updated with the new metadata ID.
37763848
inflight, _ = session.stmtsLRU.get(stmtCacheKey)
37773849
preparedStatementAfterTableAltering := inflight.preparedStatment
37783850
require.NotEqual(t, preparedStatementBeforeTableAltering.resultMetadataID, preparedStatementAfterTableAltering.resultMetadataID)
37793851
require.NotEqual(t, preparedStatementBeforeTableAltering.response, preparedStatementAfterTableAltering.response)
37803852

3781-
// FORCE SEND OLD RESULT METADATA ID (https://issues.apache.org/jira/browse/CASSANDRA-20028)
3853+
// Force the driver to send the old (stale) result metadata ID, to verify the
3854+
// server still signals the change when the driver's id is outdated.
3855+
// (https://issues.apache.org/jira/browse/CASSANDRA-20028)
37823856
closedCh := make(chan struct{})
37833857
close(closedCh)
37843858
session.stmtsLRU.add(stmtCacheKey, &inflightPrepare{
@@ -3787,15 +3861,7 @@ func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
37873861
preparedStatment: preparedStatementBeforeTableAltering,
37883862
})
37893863

3790-
// Running query with timeout to ensure there is no deadlocks.
3791-
// However, it doesn't 100% proves that there is a deadlock...
3792-
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
3793-
defer cancel()
3794-
3795-
queryAfterTableAltering2 := session.Query(selectStmt).WithContext(ctx)
3796-
queryAfterTableAltering2.conn = conn
3797-
iter = queryAfterTableAltering2.Iter()
3798-
handleRows(iter)
3864+
handleRows(pinned(selectStmt).Iter())
37993865

38003866
inflight, _ = session.stmtsLRU.get(stmtCacheKey)
38013867
preparedStatementAfterTableAltering2 := inflight.preparedStatment
@@ -3806,18 +3872,15 @@ func TestPrepareExecuteMetadataChangedFlag(t *testing.T) {
38063872
require.NotEqual(t, preparedStatementAfterTableAltering.response, preparedStatementAfterTableAltering2.response) // METADATA_CHANGED flag
38073873
require.True(t, preparedStatementAfterTableAltering2.response.flags&frm.FlagMetaDataChanged != 0)
38083874

3809-
// Executing prepared stmt and expecting that C* won't return
3810-
// Metadata_changed because the table is not being changed.
3811-
queryAfterTableAltering3 := session.Query(selectStmt).WithContext(ctx)
3812-
queryAfterTableAltering3.conn = conn
3813-
iter = queryAfterTableAltering3.Iter()
3814-
handleRows(iter)
3875+
// A subsequent query carrying the correct (updated) metadata ID must not trigger
3876+
// METADATA_CHANGED, which means the cache entry must not be replaced at all.
3877+
// Assert that by pointer identity: comparing the entry's fields would compare it
3878+
// with itself, so such an assertion could never fail.
3879+
handleRows(pinned(selectStmt).Iter())
38153880

3816-
// Ensuring metadata of prepared stmt is not changed
38173881
inflight, _ = session.stmtsLRU.get(stmtCacheKey)
3818-
preparedStatementAfterTableAltering3 := inflight.preparedStatment
3819-
require.Equal(t, preparedStatementAfterTableAltering2.resultMetadataID, preparedStatementAfterTableAltering3.resultMetadataID)
3820-
require.Equal(t, preparedStatementAfterTableAltering2.response, preparedStatementAfterTableAltering3.response)
3882+
require.Same(t, preparedStatementAfterTableAltering2, inflight.preparedStatment,
3883+
"the cached prepared statement should not have been replaced")
38213884
}
38223885

38233886
func TestStmtCacheUsesOverriddenKeyspace(t *testing.T) {

cluster.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,36 @@ type ClusterConfig struct {
270270
// the metadata to parse the rows and will not reuse the metadata from the prepared
271271
// statement.
272272
//
273+
// This flag exists because prepared-statement result metadata could not be
274+
// invalidated safely: after an ALTER the server kept answering with the old
275+
// column set, so reusing the cached metadata could misdecode rows. It defaults
276+
// to true for that reason.
277+
//
278+
// This flag is IGNORED — including when it was set to true explicitly — once the
279+
// connection exchanges result metadata IDs and the driver holds one for the
280+
// statement. That is the case on native protocol v5, and on protocol v4 once the
281+
// SCYLLA_USE_METADATA_ID extension is negotiated. Either way the underlying
282+
// problem is fixed: the server hands out a result metadata ID at prepare time,
283+
// the driver returns it with every execute, and the server answers a stale ID
284+
// with METADATA_CHANGED plus fresh metadata. Skipping is then safe, so the driver
285+
// skips and this workaround no longer applies.
286+
//
287+
// This matches scylladb/scylla-drivers#81, which specifies skipping as the safe
288+
// default "if SCYLLA_USE_METADATA_ID was negotiated or CQL v5 is used", and the
289+
// Scylla java-driver, which skips for any non-empty result metadata ID. The
290+
// Scylla python-driver implements the extension half only.
291+
//
292+
// There is deliberately no knob to force metadata for a whole session once an ID
293+
// is in play (the java-driver's skip-cql4-metadata-resolve-method has no
294+
// equivalent here). Use Query.NoSkipMetadata for a specific query; ScanCAS and
295+
// MapScanCAS already do so internally.
296+
//
273297
// See https://issues.apache.org/jira/browse/CASSANDRA-10786
274298
// See https://github.com/scylladb/scylladb/issues/20860
299+
// See https://github.com/scylladb/scylladb/pull/23292
275300
//
276-
// Default: true
301+
// Default: true, and has no effect on a connection that exchanges result
302+
// metadata IDs.
277303
DisableSkipMetadata bool
278304
// DisableShardAwarePort will prevent the driver from connecting to Scylla's shard-aware port,
279305
// even if there are nodes in the cluster that support it.
@@ -654,7 +680,14 @@ func (cfg *ClusterConfig) Validate() error {
654680
}
655681

656682
if !cfg.DisableSkipMetadata {
657-
cfg.Logger.Println("warning: enabling skipping metadata can lead to unpredictable results when executing query and altering columns involved in the query.")
683+
// The hazard this warns about is confined to connections that exchange no
684+
// result metadata ID: protocol v4 or lower against a server that does not
685+
// advertise SCYLLA_USE_METADATA_ID. Everywhere else the server reports
686+
// metadata changes via METADATA_CHANGED, DisableSkipMetadata is ignored, and
687+
// skipping is both safe and the default — so say which case is actually risky.
688+
// The protocol version is negotiated per connection, long after Validate runs,
689+
// so this cannot be narrowed down here.
690+
cfg.Logger.Println("warning: skipping result metadata can lead to unpredictable results if columns involved in a prepared query are altered, on connections that exchange no result metadata ID (protocol v4 or lower without the SCYLLA_USE_METADATA_ID extension).")
658691
}
659692

660693
if cfg.SerialConsistency > 0 && !cfg.SerialConsistency.IsSerial() {

0 commit comments

Comments
 (0)