Kandra

06. Denormalization: todos assigned to me

Page 01 flagged this and deliberately didn't fix it: Todo is partitioned by ownerId, so "show me todos assigned to me" — access pattern 2, a different user than the owner — has no cheap way to be answered by that table. This page is the fix, and it's the reason this tutorial exists in the first place: not a workaround, the normal way to solve this on Cassandra.

The relational instinct, stated plainly

-- What a relational schema would do
SELECT * FROM todos WHERE assignee_id = ?

One table, one extra WHERE clause. This is the reflex, and it's worth actually trying against Todo's current shape to see precisely where it breaks, rather than just being told it doesn't work.

// Won't compile / won't build a valid query
todoRepo.findAll { TodoTable.assigneeId eq assigneeId }

assigneeId is neither Todo's partition key nor its clustering key, and it has no @SecondaryIndex. QueryExecutor.resolveRows throws KandraQueryException the moment this query is built — not at some later point under load, at the call site, every time. The message points at @SecondaryIndex as the escape hatch, and reaching for it here would be the wrong instinct too: @SecondaryIndex is a scatter-gather across every node in the cluster, logged with a WARN on every single query, meant for low-cardinality fields on non-hot-path queries — "todos assigned to a given user" is exactly the kind of hot, high-cardinality lookup that column type warns you away from. See /foundations for why Kandra draws this line at all.

The actual reason it doesn't translate: Cassandra placed every Todo row by hashing its ownerId. A query for assigneeId = X has no way to know which node(s) hold rows where some other column happens to equal X — that information was never encoded into where the data lives. Nothing short of asking every node is going to answer it from this table, ever, no matter how the query is phrased.

Why not @LookupIndex

User.email from page 03 looks like the same shape of problem — "find rows by a column that isn't the partition key" — and it's tempting to reach for the same tool. It's the wrong one here, and the reason is structural, not a matter of taste:

@LookupIndex is one row per index value — this pattern needs many

A @LookupIndex table's own DDL is CREATE TABLE ... (indexColumn, partitionKeyColumns..., PRIMARY KEY (indexColumn)) — a single-column primary key on the indexed value itself. That's correct for email → one user: at most one user ever has a given email, so one row per email is the whole table. "Todos assigned to assigneeId" isn't that shape at all — one assignee can have many assigned todos, so the table needs its own partition and clustering key, not a bare single-column primary key on assigneeId. @LookupIndex structurally cannot express "many rows per indexed value" — it isn't a missing feature, it's a different problem than the one it was built for.

TodoByAssignee — a second, hand-rolled, denormalized table

The actual fix is the same move /foundations already told you Cassandra wants: store the data shaped for the query you need, even though it duplicates data already in Todo. Not @LookupIndex — a full second entity, with its own partition key (assigneeId, so "todos assigned to me" is a partition-scoped read) and its own clustering key (createdAt DESC, same reasoning as Todo itself — newest assignment first, for free). This is exactly the pattern Kandra's own capstone example uses for posts_by_tag: a hand-rolled table for a "many rows per lookup value" access pattern, maintained by application code, not generated by an annotation.

model/TodoByAssignee.kt
package com.example.todos.model
 
import io.kandra.core.annotations.*
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.UUID
 
@Serializable
@ScyllaTable("todos_by_assignee")
data class TodoByAssignee(
    @Serializable(with = UUIDSerializer::class)
    @PartitionKey val assigneeId: UUID,
    @Serializable(with = InstantSerializer::class)
    @ClusteringKey(order = ClusteringOrder.DESC) val createdAt: Instant,
    @Serializable(with = UUIDSerializer::class) val todoId: UUID,
    @Serializable(with = UUIDSerializer::class) val ownerId: UUID,
    val title: String,
    val done: Boolean = false,
)

createdAt here carries the same value as the corresponding Todo.createdAt — copied at write time, not derived — for the same reason page 03 kept @CreatedAt off Todo: the clustering key value has to be something the application controls exactly, not something stamped independently in two different places that could drift apart by a few milliseconds. ownerId and todoId are carried along so a client reading this table has enough to act on a row (recall from page 05: finding a specific todo needs ownerId and createdAt, both present right here).

Entity
Generated DDL

Register it alongside the others:

Application.kt
register(User::class, Todo::class, TodoByAssignee::class)

The write has to be atomic — this is where KandraBatchScope earns its keep

Creating an assigned todo now means two writes: the primary Todo row, and (only when assigneeId is set) a TodoByAssignee row. Two independent save() calls would look transactional — two sequential lines in the same handler — while actually executing as two unrelated writes, either of which can fail without rolling back the other. A crash between them would leave a todo that exists but is invisible to its assignee, or (worse) a TodoByAssignee row pointing at a todo that was never actually created. /why covers why this matters generally; here's the concrete fix.

routes/TodoRoutes.kt
import io.kandra.core.ExperimentalKandraApi
 
@OptIn(ExperimentalKandraApi::class)
fun Route.todoRoutes() {
    val todoRepo = application.kandra.suspendRepository<Todo>()
    val todoByAssigneeRepo = application.kandra.suspendRepository<TodoByAssignee>()
 
    post("/todos") {
        val ownerId = call.ownerIdOrRespondBadRequest() ?: return@post
        val req = call.receive<CreateTodoRequest>()
        val todo = Todo(
            ownerId = ownerId, createdAt = Instant.now(), todoId = UUID.randomUUID(),
            title = req.title, assigneeId = req.assigneeId,
        )
 
        if (req.assigneeId != null) {
            val forAssignee = TodoByAssignee(
                assigneeId = req.assigneeId, createdAt = todo.createdAt, todoId = todo.todoId,
                ownerId = ownerId, title = todo.title,
            )
            application.kandra.batch {
                todoRepo.saveInBatch(todo)
                todoByAssigneeRepo.saveInBatch(forAssignee)
            }
        } else {
            todoRepo.save(todo)
        }
        call.respond(HttpStatusCode.Created, todo)
    }
 
    // ...GET /todos/mine, PATCH .../done, DELETE ... unchanged from page 05
 
    get("/todos/assigned-to-me") {
        val assigneeId = call.ownerIdOrRespondBadRequest() ?: return@get
        val pageSize = call.request.queryParameters["size"]?.toIntOrNull() ?: 20
        val token = call.request.queryParameters["token"]
        val page = todoByAssigneeRepo.findPage(pageSize, token) { TodoByAssigneeTable.assigneeId eq assigneeId }
        call.respond(TodoByAssigneePage(page.items, page.nextPageToken, page.hasMore))
    }
}
application.kandra.batch { }, saveInBatch — the exact shape, verified

application.kandra is the KandraRuntime the plugin installed (an Application extension property Ktor attributes back). KandraRuntime.batch(block: suspend KandraBatchScope.() -> Unit) is the suspend entry point into KandraBatchScope — the right one to call from inside a Ktor route handler, which is already a suspend function (batchBlocking { } also exists, for non-suspend call sites, but would block the calling coroutine's thread here for no reason). Both are @ExperimentalKandraApi, hence the @OptIn on the enclosing function. Inside the block, the calls are saveInBatch/deleteInBatchnot save/delete. This isn't a naming preference: Kotlin always resolves a same-named member function of the extension receiver (here, KandraRepository's own real, immediately-executing save()) over an extension function trying to shadow it — even one declared as a member-extension inside KandraBatchScope, which is exactly this shape. A batch-scoped save() would compile cleanly and silently just call the repository's real save(), executing immediately and never joining the batch. saveInBatch sidesteps the collision entirely by not sharing a name with anything a repository already has. See /why — this is precisely the scar that shaped the API.

Both writes land in one LOGGED BATCH, or neither does. EVENTUAL-consistency lookups (not used by either entity here — both of Todo and TodoByAssignee's writes above are plain saveInBatch calls, no @LookupIndex involved) would fire outside this atomicity boundary if either entity had one; nothing here does.

GET /todos/assigned-to-me

todoByAssigneeRepo.findPage(pageSize, token) { TodoByAssigneeTable.assigneeId eq assigneeId }

Same shape as GET /todos/mine from page 05 — a partition-scoped range read, now against a table that was shaped for exactly this query from the start, instead of the one shaped for "my todos."

A known, named gap — not silently swept under the rug

Marking a todo done (page 07) or deleting it (page 08) updates Todo only — this app doesn't (yet) keep TodoByAssignee's copy of done in sync, and doesn't remove the TodoByAssignee row when the primary todo is deleted. That's not an oversight; it's the same tradeoff Kandra's own capstone example makes with posts_by_tag: a hand-rolled denormalized table gets no automatic cleanup the way a @LookupIndex table does, and maintaining it on every write path is entirely the application's job. If "assigned to me" needs to stay perfectly fresh, the fix is to extend the same KandraBatchScope block this page introduced to the done/delete routes too — a real, bounded amount of work, deliberately left undone here so this page stays about the atomicity problem it's actually teaching, not a second denormalization exercise.

Next: page 07 — two people editing the same todo at once, and why Todo (but not User) needs @Version to handle it correctly.