04. Installing the plugin
Page 03 produced two entity classes and the DDL they generate. Nothing has
touched the database yet — that starts here. This page installs the Kandra Ktor plugin, which
opens the session, runs that DDL against the local ScyllaDB from page 02,
and wires up dependency injection.
package com.example.todos
import com.example.todos.model.Todo
import com.example.todos.model.User
import io.kandra.core.KandraAuth
import io.kandra.core.SchemaMode
import io.kandra.ktor.Kandra
import io.kandra.koin.kandraKoin
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import org.koin.ktor.plugin.Koin
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
install(Koin) {
// no app-level modules yet — kandraKoin() below adds the repository bindings
}
install(Kandra) {
contactPoints = "localhost:9042"
keyspace = "todos"
localDatacenter = "datacenter1"
autoCreateKeyspace = true
schemaMode = SchemaMode.AUTO_CREATE
register(User::class, Todo::class)
auth {
provider = KandraAuth.fromEnv()
}
pool {
requestTimeoutMillis = 10_000
}
retry {
maxAttempts = 3
backoffMillis = 100
}
}
kandraKoin()
}keyspace / autoCreateKeyspace / schemaMode
keyspace = "todos"
autoCreateKeyspace = true
schemaMode = SchemaMode.AUTO_CREATEkeyspace has no default — a blank value throws KandraSchemaException immediately, the very first
check the plugin runs, before any network call. autoCreateKeyspace = true tells Kandra to run
CREATE KEYSPACE IF NOT EXISTS todos WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1} (the default ReplicationStrategy.SimpleStrategy(replicationFactor = 1))
against a bootstrap session before attaching to it — right for local dev against the single-node
Scylla from page 02, and wrong for most real deployments, where the
keyspace and its replication factor are provisioned by ops ahead of time and the app role often
shouldn't have CREATE KEYSPACE permission at all. SchemaMode.AUTO_CREATE is what actually runs
the CREATE TABLE IF NOT EXISTS DDL from page 03 for every class passed to
register(...), at this install(Kandra) { } call — not lazily on first request.
/philosophy/schema-safety covers the production shape of this:
schemaMode = SchemaMode.VALIDATE (fails startup if the entity's columns don't match what's already
in Scylla, rather than creating or altering anything) paired with schema managed by a separate
pipeline. Page 14 walks through switching this app to that
shape before it ships anywhere real.
contactPoints / localDatacenter
contactPoints = "localhost:9042"
localDatacenter = "datacenter1"contactPoints is a comma-separated host:port list — one entry here, pointing at the
docker-compose Scylla from page 02 (port 9042 is CQL's native
protocol port, the one the compose file maps to the host). localDatacenter has to match the DC
name Scylla's default snitch assigns — datacenter1 for a default single-node image, which is why
page 02's docker-compose.yml doesn't need to configure this
explicitly to line up.
register(User::class, Todo::class)
Pushes both classes into SchemaRegistry — the same process-global registry kandra-core's schema
validation runs against. This is also what drives DDL generation order (registration order) and,
via kandraKoin() below, which entities get repository bindings at all. Every entity this app adds
in later pages (TodoByAssignee in page 06, CompletionStats in page 09) gets appended to this same
call.
auth { }
auth {
provider = KandraAuth.fromEnv()
}KandraAuth.fromEnv() reads SCYLLA_USERNAME/SCYLLA_PASSWORD from the environment on every
getCredentials() call — the recommended production default, since it means no credential ever
lives in source or config files, and external credential rotation via env reload is picked up
automatically if the deployment process supports it. This is also the default value of
AuthConfig.provider — writing auth { } at all here is for visibility, not because leaving it out
would change behavior.
If SCYLLA_USERNAME genuinely isn't set in the environment (not blank — literally absent),
getCredentials() throws KandraAuthException the moment install(Kandra) { } tries to open the
session, before the app takes any traffic. The local Scylla from
page 02 doesn't enforce authentication at all, so any non-empty values
work — export SCYLLA_USERNAME=cassandra SCYLLA_PASSWORD=cassandra before running this app locally,
every time.
pool { }
pool {
requestTimeoutMillis = 10_000
}The driver's own default request timeout is 2 seconds — deliberately short for a database where a
healthy query is usually single-digit milliseconds, so a stuck request fails fast by default.
10_000 here is generous for local development against a single-shard, resource-capped Scylla
instance (from --smp 1 --memory 750M in page 02), which can
legitimately be slower than a real cluster under the same query. maxRequestsPerConnection (default
32768) is the other half of this block worth knowing about even though this app doesn't override
it: it caps how many concurrent in-flight requests one pooled connection will carry before the
driver queues more behind it — the throughput ceiling per connection, not a timeout.
retry { }
retry {
maxAttempts = 3
backoffMillis = 100
}Retries, with linear backoff, on a fixed, narrow set of exception types:
WriteTimeoutException, ReadTimeoutException, NoNodeAvailableException — transient,
infrastructure-shaped failures where retrying the same request again is plausibly the right move.
It deliberately does not retry on KandraOptimisticLockException (page 07's LWT conflict — that
means someone else's write legitimately won, not that this write failed transiently; blindly
retrying it would silently discard the caller's knowledge that a conflict happened) or
KandraValidationException (retrying a request that fails validation just fails the same way three
more times). Retry is for "the network or a node had a bad moment," not "make errors go away."
First mention: debug { } and metrics { }
Both exist starting now (they're on KandraConfig regardless of whether this app's install(Kandra)
block mentions them), but neither is configured yet — debug { }'s query/slow-query/batch logging
and metrics { }'s per-query timer callback both get the full treatment, with this app's actual read
and write paths as the worked example, in page 11. Nothing about
waiting until then costs you anything: both default to off/no-op.
Wiring Koin: install(Koin) { } then install(Kandra) { } then kandraKoin()
install(Koin) { /* ... */ }
install(Kandra) { /* ... */ }
kandraKoin()Application.kandraKoin() (from kandra-koin) does two things that only work if both installs have
already run: it reads Application.kandraSession/Application.kandra — Ktor attributes
install(Kandra) { } sets, so calling kandraKoin() before install(Kandra) reads attributes that
don't exist yet — and it calls Koin's getKoin() to load its generated module, which throws if no
Koin instance is attached to the application, so calling kandraKoin() before install(Koin) { }
fails the same way. Verified directly from kandra-koin's KandraKoin.kt: val runtime = kandra
and val session = kandraSession are the first two lines of the function body, executed
unconditionally before anything else. Relative order between install(Koin) and install(Kandra)
doesn't matter — only that kandraKoin() itself runs last, after both.
kandraKoin() walks every class in SchemaRegistry (so it has to run after register(...) has
executed, which happens inside install(Kandra) { }) and binds two named singletons per entity:
named("UserRepo")/named("UserSuspendRepo"), named("TodoRepo")/named("TodoSuspendRepo"), and
so on as more entities get registered in later pages. Those bindings share the plugin's own
BatchEngine — same shutdown-drain guard, same in-flight counter as everything else Kandra does.
kandraKoin()'s named bindings are genuinely useful if you're wiring a repository into a plain
Kotlin class outside the routing DSL — a service layer, a background job — where
Application.kandra isn't in scope the way it is inside a Route extension function. Inside route
code, though, page 05 onward uses the plainer
application.kandra.suspendRepository<Todo>() accessor directly, built once per route-registration
function rather than per request — kandra-runtime's own documentation is explicit that repository
instances aren't free (each one builds its own prepared-statement cache and, once
page 10 turns it on, its own @CacheResult cache), so constructing one fresh
inside every request handler would quietly throw both caches away on every single call. kandraKoin()
is still wired up here because it costs nothing and the named bindings are there if this app grows a
service layer later — just don't reach for by inject(named("TodoSuspendRepo"))) inside a route
handler when a single hoisted val does the same job more cheaply.
Checkpoint
Run the app (./gradlew run, with SCYLLA_USERNAME/SCYLLA_PASSWORD exported per the callout
above). It should start cleanly, log "Kandra: keyspace 'todos' ensured.", and create users and
users_by_email and todos in the todos keyspace — confirm with docker exec -it <container> cqlsh -e "DESCRIBE KEYSPACE todos;". Nothing serves HTTP requests yet beyond whatever Ktor's default
routing gives you for free; page 05 adds the first real routes.