Skip to content

Commit 7bd041d

Browse files
committed
Merge branch 'cassandra-6.0' into trunk
* cassandra-6.0: Ensure SAI sends range tombstones to the coordinator for queries on static columns
2 parents 0cdde30 + 98f0a34 commit 7bd041d

3 files changed

Lines changed: 59 additions & 8 deletions

File tree

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Merged from 6.0:
2020
* Fix a removed TTLed row re-appearance in a materialized view after a cursor compaction (CASSANDRA-21152)
2121
* Rework ZSTD dictionary compression logic to create a trainer per training (CASSANDRA-21209)
2222
Merged from 5.0:
23+
* Ensure SAI sends range tombstones to the coordinator for queries on static columns (CASSANDRA-21332)
2324
Merged from 4.1:
2425
* Add Paxos v2 option and informatin in cassandra.yaml (CASSANDRA-21316)
2526
* Harden data resurrection startup check with atomic heartbeat file write with fallback (CASSANDRA-21290)

src/java/org/apache/cassandra/index/sai/plan/StorageAttachedIndexSearcher.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,10 @@ private UnfilteredRowIterator queryStorageAndFilter(List<PrimaryKey> keys)
503503
queryContext.partitionsRead++;
504504
queryContext.checkpoint();
505505

506-
List<Row> filtered = filterPartition(partition, filterTree, queryContext);
506+
// If there is an unresolved static expression during RFP, completion reads to silent replicas will
507+
// fetch entire partitions. If we don't return range tombstones in this initial read, there may not be
508+
// enough information at the coordinator for RFP to shadow logically deleted rows from those replicas.
509+
List<Unfiltered> filtered = filterPartition(partition, filterTree, queryContext, command.rowFilter().hasStaticExpression());
507510

508511
// Note that we record the duration of the read after post-filtering, which actually
509512
// materializes the rows from disk.
@@ -523,11 +526,11 @@ public void close()
523526
}
524527
}
525528

526-
private static List<Row> filterPartition(UnfilteredRowIterator partition, FilterTree tree, QueryContext context)
529+
private static List<Unfiltered> filterPartition(UnfilteredRowIterator partition, FilterTree tree, QueryContext context, boolean matchTombstones)
527530
{
528531
Row staticRow = partition.staticRow();
529532
DecoratedKey partitionKey = partition.partitionKey();
530-
List<Row> matches = new ArrayList<>();
533+
List<Unfiltered> matches = new ArrayList<>();
531534
boolean hasMatch = false;
532535

533536
while (partition.hasNext())
@@ -540,10 +543,15 @@ private static List<Row> filterPartition(UnfilteredRowIterator partition, Filter
540543

541544
if (tree.isSatisfiedBy(partitionKey, (Row) unfiltered, staticRow))
542545
{
543-
matches.add((Row) unfiltered);
546+
matches.add(unfiltered);
544547
hasMatch = true;
545548
}
546549
}
550+
else if (matchTombstones && unfiltered.isRangeTombstoneMarker())
551+
{
552+
// Note that range tombstones do not constitute matches, and will be discarded if no actual rows match.
553+
matches.add(unfiltered);
554+
}
547555
}
548556

549557
// We may not have any non-static row data to filter...
@@ -571,9 +579,9 @@ private static List<Row> filterPartition(UnfilteredRowIterator partition, Filter
571579

572580
private static class SinglePartitionIterator extends AbstractUnfilteredRowIterator
573581
{
574-
private final Iterator<Row> rows;
582+
private final Iterator<Unfiltered> rows;
575583

576-
public SinglePartitionIterator(UnfilteredRowIterator partition, Row staticRow, Iterator<Row> rows)
584+
public SinglePartitionIterator(UnfilteredRowIterator partition, Row staticRow, Iterator<Unfiltered> rows)
577585
{
578586
super(partition.metadata(),
579587
partition.partitionKey(),
@@ -766,7 +774,8 @@ public UnfilteredRowIterator readAndValidatePartition(PrimaryKey pk, List<Primar
766774
queryContext.partitionsRead++;
767775
queryContext.checkpoint();
768776

769-
List<Row> clusters = filterPartition(partition, filterTree, queryContext);
777+
// Scored queries do not involve RFP, and therefore can ignore range tombstones.
778+
List<Unfiltered> clusters = filterPartition(partition, filterTree, queryContext, false);
770779

771780
if (clusters == null)
772781
{
@@ -794,7 +803,7 @@ public UnfilteredRowIterator readAndValidatePartition(PrimaryKey pk, List<Primar
794803
processedKeys.add(pk);
795804
return null;
796805
}
797-
representativeRow = clusters.get(0);
806+
representativeRow = (Row) clusters.get(0);
798807
assert clusters.size() == 1 : "Expect 1 result row, but got: " + clusters.size();
799808
}
800809

test/distributed/org/apache/cassandra/distributed/test/sai/ReplicaFilteringWithStaticsTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,47 @@ public void testMissingStaticRowWithNonStaticExpression(boolean sai)
162162
assertRows(CLUSTER.coordinator(1).execute(select, ConsistencyLevel.ALL), row(0, 1, 2, 6, 7));
163163
}
164164

165+
@Test
166+
public void testRangeTombstoneWithStaticSAI()
167+
{
168+
testRangeTombstoneWithStatic(true);
169+
}
170+
171+
@Test
172+
public void testRangeTombstoneWithStatic()
173+
{
174+
testRangeTombstoneWithStatic(false);
175+
}
176+
177+
private void testRangeTombstoneWithStatic(boolean sai)
178+
{
179+
String table = "range_tombstone_with_static" + (sai ? "_sai" : "");
180+
CLUSTER.schemaChange(withKeyspace("CREATE TABLE %s." + table + " (pk0 int, ck0 boolean, ck1 double, s1 int static, v0 boolean," + " PRIMARY KEY (pk0, ck0, ck1)) WITH read_repair = 'NONE'"));
181+
disableCompaction(CLUSTER, KEYSPACE, table);
182+
183+
if (sai)
184+
{
185+
CLUSTER.schemaChange(withKeyspace("CREATE INDEX ON %s." + table + "(s1) USING 'sai'"));
186+
SAIUtil.waitForIndexQueryable(CLUSTER, KEYSPACE);
187+
}
188+
189+
// Node 3 gets a row at ck0=false with an old s1 value. This will be locally live but globally dead once the range tombstone on node 2 is considered.
190+
CLUSTER.get(3).executeInternal(withKeyspace("INSERT INTO %s." + table + " (pk0, ck0, ck1, s1, v0) VALUES (1, false, 1.0, 99, false) USING TIMESTAMP 1"));
191+
192+
// Node 2 gets a range tombstone covering all rows (ck0 is boolean, <= true covers everything). Nodes 1 and 3 never receive this.
193+
CLUSTER.get(2).executeInternal(withKeyspace("DELETE FROM %s." + table + " USING TIMESTAMP 2 WHERE pk0 = 1 AND ck0 <= true"));
194+
195+
// Node 2 also gets a new surviving row at ck0=true with a new s1 value. This is the only row that should be visible after reconciliation.
196+
CLUSTER.get(2).executeInternal(withKeyspace("INSERT INTO %s." + table + " (pk0, ck0, ck1, s1, v0) VALUES (1, true, 5.0, 42, true) USING TIMESTAMP 3"));
197+
198+
// The query on s1=42 should return only the single surviving row, and the first pass input to RFP get exactly that.
199+
// However, since there is an unresolved static, RFP completion reads for nodes 1 and 3 must read the entire partition.
200+
// This returns the rows written before the deletion, which never arrived there. If the range tombstone from
201+
// node 2 is not included in the first pass query, it will not be available to shadow the logically deleted rows.
202+
String select = withKeyspace("SELECT ck0, ck1 FROM %s." + table + " WHERE s1 = 42" + (sai ? "" : " ALLOW FILTERING"));
203+
assertRows(CLUSTER.coordinator(1).executeWithPaging(select, ALL, 1), row(true, 5.0));
204+
}
205+
165206
@AfterClass
166207
public static void shutDownCluster()
167208
{

0 commit comments

Comments
 (0)