10. Caching
The app is getting real traffic now, and the access-pattern shape is what you'd expect from a todo
list: far more reads than writes. Every page load re-fetches todos; a given todo is only written
when it's created, reassigned, marked done, or soft-deleted. That imbalance is exactly what
@CacheResult exists for — but it's worth being precise about what it caches before wiring it
in, because the honest answer is narrower than "caches the todo list."
What @CacheResult actually caches
KandraCache — the thing @CacheResult turns on — sits in front of exactly one method:
findById. find, findAll, findPage, and exists never touch it, at all, regardless of
whether the entity has @CacheResult. That's deliberate, not an oversight: caching a paginated
range scan safely means caching a token, a page size, and a result set that can change shape as new
rows are written into the range — a materially harder problem than caching "the row with this
exact key," which is what findById already is.
GET /todos/mine is built on findPage (page 05), scoped by partition key with no clustering-key
predicate — a range read over "everything this owner has." @CacheResult on Todo does not
cache that call. What it caches is repeated findById(ownerId, createdAt) lookups against one
specific todo — which this app already does, in the read-before-write step of
page 07's PATCH /todos/{id}/done handler. If your hot path were
purely "list my todos" with no repeated single-todo fetches, @CacheResult here would do nothing
useful — know which read pattern you're actually optimizing before reaching for it.
That's still a real, worthwhile cache for this app: every done/assignment/edit flow re-reads the
one todo it's about to change, often more than once per user session, and those reads are cheap to
serve from memory instead of a round trip to ScyllaDB.
Adding it to Todo
@ScyllaTable("todos")
@CacheResult(ttlSeconds = 30, maxSize = 5_000)
@SoftDelete(ttlSeconds = 604_800, markerProperty = "isDeleted")
data class Todo(
@PartitionKey val ownerId: UUID,
@ClusteringKey(order = ClusteringOrder.DESC) val createdAt: Instant,
val todoId: UUID,
val title: String,
val assigneeId: UUID?,
val done: Boolean = false,
@Version val version: Long = 1L,
@UpdatedAt val updatedAt: Instant = Instant.EPOCH,
val isDeleted: Boolean = false,
)@CacheResult is its own class-level annotation, independent of @ScyllaTable — it just populates
TableSchema.cacheConfig, which KandraRepository/KandraSuspendRepository read when they build
their per-repository KandraCache<Any, Todo>. ttlSeconds = 30 is short on purpose: this app's
todos change often enough (done-toggles, reassignment) that a long TTL would mean serving stale
data for longer than the invalidation-on-write path already guarantees freshness for — 30 seconds
is "smooth out repeated reads within one request burst," not "avoid hitting the database."
The Caffeine dependency is real, not implied
kandra-runtime declares Caffeine as compileOnly — it's on Kandra's own build classpath to
compile against, but not pulled into your app transitively. KandraCache resolves
com.github.benmanes.caffeine.cache.Caffeine via Class.forName at construction time; if it's not
on your runtime classpath, the cache silently becomes a no-op (isEnabled = false, every
getIfPresent returns null, every put/invalidate does nothing) and logs exactly one WARN.
Nothing throws. Nothing else tells you caching quietly isn't happening — the app behaves
identically, just slower, and the only signal is a log line you have to be looking for.
dependencies {
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
}Add that (already implied by page 02's dependency list if you
included it there — if you skipped it as "not needed yet," this is the page where it becomes
needed) and @CacheResult does real work. Skip it, and @CacheResult(ttlSeconds = 30, maxSize = 5_000) on the entity compiles and runs fine, just as a permanent no-op.
findById populates it, writes invalidate it — but which writes?
val todoRepo = application.kandra.suspendRepository<Todo>()
val todo = todoRepo.findById(ownerId, createdAt) // cache miss → query → cache.put(key, todo)
val same = todoRepo.findById(ownerId, createdAt) // cache hit → no queryInvalidation is wired into save, saveIfNotExists (only when it actually applied),
saveAll, update, updateForce, saveWithNulls, delete, and deleteAll — every write path
this app already uses for Todo (update() from page 07, delete() for the soft-delete flow from
page 08). It is not wired into deleteById, deleteBy, or any of append/remove/put/
increment/decrement — this app doesn't call any of those on Todo, so it's not a live gap
here, but it's worth knowing if you extend the routes later: a hand-rolled deleteById(ownerId, createdAt) call would soft-delete the row correctly (on the suspend repo) but leave a stale
findById result cached until the 30-second TTL expires on its own.
The real gotcha: the invalidation key has to match the read key, exactly
This is the load-bearing lesson of this page, and it's a real bug Kandra shipped once
(ISS-028),
not a hypothetical.
findById's cache key isn't always "the ID." For a single-partition-key entity like User, it's a
bare UUID. For Todo — partition key and clustering key — the full primary key is two
values, so findById's real cache key is an ordered List(ownerId, createdAt), not ownerId
alone. That distinction is exactly where the bug lived: the invalidation helper used to derive its
key from partitionKeyOf(entity) — partition key only, a bare ownerId — while findById's
actual cache key for the same row was the two-element List. A bare UUID and a two-element
List are never equal(), so every invalidation call for a clustering-keyed cached entity
silently missed the real cache entry:
findById(ownerId, createdAt)— cache miss, queries ScyllaDB, caches the row under key[ownerId, createdAt].- Someone calls
todoRepo.update(old, new)to mark it done. The write succeeds. Invalidation runs — but (in the buggy version) it computes the key as bareownerId, which was never the key any entry was stored under. The invalidation call executes, "succeeds," and does nothing. findById(ownerId, createdAt)again — cache hit, on the pre-update row. The caller seesdone: falsefor up tottlSeconds(30s here) after the update genuinely committed to ScyllaDB. No exception, no log line pointing at the cache — the write looks like it worked, and the very next read looks like it didn't.
The fix, verified in current kandra-runtime source (KandraRepository.kt/
KandraSuspendRepository.kt), replaced partitionKeyOf with cacheKeyOf, which reuses the same
keyValuesOf helper findById itself uses to build its lookup key — partition and clustering
columns, same bare-value-vs-List shape convention in both places. Invalidation and lookup now
derive their keys from literally the same code path, so they can't drift apart again by
construction, not just by discipline. This app is on a version of Kandra with the fix — the
walkthrough above happened, historically, on the codebase this tutorial is built against, and it's
already fixed by the time you're reading this. It's included here because the shape of the bug
generalizes past Kandra: if you ever build your own cache on top of any keyed store — a local
in-memory cache in front of a REST call, a Redis layer over a multi-column key — the invalidation
key and the lookup key have to be computed by the same function, not two functions that are
supposed to agree. "Supposed to agree" is exactly how this one drifted.
Verifying it
val existing = todoRepo.findById(ownerId, createdAt)!! // populates cache
val updated = existing.copy(title = "Buy milk (urgent)")
todoRepo.update(existing, updated) // invalidates the SAME key
val reread = todoRepo.findById(ownerId, createdAt)!! // cache miss again, fresh row
check(reread.title == "Buy milk (urgent)") // passes on current Kandraas of Kandra v0.4.6— Verified against kandra-runtime — cacheKeyOf is the live
invalidation-key implementation.
Next: /tutorial/11-observability — now that there's a cache, a
retry policy, and multiple tables in play, it's worth being able to see what the app is actually
doing under load.