kandra-runtime
kandra-runtime is where a @ScyllaTable entity and its TableSchema (built by kandra-core)
turn into real DataStax driver calls: KandraRepository<T> (blocking) and
KandraSuspendRepository<T> (suspend) for CRUD, BatchEngine for retry/shutdown/metrics-wrapped
execution, StatementBuilder for CQL construction, a small predicate DSL (QueryContext,
KandraColumnRef) for type-safe queries against KSP-generated *Table objects, KandraCodec for
Kotlin ⇄ driver type conversion, and an optional Caffeine-backed KandraCache. Nothing in this
module reads annotations — everything here operates on the already-built TableSchema.
Package map: io.kandra.runtime (KandraRuntime, BatchEngine, StatementBuilder,
KandraBatchScope, config classes), io.kandra.runtime.repository (the two repository classes),
io.kandra.runtime.dsl (QueryContext, KandraPredicate, KandraColumnRef, KandraPage,
KandraRawQuery), io.kandra.runtime.codec, io.kandra.runtime.cache, io.kandra.runtime.driver
(suspend CqlSession extensions).
Why this exists as a separate module
kandra-runtime is the first module in the dependency graph that pulls in the actual DataStax Java
driver (implementation(libs.datastax.driver)) and coroutines
(implementation(libs.coroutines.core)) — kandra-core has neither. A consumer that only wants to
validate entity shapes or generate DDL offline (a CI check, a schema-diffing tool) can depend on
kandra-core alone and never pull in a network-capable driver at all; anything that actually wants
to talk to ScyllaDB depends on kandra-runtime (implementation(project(":kandra-core")) plus the
driver) to get repositories, and kandra-ktor/kandra-koin/kandra-kodein/kandra-multidc all
depend on kandra-runtime in turn. Caffeine is compileOnly here, not implementation — the
@CacheResult cache is reflection-based specifically so a consumer who never adds
com.github.ben-manes.caffeine:caffeine to their own classpath gets a silent no-op cache instead of
a hard dependency they didn't ask for (see KandraCache below).
KandraRuntime — the entry point
class KandraRuntime(val session: CqlSession, val batchEngine: BatchEngine, val codec: KandraCodec) {
val isShuttingDown: AtomicBoolean // delegates to batchEngine.isShuttingDown
val inFlightCount: AtomicInteger // delegates to batchEngine.inFlightCount
@ExperimentalKandraApi suspend fun batch(block: suspend KandraBatchScope.() -> Unit)
@ExperimentalKandraApi fun batchBlocking(block: KandraBatchScope.() -> Unit)
fun <T : Any> repository(entityClass: KClass<T>): KandraRepository<T>
fun <T : Any> suspendRepository(entityClass: KClass<T>): KandraSuspendRepository<T>
inline fun <reified T : Any> repository(): KandraRepository<T>
inline fun <reified T : Any> suspendRepository(): KandraSuspendRepository<T>
suspend fun isHealthy(): Boolean // SELECT release_version FROM system.local; false on any exception
}repository()/suspendRepository() call SchemaRegistry.get(entityClass) and build a brand-new
repository instance every call — see the Gotchas section for why this matters for hot paths.
isHealthy() never throws, so it's safe to wire directly into a health-check route (kandra-ktor
does exactly this for GET /kandra/health).
routing {
get("/users/{id}") {
val repo = application.kandra.suspendRepository<User>()
val user = repo.findById(UUID.fromString(call.parameters["id"]!!))
call.respond(user ?: HttpStatusCode.NotFound)
}
}Repository API
KandraRepository<T> and KandraSuspendRepository<T> expose the identical surface — the suspend
repository's writes route through BatchEngine's *Suspend methods (truly non-blocking
prepare/execute via CqlSession.prepareSuspend/executeSuspend), the blocking repository through
the plain synchronous ones. Both are constructed (session, schema, entityClass, batchEngine) and
build their own StatementBuilder(session) (its own 1000-entry prepared-statement cache) and
KandraCache<Any, T>(schema.cacheConfig) internally — see Gotchas for why constructing repositories
per-request instead of once is expensive.
Save family
fun save(entity: T, ttlSeconds: Int? = null, timestampMicros: Long? = null, consistency: KandraConsistency? = null)
fun saveIfNotExists(entity: T, serialConsistency: KandraConsistency = KandraConsistency.LOCAL_SERIAL): Boolean
fun saveWithNulls(entity: T, ttlSeconds: Int? = null)
fun saveAll(entities: List<T>, useBatch: Boolean = true)userRepo.save(user, ttlSeconds = 3600)
val created = userRepo.saveIfNotExists(user) // false if the partition key already exists
userRepo.saveWithNulls(user.copy(nickname = null)) // tombstones `nickname`
userRepo.saveAll(users, useBatch = true) // chunks at 100 statements by defaultsave— runs any registered validator, stamps@CreatedAt/@UpdatedAtvia reflectivecopy(), sets the initial@Versionvalue if present (1LforLong,Instant.now()forInstant), then executes oneLOGGED BATCH: the primaryINSERTplus oneINSERTper@LookupIndex(consistency = BATCH)table.EVENTUALlookups fire on a background coroutine after the batch commits; failures there are logged and forwarded toKandraEventListener.onEventualWriteFailedif one is registered. A nullable field set tonullencodes toKandraUnset→ the driver'sunset(idx), not a boundnull— no tombstone, the existing stored value is left alone. ThrowsKandraQueryExceptionimmediately on a counter table.saveIfNotExists— LWTINSERT ... IF NOT EXISTS. Throws up front ifserialConsistencyisn'tLOCAL_SERIAL/SERIAL. Reads the driver's synthetic[applied]column; iffalse, returnsfalsewithout touching lookup tables at all. If applied,BATCHlookups go in a second, separateLOGGED BATCH— not atomic with the primary insert, so a crash between the two can leave the primary row without its lookup rows.saveWithNulls— bindsnulldirectly (setBytesUnsafe) instead ofunset(), which does write a tombstone. Does not inject an initial@Versionvalue the waysavedoes.saveAll— no-op on an empty list.useBatch = falsecallssave()per entity (own atomic batch each, but skips the validator — only singlesave()validates).useBatch = true(default) builds one flat statement list, estimates size asstatements.size * 512bytes (a fixed heuristic, not real serialized size) and warns abovebatchWarnThresholdKb; if the count exceedsbatchMaxChunkSizeandbatchAutoChunkis true, it splits into multiple independentLOGGED BATCHes — breaking cross-chunk atomicity.
Once statement count exceeds batchMaxChunkSize (default 100), a large saveAll issues several
independent LOGGED BATCH statements. A failure partway through leaves earlier chunks committed and
later ones not — saveAll is not one atomic operation once it's large enough to chunk.
Update family
fun update(old: T, new: T)
fun updateForce(entity: T)val loaded = userRepo.findById(userId)!!
val changed = loaded.copy(email = "new@example.com")
userRepo.update(loaded, changed) // LWT IF version=? when @Version is present, else a blind overwriteupdate(old, new)validatesnew. With a@Versioncolumn: readsold's current version via reflection, computes the next value, and issuesUPDATE ... SET ... WHERE pk=? IF <versionCol> = ?withLOCAL_SERIAL(hardcoded, not configurable per-call). A non-applied result throwsKandraOptimisticLockException. Without a@Versioncolumn,update()issues no CQLUPDATEat all — it does a full-rowINSERToverwriting every column, with no concurrency check whatsoever;oldis used purely to diff which lookup-table rows to delete/insert againstnew, never compared to what's actually stored.updateForce(entity)bypasses the@Versioncheck entirely, even if one exists — always a full-row overwrite in a batch.
updateForce has no separate "old" entity to diff against, so it diffs entity against itself
(pre- vs. post-timestamp-injection) — the lookup-index-column-changed check can never fire, because
both sides of the diff have the identical value. If email drives a @LookupIndex and you change it
via updateForce, the old lookup row for the previous email is never deleted. Only two-argument
update(old, new) correctly diffs a real "before" against a real "after."
Delete family
fun delete(entity: T)
fun deleteAll(entities: List<T>)
fun deleteById(vararg keyValues: Any)
fun deleteBy(block: QueryContext.() -> Unit)userRepo.delete(user) // hard delete unless @SoftDelete
userRepo.deleteAll(inactiveUsers) // N sequential deletes; warns above tombstoneWarnThreshold rows
userRepo.deleteById(userId) // full primary key required — partition + clustering columns
userRepo.deleteBy { UserTable.email eq "x@example.com" }delete(entity)— if@SoftDeletewith a configured TTL: issuesUPDATE ... USING TTL <ttl>over every non-transient, non-counter, non-marker column (rewriting the whole row with a fresh TTL), then a separateUPDATEwritingtrueto the marker column without a TTL — the marker must outlive the rest of the row sofindActive()can still tell it apart from a live row after the other columns expire. Lookup-table rows are deliberately left alone on a soft delete — a soft-deleted row still "exists" until its TTL expires, so it has to remain resolvable via its@LookupIndextoo, exactly like a directfindById/findActivestill sees it. Otherwise (hard delete): oneLOGGED BATCHofDELETE FROM <table> WHERE pk=...plus oneDELETEper lookup table — this is the only path that removes lookup rows.deleteAll(entities)— no-op on empty; abovetombstoneWarnThreshold(default 1000) rows, logs a WARN naminggc_grace_seconds. Then callsdelete()once per entity, sequentially — N separate round trips, not one combined batch.deleteById(vararg keyValues)— must cover the full primary key, partition then clustering columns in schema order; passing fewer throwsKandraSchemaExceptionnaming exactly which columns are missing rather than silently scoping to a partial key (seeISS-025). Looks the entity up first (bypassing the entity cache), then deletes it and invalidates the cache entry if found.deleteBy(block)— resolves one matching entity via the query DSL, then deletes it throughBatchEngine(so soft-delete and retry apply). No-ops if nothing matches.
Blocking KandraRepository.deleteById() looks the entity up via QueryExecutor first, then — if
found — issues the delete LOGGED BATCH directly via session.execute, bypassing BatchEngine
entirely: no retry-on-timeout, no shutdown rejection, and it always hard-deletes, even on a
@SoftDelete-annotated entity. The suspend version delegates to batchEngine.deleteSuspend
instead, so it does honor soft-delete/retry. Both versions do invalidate the KandraCache entry
when the entity was found.
Read family
fun findById(vararg idValues: Any, consistency: KandraConsistency? = null): T?
fun find(block: QueryContext.() -> Unit): T?
fun findAll(limit: Int? = null, block: QueryContext.() -> Unit): List<T>
fun findPage(pageSize: Int, pageToken: String? = null, block: QueryContext.() -> Unit = {}): KandraPage<T>
fun findActive(): List<T>
fun exists(block: QueryContext.() -> Unit): Boolean
fun raw(cql: String, vararg params: Any?): List<Row>
fun rawQuery(query: KandraRawQuery): List<Row>val u = userRepo.findById(userId) // cached if @CacheResult
val one = userRepo.find { UserTable.email eq "a@b.com" } // fetches all matches, takes the first
val page = userRepo.findPage(pageSize = 50) { UserTable.status eq "ACTIVE" }
val next = userRepo.findPage(pageSize = 50, pageToken = page.nextPageToken) { UserTable.status eq "ACTIVE" }
val active = todoRepo.findActive() // requires @SoftDelete(markerProperty = "...")findById— checks theKandraCachefirst (no-op if there's no@CacheResultor Caffeine isn't on the classpath); on a miss, queries by the full primary key and populates the cache.idValuesmust cover the full key — aKandraSchemaExceptionnames what's missing otherwise.find(block)—findAll(block).firstOrNull()under the hood — it still fetches and decodes every matching row before taking the first, not aLIMIT 1, unless the block itself callslimit(1).findAll(limit, block)— predicate resolution, in order: (1) throwsKandraQueryException("Query must have at least one predicate.")on an empty block; (2) anInpredicate must target a partition key or a@SecondaryIndexcolumn, else throwsKandraQueryExceptionpointing you at@SecondaryIndex—INon a composite partition key throwsKandraSchemaExceptioninstead, a different exception type, because that check happens insideStatementBuilder; (3) a predicate on a@LookupIndexcolumn supportsEqonly, resolves via the lookup table, then queries the primary table by the full reconstructed key; a lookup miss returnsemptyList()without touching the primary table; (4) a@SecondaryIndexpredicate logs a WARN on every call, then falls through to; (5) direct CQL —WHERE col op ? [AND ...],LIMIT nif set. Kandra never appendsALLOW FILTERINGin this path.findPage— driver-native, token-range paging, not offset-based. With no predicate: a full token-range scan (SELECT * FROM <table>, noWHERE) — the DataStax driver pages across token ranges automatically; this is the sanctioned way to iterate a whole table, and it logs an INFO the first time. With a@LookupIndexequality predicate: resolves the lookup row once, then paginates the primary table by the resolved key.pageTokenis the driver's raw paging state, base64-encoded.findActive()— requires@SoftDelete(markerProperty = "..."), elseKandraSchemaException. Internally issuesSELECT * FROM <table> WHERE <marker> = false ALLOW FILTERINGand logs a WARN every call, naming the cost.exists(block)— routes through the same resolution asfindAllwithlimitOne = true, selectKeys = true, but those flags are only honored on the final direct-CQL branch (SELECT pk... LIMIT 1). Via a lookup orINpredicate,exists()runs the full query and just checks non-emptiness — no cheaper thanfind()for those paths.raw/rawQuery—rawprepares and binds CQL directly, warning ifparamsis empty and the CQL contains a'or the literal"="(a narrow heuristic, not a security guarantee).rawQuery(KandraRawQuery.cql("...").bind(...).build())is always parameterized by construction — prefer it.
Everywhere else on this site says Kandra's query DSL has no way to express ALLOW FILTERING — that
claim is about the QueryContext/findAll predicate path, and it's still true there.
findActive() is a different, narrower code path: a framework-controlled, single fixed query
(WHERE <marker column> = false) that ScyllaDB genuinely cannot serve without a scan unless the
marker column has its own @SecondaryIndex, so Kandra issues ALLOW FILTERING explicitly and logs
a WARN naming the cost every single call. It is not exposed for you to write your own — you can't
reach ALLOW FILTERING through QueryContext no matter what you do — but if you lean on
findActive() on a large table, know exactly what it's doing under the hood. For a large table,
@SecondaryIndex on the marker column (as the WARN suggests) turns this into an indexed query
instead of a full scan-with-filter.
Collection & counter family
fun <V> append(entity: T, field: KProperty1<T, Collection<V>?>, values: Collection<V>, consistency: KandraConsistency? = null)
fun <V> remove(entity: T, field: KProperty1<T, Collection<V>?>, values: Collection<V>, consistency: KandraConsistency? = null)
fun <K, V> put(entity: T, field: KProperty1<T, Map<K, V>?>, entries: Map<K, V>, consistency: KandraConsistency? = null)
fun increment(field: KProperty1<T, Long?>, partitionKeys: Map<String, Any>, by: Long = 1L, consistency: KandraConsistency? = null)
fun decrement(field: KProperty1<T, Long?>, partitionKeys: Map<String, Any>, by: Long = 1L, consistency: KandraConsistency? = null)userRepo.append(user, User::tags, listOf("vip"))
userRepo.remove(user, User::tags, listOf("trial"))
userRepo.put(user, User::metadata, mapOf("theme" to "dark"))
counterRepo.increment(PageViews::count, mapOf("pageId" to id)) // +1
counterRepo.decrement(PageViews::count, mapOf("pageId" to id), by = 5) // -5append/remove build col = col + ? / col = col - ? for CQL list/set columns. put is
col = col + ? too — for a CQL map, driver-level + is a merge/overwrite-by-key, the correct
"put" semantics; there's no map-remove-by-key method. increment/decrement only work on
schema.isCounterTable entities (else KandraSchemaException); partitionKeys is a
Map<String, Any> keyed by property name (falling back to CQL name) — you don't need to load the
entity to bump a counter, but a clustering-keyed counter table needs an entry for each clustering
key too, or StatementBuilder throws naming the missing column.
append/remove/put/increment/decrement call session.execute/executeSuspend directly from
the repository, not through BatchEngine — no retry-on-transient-failure, no shutdown rejection, no
metrics recording, no slow-query logging, and no KandraCache invalidation for any of the five.
Query DSL — io.kandra.runtime.dsl
sealed class KandraPredicate {
data class Eq(val column: String, val value: Any?) : KandraPredicate()
data class Gt(val column: String, val value: Any?) : KandraPredicate()
data class Gte(val column: String, val value: Any?) : KandraPredicate()
data class Lt(val column: String, val value: Any?) : KandraPredicate()
data class Lte(val column: String, val value: Any?) : KandraPredicate()
data class In(val column: String, val values: List<Any?>) : KandraPredicate()
}
class KandraColumnRef<T>(val cqlName: String, val isLookup: Boolean = false)
class QueryContext {
infix fun <T> KandraColumnRef<T>.eq(value: T)
infix fun <T> KandraColumnRef<T>.gt(value: T)
infix fun <T> KandraColumnRef<T>.gte(value: T)
infix fun <T> KandraColumnRef<T>.lt(value: T)
infix fun <T> KandraColumnRef<T>.lte(value: T)
infix fun <T> KandraColumnRef<T>.isIn(values: List<T>)
fun limit(n: Int)
}
data class KandraPage<T>(val items: List<T>, val nextPageToken: String?, val hasMore: Boolean)
interface KandraTable<T> // marker interface implemented by KSP-generated *Table objects
class KandraRawQuery internal constructor(val cql: String, val params: List<Any?>) {
companion object { fun cql(template: String): KandraRawQueryBuilder }
}
class KandraRawQueryBuilder(template: String) {
fun bind(vararg values: Any?): KandraRawQueryBuilder
fun build(): KandraRawQuery
}repository.findAll {
UserTable.status eq "ACTIVE"
limit(20)
}KandraColumnRef is what a kandra-codegen-generated *Table object exposes per column;
isLookup is metadata only — QueryExecutor determines lookup-vs-direct routing from
schema.lookupTables at query time regardless of the flag. Predicates are ANDed in the order added;
there's no or. KandraRawQuery has no public constructor — always build it with
KandraRawQuery.cql("...").bind(...).build().
Earlier versions required +UserTable.email.eq(...) — eq/etc. lived on KandraColumnRef and
returned a plain KandraPredicate that QueryContext.unaryPlus() registered. Forgetting the + on
one predicate out of several compiled cleanly (Kotlin only warns on an unused expression) and
silently dropped that predicate — with no way to detect it, since the discarded value never reached
QueryContext. eq/gt/gte/lt/lte/isIn are now member extensions on QueryContext itself
— the comparison is the registration, so there's no intermediate value left to forget. Don't write
+UserTable.x.eq(y) anywhere in current code; it won't compile.
BatchEngine
Not meant to be constructed directly — it's what both repositories call into for every write, and
what KandraBatchScope collects statements through. @InternalKandraApi.
class BatchEngine(
session: CqlSession,
statementBuilder: StatementBuilder,
scope: CoroutineScope,
eventListener: KandraEventListener? = null,
retryConfig: RetryConfig = RetryConfig(),
debugConfig: DebugConfig = DebugConfig(),
codec: KandraCodec = KandraCodec.default,
) {
val isShuttingDown: AtomicBoolean
val inFlightCount: AtomicInteger
fun setMetrics(recorder: KandraMetrics)
fun configureBatchLimits(warnThresholdKb: Int, maxChunkSize: Int, autoChunk: Boolean, tombstoneWarnThreshold: Int = 1000)
fun <T : Any> registerValidator(klass: KClass<T>, validator: KandraValidator<T>)
}- Retry —
executeWithRetry/Suspendwrap every execution. On aThrowablewhose class is inretryConfig.retryOn(defaultWriteTimeoutException,ReadTimeoutException,NoNodeAvailableException), retries with linear backoff (min(backoffMillis * (attempt + 1), maxBackoffMillis)) up tomaxAttempts(default 3) —Thread.sleepon the blocking path,kotlinx.coroutines.delayon the suspend path. Anything not inretryOnrethrows immediately. After exhausting attempts:KandraQueryException("Query failed after N attempts", lastError). - Shutdown — every execute call starts with
checkNotShuttingDown(), throwingKandraQueryExceptionifisShuttingDownis set;inFlightCountincrements/decrements around every call, feeding graceful shutdown's drain wait inkandra-ktor. - Metrics/slow-query logging — after a successful execute, if
debugConfig.logSlowQueriesMs > 0and elapsed time exceeds it, logs a WARN, then callsmetricsRecorder?.record(...). Both only on success, never on a retried-then-failed call.
StatementBuilder
Builds every BoundStatement. @InternalKandraApi.
- A
Collections.synchronizedMap-wrappedLinkedHashMapin access-order mode, capped atcacheSize(default 1000, matchingKandraConfig.preparedStatementCacheSize), caches prepared statements by exact CQL string. Eviction of the LRU entry logs a WARN suggesting a larger cache. insertPrimary— aKandraUnset-encoded value callsunset(idx), except for partition/clustering key columns, where an unset key value throwsKandraSchemaExceptionimmediately — key columns can never be left unbound. A plainINSERTissetIdempotent(false);INSERT ... IF NOT EXISTSissetIdempotent(true).insertPrimaryWithNulls— identical CQL, but binds actualnullinstead of callingunset— this is what makessaveWithNullstombstone instead of leaving the value alone.insertLookup/deleteLookup— simple single-table statements; neither applies any consistency-level override — they always use the driver's default consistency, regardless of the entity's@WriteConsistencyor a per-callconsistencyparameter passed to the repository method.selectById/selectByPartitionKeyIn— applyresolveReadConsistency; both idempotent.selectByPartitionKeyInthrowsKandraSchemaExceptionif the partition key isn't single-column.appendToCollection/removeFromCollection/counterUpdate— allsetIdempotent(false)(collection and counter mutations are never safe to blindly retry); all throwKandraSchemaExceptionif the named column isn't found in the schema.
Codec — io.kandra.runtime.codec.KandraCodec
object KandraUnset // sentinel: "leave this column unchanged," never a bound null
class KandraCodec {
@ExperimentalKandraApi fun <T : Any> registerEncoder(klass: KClass<T>, encoder: (T) -> Any?)
@ExperimentalKandraApi fun <T : Any> registerDecoder(klass: KClass<T>, decoder: (Row, String) -> T?)
fun encode(value: Any?, type: KType): Any?
fun decode(row: Row, column: ColumnSchema): Any?
companion object { val default: KandraCodec }
}encode(null, type) returns KandraUnset if type.isMarkedNullable, else throws
KandraQueryException. Non-null values check custom encoders first, then encode an Enum as its
.name String; everything else passes through as-is for the driver to handle natively.
decode() treats List/Set/Map columns specially: Cassandra can't store an empty (non-frozen)
collection any other way than NULL, so a NULL collection column always decodes to an empty
collection — regardless of whether the Kotlin property is declared nullable. This is what makes
tags: Set<String> = emptySet() readable after a round trip. Every other type throws
KandraQueryException on a NULL column paired with a non-nullable Kotlin type. Custom codecs are
per-KandraCodec-instance; KandraCodec.default is a shared singleton, and both repositories are
constructed with default params — i.e. always KandraCodec.default — so a custom encoder registered
elsewhere may not be visible unless every repository shares the same codec instance.
Caching — io.kandra.runtime.cache.KandraCache
internal class KandraCache<K : Any, V : Any>(config: CacheResultConfig?) {
fun getIfPresent(key: K): V?
fun put(key: K, value: V)
fun invalidate(key: K)
val isEnabled: Boolean
}Reflection-based: resolves Caffeine's Cache interface by Class.forName at construction. If
config == null (no @CacheResult), Caffeine isn't on the classpath, or reflective method
resolution fails, the cache silently becomes a no-op (isEnabled == false) — no exception, just a
one-time WARN. Only findById reads/writes this cache; find/findAll/findPage/exists never
touch it. Invalidated from save, saveIfNotExists (only if applied), saveAll, update,
updateForce, saveWithNulls, delete, deleteAll, and deleteById (when the entity was found).
Not invalidated by deleteBy, append, remove, put, increment, or decrement.
deleteBy routes through BatchEngine (so soft-delete/retry apply there) but still skips cache
invalidation — a cached findById result can keep returning a row deleteBy just removed, until
the cache's own TTL expires it.
Config classes
class ConsistencyConfig {
var defaultRead: KandraConsistency = KandraConsistency.LOCAL_ONE
var defaultWrite: KandraConsistency = KandraConsistency.LOCAL_QUORUM
var defaultSerialConsistency: KandraConsistency = KandraConsistency.LOCAL_SERIAL
}
class DebugConfig {
var logQueries: Boolean = false // CQL template only, never bound values — a PII guard
var logSlowQueriesMs: Long = 0L // 0 = disabled
var logBatches: Boolean = false // checked by save()/saveAll() only, not update()/delete()
}
class RetryConfig {
var maxAttempts: Int = 3
var backoffMillis: Long = 100
var maxBackoffMillis: Long = 2000
var retryOn: Set<KClass<out Throwable>> = setOf(WriteTimeoutException::class, ReadTimeoutException::class, NoNodeAvailableException::class)
}These are the same classes kandra-ktor's retry { }/debug { }/consistency { } blocks
configure — see /modules/kandra-ktor.
KandraBatchScope
class KandraBatchScope internal constructor(session: CqlSession, batchEngine: BatchEngine) {
fun <T : Any> KandraRepository<T>.saveInBatch(entity: T, ttlSeconds: Int? = null)
fun <T : Any> KandraSuspendRepository<T>.saveInBatch(entity: T, ttlSeconds: Int? = null)
fun <T : Any> KandraRepository<T>.deleteInBatch(entity: T)
fun <T : Any> KandraSuspendRepository<T>.deleteInBatch(entity: T)
fun <T : Any> KandraSuspendRepository<T>.saveIfNotExistsInBatch(entity: T): Boolean // always throws
}@ExperimentalKandraApi. Only reachable via KandraRuntime.batch { } (suspend) /
batchBlocking { }. Collects statements from saveInBatch/deleteInBatch calls — same
primary-row-plus-BATCH-lookup shape as the standalone methods, but EVENTUAL lookups are not
included — into one list, then executes them as a single LOGGED BATCH on scope exit via a plain
blocking session.execute(batch), even inside the suspend fun batch { } variant. No cache
invalidation happens for entities saved/deleted through a batch scope — batch collection bypasses
the repositories' own save/delete, which are what call cache.invalidate.
runtime.batchBlocking {
userRepo.saveInBatch(user)
walletRepo.saveInBatch(wallet)
auditRepo.deleteInBatch(oldAudit)
}Kotlin always resolves a member function of the receiver over an extension function with the same
name — even a member-extension declared inside the exact scope you're standing in, which is exactly
what a same-named save/delete here would have been. Every repository already has its own real
save/delete member, so a same-named batch-scope version would be structurally unreachable
through normal call syntax, defeating the batch silently with no compiler warning. This is a real
bug Kandra shipped and fixed — see
/battle-scars/batch-scope-shadowing for the full story.
Distinct names (saveInBatch, not save) make the wrong call a compile error instead of a silent
no-atomicity bug. saveIfNotExistsInBatch always throws KandraQueryException — LWT can't share a
LOGGED BATCH with regular statements, and this is the guard that says so instead of producing a
batch the driver would reject or mishandle.
CqlSession extensions — io.kandra.runtime.driver
suspend fun CqlSession.prepareSuspend(cql: String): PreparedStatement
suspend fun CqlSession.executeSuspend(statement: Statement<*>): AsyncResultSet
suspend fun CqlSession.executeSuspendAll(statement: Statement<*>): List<Row> // drains every pageexecuteSuspendAll is the only helper here that fully materializes a multi-page result via
fetchNextPage() — QueryExecutor's findAll/raw/rawQuery paths use it from the suspend side.
KandraEntityLogger — internal, @InternalKandraApi
internal object KandraEntityLogger {
fun safeToString(entity: Any, schema: TableSchema): String
}Reflectively renders ClassName(prop=value, ...), substituting *** for any property whose
matching ColumnSchema.isSensitive is true — a utility for consumers (kandra-ktor) that want
@Sensitive-aware safe logging; not automatically wired into any exception message inside
kandra-runtime itself.
Full API reference
kandra-runtime on Dokka → ·
io.kandra.runtime.repository package → ·
KandraSuspendRepository → ·
io.kandra.runtime.dsl package →
Where this shows up elsewhere on this site
/why— the reasoning behind cheap-by-default writes and opt-in LWT./battle-scars/batch-scope-shadowing— the fullsaveInBatch/deleteInBatchnaming story./battle-scars/clustering-keys-and-lookup-tables— whydeleteById/selectByIdrequire the full primary key./battle-scars/cache-invalidation— theKandraCachekey-shape bug this page's cache section references./recipes/lookup-tables,/recipes/caching,/recipes/optimistic-locking,/recipes/multi-table-writes— task-oriented walkthroughs.