Kandra

Recipe: time-series data

Problem: you have data that naturally accumulates over time within some owner — events for a user, transactions for an account, posts for an author — and the query you actually need is "give me this owner's records, newest first," often paginated. This is the single most common access pattern on Cassandra, and it's a clustering-key pattern, not an ORDER BY ... LIMIT N bolted onto an unordered table. Get the clustering key right and it's close to free; get it wrong and there's no query-time fix.

The scenario

Transaction history for a wallet: given a walletId, return transactions newest-first, paginated.

Transaction.kt
import io.kandra.core.annotations.*
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID
 
@ScyllaTable("transactions")
data class Transaction(
    @PartitionKey val walletId: UUID,
    @ClusteringKey(order = ClusteringOrder.DESC) val createdAt: Instant,
    @ClusteringKey(order = ClusteringOrder.DESC, index = 1) val txId: UUID,
    val amount: BigDecimal,
    val memo: String,
)

walletId is the partition key — every query in this recipe supplies it, so all of a wallet's transactions live together on the same replicas. createdAt DESC is the clustering key that makes "newest first" the table's native physical sort order, not something computed at query time. txId is a second clustering column (index = 1), included to guarantee row uniqueness — createdAt alone isn't safe as a sole clustering key when two transactions can land in the same millisecond.

Prefer @GeneratedUuid over a raw Instant clustering key

The two-column createdAt + txId shape above is one valid way to solve the same-millisecond collision problem. For a clustering key that only needs to sort by creation time — no separate, independently-meaningful id required — a single @GeneratedUuid @ClusteringKey val id: UUID column does both jobs at once: UuidStrategy.TIME_ORDERED (the default) generates a UUIDv7, which sorts chronologically the same way createdAt DESC does, with no collision risk and one fewer column to maintain. See /modules/kandra-core for the annotation and /battle-scars/time-ordered-clustering-keys for why this exists.

The sort order is a schema decision, not a query decision

ClusteringOrder.DESC here is chosen once, in the entity definition, and every read against this partition comes back in that order for free — no ORDER BY at query time, because there is no ORDER BY at query time for a different order than the one baked into the clustering key. If a different access pattern later needs createdAt ASC for the same data, that's a different table, not a query-time flag. See /foundations.

Paginating with findPage

KandraSuspendRepository.kt
suspend fun findPage(
    pageSize: Int,
    pageToken: String? = null,
    block: QueryContext.() -> Unit = {}
): KandraPage<T>
data class KandraPage<T>(
    val items: List<T>,
    val nextPageToken: String?,
    val hasMore: Boolean
)
val page1 = txnRepo.findPage(pageSize = 20) {
    TransactionTable.walletId eq walletId
}
// page1.nextPageToken is a base64-encoded driver paging state
 
val page2 = txnRepo.findPage(pageSize = 20, pageToken = page1.nextPageToken) {
    TransactionTable.walletId eq walletId
}

nextPageToken is the DataStax driver's own paging state, base64-encoded — not an offset, not a timestamp you construct yourself. Pass it straight back into the next findPage call. hasMore is true exactly when nextPageToken is non-null; there's no separate "is this really the last page" signal to reconcile against it.

Walking every page
var token: String? = null
val all = mutableListOf<Transaction>()
do {
    val page = txnRepo.findPage(pageSize = 50, pageToken = token) {
        TransactionTable.walletId eq walletId
    }
    all += page.items
    token = page.nextPageToken
} while (token != null)

This is the pattern the capstone's real test used to walk a 30+-row feed across multiple pages and confirm no duplicates/gaps — see /recipes/social-feed-capstone.

"Give me the latest N" without pagination

When you just want a bounded top-N and don't need a page token at all, findAll(limit = N) against a partition-scoped query is cheaper and simpler than findPage:

// 5 most recent transactions for this wallet
val recent = txnRepo.findAll(limit = 5) {
    TransactionTable.walletId eq walletId
}

Because createdAt is clustered DESC, "most recent 5" is "first 5 rows returned" — no in-memory sort, no over-fetch-then-truncate. This only works because the clustering order matches the query's intent; if the table were clustered ASC, this same call would hand back the oldest five transactions instead, and there'd be no query-time way to flip it.

Range queries on the clustering key

The clustering key also supports range predicates, not just equality — useful for "transactions in the last 24 hours" style queries:

val recentWindow = txnRepo.findAll {
    TransactionTable.walletId eq walletId
    TransactionTable.createdAt gte cutoff
}

eq/gt/gte/lt/lte/isIn are all infix members on QueryContext — no + prefix. (Older Kandra versions required +TransactionTable.createdAt.gte(cutoff); forgetting the + compiled cleanly and silently dropped the predicate. The current DSL makes the comparison itself the registration, so there's no intermediate value to forget — see /foundations and /philosophy/schema-safety.)

findAll requires a predicate; findPage doesn't — different failure modes

findAll/find throw KandraQueryException("Query must have at least one predicate.") if called with an empty block — there's no accidental full-table findAll(). findPage, on the other hand, allows zero predicates: called that way, it runs a full token-range scan across the whole table, using the driver's native paging to walk every partition, and logs "findPage on '<table>' with no predicates — full token-range scan (driver paging). Use sparingly on large tables." This is never ALLOW FILTERING — no scatter-gather re-query per node — but it is still an unbounded, whole-table read. Always supply the partition key predicate on findPage calls like the ones above unless you genuinely mean "walk the entire table," since the API will let you do that by omission where findAll won't.

Why this is the whole game on Cassandra

There's no query-time way to ask for "this wallet's transactions, but sorted differently" or "transactions across all wallets, newest first" from this table — either needs a different table, denormalized for that access pattern (see /recipes/lookup-tables and /philosophy/denormalization). That's not a Kandra limitation — it's what "the clustering key decides sort order, and the partition key decides locality" means in practice, and it's the reason time-series modeling on Cassandra starts with "what will I query by and in what order" before a single data class gets written.