context-attachment-manager
Claude Desktop용 플러그인, 다음 메시지에 사용할 과거 이미지 선택 가능
Kandra는 Kotlin 우선 ORM으로 ScyllaDB·Cassandra용 Ktor 플러그인이며, 분산 데이터 모델링을 배우는 문서입니다
Documentation for Kandra, a Kotlin-first ORM for ScyllaDB and Cassandra shipped as a Ktor plugin. Learn distributed data modeling, not relational habits ported over.
플러그인KotlinGitHub @Dev-Pasaka온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Lightweight Kotlin ORM for ScyllaDB/Cassandra, shipped as a Ktor plugin — async driver, optimistic locking, soft deletes, schema migrations, and query caching.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Kandra is a lightweight Kotlin-first ORM for ScyllaDB/Cassandra that ships as a Ktor server plugin.
First-class support for denormalized lookup tables, composite partition keys, TTL, pagination, LWT, and counter tables — patterns ScyllaDB requires that no existing Kotlin ORM handles natively.
Website: kandraorm.com
| Module | Purpose |
|---|---|
kandra-core | Annotations, schema model, DDL generator, registry, exceptions |
kandra-runtime | Driver wiring, codec, batch engine, query DSL, repositories |
kandra-ktor | Ktor ApplicationPlugin implementation |
kandra-kodein | Kodein DI auto-binding |
kandra-koin | Koin DI auto-binding |
kandra-codegen | KSP processor — generates type-safe *Table objects |
kandra-test | FakeKandraSession, Testcontainers integration helpers, and KandraTestBackend / KandraCloud (switch live tests between Docker and a live cluster) |
kandra-multidc | Multi-datacenter utilities and documentation — experimental: not yet validated against a real multi-region cluster (see Multi-Datacenter below) |
kandra-migrate | Versioned, checksum-validated schema migration runner |
kandra-jakarta | Jakarta Bean Validation adapter (JakartaKandraValidator) |
kandra-bom | Bill of Materials for version alignment |
This README covers the common cases end-to-end. For everything else:
| Doc | Contents |
|---|---|
docs/USER_GUIDE.md | The full reference — every annotation, config option, and repository method in depth |
docs/production-example.md | End-to-end walkthrough: entities → plugin config → DI wiring → route handlers, with the reasoning behind each choice |
docs/changelog/ | What changed in each version, one file per version |
docs/features/ | Feature-by-feature reference, one file per area |
docs/issues/ | Known gaps and issues — open, fixed, and closed — with the reasoning behind each |
docs/reviews/ | Critical, source-verified reviews — library-wide (security/performance/consistency/scalability/DX) and documentation-reality-check passes |
docs/test-plan/ | Step-by-step plan for building a real Ktor app against the published artifact and a real ScyllaDB cluster, with an exhaustive functional/edge-case coverage matrix and scoring rubric |
docs/history/ | The original build specs used to generate each version (0.1.0 → 0.4.0) — historical context, not current docs |
Kandra ships Claude Code skills as an installable plugin — a project-wide
overview (kandra), a kandra-setup skill for adding Kandra to a build, and one exhaustive, module-specific
skill per module (kandra-core, kandra-runtime, kandra-ktor, kandra-kodein, kandra-koin,
kandra-codegen, kandra-test, kandra-multidc, kandra-migrate, kandra-jakarta). Each module skill
documents every public class, function, and property in that module, traced from the actual implementation —
including footguns that don't show up as compile errors (e.g. update(old, new) taking two entities, or which
entity annotations kandra-codegen actually reads versus which are silently ignored).
Install in any project (recommended):
/plugin marketplace add Dev-Pasaka/kandra
/plugin install kandra@kandra
Or copy the skills into a single project:
mkdir -p .claude/skills
cp -r /path/to/kandra/plugins/kandra/skills/. .claude/skills/
Claude Code loads the relevant skill automatically based on what you're doing — adding Kandra to a build,
defining @ScyllaTable entities, calling repository methods, configuring install(Kandra) { ... }, wiring DI,
writing tests, or authoring a schema migration. The skills live in plugins/kandra/skills/
(.claude/skills in this repo is a symlink to it).
Built and tested against Ktor 3.6.x (Kotlin 2.3.x; Koin 4.x / Kodein 7.33 for the DI modules). Ktor 2.x is
no longer supported: Ktor 3.6 is compiled with Kotlin 2.3 metadata, so a single artifact cannot serve both
majors (GH #160). The whole test suite, including the testApplication and Testcontainers tests, runs
against Ktor 3 in CI, and the Ktor-facing modules are also re-tested in a CI matrix against Ktor 3.5.2 and the latest published 3.x release (-PktorVersionOverride=<version> reproduces it locally). Ktor 4.x is not covered.
// build.gradle.kts
dependencies {
implementation(platform("com.kandraorm.kandra:kandra-bom:0.5.2"))
implementation("com.kandraorm.kandra:kandra-ktor")
implementation("com.kandraorm.kandra:kandra-koin") // or kandra-kodein
ksp("com.kandraorm.kandra:kandra-codegen") // optional — type-safe table objects
testImplementation("com.kandraorm.kandra:kandra-test")
// Optional: multi-DC utilities
implementation("com.kandraorm.kandra:kandra-multidc")
// Optional: versioned schema migrations
implementation("com.kandraorm.kandra:kandra-migrate")
}
@ScyllaTable("users")
data class User(
@PartitionKey
val userId: UUID,
@LookupIndex(tableSuffix = "by_email", consistency = LookupConsistency.BATCH)
val email: String,
@LookupIndex(tableSuffix = "by_phone", consistency = LookupConsistency.EVENTUAL)
val phone: String,
@Column("full_name")
val name: String,
@CreatedAt val createdAt: Instant = Instant.EPOCH,
@UpdatedAt val updatedAt: Instant = Instant.EPOCH
)
// Composite partition key
@ScyllaTable("transactions_by_user_chain")
data class Transaction(
@PartitionKey(index = 0) val userId: UUID,
@PartitionKey(index = 1) val chain: String,
@ClusteringKey(order = ClusteringOrder.DESC, index = 0)
val createdAt: Instant,
val amount: Double,
val status: TransactionStatus
)
// TTL — OTP codes expire in 5 minutes
@ScyllaTable("otp_codes")
@Ttl(300)
data class OtpCode(
@PartitionKey val phone: String,
val code: String,
val createdAt: Instant
)
// Counter table
@ScyllaTable("chain_stats")
data class ChainStats(
@PartitionKey val chain: String,
@Counter val totalTransactions: Long,
@Counter val totalVolumeUsd: Long
)
fun Application.configureDatabase() {
install(Kandra) {
contactPoints = "scylla:9042"
keyspace = "coinx"
localDatacenter = "datacenter1"
autoCreateKeyspace = true // CREATE KEYSPACE IF NOT EXISTS
schemaMode = SchemaMode.AUTO_CREATE // CREATE TABLE IF NOT EXISTS
register(User::class, Transaction::class, OtpCode::class, ChainStats::class)
// Auth — defaults to env vars SCYLLA_USERNAME / SCYLLA_PASSWORD
auth {
provider = KandraAuth.fromEnv() // production default
// provider = KandraAuth.static("cassandra", "cassandra") // local dev
// provider = KandraAuth.fromFile("/run/secrets/db-user", "/run/secrets/db-pass") // k8s secrets
refreshIntervalSeconds = 3600 // optional credential rotation
}
pool {
requestTimeoutMillis = 10_000
maxRequestsPerConnection = 32768
}
retry {
maxAttempts = 3
backoffMillis = 100
}
debug {
logQueries = false // CQL templates at DEBUG (no bound values)
logSlowQueriesMs = 500 // WARN if query exceeds this threshold
rawQueryStrictMode = false // true = raw()/rawQuery()'s injection guard throws instead of warning
}
eventListener = object : KandraEventListener {
override fun onEventualWriteFailed(tableName: String, entity: Any, error: Throwable) {
logger.error("Eventual write failed on $tableName for $entity", error)
}
}
}
kandraKoin() // or kandraKodein()
}
If kandra-codegen is on the classpath (with koin-core/kodein-di also present), prefer its
generated typed accessors over hand-typed qualifier strings — a typo or entity rename now fails to
compile instead of throwing at first resolution, and no unchecked cast is needed at the call site:
// generated by kandra-codegen — wraps the exact named("UserSuspendRepo") lookup below internally
fun KoinComponent.userRepo(): KandraSuspendRepository<User> = get(named("UserSuspendRepo"))
Without kandra-codegen, use the hand-typed lookup kandraKoin() binds under directly:
fun Route.userRoutes() {
val userRepo by inject<KandraSuspendRepository<*>>(named("UserSuspendRepo"))
val txnRepo by inject<KandraSuspendRepository<*>>(named("TransactionSuspendRepo"))
post("/users") {
val user = call.receive<User>()
val saved = userRepo.saveAndGet(user)
// ↑ @CreatedAt and @UpdatedAt auto-set to Instant.now() — saveAndGet() returns the entity
// with those generated values populated; save() (Unit-returning) discards them
// ↑ writes users (primary) + users_by_email (BATCH) atomically
// ↑ users_by_phone fires asynchronously (EVENTUAL)
call.respond(HttpStatusCode.Created, saved)
}
post("/users/register") {
val user = call.receive<User>()
val inserted = userRepo.saveIfNotExists(user) // LWT — no duplicate emails
if (!inserted) call.respond(HttpStatusCode.Conflict)
else call.respond(HttpStatusCode.Created)
}
get("/users/email/{email}") {
// Generated by kandra-codegen — fully type-safe:
val user = userRepo.find { UserTable.email eq call.parameters["email"]!! }
call.respond(user ?: HttpStatusCode.NotFound)
}
get("/users/{id}") {
val user = userRepo.findById(UUID.fromString(call.parameters["id"]!!))
call.respond(user ?: HttpStatusCode.NotFound)
}
// Paginated transaction history
get("/users/{id}/transactions") {
val pageToken = call.request.queryParameters["page"]
val page = txnRepo.findPage(pageSize = 20, pageToken = pageToken) {
TransactionTable.userId eq UUID.fromString(call.parameters["id"]!!)
}
call.respond(mapOf("items" to page.items, "nextPage" to page.nextPageToken))
}
// Existence check without SELECT *
get("/users/exists/{email}") {
val exists = userRepo.exists { UserTable.email eq call.parameters["email"]!! }
call.respond(mapOf("exists" to exists))
}
}
| Annotation | Target | Description |
|---|---|---|
@ScyllaTable(name) | Class | Maps a class to a CQL table |
@PartitionKey(index) | Property | Partition key field. Use multiple with index for composite PKs |
@ClusteringKey(order, index) | Property | Clustering column |
@LookupIndex(tableSuffix, consistency) | Property | Denormalized lookup table |
@Column(name) | Property | Overrides the CQL column name |
@Transient | Property | Exclude from all CQL operations |
@Ttl(seconds) | Class | Default TTL for the primary table |
@Counter | Property | ScyllaDB COUNTER column (all non-key cols must use this) |
@CreatedAt | Property | Auto-set to Instant.now() on INSERT (must be Instant) |
@UpdatedAt | Property | Auto-set to Instant.now() on INSERT and UPDATE |
@Version | Property | Optimistic lock counter (Long/Instant) — update() uses LWT IF version = ? |
@SoftDelete(ttlSeconds) | Class | delete() sets a TTL on non-key columns instead of issuing DELETE |
@Sensitive | Property | Masked as *** in KandraEntityLogger output — never logged in plaintext |
@CacheResult(ttlSeconds, maxSize) | Class | Caffeine-backed findById cache, invalidated on save/update/delete |
@ScyllaTable("transactions_by_user_chain")
data class Transaction(
@PartitionKey(index = 0) val userId: UUID, // ┐ together form
@PartitionKey(index = 1) val chain: String, // ┘ PRIMARY KEY ((user_id, chain), ...)
@ClusteringKey(order = ClusteringOrder.DESC, index = 0) val createdAt: Instant,
val amount: Double
)
Generated DDL:
CREATE TABLE IF NOT EXISTS transactions_by_user_chain (
user_id UUID,
chain TEXT,
created_at TIMESTAMP,
amount DOUBLE,
PRIMARY KEY ((user_id, chain), created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
A @LookupIndex field triggers automatic creation of a denormalized table:
@LookupIndex(tableSuffix = "by_email", consistency = LookupConsistency.BATCH)
val email: String
Generates:
CREATE TABLE IF NOT EXISTS users_by_email (
email TEXT,
user_id UUID,
PRIMARY KEY (email)
);
LookupConsistency.BATCH — included in the same LOGGED batch (atomic).
LookupConsistency.EVENTUAL — written asynchronously via CoroutineScope.launch after the batch commits.
Despite firing asynchronously, an EVENTUAL write shares the same safeguards as every other write:
it retries on transient errors per retry { }'s retryOn set, counts toward inFlightCount so
graceful shutdown waits for it to finish before closing the session, and is rejected (surfaced via
KandraEventListener.onEventualWriteFailed, same as any other failure) once shutdown has begun. The
only thing "eventual" about it is that the caller doesn't wait for it before save()/update() returns.
install(Kandra) {
schemaMode = SchemaMode.AUTO_CREATE // CREATE TABLE IF NOT EXISTS (default)
schemaMode = SchemaMode.VALIDATE // throws on missing columns; warns on extras
schemaMode = SchemaMode.NONE // skip all DDL — you manage it
}
install(Kandra) {
autoCreateKeyspace = true
replicationStrategy = ReplicationStrategy.SimpleStrategy(replicationFactor = 1)
// current ScyllaDB images (e.g. scylladb/scylla:latest) create keyspaces with tablets enabled, which rejects
// SimpleStrategy -- for local dev against one, use SimpleStrategy(replicationFactor = 1, disableTablets = true)
// or:
replicationStrategy = ReplicationStrategy.NetworkTopologyStrategy(
dcReplicationMap = mapOf("us-east" to 3, "eu-west" to 2)
)
}
Added in 0.5.0.
One install(Kandra), one CqlSession (one connection pool), many keyspaces. The keyspace is a
property of the entity, set at compile time:
@ScyllaTable(name = "invoices", keyspace = "billing") // keyspace = "" (default) = the plugin's default keyspace
data class Invoice(@PartitionKey val id: UUID, val total: Long)
Declare each extra keyspace's settings in the plugin (the annotation, not the DSL, decides which entities live where):
install(Kandra) {
keyspace = "core" // default keyspace (unchanged)
keyspace("billing") { // extra keyspace: settings only
autoCreate = true
replicationStrategy = ReplicationStrategy.SimpleStrategy(3)
}
register(User::class, Invoice::class) // Invoice is @ScyllaTable(keyspace = "billing")
}
keyspace is neither the default nor declared with keyspace("x") { } fails
startup with a KandraSchemaException that names the entity and the missing declaration.keyspace is still an error unless at least one keyspace("x") { } is declared; then
every entity must name a declared keyspace. Declared names must be valid CQL identifiers, unique, and
different from the default.AUTO_CREATE / AUTO_MIGRATE / VALIDATE), system_schema
lookups and DDL locks all run per keyspace (the default keyspace keeps its original lock name; other
keyspaces get their own <keyspace>.kandra_ddl_locks). Lookup tables live in their parent entity's keyspace.keyspace.table name. Koin/Kodein bindings are
per entity (not per keyspace); two entities with the same simple name collide even across keyspaces.SimpleStrategy keyspaces are rejected unless you opt in with
SimpleStrategy(rf, disableTablets = true) (ScyllaDB only); clusters with
tablets_mode_for_new_keyspaces: enforced reject SimpleStrategy entirely, so use NetworkTopologyStrategy.KandraMigrationRunner(session, keyspace = "billing") keeps kandra_migrations per keyspace.keyspace generate exactly the same unqualified CQL as before.batch { } that mixes entities from different keyspaces works (covered by
MultiKeyspaceRuntimeIntegrationTest), with the usual cross-partition logged-batch cost. See docs/features/ktor-plugin.md.val inserted = userRepo.saveIfNotExists(user)
if (!inserted) throw ConflictException("Email already registered")
// Need the row as actually persisted (e.g. its generated @Version) instead of a Boolean?
val persisted: User? = userRepo.saveIfNotExistsAndGet(user) // null if it already existed
Generates INSERT ... IF NOT EXISTS. Lookup table writes only happen when [applied] == true. Cannot be batched with other statements (ScyllaDB constraint).
@ScyllaTable("otp_codes")
@Ttl(300) // 5 minutes default
data class OtpCode(@PartitionKey val phone: String, val code: String)
// Override per-save:
otpRepo.save(otp, ttlSeconds = 120) // 2 minutes for this one
val page1 = txnRepo.findPage(pageSize = 20) {
TransactionTable.userId eq userId
}
// page1.nextPageToken is a base64 paging state
val page2 = txnRepo.findPage(pageSize = 20, pageToken = page1.nextPageToken) {
TransactionTable.userId eq userId
}
Kandra does not support ALLOW FILTERING at all — there is no DSL escape hatch for it, by design (see
Secondary Index below). Querying a column that isn't a partition/clustering key
requires @SecondaryIndex on it.
// 5 most recent transactions
val recent = txnRepo.findAll(limit = 5) {
TransactionTable.userId eq userId
}
// Requires @SecondaryIndex on `status` (see below) — logs a WARN when used, since it's a
// scatter-gather query across all nodes, not a partition-scoped one
val flagged = userRepo.findAll {
UserTable.status eq AccountStatus.FLAGGED
}
findAll/find/findActivewithoutlimitcap at 10,000 rows — silently. Any unpaged query that returns more thanmaxUnpagedResultRows(10,000) rows logs a WARN and truncates the result to that many rows rather than throwing — there is no exception, just a server log line. Don't rely on an unboundedfindAll { ... }to return a genuinely complete result set for a large table; usefindPage()/findPageSuspend()(see Pagination above) instead.
statsRepo.increment(ChainStats::totalTransactions, mapOf("chain" to "BASE"), by = 1L)
statsRepo.increment(ChainStats::totalVolumeUsd, mapOf("chain" to "BASE"), by = amountUsd)
statsRepo.decrement(ChainStats::totalTransactions, mapOf("chain" to "BASE"))
Counter tables cannot use save() — call increment()/decrement() only.
Atomic — no read-modify-write race condition:
// Add roles to user (SET col = col + ?)
userRepo.append(user, User::roles, setOf("vip", "early_adopter"))
// Remove a tag (SET col = col - ?)
userRepo.remove(user, User::tags, setOf("inactive"))
// Update wallet metadata (MAP update)
walletRepo.put(wallet, Wallet::metadata, mapOf("kyc_level" to "1"))
install(Kandra) {
codec.registerEncoder(WalletAddress::class) { it.value }
codec.registerDecoder(WalletAddress::class) { row, col -> WalletAddress(row.getString(col)!!) }
}
BigDecimal is supported natively → CQL DECIMAL.
userRepo.deleteById(userId)
userRepo.deleteBy { UserTable.email eq "[email protected]" }
// deleteBy resolves via lookup, fetches full entity, then cleans all lookup tables
walletRepo.saveAll(listOf(wallet1, wallet2, wallet3))
// Emits a single LOGGED batch for all primary + BATCH lookup inserts
KSP generates a *Table object for each entity:
// Generated: UserTable.kt
object UserTable : KandraTable<User> {
val userId = KandraColumnRef<UUID>("user_id")
val email = KandraColumnRef<String>("email", isLookup = true)
val name = KandraColumnRef<String>("full_name")
}
Use in queries:
val user = userRepo.find { UserTable.email eq "[email protected]" }
val users = userRepo.findAll { UserTable.userId isIn listOf(id1, id2) }
class UserRepositoryTest {
@AfterEach fun cleanup() = KandraTestUtils.reset() // 0.5.0: SchemaRegistry.clear() under @OptIn(InternalKandraApi::class)
@Test
fun `save captures batch statement`() {
val runtime = KandraTestUtils.inMemory(User::class)
val fakeSession = runtime.session as FakeKandraSession
val repo = runtime.repository(User::class)
repo.save(User(UUID.randomUUID(), "[email protected]", "+1234567890", "Alice"))
assert(fakeSession.capturedBatches().isNotEmpty())
}
}
kandra-test does not bring JUnit onto your test classpath: add org.junit.jupiter:junit-jupiter (with tasks.test { useJUnitPlatform() }) yourself. For
KandraTestcontainers, use Testcontainers 1.21.4 or newer -- 1.20.x cannot talk to current Docker releases. The fake session records statements; it
stores no data and returns no rows.
Since 0.5.1 (changelog) the DataStax driver comes with
kandra-testas anapidependency,KandraTestUtils.reset()replacesSchemaRegistry.clear()(no opt-in), andcapturedBatchViews()exposes each statement's CQL and bound values. On 0.5.0, addjava-driver-coreyourself and wrapSchemaRegistry.clear()in@OptIn(InternalKandraApi::class).
| Kotlin | CQL |
|---|---|
UUID | UUID |
String | TEXT |
Int | INT |
Long | BIGINT |
Boolean | BOOLEAN |
Double | DOUBLE |
Float | FLOAT |
Instant | TIMESTAMP |
LocalDate | DATE |
ByteArray | BLOB |
BigDecimal | DECIMAL |
List<T> | LIST<T> |
Set<T> | SET<T> |
Map<K,V> | MAP<K,V> |
Any Enum subclass | TEXT (stored as name) |
| Custom via codec | Any |
# Unit tests (no database required)
JAVA_HOME=<jdk-21-path> ./gradlew :kandra-core:test
# Full suite, all modules — kandra-test and kandra-ktor spin up a real
# Cassandra/ScyllaDB-compatible container via Testcontainers automatically,
# no manual database setup or exclusions needed. Requires Docker running locally.
JAVA_HOME=<jdk-21-path> ./gradlew test
# Full build
JAVA_HOME=<jdk-21-path> ./gradlew build
Note: Run with Java 21. The default Java 25 breaks the Kotlin compiler embedded in Gradle 8.11 (version string parse bug).
KandraAuth replaces the old username/password string fields with a pluggable provider:
// From environment variables (recommended for production)
KandraAuth.fromEnv() // reads SCYLLA_USERNAME, SCYLLA_PASSWORD
KandraAuth.fromEnv("DB_USER", "DB_PASS") // custom var names
// From files (Kubernetes secrets, Docker secrets) — two plain-text files, not one JSON file
KandraAuth.fromFile("/run/secrets/scylla-username", "/run/secrets/scylla-password")
// Hardcoded — for local dev only
KandraAuth.static("cassandra", "cassandra")
// Custom — Vault, AWS Secrets Manager, etc.
KandraAuth.custom { KandraCredentials(vault.read("username"), vault.read("password")) }
Credential rotation — set refreshIntervalSeconds and Kandra re-fetches credentials in the background without restarting the session. The new credentials apply to the next new connection:
auth {
provider = KandraAuth.custom { secretsClient.getCassandraCredentials() }
refreshIntervalSeconds = 3600
}
The onCredentialRefreshed() / onAuthFailed() callbacks on KandraEventListener let you observe rotation events.
One-way TLS (server authentication only):
install(Kandra) {
ssl {
enabled = true
trustStorePath = "/etc/ssl/scylla-truststore.jks"
trustStorePassword = System.getenv("TRUST_STORE_PASSWORD")
hostnameVerification = true
}
}
Mutual TLS (mTLS — client certificate required):
ssl {
enabled = true
trustStorePath = "/etc/ssl/scylla-truststore.jks"
trustStorePassword = System.getenv("TRUST_STORE_PASSWORD")
keyStorePath = "/etc/ssl/client-keystore.p12"
keyStorePassword = System.getenv("KEY_STORE_PASSWORD")
keyStoreType = "PKCS12"
}
SSL handshake failures are wrapped as KandraAuthException. Keyspace permission validation runs at startup — disable with validatePermissions = false in restricted environments.
SslConfig also supports minimumTlsVersion (default "TLSv1.2", must be one of TLSv1/TLSv1.1/TLSv1.2/TLSv1.3), cipherSuites (restricts the handshake to exactly this list), and requireEncryption (off by default — set to true to make Kandra refuse to start with ssl.enabled = false, as a hard guard against TLS being accidentally left off). All three are enforced (ISS-070):
ssl {
enabled = true
requireEncryption = true
minimumTlsVersion = "TLSv1.3"
cipherSuites = listOf("TLS_AES_256_GCM_SHA384")
trustStorePath = "/etc/ssl/scylla-truststore.jks"
trustStorePassword = System.getenv("TRUST_STORE_PASSWORD")
}
Add kandra-multidc and configure in the plugin:
install(Kandra) {
localDatacenter = "us-east"
loadBalancing {
tokenAware = true
dcAwareFailover = true
allowedRemoteDcs = listOf("eu-west")
}
failover {
onLocalDcUnavailable = FailoverPolicy.RETRY_REMOTE_DC // default: THROW
}
speculativeExecution {
enabled = true
delayMillis = 100 // fire speculative request after 100ms
maxAttempts = 2
}
}
Consistency level guide:
| Scenario | defaultRead | defaultWrite |
|---|---|---|
| Single DC (default) | LOCAL_ONE | LOCAL_QUORUM |
| Multi-DC active-active | LOCAL_QUORUM | EACH_QUORUM |
| Strong global | QUORUM | QUORUM |
Override at the class level with annotations:
@ReadConsistency(KandraConsistency.LOCAL_QUORUM)
@WriteConsistency(KandraConsistency.EACH_QUORUM)
@ScyllaTable("critical_balances")
data class Balance(...)
Or per-call:
repo.save(entity, consistency = KandraConsistency.EACH_QUORUM)
repo.findById(id, consistency = KandraConsistency.QUORUM)
Resolution order: per-call override → class annotation → ConsistencyConfig default
Not every level in the table above is valid for every operation.
EACH_QUORUMis write-only — using it as a read consistency (defaultRead,@ReadConsistency, or a per-call override on a read) throwsKandraQueryException(Cassandra/Scylla reject it server-side for reads).SERIAL/LOCAL_SERIALare the opposite: they're only valid as the separateserialConsistencyparameter of a conditional write (saveIfNotExists()/update()'sIFcheck) — using either as a regulardefaultWrite/@WriteConsistency/per-call write consistency throws the same exception. Both checks run before the value ever reaches the driver, so the error is always a clear Kandra message, not an opaque driver one (GH #140/#141).
Strict Mode (GH #5) — opt-in warning for a common multi-DC footgun: LOCAL_ONE/ONE reads/writes
hit a single node's local view, which may not yet have the latest write from another DC.
install(Kandra) {
consistency {
strictMode = true // opt-in, default false — WARN-only, never throws
}
loadBalancing {
allowedRemoteDcs = listOf("dc-eu") // presence of remote DCs is what makes strictMode fire
}
}
Logs a WARN whenever a query resolves to LOCAL_ONE/ONE and the deployment is multi-DC
(auto-derived from loadBalancing.allowedRemoteDcs.isNotEmpty(), not a separate setting). Purely
observability — never changes query behavior. See docs/features/multidc.md
and ISS-037.
The multi-DC feature is not yet fully tested against a genuine multi-region topology. Every round so far (Testcontainers, self-hosted, ScyllaDB Cloud) has been an approximation — see multidc.md's "Known gap" section for exactly what has and hasn't been validated.
Do not begin experimental testing against a real multi-cluster DC topology yet. A follow-up
critical audit done immediately after the 2026-09-08 review's fixes landed
(docs/reviews/2026-09-09-post-fix-critical-audit.md)
found that two of those fixes have real, confirmed bugs, and surfaced several severe pre-existing
gaps nobody had caught before. Most urgently:
SchemaMode.AUTO_CREATE/AUTO_MIGRATE's DDL claim never resets after its first successful
run — schema changes (new tables, new columns) silently stop applying after the very first
successful startup, for the lifetime of the keyspace
(ISS-077).findById() cache
hits, lookup-index reads, and versioned-update lookup-table writes
(ISS-082,
ISS-083) — undermining exactly the
guarantee a multi-DC test round exists to validate.NetworkTopologyStrategy
and will false-positive-warn on correctly-configured clusters
(ISS-085).See the audit write-up and docs/issues/README.md's Open table (ISS-077
through ISS-096) for the full list, including AUTO_MIGRATE's key-column data-collision risk and an
unbounded findActive() OOM risk. (kandra-multidc's test suite and kandra-ktor's SSL round-trip
suite now run in CI — see Testing against a real multi-DC topology
and .github/workflows/multidc.yml — GH #97 / ISS-084.)
KandraBatchScope lets you group saves and deletes from different repositories into a single LOGGED BATCH:
@OptIn(ExperimentalKandraApi::class)
application.kandra.batch {
userRepo.saveInBatch(user)
walletRepo.saveInBatch(wallet)
auditRepo.saveInBatch(auditEntry)
}
// ↑ all three execute as one atomic LOGGED BATCH
saveInBatch/deleteInBatch (not save/delete) are deliberate: Kotlin always resolves a
same-named repository member over an extension, even a member-extension of the batch scope
itself, so a same-named batch method would silently never be reachable.
batch { } is suspend and only accepts KandraSuspendRepository.saveInBatch/deleteInBatch calls
(its scope type is KandraBatchScope); batchBlocking { } is its non-suspend counterpart and only
accepts KandraRepository.saveInBatch/deleteInBatch calls (KandraBlockingBatchScope). The two
scope types are deliberately distinct so calling the blocking repository's saveInBatch from inside
a suspend batch { } block (which would block the calling coroutine's dispatcher thread) is a
compile error rather than a silent foot-gun.
Limitations: reads are not available inside a batch; saveIfNotExists throws (LWT cannot be mixed with regular statements in a batch).
Idempotent event replay — set an explicit write timestamp to avoid duplicate data:
import io.kandra.core.KandraTimestamp
// Replay from a Kafka event
repo.save(entity, timestampMicros = KandraTimestamp.fromInstant(event.occurredAt))
// Write-time "now"
repo.save(entity, timestampMicros = KandraTimestamp.now())
TTL and TIMESTAMP can be combined:
repo.save(entity, ttlSeconds = 300, timestampMicros = KandraTimestamp.fromInstant(event.at))
// → INSERT ... USING TTL 300 AND TIMESTAMP 1719273600000000
Scatter-gather reads for multiple partition keys:
val users = userRepo.findAll {
UserTable.userId isIn listOf(id1, id2, id3)
}
// ↑ generates: SELECT * FROM users WHERE user_id IN ?
// ↑ empty list → returns emptyList() immediately (no query)
// ↑ DEBUG log: "IN query on partition key users.user_id"
Only works for single-column partition keys. Composite partition key tables throw KandraQueryException.
For columns that need ad-hoc queries without a lookup table:
@ScyllaTable("products")
data class Product(
@PartitionKey val productId: UUID,
@SecondaryIndex val category: String, // → CREATE INDEX IF NOT EXISTS products_category_idx ON products (category)
val name: String
)
// Query by secondary index
val electronics = productRepo.findAll { ProductTable.category eq "electronics" }
// ↑ WARN: "Secondary index query on products.category — scatter-gather across all nodes"
Use @LookupIndex instead for high-cardinality columns — it creates a proper denormalized table.
KandraTestcontainers provides per-test isolated Cassandra keyspaces via Testcontainers:
// Add to build.gradle.kts:
// testImplementation("com.kandraorm.kandra:kandra-test")
// testImplementation("org.testcontainers:cassandra:1.21.4") // 0.5.1 and earlier only — kandra-test brings it since 0.5.2 (GH #190)
class UserRepositoryTest {
private lateinit var db: KandraRuntimeHandle
@BeforeEach
fun setup() {
db = KandraTestcontainers.freshKeyspace(User::class)
}
@AfterEach
fun cleanup() = db.close() // drops the keyspace
@Test
fun `save and find user`() {
val repo = db.runtime.repository<User>()
val user = User(UUID.randomUUID(), "[email protected]", "Alice")
repo.save(user)
assertEquals(user, repo.findById(user.userId))
}
}
Each test class gets an isolated kandra_test_{uuid} keyspace. The shared CassandraContainer starts once per JVM (lazy singleton).
Choosing the backend. KANDRA_TEST_BACKEND=docker (the default when unset) uses the Testcontainers
container; KANDRA_TEST_BACKEND=cloud points KandraTestcontainers.freshKeyspace and anything built on
KandraTestBackend at a live cluster described by the KANDRA_CLOUD_* environment variables
(KANDRA_CLOUD_CONTACT_POINTS, KANDRA_CLOUD_USERNAME, KANDRA_CLOUD_PASSWORD, KANDRA_CLOUD_LOCAL_DC,
and for TLS on port 9142 KANDRA_CLOUD_TLS, KANDRA_CLOUD_CA_PEM or KANDRA_CLOUD_TRUSTSTORE). Credentials
only ever come from the environment. Tests that use KandraCloud.assumeConfigured() are skipped when
KANDRA_CLOUD_CONTACT_POINTS is unset, so contributors without a cluster stay green. In cloud mode the
keyspaces are named kt_<tag>_<8 hex> and are always dropped on close. See
docs/features/testing.md.
@ScyllaTable("balances")
data class Balance(
@PartitionKey val userId: UUID,
val amountUsd: BigDecimal,
@Version val version: Long = 1L
)
balanceRepo.save(balance) // version starts at 1
val loaded = balanceRepo.findById(userId)!!
balanceRepo.update(loaded, loaded.copy(amountUsd = newAmount))
// ↑ UPDATE ... IF version = ? — throws KandraOptimisticLockException on conflict
balanceRepo.updateForce(loaded.copy(amountUsd = newAmount)) // bypasses the version check entirely
// Need the row as actually persisted (post-update @Version/@UpdatedAt) instead of nothing back?
val updated: Balance = balanceRepo.updateAndGet(loaded, loaded.copy(amountUsd = newAmount))
@Version also accepts Instant fields. save() always sets the initial version; only update() enforces the LWT check. save()/saveIfNotExists()/update() all have an ...AndGet() sibling
(saveAndGet, saveIfNotExistsAndGet, updateAndGet) that returns the entity actually persisted —
including generated/incremented values the plain version computes and discards — instead of Unit/Boolean.
No automatic retry on transient errors. Every other write path retries on
WriteTimeoutException/ReadTimeoutException/NoNodeAvailableExceptionperRetryConfig;update()/updateSuspend()on a@Versionentity deliberately execute theIF version = ?statement exactly once — on a transient error the write may have already been applied server-side, and retrying the identical conditional statement would then see[applied] = falseand raise a spuriousKandraOptimisticLockException. If you want retry-on-timeout semantics for a versioned update, catch the transient exception yourself, re-fetch the entity, and reissueupdate()with the freshly-readold— preferupdateAndGet()for the retry itself, since its returned entity already carries the fresh version a follow-upupdate()needs, with no extrafindById()round-trip. Seedocs/USER_GUIDE.mdfor the full recipe and ISS-032.
Kandra distinguishes "field not provided" from "field explicitly cleared" to avoid accidental tombstones:
userRepo.save(user.copy(middleName = null))
// ↑ null nullable fields encode to UNSET → stmt.unset(idx) → no tombstone written
userRepo.saveWithNulls(user.copy(middleName = null))
// ↑ writes an explicit NULL — use only when you intend to tombstone the column
// Since 0.5.2 (GH #189): the same choice for updates and batches
userRepo.updateWithNulls(old, old.copy(middleName = null)) // clears it, with the @Version check
kandra.batch { userRepo.saveWithNullsInBatch(user.copy(middleName = null)) }
@ScyllaTable("sessions", gcGraceSeconds = 3600)
@SoftDelete(ttlSeconds = 86_400)
data class Session(@PartitionKey val sessionId: UUID, val userId: UUID? = null)
sessionRepo.delete(session)
// ↑ UPDATE ... USING TTL 86400 — sets a TTL on non-key columns instead of DELETE
Every non-key, non-marker column on a
@SoftDeleteentity must be nullable or have a constructor default (userId: UUID? = nullabove, not plainUUID) —SchemaRegistryrejects the entity at registration time otherwise. AfterttlSeconds, that column reads back asNULL; a non-nullable property with no default has no legal value to decode into, and would throw on any read that reaches the row. See ISS-098.
gcGraceSeconds on @ScyllaTable controls how long the tombstoned row's primary key survives before ScyllaDB's compaction reclaims it. For a findActive() that filters out soft-deleted rows, add @SoftDelete(ttlSeconds = ..., markerProperty = "isDeleted") with a Boolean field — see docs/features/repositories.md and ISS-007. @LookupIndex rows on a soft-deleted entity are deliberately kept alive until the entity's own TTL expires, not hard-deleted — see ISS-030 and ISS-035 for the storage-growth implication on high-churn tables combining both annotations.
The marker column itself also has a TTL now (
@SoftDelete(markerTtlSeconds = ...), defaulting tottlSeconds + gcGraceSeconds) — so a soft-deleted row eventually disappears completely instead of leaving a permanent key+marker husk. See ISS-097.
findActive(allowFullScan: Boolean = false)— breaking change as of GH #12. If the marker column has@SecondaryIndex,findActive()queries it directly, noALLOW FILTERINGneeded. Without one, it now throwsKandraQueryExceptionunless called withallowFullScan = true— previously it silently ranALLOW FILTERINGwith just a WARN log. Add@SecondaryIndexto the marker column, or passallowFullScan = trueto keep the old scatter-gather behavior. See ISS-036.
install(Kandra) {
batchWarnThresholdKb = 50 // WARN log when a batch approaches ScyllaDB's limit (default: 5)
batchMaxChunkSize = 100 // max statements per LOGGED BATCH chunk (default: 100)
batchAutoChunk = true // saveAll() silently splits oversized batches (default: true)
}
walletRepo.saveAll(largeListOfWallets)
// ↑ auto-chunked into multiple LOGGED BATCHes if batchAutoChunk = true
Backpressure: opt-in client-side admission control, off by default:
install(Kandra) {
throttle {
enabled = true
maxConcurrentRequests = 10_000 // requests the driver will have in flight at once
maxQueueSize = 10_000 // additional requests allowed to queue past that
}
}
Once enabled, a request beyond maxConcurrentRequests + maxQueueSize is rejected immediately
instead of queuing unbounded — surfaced as KandraThrottledException, not the raw driver
RequestThrottlingException. Never retried regardless of RetryConfig.retryOn: retrying an
already-throttled request immediately just adds another request on top of an overloaded throttler.
Enabling this alongside speculativeExecution means each speculative retry also counts toward
maxConcurrentRequests — size both together, or go conservative on one of them (GH #81/#103).
data class User(
@PartitionKey val userId: UUID,
val email: String,
@Sensitive val ssn: String,
@Sensitive val apiKey: String
)
KandraEntityLogger masks @Sensitive fields as *** in any log output — DEBUG query logs, error messages, and toString() calls never leak these values in plaintext.
install(Kandra) {
validate<User> { user ->
buildList {
if (!user.email.contains("@")) add(KandraValidationError("email", "must be a valid address"))
if (user.age < 18) add(KandraValidationError("age", "must be an adult"))
}
}
}
Runs before every save()/update(). A non-empty list throws KandraValidationException with the collected KandraValidationErrors — nothing is written to ScyllaDB.
install(Kandra) {
schemaMode = SchemaMode.AUTO_MIGRATE
// ↑ CREATE TABLE IF NOT EXISTS, then diffs against system_schema.columns
// ↑ ALTER TABLE ADD for any new columns found on the entity
// ↑ logs WARN for Scylla columns with no matching entity field
}
Safer than AUTO_CREATE for evolving schemas in place — it never drops or renames columns, only adds.
One exception: it refuses to add a missing @PartitionKey/@ClusteringKey column. CQL has no way
to ALTER TABLE ADD a column into an existing primary key — doing it anyway would create it as a
plain column while Kandra keeps treating it as part of row identity, silently collapsing distinct
logical rows onto the same physical row. AUTO_MIGRATE throws KandraSchemaException at startup
instead; use kandra-migrate for that kind of key-shape change (new table + backfill) (GH #92).
kandra-migrate, 0.4.2)For changes AUTO_MIGRATE can't express (renames, data backfills, index changes):
object V2_AddPhoneIndex : KandraMigration(version = 2, name = "add phone secondary index") {
override fun up(session: CqlSession) {
session.execute("CREATE INDEX IF NOT EXISTS users_phone_idx ON users (phone)")
}
}
val runner = KandraMigrationRunner(session)
runner.run(V2_AddPhoneIndex)
// ↑ applies pending migrations in version order, records checksums in a kandra_migrations table
// ↑ throws KandraMigrationException if an already-applied migration's checksum no longer matches
Call this before install(Kandra) with schemaMode = SchemaMode.NONE for migration-managed schemas.
install(Kandra) {
metrics {
enabled = true
recorder = KandraMetrics { table, operation, durationMs ->
meterRegistry.timer("kandra.query", "table", table, "operation", operation)
.record(durationMs, TimeUnit.MILLISECONDS)
}
}
}
recorder fires after every query (blocking and suspend paths) with the table name (keyspace.table for entities that declare a keyspace), the operation (save, saveIfNotExists, update, delete, deleteById, saveAll, batch, append, increment, ...; see the User Guide for the full list), and duration — bridge it into Micrometer, Dropwizard, or any metrics backend you use.
install(Kandra) {
healthCheck = true // exposes GET /kandra/health → {"status": "UP"} or {"status": "DOWN"}
shutdown {
graceful = true
drainTimeoutMs = 5_000 // wait for in-flight queries before closing the session
}
}
On ApplicationStopping, Kandra sets KandraRuntime.isShuttingDown = true and drains inFlightCount down to zero (or the timeout) before closing the driver session. This drain covers LookupConsistency.EVENTUAL lookup writes too — they retry on transient errors and are counted in inFlightCount exactly like every other write, so a save()/update() on an entity with an EVENTUAL lookup index can no longer race an in-progress shutdown and hit an already-closed session.
userRepo.raw("SELECT * FROM users WHERE email = '$untrustedInput'")
// ↑ WARN logged: raw CQL looks like it has a literal, a `;` statement terminator, or a `--`/`/* */`
// comment marker spliced directly into it — regardless of whether any `?` is also bound elsewhere
val query = KandraRawQuery.cql("SELECT * FROM users WHERE email = ?").bind(untrustedInput).build()
userRepo.rawQuery(query)
// ↑ safe — always parameterised
This is a heuristic, not a CQL parser — it can miss a punctuation-free payload and can false-positive
on a legitimately embedded literal. By default it only warns; set debug { rawQueryStrictMode = true }
to make it throw KandraQueryException instead, so raw()/rawQuery() fail closed (GH #32/#142).
@ScyllaTable("products")
@CacheResult(ttlSeconds = 60, maxSize = 10_000)
data class Product(@PartitionKey val productId: UUID, val name: String, val priceUsd: BigDecimal)
productRepo.findById(id) // first call hits ScyllaDB, populates the Caffeine cache
productRepo.findById(id) // subsequent calls within ttlSeconds are served from cache
productRepo.save(updated) // save/update/delete invalidate the cached entry
caffeine is a compileOnly dependency — caching is disabled gracefully if it's not on the classpath.
Per-process only: the cache lives entirely in each application instance's JVM heap. A write on
instance A never invalidates instance B/C's cached copy of the same row — in a horizontally-scaled
or multi-DC deployment, other instances keep serving their own stale entries until their own TTL
expires, independent of any consistency level configured for the write. Avoid @CacheResult (or
pair it with a short ttlSeconds) on tables where cross-instance freshness matters.
docs/site/| Build prompts for the separate documentation website project, one file per Kandra version |
| API docs (Dokka) | Generated per-release from source, published on tag |
plugins/kandra/skills/ | Per-module Claude Code skills — see Using with Claude Code below |