Skip to content

Commit 9f4cfb2

Browse files
committed
feat: add old blog posts
1 parent 50fd197 commit 9f4cfb2

97 files changed

Lines changed: 15798 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
title: No constraints to discover, and a column order to rebuild
3+
status: published
4+
author:
5+
name: LibreDB
6+
picture: ''
7+
slug: cassandra-column-order-and-no-foreign-keys
8+
description: The catalog reports no position for regular columns and sorts by name, so the tree rebuilds the order a where clause actually needs.
9+
coverImage: ''
10+
tags:
11+
- value: engineering
12+
label: Engineering
13+
- value: databases
14+
label: Databases
15+
publishedAt: 2026-01-09T09:00:00.000Z
16+
---
17+
18+
Open the ER diagram on a Cassandra connection and the canvas has boxes and no
19+
lines. Open one of those boxes and the columns sit in an order nobody typed. Both
20+
are the same fact seen twice: a Cassandra table schema publishes a column order
21+
for the partition key and the clustering columns, and nothing else - and it
22+
publishes no relations at all. Every engine answer quoted below was measured
23+
against Apache Cassandra 5.0.9, the version `system.local.release_version`
24+
reports on the official `cassandra:5.0.9` image.
25+
26+
## Why the relation list is always empty here
27+
28+
An empty edge list is usually ambiguous. It can mean the read found nothing, the
29+
read was refused, or the concept does not exist on this engine, and a client that
30+
reports `[]` for all three has told you nothing.
31+
32+
Cassandra settles it. The provider declares no foreign keys and the relation list
33+
is always empty, because the clause does not exist in this model, and index
34+
uniqueness is always false for the same reason. Neither is a gap in the read.
35+
`ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` answers `line 1:48 mismatched
36+
input 'FOREIGN' expecting EOF`, and `CREATE UNIQUE INDEX` answers `no viable
37+
alternative at input 'UNIQUE'`. The keywords are not in the grammar, so there is
38+
no statement that could create the object the diagram is looking for.
39+
40+
That is recorded in the capability set rather than left to the canvas to imply:
41+
`declaresForeignKeys` is `false`, so a reader knows `foreignKeys: []` means this
42+
engine has none rather than this schema declares none. The [ER diagram
43+
feature](/features) publishes the matching limit on the product side - edges come
44+
from declared foreign keys, and a relationship your application enforces in code
45+
but never declares has nothing to discover.
46+
47+
## A primary key that means something else
48+
49+
The relational reflex is to read a primary key as an identity. In CQL it is an
50+
access path, and it is two things wearing one name.
51+
52+
The partition key decides which partition a row lives in. The clustering columns
53+
decide the order of rows inside that partition. Only the brackets in the DDL say
54+
which columns do which job, and the difference is not cosmetic:
55+
56+
```sql
57+
-- probe.composite_pk
58+
PRIMARY KEY ((tenant, day), ts)
59+
60+
-- probe.pk_flat
61+
PRIMARY KEY (tenant, day, ts)
62+
```
63+
64+
Those two tables differ by one pair of brackets and nothing else. `SELECT * FROM
65+
probe.pk_flat WHERE tenant = 'a'` is served, because `tenant` is the whole
66+
partition key there. The
67+
identical restriction on `probe.composite_pk` answers code 2200, `Cannot execute
68+
this query as it might involve data filtering and thus may have unpredictable
69+
performance` - `tenant` is half a partition key, and half is not a partition.
70+
71+
Ordering follows the same rule. `SELECT * FROM t ORDER BY name` answers `ORDER BY
72+
is only supported when the partition key is restricted by an EQ or an IN`, because
73+
a sort order only exists inside a partition.
74+
75+
So the useful thing a schema tree can tell you about a Cassandra table is not
76+
which column is "the key". It is which columns are the partition key, in what
77+
order, and which are the clustering columns, in what order.
78+
79+
## What the catalog does not preserve
80+
81+
Introspection is four statements against `system_schema`, scoped to the pinned
82+
keyspace: tables, views, columns and indexes. The column read is the one that
83+
matters here:
84+
85+
```sql
86+
SELECT table_name, column_name, type, kind, position, clustering_order
87+
FROM system_schema.columns WHERE keyspace_name = 'probe'
88+
```
89+
90+
Two properties of that result set decide what a tree can honestly show:
91+
92+
| Column kind | `position` | Ordering in the result |
93+
| --- | --- | --- |
94+
| `partition_key` | 0-based within its kind | by column name |
95+
| `clustering` | 0-based within its kind | by column name |
96+
| `regular`, `static` | always `-1` | by column name |
97+
98+
The server returns rows sorted by column name, and `position` is `-1` for every
99+
regular and static column. So the order the table was written in cannot be
100+
reconstructed from the catalog.
101+
102+
`nullable` comes out of the same read and is `false` for exactly the primary-key
103+
components, because CQL has no `NOT NULL` to declare on anything else and every
104+
regular column of an existing row may be absent entirely.
105+
106+
## Rebuilding a Cassandra table schema column order from the partition key out
107+
108+
Declaration order is gone, so the tree does not pretend to have it. It builds an
109+
order that means something instead: partition key by `position`, then clustering
110+
columns by `position`, then everything else alphabetically.
111+
112+
That is not an arbitrary tie-break. It is the order `DESCRIBE TABLE` prints, and
113+
it is the order the primary key has to be written in a `WHERE` clause. Reading a
114+
table node top to bottom gives you the prefix rules in the sequence you need them,
115+
and `clustering_order` comes along on each clustering column, so the direction the
116+
rows are stored in is on the same line as the column that stores them.
117+
118+
The same distinction is why the schema-diff migration generator will not emit a
119+
Cassandra `CREATE TABLE`. `system_schema.columns` separates the two roles by
120+
`kind`, but `ColumnDiff` keeps a single boolean for "is primary", so both tables
121+
in the bracket example above reduce to the same three key columns and the shared
122+
serializer would pick the flat layout. Generating that would repartition the data
123+
and succeed while doing it, so a comment naming the reason is emitted in place of
124+
the DDL. One boolean is not enough to describe this key, in the diff generator or
125+
anywhere else.
126+
127+
## Reading the tree as a query guide
128+
129+
The tree for a Cassandra keyspace is a list of access paths. Each table names its
130+
partition key first, so you can see which restriction any query against it has to
131+
carry; then its clustering columns in storage order, so you can see what may be
132+
ranged over and what may be sorted; then the rest of the columns, which you can
133+
select and cannot filter on without `ALLOW FILTERING`.
134+
135+
Secondary indexes are read from `system_schema.indexes`, with `options.target`
136+
naming the indexed column, and each one carries `unique: false` - the engine's
137+
answer, not the schema's. Materialized views come from `system_schema.views` and
138+
appear as tables with their own columns; they are disabled by default in 5.0
139+
(`materialized_views_enabled: false`), so on a stock install that list is empty
140+
and the tree shows tables only.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
title: Paging CQL, and the running statement you cannot stop
3+
status: published
4+
author:
5+
name: LibreDB
6+
picture: ''
7+
slug: cassandra-cql-paging-and-cancellation
8+
description: 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.
9+
coverImage: ''
10+
tags:
11+
- value: engineering
12+
label: Engineering
13+
- value: databases
14+
label: Databases
15+
publishedAt: 2026-02-26T09:00:00.000Z
16+
---
17+
18+
The editor bounds every SELECT before it sends it, and on Apache Cassandra the
19+
`LIMIT` / `OFFSET` pair that paging usually rests on has only its first half.
20+
`LIMIT n` is correct CQL. `OFFSET n` is not in the grammar at all, so the shared
21+
row limiter has to be overridden here in three places - and two of the three are
22+
about text rather than about rows.
23+
24+
The statements below were measured against Apache Cassandra 5.0.9, the official
25+
`cassandra:5.0.9` image, before the provider existed.
26+
27+
## Why there is no second page: CQL has LIMIT, and no OFFSET
28+
29+
The shared limiter appends a bound to a statement that carries none. That part is
30+
right on CQL. Page two is where it stops:
31+
32+
```sql
33+
SELECT id, name FROM probe.customers LIMIT 5 OFFSET 5
34+
-- line 1:45 mismatched input 'OFFSET' expecting EOF
35+
SELECT id, name FROM probe.customers OFFSET 5
36+
-- line 1:37 mismatched input 'OFFSET' expecting EOF
37+
```
38+
39+
There is no clause to write. Cassandra pages a result set on the wire: the server
40+
hands back its own cursor for the next page, which is not an offset a fresh
41+
statement can restate. A second page cannot be assembled by rewriting the text,
42+
which is the only thing a query rewriter is allowed to do.
43+
44+
So the grid on this engine gives you a first page and nothing after it. What the
45+
request does when it arrives anyway is the part worth reading.
46+
47+
## Refusing beats re-serving page one
48+
49+
There were three ways to answer a page-two request, and two of them are worse in
50+
a way that is easy to miss.
51+
52+
**Send the clause anyway.** The user gets `mismatched input 'OFFSET'` about a
53+
keyword they never typed, from a rewrite they never saw. The error is true and
54+
unactionable: nothing in it says which layer wrote the word.
55+
56+
**Drop the clause silently.** This is the expensive one. Without `OFFSET` the
57+
statement is page one again, and the editor appends what comes back to what it is
58+
already showing. The user sees rows one through five, then rows one through five
59+
again, presented as rows six through ten. Nothing errors. That is not a missing
60+
feature, it is a wrong answer, and it is a wrong answer that looks like real data
61+
in a screenshot.
62+
63+
**Refuse the request, with the reason.** `prepareQuery` declines a page after the
64+
first and says why, and the editor reports that instead of pretending. The
65+
refusal covers every SELECT, including one that already carries its own `LIMIT n`
66+
and is therefore never rewritten - because the failure mode being avoided is not
67+
the rewrite, it is the duplicate rows.
68+
69+
This is the same rule the [capability declarations](/features) follow everywhere
70+
else in the product: a control that cannot work is absent with its reason
71+
attached, rather than offered and then quietly wrong. The engine row on the
72+
[databases page](/databases) states the same boundary before you connect.
73+
74+
## Keeping the filtering clause last when a bound is added
75+
76+
The second override is a clause-order fact. `ALLOW FILTERING` must be the last
77+
thing in a CQL SELECT:
78+
79+
```sql
80+
SELECT * FROM probe.orders WHERE amount > 5 LIMIT 3 ALLOW FILTERING
81+
-- 3 rows
82+
SELECT * FROM probe.orders WHERE amount > 5 ALLOW FILTERING LIMIT 3
83+
-- line 1:60 mismatched input 'LIMIT'
84+
```
85+
86+
The shared limiter appends, so on this dialect it would produce the second form
87+
every time. The provider transposes the two clauses instead, preserving the
88+
writer's own spacing, so the bound lands before `ALLOW FILTERING` rather than
89+
after it.
90+
91+
The alternative was to leave a statement ending in `ALLOW FILTERING` unbounded.
92+
That is exactly backwards: a user types those two words precisely when a scan is
93+
about to happen, which is the statement that most needs a ceiling on the rows it
94+
brings back.
95+
96+
One shape is knowingly left unbounded, and it is worth naming rather than
97+
discovering. A statement whose last clause is `PER PARTITION LIMIT n` reads as
98+
already bounded to the shared reader, so nothing is injected. `... PER PARTITION
99+
LIMIT 2 LIMIT 3` is valid CQL, but adding the second bound would mean stripping
100+
the clause the reader matched, and a rewriter that removes clauses is a rewriter
101+
that can corrupt a statement.
102+
103+
## A statement whose rewrite would end inside a comment
104+
105+
The third override is not about rows at all. A CQL line comment may not be
106+
closed by the end of the input:
107+
108+
```sql
109+
SELECT * FROM probe.customers LIMIT 3 -- note
110+
-- line 1:45 mismatched character '<EOF>' expecting set null
111+
SELECT * FROM probe.customers LIMIT 3 -- note\n
112+
-- 3 rows
113+
SELECT * FROM probe.customers LIMIT 3 // note\n
114+
-- 3 rows
115+
```
116+
117+
Two facts are stacked there. `//` is a line comment in CQL as well as `--`; that
118+
half is a shared grammar fact, probed on Cassandra 5.0.9 and on ScyllaDB
119+
2026.2.4 rather than assumed from a neighbouring dialect. While it was a private
120+
scan inside this provider, the shared readers were blind to it, and the statement
121+
splitter cut `SELECT id FROM probe.customers // note; DROP TABLE probe.customers`
122+
- one statement to this server, with both the semicolon and the write inside the
123+
comment - into a read and a bare `DROP`.
124+
125+
The half that stays local to Cassandra is the end-of-input rule. The limiter
126+
inserts its clause before trailing trivia, and the trim that follows drops the
127+
newline that closed the comment. A valid statement becomes a syntax error, caused
128+
entirely by the bound the user did not ask for. So a statement whose rewritten
129+
form would end inside a line comment is left exactly as written, and the result is
130+
reported as unlimited rather than as bounded. The check walks the shared span
131+
reader, which is also what keeps a `//` inside a string literal - `WHERE url =
132+
'http://x'` - from being read as the start of a comment.
133+
134+
## No cancel frame, no kill, no driver method
135+
136+
Everything above assumes the statement comes back. When it does not, this engine
137+
offers nothing to press.
138+
139+
There is no query cancellation on Cassandra at all: the native protocol has no
140+
cancel frame, CQL has no `KILL` statement, and the driver publishes no cancel
141+
method - its client surface is `connect`, `execute`, `eachRow`, `stream`,
142+
`batch`, `getReplicas`, `getState`, `log` and `shutdown` - so the only bound on a
143+
running statement is a client-side read timeout. `cancelQuery()` is therefore not
144+
implemented, and because both routes detect support by the method's presence, the
145+
cancel endpoint answers *"Query cancellation is not supported for this database
146+
type"* rather than reporting a cancellation that silently did nothing.
147+
148+
The timeout is the driver's `readTimeout`, 12000 ms by default, set from the
149+
provider's query timeout. Read what it does precisely: after it expires this
150+
client stops waiting. The coordinator carries on. The work does not stop, the
151+
watching stops.
152+
153+
The per-statement server-side deadline is not available either. `USING TIMEOUT`
154+
is not in 5.0's grammar: `SELECT * FROM probe.orders USING TIMEOUT 1ms` answers
155+
`line 1:27 mismatched input 'USING' expecting EOF`.
156+
157+
Monitoring does list the node's running statements, read from
158+
`system_views.queries` - thread id, task text and two microsecond readings. So you can watch a statement run, including
159+
the read that is watching it, and there is no action attached to any row in that
160+
list. Seeing is not reaching. The maintenance toolkit does not fill the gap
161+
either: compaction, flush, cleanup and repair are `nodetool` actions over JMX, so
162+
`supportsMaintenance` is false and the operations list is empty.
163+
164+
So bound the statement in the text, restrict the partition key, and read the
165+
timeout as the thing it actually is - a limit on how long you will wait, not a
166+
limit on how long the cluster will work.

0 commit comments

Comments
 (0)