Skip to content

Commit faa38a8

Browse files
committed
Disable skip metadata for CQL4 for broken cases
CQL <= 4 has multiple flaws in regard to prepared statement invalidation, it is effectively completely broken. As a result, the driver may read data that does not match the cached metadata, leading to deserialization failures or incorrect deserialization. To address it, this commit introduce: `PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4(advanced.prepared-statements.disable-skip-metadata-for-cql4)` Which allows to control skip metadata flag for older protocols. It is defaulted to true, to make driver behave properly in all scenarios. More info at: scylladb/scylladb#20860
1 parent ce575f3 commit faa38a8

File tree

10 files changed

+209
-7
lines changed

10 files changed

+209
-7
lines changed

core/src/main/java/com/datastax/dse/driver/internal/core/cql/DseConversions.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry;
4040
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
4141
import com.datastax.oss.driver.internal.core.cql.Conversions;
42+
import com.datastax.oss.driver.internal.core.cql.DefaultPreparedStatement;
4243
import com.datastax.oss.protocol.internal.Message;
4344
import com.datastax.oss.protocol.internal.request.Execute;
4445
import com.datastax.oss.protocol.internal.request.Query;
@@ -115,8 +116,15 @@ public static Message toContinuousPagingMessage(
115116
protocolVersion, DefaultProtocolFeature.UNSET_BOUND_VALUES)) {
116117
Conversions.ensureAllSet(boundStatement);
117118
}
118-
boolean skipMetadata =
119-
boundStatement.getPreparedStatement().getResultSetDefinitions().size() > 0;
119+
120+
boolean skipMetadata;
121+
if (boundStatement.getPreparedStatement() instanceof DefaultPreparedStatement) {
122+
skipMetadata =
123+
((DefaultPreparedStatement) boundStatement.getPreparedStatement()).isSkipMetadata();
124+
} else {
125+
skipMetadata = boundStatement.getPreparedStatement().getResultSetDefinitions().size() > 0;
126+
;
127+
}
120128
DseQueryOptions queryOptions =
121129
new DseQueryOptions(
122130
consistencyCode,

core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,23 @@ public enum DefaultDriverOption implements DriverOption {
689689
* <p>Value-type: boolean
690690
*/
691691
PREPARE_ON_ALL_NODES("advanced.prepared-statements.prepare-on-all-nodes"),
692+
693+
/**
694+
* CQL <= 4 has multiple flaws in regard to prepared statement invalidation, it is effectively
695+
* completely broken. As a result, the driver may read data that does not match the cached
696+
* metadata, leading to deserialization failures or incorrect deserialization. To address it, by
697+
* default, driver is configured to make server send metadata with every response, via setting
698+
* skip metadata flag to `false` for older protocols. Unfortunately it leads to reduced
699+
* performance both on driver and server side and to excessive traffic.
700+
*
701+
* <p>To avoid can set given flag to `false` and do the following: 1. Avoid altering table or
702+
* types under use 2. Make sure that right after alter prepared statements are being re-prepared
703+
*
704+
* <p>Value-type: boolean
705+
*/
706+
PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4(
707+
"advanced.prepared-statements.disable-skip-metadata-for-cql4"),
708+
692709
/**
693710
* Whether the driver tries to prepare on new nodes at all.
694711
*

core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) {
258258
map.put(TypedDriverOption.REQUEST_TIMEOUT, requestTimeout);
259259
map.put(TypedDriverOption.REQUEST_CONSISTENCY, "LOCAL_ONE");
260260
map.put(TypedDriverOption.REQUEST_PAGE_SIZE, requestPageSize);
261+
map.put(TypedDriverOption.PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4, true);
261262
map.put(TypedDriverOption.REQUEST_SERIAL_CONSISTENCY, "SERIAL");
262263
map.put(TypedDriverOption.REQUEST_DEFAULT_IDEMPOTENCE, false);
263264
map.put(TypedDriverOption.GRAPH_TRAVERSAL_SOURCE, "g");

core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,10 @@ public String toString() {
583583
/** Whether `Session.prepare` calls should be sent to all nodes in the cluster. */
584584
public static final TypedDriverOption<Boolean> PREPARE_ON_ALL_NODES =
585585
new TypedDriverOption<>(DefaultDriverOption.PREPARE_ON_ALL_NODES, GenericType.BOOLEAN);
586+
/** Disable skip metadata flag for CQL4. */
587+
public static final TypedDriverOption<Boolean> PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4 =
588+
new TypedDriverOption<>(
589+
DefaultDriverOption.PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4, GenericType.BOOLEAN);
586590
/** Whether the driver tries to prepare on new nodes at all. */
587591
public static final TypedDriverOption<Boolean> REPREPARE_ENABLED =
588592
new TypedDriverOption<>(DefaultDriverOption.REPREPARE_ENABLED, GenericType.BOOLEAN);

core/src/main/java/com/datastax/oss/driver/internal/core/cql/Conversions.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,15 @@ public static Message toMessage(
194194
protocolVersion, DefaultProtocolFeature.UNSET_BOUND_VALUES)) {
195195
ensureAllSet(boundStatement);
196196
}
197-
boolean skipMetadata =
198-
boundStatement.getPreparedStatement().getResultSetDefinitions().size() > 0;
197+
198+
boolean skipMetadata;
199+
if (boundStatement.getPreparedStatement() instanceof DefaultPreparedStatement) {
200+
skipMetadata =
201+
((DefaultPreparedStatement) boundStatement.getPreparedStatement()).isSkipMetadata();
202+
} else {
203+
skipMetadata = boundStatement.getPreparedStatement().getResultSetDefinitions().size() > 0;
204+
}
205+
199206
QueryOptions queryOptions =
200207
new QueryOptions(
201208
consistencyCode,
@@ -382,6 +389,12 @@ public static DefaultPreparedStatement toPreparedStatement(
382389

383390
Partitioner partitioner = PartitionerFactory.partitioner(variableDefinitions, context);
384391

392+
DriverExecutionProfile defaultExecutionProfile =
393+
(request.getExecutionProfileNameForBoundStatements() == null
394+
|| request.getExecutionProfileNameForBoundStatements().isEmpty())
395+
? context.getConfig().getDefaultProfile()
396+
: context.getConfig().getProfile(request.getExecutionProfileNameForBoundStatements());
397+
385398
return new DefaultPreparedStatement(
386399
ByteBuffer.wrap(response.preparedQueryId).asReadOnlyBuffer(),
387400
request.getQuery(),
@@ -409,7 +422,8 @@ public static DefaultPreparedStatement toPreparedStatement(
409422
request.areBoundStatementsTracing(),
410423
context.getCodecRegistry(),
411424
context.getProtocolVersion(),
412-
lwtInfo != null && lwtInfo.isLwt(response.variablesMetadata.flags));
425+
lwtInfo != null && lwtInfo.isLwt(response.variablesMetadata.flags),
426+
defaultExecutionProfile);
413427
}
414428

415429
public static ColumnDefinitions toColumnDefinitions(

core/src/main/java/com/datastax/oss/driver/internal/core/cql/DefaultPreparedStatement.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.datastax.oss.driver.api.core.ConsistencyLevel;
2727
import com.datastax.oss.driver.api.core.CqlIdentifier;
2828
import com.datastax.oss.driver.api.core.ProtocolVersion;
29+
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
2930
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
3031
import com.datastax.oss.driver.api.core.cql.BoundStatement;
3132
import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder;
@@ -43,9 +44,12 @@
4344
import java.util.List;
4445
import java.util.Map;
4546
import net.jcip.annotations.ThreadSafe;
47+
import org.slf4j.Logger;
48+
import org.slf4j.LoggerFactory;
4649

4750
@ThreadSafe
4851
public class DefaultPreparedStatement implements PreparedStatement {
52+
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultPreparedStatement.class);
4953

5054
private final ByteBuffer id;
5155
private final RepreparePayload repreparePayload;
@@ -69,6 +73,8 @@ public class DefaultPreparedStatement implements PreparedStatement {
6973
private final Duration timeoutForBoundStatements;
7074
private final Partitioner partitioner;
7175
private final boolean isLWT;
76+
private volatile boolean skipMetadata;
77+
private final DriverExecutionProfile defaultExecutionProfile;
7278

7379
public DefaultPreparedStatement(
7480
ByteBuffer id,
@@ -95,7 +101,8 @@ public DefaultPreparedStatement(
95101
boolean areBoundStatementsTracing,
96102
CodecRegistry codecRegistry,
97103
ProtocolVersion protocolVersion,
98-
boolean isLWT) {
104+
boolean isLWT,
105+
DriverExecutionProfile defaultExecutionProfile) {
99106
this.id = id;
100107
this.partitionKeyIndices = partitionKeyIndices;
101108
// It's important that we keep a reference to this object, so that it only gets evicted from
@@ -122,6 +129,14 @@ public DefaultPreparedStatement(
122129
this.codecRegistry = codecRegistry;
123130
this.protocolVersion = protocolVersion;
124131
this.isLWT = isLWT;
132+
this.defaultExecutionProfile = defaultExecutionProfile;
133+
this.skipMetadata =
134+
resolveSkipMetadata(
135+
resultMetadataId,
136+
resultSetDefinitions,
137+
executionProfileForBoundStatements != null
138+
? executionProfileForBoundStatements
139+
: this.defaultExecutionProfile);
125140
}
126141

127142
@NonNull
@@ -147,6 +162,10 @@ public Partitioner getPartitioner() {
147162
return partitioner;
148163
}
149164

165+
public boolean isSkipMetadata() {
166+
return skipMetadata;
167+
}
168+
150169
@NonNull
151170
@Override
152171
public List<Integer> getPartitionKeyIndices() {
@@ -172,6 +191,14 @@ public boolean isLWT() {
172191
@Override
173192
public void setResultMetadata(
174193
@NonNull ByteBuffer newResultMetadataId, @NonNull ColumnDefinitions newResultSetDefinitions) {
194+
this.skipMetadata =
195+
resolveSkipMetadata(
196+
newResultMetadataId,
197+
newResultSetDefinitions,
198+
executionProfileForBoundStatements != null
199+
? executionProfileForBoundStatements
200+
: this.defaultExecutionProfile);
201+
175202
this.resultMetadata = new ResultMetadata(newResultMetadataId, newResultSetDefinitions);
176203
}
177204

@@ -242,4 +269,43 @@ private ResultMetadata(ByteBuffer resultMetadataId, ColumnDefinitions resultSetD
242269
this.resultSetDefinitions = resultSetDefinitions;
243270
}
244271
}
272+
273+
private static boolean resolveSkipMetadata(
274+
ByteBuffer resultMetadataId,
275+
ColumnDefinitions resultSet,
276+
DriverExecutionProfile executionProfileForBoundStatements) {
277+
278+
if (resultSet == null || resultSet.size() == 0) {
279+
// there is no reason to send this flag, there will be no rows in the response and,
280+
// consequently, no metadata.
281+
return false;
282+
}
283+
if (resultMetadataId != null && resultMetadataId.capacity() > 0) {
284+
// It is CQL 5 or higher.
285+
// Prepared statement invalidation works perfectly no need to disable skip metadata
286+
return true;
287+
}
288+
289+
// It is CQL 4 or lower.
290+
boolean disableSkipMetadata = true;
291+
if (executionProfileForBoundStatements != null) {
292+
disableSkipMetadata =
293+
executionProfileForBoundStatements.getBoolean(
294+
DefaultDriverOption.PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4);
295+
}
296+
297+
if (disableSkipMetadata) {
298+
return false;
299+
}
300+
301+
LOGGER.warn(
302+
"Due to the flaws in CQL <=4 protocol using prepared statements with skip metadata flag can lead to "
303+
+ "prepared statement invalidation issues when table or returned type is altered."
304+
+ "These issues may lead to broken deserialization or data corruption. "
305+
+ "To avoid this you have following options:"
306+
+ "1. Avoid altering table or types under use, "
307+
+ "2. Make sure that right after alter prepared statements are being re-prepared, "
308+
+ "3. Set disable-skip-metadata-for-cql4 to true to make server return metadata with each response. It goes with negative performance impact.");
309+
return true;
310+
}
245311
}

core/src/main/resources/reference.conf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,6 +2182,22 @@ datastax-java-driver {
21822182
# Overridable in a profile: yes
21832183
prepare-on-all-nodes = true
21842184

2185+
# There is known problem in CQL 4.x when prepared statement invalidation could be voided:
2186+
# https://github.com/scylladb/scylladb/issues/20860
2187+
# When it happens metadata on client side does not match data and deserialization can go wrong in many ways
2188+
# To avoid driver can disable skip metadata flag to make server respond with metadata on every query.
2189+
# Unfortunately it causes excessive network traffic and CPU overhead on both server and driver side.
2190+
# This option controls how driver resolves skip metadata flag for CQL4 prepared statements.
2191+
# "smart" - disable flag only for wildcard selects (select * from) and selects that
2192+
# return UDTs, including collections of UDTs and maps that contain UDTs
2193+
# "always-on" - flag is always set
2194+
# "always-off" - flag is always disabled
2195+
# Default is "smart"
2196+
# Required: yes
2197+
# Modifiable at runtime: yes, the new value will be used for requests issued after the change.
2198+
# Overridable in a profile: yes
2199+
disable-skip-metadata-for-cql4 = smart
2200+
21852201
# How the driver replicates prepared statements on a node that just came back up or joined the
21862202
# cluster.
21872203
reprepare-on-up {

core/src/test/java/com/datastax/oss/driver/internal/core/cql/RequestHandlerTestHarness.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ protected RequestHandlerTestHarness(Builder builder) {
122122
when(defaultProfile.getBoolean(DefaultDriverOption.REQUEST_DEFAULT_IDEMPOTENCE))
123123
.thenReturn(builder.defaultIdempotence);
124124
when(defaultProfile.getBoolean(DefaultDriverOption.PREPARE_ON_ALL_NODES)).thenReturn(true);
125+
when(defaultProfile.getBoolean(DefaultDriverOption.PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4))
126+
.thenReturn(true);
125127

126128
when(config.getDefaultProfile()).thenReturn(defaultProfile);
127129
when(context.getConfig()).thenReturn(config);

core/src/test/java/com/datastax/oss/driver/internal/core/tracker/RequestLogFormatterTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ private PreparedStatement mockPreparedStatement(String query, Map<String, DataTy
294294
false,
295295
context.getCodecRegistry(),
296296
context.getProtocolVersion(),
297-
false);
297+
false,
298+
null);
298299
}
299300
}

integration-tests/src/test/java/com/datastax/oss/driver/core/cql/BoundStatementCcmIT.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@
5656
import com.datastax.oss.driver.internal.core.DefaultProtocolFeature;
5757
import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry;
5858
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
59+
import com.datastax.oss.driver.internal.core.cql.DefaultPreparedStatement;
5960
import com.datastax.oss.driver.internal.core.util.RoutingKey;
6061
import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures;
6162
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
6263
import com.datastax.oss.protocol.internal.util.Bytes;
6364
import com.datastax.oss.protocol.internal.util.collection.NullAllowingImmutableMap;
65+
import com.tngtech.java.junit.dataprovider.DataProvider;
66+
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
67+
import com.tngtech.java.junit.dataprovider.UseDataProvider;
6468
import java.nio.ByteBuffer;
6569
import java.time.Duration;
6670
import java.util.List;
@@ -75,8 +79,10 @@
7579
import org.junit.rules.RuleChain;
7680
import org.junit.rules.TestName;
7781
import org.junit.rules.TestRule;
82+
import org.junit.runner.RunWith;
7883

7984
@Category(ParallelizableTests.class)
85+
@RunWith(DataProviderRunner.class)
8086
public class BoundStatementCcmIT {
8187

8288
private CcmRule ccmRule = CcmRule.getInstance();
@@ -397,6 +403,59 @@ public void should_set_all_occurrences_of_variable() {
397403
should_set_all_occurrences_of_variable(ps.boundStatementBuilder().setInt(id, 12).build());
398404
}
399405

406+
@DataProvider
407+
public static Object[][] disableSkipMetadataForCql4() {
408+
return new Object[][] {
409+
{
410+
true,
411+
},
412+
{
413+
false,
414+
}
415+
};
416+
}
417+
418+
@Test
419+
@UseDataProvider(value = "disableSkipMetadataForCql4")
420+
public void prepared_stmt_metadata_update_loopholes_test(boolean disableSkipMetadataForCql4) {
421+
// v0 is an int column, but we'll bind a String to it
422+
try (CqlSession session =
423+
sessionWithSkipCQL4MetadataResolveMethod(disableSkipMetadataForCql4)) {
424+
String disableSkipMetadataForCql4Str = String.format("%b", disableSkipMetadataForCql4);
425+
426+
String table = String.format("skip_metadata_test_%s_table", disableSkipMetadataForCql4Str);
427+
428+
session.execute(String.format("CREATE TABLE %s (pk int, v int, PRIMARY KEY (pk))", table));
429+
430+
session.execute(String.format("INSERT INTO %s (pk, v) VALUES (1, 1)", table));
431+
432+
PreparedStatement stmt =
433+
session.prepare(String.format("SELECT * FROM %s WHERE pk = ?", table));
434+
435+
boolean isCQL4orLower = stmt.getResultMetadataId() == null;
436+
boolean isPreparedStatementInvalidationBroken = isCQL4orLower && !disableSkipMetadataForCql4;
437+
438+
if (isCQL4orLower) {
439+
assertThat(((DefaultPreparedStatement) stmt).isSkipMetadata())
440+
.isEqualTo(!disableSkipMetadataForCql4);
441+
}
442+
443+
Row row = session.execute(stmt.bind(1)).one();
444+
assertThat(row.getColumnDefinitions().size()).isEqualTo(2);
445+
446+
session.execute(String.format("ALTER TABLE %s ADD z int;", table));
447+
448+
int expectedColumnCount = 3;
449+
if (isPreparedStatementInvalidationBroken) {
450+
// When case of CQL4 and skip metadata is set prepared statements will not be invalidated.
451+
expectedColumnCount = 2;
452+
}
453+
454+
row = session.execute(stmt.bind(1)).one();
455+
assertThat(row.getColumnDefinitions().size()).isEqualTo(expectedColumnCount);
456+
}
457+
}
458+
400459
private void should_set_all_occurrences_of_variable(BoundStatement bs) {
401460
assertThat(bs.getInt(0)).isEqualTo(12);
402461
assertThat(bs.getInt(1)).isEqualTo(12);
@@ -448,6 +507,20 @@ private CqlSession sessionWithCustomCodec(CqlIntToStringCodec codec) {
448507
.build();
449508
}
450509

510+
private CqlSession sessionWithSkipCQL4MetadataResolveMethod(Boolean disableSkipMetadataForCql4) {
511+
return (CqlSession)
512+
SessionUtils.baseBuilder()
513+
.addContactEndPoints(ccmRule.getContactPoints())
514+
.withKeyspace(sessionRule.keyspace())
515+
.withConfigLoader(
516+
SessionUtils.configLoaderBuilder()
517+
.withBoolean(
518+
DefaultDriverOption.PREPARE_DISABLE_SKIP_METADATA_FOR_CQL4,
519+
disableSkipMetadataForCql4)
520+
.build())
521+
.build();
522+
}
523+
451524
private boolean supportsPerRequestKeyspace(CqlSession session) {
452525
InternalDriverContext context = (InternalDriverContext) session.getContext();
453526
ProtocolVersionRegistry protocolVersionRegistry = context.getProtocolVersionRegistry();

0 commit comments

Comments
 (0)