Skip to content

← All posts

Paging CQL, and the running statement you cannot stop

A second page is refused with its reason rather than silently re-serving the first, and there is no cancel path at all on this engine to fall back on.

The editor bounds every SELECT before it sends it, and on Apache Cassandra the LIMIT / OFFSET pair that paging usually rests on has only its first half. LIMIT n is correct CQL. OFFSET n is not in the grammar at all, so the shared row limiter has to be overridden here in three places - and two of the three are about text rather than about rows.

The statements below were measured against Apache Cassandra 5.0.9, the official cassandra:5.0.9 image, before the provider existed.

Why there is no second page: CQL has LIMIT, and no OFFSET

The shared limiter appends a bound to a statement that carries none. That part is right on CQL. Page two is where it stops:

SELECT id, name FROM probe.customers LIMIT 5 OFFSET 5
-- line 1:45 mismatched input 'OFFSET' expecting EOF
SELECT id, name FROM probe.customers OFFSET 5
-- line 1:37 mismatched input 'OFFSET' expecting EOF

There is no clause to write. Cassandra pages a result set on the wire: the server hands back its own cursor for the next page, which is not an offset a fresh statement can restate. A second page cannot be assembled by rewriting the text, which is the only thing a query rewriter is allowed to do.

So the grid on this engine gives you a first page and nothing after it. What the request does when it arrives anyway is the part worth reading.

Refusing beats re-serving page one

There were three ways to answer a page-two request, and two of them are worse in a way that is easy to miss.

Send the clause anyway. The user gets mismatched input 'OFFSET' about a keyword they never typed, from a rewrite they never saw. The error is true and unactionable: nothing in it says which layer wrote the word.

Drop the clause silently. This is the expensive one. Without OFFSET the statement is page one again, and the editor appends what comes back to what it is already showing. The user sees rows one through five, then rows one through five again, presented as rows six through ten. Nothing errors. That is not a missing feature, it is a wrong answer, and it is a wrong answer that looks like real data in a screenshot.

Refuse the request, with the reason. prepareQuery declines a page after the first and says why, and the editor reports that instead of pretending. The refusal covers every SELECT, including one that already carries its own LIMIT n and is therefore never rewritten - because the failure mode being avoided is not the rewrite, it is the duplicate rows.

This is the same rule the capability declarations follow everywhere else in the product: a control that cannot work is absent with its reason attached, rather than offered and then quietly wrong. The engine row on the databases page states the same boundary before you connect.

Keeping the filtering clause last when a bound is added

The second override is a clause-order fact. ALLOW FILTERING must be the last thing in a CQL SELECT:

SELECT * FROM probe.orders WHERE amount > 5 LIMIT 3 ALLOW FILTERING
-- 3 rows
SELECT * FROM probe.orders WHERE amount > 5 ALLOW FILTERING LIMIT 3
-- line 1:60 mismatched input 'LIMIT'

The shared limiter appends, so on this dialect it would produce the second form every time. The provider transposes the two clauses instead, preserving the writer’s own spacing, so the bound lands before ALLOW FILTERING rather than after it.

The alternative was to leave a statement ending in ALLOW FILTERING unbounded. That is exactly backwards: a user types those two words precisely when a scan is about to happen, which is the statement that most needs a ceiling on the rows it brings back.

One shape is knowingly left unbounded, and it is worth naming rather than discovering. A statement whose last clause is PER PARTITION LIMIT n reads as already bounded to the shared reader, so nothing is injected. ... PER PARTITION LIMIT 2 LIMIT 3 is valid CQL, but adding the second bound would mean stripping the clause the reader matched, and a rewriter that removes clauses is a rewriter that can corrupt a statement.

A statement whose rewrite would end inside a comment

The third override is not about rows at all. A CQL line comment may not be closed by the end of the input:

SELECT * FROM probe.customers LIMIT 3 -- note
-- line 1:45 mismatched character '<EOF>' expecting set null
SELECT * FROM probe.customers LIMIT 3 -- note\n
-- 3 rows
SELECT * FROM probe.customers LIMIT 3 // note\n
-- 3 rows

Two facts are stacked there. // is a line comment in CQL as well as --; that half is a shared grammar fact, probed on Cassandra 5.0.9 and on ScyllaDB 2026.2.4 rather than assumed from a neighbouring dialect. While it was a private scan inside this provider, the shared readers were blind to it, and the statement splitter cut SELECT id FROM probe.customers // note; DROP TABLE probe.customers

  • one statement to this server, with both the semicolon and the write inside the comment - into a read and a bare DROP.

The half that stays local to Cassandra is the end-of-input rule. The limiter inserts its clause before trailing trivia, and the trim that follows drops the newline that closed the comment. A valid statement becomes a syntax error, caused entirely by the bound the user did not ask for. So a statement whose rewritten form would end inside a line comment is left exactly as written, and the result is reported as unlimited rather than as bounded. The check walks the shared span reader, which is also what keeps a // inside a string literal - WHERE url = 'http://x' - from being read as the start of a comment.

No cancel frame, no kill, no driver method

Everything above assumes the statement comes back. When it does not, this engine offers nothing to press.

There is no query cancellation on Cassandra at all: the native protocol has no cancel frame, CQL has no KILL statement, and the driver publishes no cancel method - its client surface is connect, execute, eachRow, stream, batch, getReplicas, getState, log and shutdown - so the only bound on a running statement is a client-side read timeout. cancelQuery() is therefore not implemented, and because both routes detect support by the method’s presence, the cancel endpoint answers “Query cancellation is not supported for this database type” rather than reporting a cancellation that silently did nothing.

The timeout is the driver’s readTimeout, 12000 ms by default, set from the provider’s query timeout. Read what it does precisely: after it expires this client stops waiting. The coordinator carries on. The work does not stop, the watching stops.

The per-statement server-side deadline is not available either. USING TIMEOUT is not in 5.0’s grammar: SELECT * FROM probe.orders USING TIMEOUT 1ms answers line 1:27 mismatched input 'USING' expecting EOF.

Monitoring does list the node’s running statements, read from system_views.queries - thread id, task text and two microsecond readings. So you can watch a statement run, including the read that is watching it, and there is no action attached to any row in that list. Seeing is not reaching. The maintenance toolkit does not fill the gap either: compaction, flush, cleanup and repair are nodetool actions over JMX, so supportsMaintenance is false and the operations list is empty.

So bound the statement in the text, restrict the partition key, and read the timeout as the thing it actually is - a limit on how long you will wait, not a limit on how long the cluster will work.