[ 
https://issues.apache.org/jira/browse/CASSANDRA-21836?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18138479#comment-18138479
 ] 

Caleb Rackliffe edited comment on CASSANDRA-21836 at 11/5/26, 3:47 AM:
-----------------------------------------------------------------------

When a query ANDs two SAI predicates on non-primary-key (i.e. mutable) columns 
and runs at a CL that requires reconciliation (anything but ONE/LOCAL_ONE), we 
use a concept called non-strict filtering (see {{{}RowFilter.isStrict(){}}}). 
We recognized in CASSANDRA-19018 that a single replica can't safely intersect 
two mutable columns' indexes locally, because an unrepaired row can have the 
two matching values split across replicas, and a strict local AND could 
silently drop a row that only matches after coordinator-side reconciliation. 
This is why {{QueryController.getIndexQueryResults()}} builds a 
{{KeyRangeUnionIterator}} instead of a {{KeyRangeIntersectionIterator}} for the 
unrepaired group, and leaves the real AND to be applied after merging.

This works, but it can really perform poorly when faced with extremely 
non-selective predicates. If one side is not selective at all (ex. score >= 0, 
which matches every row in the description above), the union is effectively the 
whole table. {{ReplicaFilteringProtection.consumeEntirePartitions}} then has to 
walk and validate that whole candidate set partition by partition, and every 
partition that fails the real AND still costs a 
ShortReadRowsProtection/ShortReadPartitionsProtection retry that re-enters 
{{{}StorageAttachedIndexSearcher{}}}. That's cheap against Memtable indexes, 
but not cheap against on-disk segments, and the cumulative cost of thousands of 
these retries is what trips the read timeout.

tl;dr Non-strict mode has no cost bound tied to how large the degraded union 
actually is.

On 6.0+, CASSANDRA-18112 allows us to select indexes to use at query time. In 
this case we can use {{excluded_indexes = \{score_idx} with ALLOW FILTERING}} 
to bypass the useless non-selective predicate on {{score}} and return a much 
more reasonable set of rows to the coordinator. On 5.0, we don't have index 
hints, so it's a little more annoying. You can just not index that column, 
leave the predicate out of the CQL query and filter at the client, or drop to 
ONE/LOCAL_ONE.

The core of the issue here is the lack of a more intelligent query planner that 
can determine predicate selectivity up-front and automatically do what 
{{exclude_indexes}} does without being explicitly asked. If that can be done 
cheaply, it might be a reasonable mitigation for this class of problematic 
queries. I'm going to dig into that a bit, but for now, we can say with 
relative certainty that there isn't a pressing correctness or data loss issue 
here.


was (Author: maedhroz):
When a query ANDs two SAI predicates on non-primary-key (i.e. mutable) columns 
and runs at a CL that requires reconciliation (anything but ONE/LOCAL_ONE), we 
use a concept called non-strict filtering (see {{RowFilter.isStrict()}}). We 
recognized in CASSANDRA-19018 that a single replica can't safely intersect two 
mutable columns' indexes locally, because an unrepaired row can have the two 
matching values split across replicas, and a strict local AND could silently 
drop a row that only matches after coordinator-side reconciliation. This is why 
{{QueryController.getIndexQueryResults()}} builds a {{KeyRangeUnionIterator}} 
instead of a {{KeyRangeIntersectionIterator}} for the unrepaired group, and 
leaves the real AND to be applied after merging.

This works, but it can really perform poorly when faced with extremely 
non-selective predicates. If one side is not selective at all (ex. score >= 0, 
which matches every row in the description above), the union is effectively the 
whole table. {{ReplicaFilteringProtection.consumeEntirePartitions}} then has to 
walk and validate that whole candidate set partition by partition, and every 
partition that fails the real AND still costs a 
ShortReadRowsProtection/ShortReadPartitionsProtection retry that re-enters 
{{StorageAttachedIndexSearcher}}. That's cheap against Memtable indexes, but 
not cheap against on-disk segments, and the cumulative cost of thousands of 
these retries is what trips the read timeout.

tl;dr Non-strict mode has no cost bound tied to how large the degraded union 
actually is.

On 6.0+, CASSANDRA-18112 allows us to select indexes to use at query time. In 
this case we can use {{excluded_indexes = {score_idx} with ALLOW FILTERING}} to 
bypass the useless non-selective predicate on {{score}} and return a much more 
reasonable set of rows to the coordinator. On 5.0, we don't have index hints, 
so it's a little more annoying. You can just not index that column, leave the 
predicate out of the CQL query and filter at the client, or drop to 
ONE/LOCAL_ONE.

The core of the issue here is the lack of a more intelligent query planner that 
can determine predicate selectivity up-front and automatically do what 
{{exclude_indexes}} does without being explicitly asked. If that can be done 
cheaply, it might be a reasonable mitigation for this class of problematic 
queries. I'm going to dig into that a bit, but for now, we can say with 
relative certainty that there isn't a pressing correctness or data loss issue 
here.

> A paged SELECT that intersects two SAI indexes with AND times out when the 
> consistency level requires more than one replica. Once the data is on disk, 
> the same query returns immediately at ONE or when it is not paged.
> -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
>
>                 Key: CASSANDRA-21836
>                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21836
>             Project: Apache Cassandra
>          Issue Type: Bug
>          Components: Feature/SAI
>            Reporter: Diego Sevilla Ruiz
>            Assignee: Caleb Rackliffe
>            Priority: Normal
>         Attachments: docker-compose.yml, reproduce.py, reproduce.sh
>
>
> h3. Environment
>  * Cassandra 5.0.9 (Docker image cassandra:5.0.9), the latest 5.0 release on 
> 2026-11-01
>  * 2 nodes, 16 tokens each, one datacenter, GossipingPropertyFileSnitch
>  * Keyspace with SimpleStrategy, RF = 2
>  * Python driver 3.30.1, and cqlsh
>  * Reproduced with both cassandra.yaml and cassandra_latest.yaml (BIG and BTI 
> formats)
>  
> h3. Steps to reproduce
> The attached reproduce.sh runs the whole sequence with the attached 
> docker-compose.yml and reproduce.py, and exits with status 1 when the timeout 
> occurs. By hand:
> {{CREATE KEYSPACE ks WITH replication = \{'class': 'SimpleStrategy', 
> 'replication_factor': 2};}}
> {{CREATE TABLE ks.q (id bigint, d timestamp, owner bigint, score int, title 
> text, PRIMARY KEY (id, d));}}
> {{CREATE INDEX q_owner ON ks.q (owner) USING 'sai';}}
> {{CREATE INDEX q_score ON ks.q (score) USING 'sai';}}
> Insert 20,000 rows at LOCAL_QUORUM, with id = i, owner = i % 5000, score = i 
> % 50, a fixed d and a short title. Wait for the indexes to be queryable and 
> run nodetool flush ks on both nodes. Then, in cqlsh:
> {{CONSISTENCY LOCAL_QUORUM;}}
> {{SELECT id FROM ks.q WHERE owner = 7 AND score >= 0;}}
> h3. Expected
> 4 rows, as returned at ONE.
> h3. Actual
> {{{}ReadTimeout: Error from server: code=1200 [Coordinator node timed out 
> waiting for replica nodes' responses] message="Operation timed out - received 
> only 0 responses." info={'consistency': 'ONE', 'required_responses': 1, 
> 'received_responses': 0{}}}}
> The error reports ONE although the query ran at LOCAL_QUORUM.
> h3. Results
> Synthetic data, randomly chosen owner values, LOCAL_QUORUM, page size 5000, 
> owner = ? AND score >= 0:
> ||Rows||Indexes created||Configuration||Result||
> |100,000|after the load|cassandra.yaml|40 of 40 ReadTimeout|
> |100,000|after the load|cassandra_latest.yaml|40 of 40 ReadTimeout|
> |100,000|before the load|cassandra.yaml|40 of 40 ReadTimeout|
> |20,000|before the load|cassandra.yaml|40 of 40 ReadTimeout|
> |20,000|after the load|cassandra.yaml|40 of 40 ReadTimeout|
> In the 100,000 and 20,000 row runs with cassandra.yaml, the same owners 
> returned correctly at ONE and at LOCAL_QUORUM without paging.
> Real dataset (about 197,000 rows, cassandra_latest.yaml, query of the same 
> shape on owneruserid and score): 25 of 25 randomly chosen owners failed.
> h3. Trace
> The following sequence repeats, alternating between the two replicas, until 
> the timeout. Taken from the real dataset:
> {{Executing read on stackoverflow.questions using indexes 
> questions_owneruserid_sai,questions_score_sai}}
> {{Read 0 live rows and 0 tombstone cells}}
> {{Index query accessed memtable indexes, 6 SSTable indexes, and 6 segments, 
> post-filtered 0 rows in 0 partitions, and took ...}}
> {{Requesting 5000 extra rows from 
> Full(/172.20.0.2:7070,(8878228422593045259,-9114996066678673620]) for short 
> read protection}}
> The requested range is the one that wraps around the ring. I do not know 
> whether that is significant.
> h3. Other observations
> Real dataset, same query shape:
> ||Variation||Result||
> |LOCAL_QUORUM, QUORUM or TWO, paged|ReadTimeout|
> |Page size 10|ReadTimeout|
> |LIMIT 2|ReadTimeout|
> |LIMIT 10|ReadTimeout, after 10 s instead of 5 s|
> |ONE or LOCAL_ONE, paged|OK, a few milliseconds|
> |LOCAL_QUORUM, not paged|OK, 0.8 s|
> |Adding ALLOW FILTERING|ReadTimeout|
> |Equality instead of a range: owneruserid = 120 AND score = 3|ReadTimeout|
> |Both conditions, but no row matches both|OK|
> |One SAI index plus a non-indexed column with ALLOW FILTERING|OK|
> |One SAI index alone|OK|
> Synthetic data, 20,000 rows, cqlsh at LOCAL_QUORUM:
> ||Query||Page size 100 or 5000||Paging off||
> |owner = 7 AND score >= 0|ReadTimeout|OK, 4 rows|
> |owner = 7 AND score = 7|OK, 4 rows|OK, 4 rows|
> So equality did not fail on the synthetic data, where every row of an owner 
> matches it, but did fail on the real dataset, where 1 of the owner's 4 rows 
> matches. The cause of that difference has not been isolated.
> Before a flush, on 20,000 rows held in memtables, the equality query returned 
> correctly. A separate test with partition key plus one SAI index also 
> returned correctly at LOCAL_QUORUM, with the data in memory and on disk.
> h3. Not tested
>  * trunk or any version other than 5.0.9
>  * A single node or RF = 1
>  * More than two nodes
> h3. Workaround
> Use ONE or LOCAL_ONE, disable paging for the query, or avoid intersecting two 
> SAI indexes.
> h3. Related
> A web search found no ticket describing this loop. CASSANDRA-19007 covers 
> multi-column filtering on out-of-sync replicas and may be related.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to