Recipe: multi-table writes
Problem: a single logical operation needs to write to more than one table atomically —
Cassandra's only cross-table atomicity primitive is a LOGGED BATCH, and there's no ambient
@Transactional that makes a few sequential save() calls atomic just because they happened to run
inside the same function. KandraBatchScope is Kandra's explicit, opt-in way to get that guarantee
— but the API shape it settled on exists specifically because an earlier shape looked correct,
compiled, and silently provided zero atomicity. Read that part before you write this code.
The scenario
Creating a post that also needs a denormalized posts_by_tag row per tag, atomically — the same
multi-write this site's own capstone builds (see
/recipes/social-feed-capstone). A
partial failure here — the post committed, one tag row missing — leaves a post visible in the
author's feed but invisible to tag search, or vice versa. That's exactly the bug class
KandraBatchScope exists to close.
@OptIn(ExperimentalKandraApi::class)
application.kandra.batch {
postRepo.saveInBatch(post)
tags.forEach { tag ->
postByTagRepo.saveInBatch(PostByTag(tag, post.createdAt, post.postId, post.authorId))
}
}KandraRuntime.batch { } is suspend; .batchBlocking { } is its blocking twin, for non-suspend
contexts. In Ktor, both hang off application.kandra (Application.kandra: KandraRuntime). Every
statement collected inside the block is folded into a single BatchStatement and executed as one
LOGGED BATCH when the block exits — genuinely atomic, at the cost of a cross-partition batch.
saveInBatch/deleteInBatch — not save/delete, and that's deliberate
This is the single most important thing to understand about this API, because the bug it fixes was
invisible short of reading debug logs line by line. KandraBatchScope originally declared save/
delete as member-extension functions on the repository types, intended to be called as
with(repo) { save(entity) } (or, in the old KDoc example, even bare repo.save(entity)) inside a
batch { }/batchBlocking { } block — the extension would collect the statement instead of
executing it.
It never worked. Kotlin always resolves a member function of the extension receiver over an
extension function with a matching name — unconditionally, even when the extension is itself a
member-extension of a closer implicit receiver, which is exactly what KandraBatchScope's save
was. Since every repository already has its own real save/delete member, repo.save(entity)
inside a batch block always resolved to the repository's own, immediately-executing method —
never the batch-collecting one. For suspend repositories this failed to compile outright
("suspension functions can only be called within a coroutine body"); for blocking repositories it
silently compiled, returned success, and executed N independent LOGGED BATCHes instead of one
— confirmed live, via debug.logBatches, as three separate batch executions for a two-tag post
instead of the one combined batch the API was supposed to guarantee. Zero atomicity, with no error,
no warning, and no signal short of reading batch logs.
Fixed by renaming to saveInBatch/deleteInBatch/saveIfNotExistsInBatch — names that don't
collide with any real repository member, so there is no ambiguity left for Kotlin to resolve
incorrectly. If it compiles as repo.saveInBatch(entity), it can only have resolved to the
batch-collecting extension — the naming itself is the fix, not just a cosmetic rename.
fun <T : Any> KandraSuspendRepository<T>.saveInBatch(entity: T, ttlSeconds: Int? = null)
fun <T : Any> KandraRepository<T>.saveInBatch(entity: T, ttlSeconds: Int? = null)
fun <T : Any> KandraSuspendRepository<T>.deleteInBatch(entity: T)
fun <T : Any> KandraRepository<T>.deleteInBatch(entity: T)Call these directly on the repository — no with(repo) { } wrapper needed (and none of the wording
above should be read as endorsing that pattern; it was only ever relevant to the broken shape):
application.kandra.batchBlocking {
postRepo.saveInBatch(post)
postByTagRepo.deleteInBatch(staleTagRow)
}Fixing ISS-026 surfaced an identical bug in KandraBatchScope's saveIfNotExists guard
(ISS-027): a same-named member-extension whose only job was to throw — LWT can't share a LOGGED BATCH with regular statements — was itself unreachable via the member-over-extension rule, so
repo.saveIfNotExists(entity) inside a batch block silently executed a real, immediate LWT
write instead of throwing. Fixed the same way: renamed to saveIfNotExistsInBatch, which always
throws KandraQueryException — LWT genuinely cannot be mixed into a LOGGED BATCH with other
statements, so there is no working version of this call inside a batch scope, by design, not by
current limitation.
What's inside the atomic guarantee, and what isn't
- Included: every
saveInBatch/deleteInBatchcall's primary-row statement, plus anyLookupConsistency.BATCHlookup writes for that entity — all folded into the oneLOGGED BATCHexecuted on block exit. - Not included:
LookupConsistency.EVENTUALlookup writes never join the batch — they still fire separately, fire-and-forget, after the batch commits, exactly as they would outside a batch scope. A batch block doesn't make anEVENTUALlookup atomic; only the entity's ownLookupConsistencysetting decides that (see/recipes/lookup-tables). - Not available at all: reads.
findAll,findById, and every other read operation are simply absent fromKandraBatchScope— there is no way to read-then-conditionally-write inside a batch block, because Cassandra batches don't support that shape either. - Not available at all:
saveIfNotExistsInBatch— always throws, per ISS-027 above. LWT andLOGGED BATCHare mutually exclusive in CQL itself; Kandra just surfaces that as an explicit runtime error instead of a confusing driver exception.
Verifying it actually batched
debug.logBatches = true on install(Kandra) { } logs one line per LOGGED BATCH execution with
its statement count — the same signal that caught the original bug. For a two-write batch, you
should see exactly one line covering both statements, not two lines:
Executing LOGGED BATCH with 3 statements for postsnot
Executing LOGGED BATCH with 2 statements for posts
Executing LOGGED BATCH with 1 statements for posts_by_tagIf you see the second shape, something in the call isn't actually going through the batch-collecting
extensions — check for a stray with(repo) { } or a direct repo.save(...) call that slipped back
in.
The @OptIn(ExperimentalKandraApi::class) requirement on batch APIs is a real signal, not
boilerplate — this corner of the API had two structural bugs found against a live cluster before it
worked as documented. Treat the experimental marker as "verify this behaves the way you expect
before shipping it," not as noise to suppress.
See /philosophy/atomicity for the broader argument on why Cassandra has
no ambient transaction and what that means for how you should think about multi-table writes, and
/battle-scars/batch-scope-shadowing for the full story of
this Kotlin resolution rule — it applies anywhere you try to shadow a member function with a
same-named extension, not just here.