Kandra

kandra-multidc

Reality check first: kandra-multidc is a thin module. Its entire source is one object with one function, KandraMultiDc.describe(config: KandraConfig): String. It contributes no config DSL, no load-balancing policy implementation, no failover logic — all of that already lives in kandra-ktor's KandraConfig and is active whether or not kandra-multidc is on the classpath at all. Don't expect classes like MultiDcPolicy or DcAwareRouter — they don't exist. What this module actually adds is a startup-logging convenience, plus the doc comment on KandraMultiDc.kt that collects the multi-DC guidance in one place.

Why this exists as a separate module

kandra-multidc declares api(project(":kandra-core")), api(project(":kandra-runtime")), api(project(":kandra-ktor"))api, not implementation, unlike every other Kandra module. This is the one place in the whole dependency graph where that choice matters: because describe()'s signature takes a KandraConfig and its KDoc example spans loadBalancing/failover/ speculativeExecution/consistency, a consumer of kandra-multidc needs those kandra-ktor types visible transitively without adding a separate implementation(project(":kandra-ktor")) line themselves. That's a deliberate exception to "depend on only what you use" for a module whose entire purpose is to be a thin convenience layered on kandra-ktor — since kandra-multidc has no API surface of its own beyond that one function, forcing consumers to also separately declare the module underneath it would add friction with no corresponding benefit.

The practical implication: you do not need kandra-multidc to configure multi-DC behavior at all. loadBalancing { }, failover { }, speculativeExecution { }, and consistency { } are all plain kandra-ktor KandraConfig blocks, usable with just kandra-ktor on the classpath. Adding kandra-multidc buys you exactly one thing: KandraMultiDc.describe().

KandraMultiDc.describe(config)

object KandraMultiDc {
    fun describe(config: KandraConfig): String
}

Builds a multi-line, human-readable summary of the multi-DC-relevant fields of a KandraConfig, intended to be logged once at startup:

import io.kandra.multidc.KandraMultiDc
 
fun main() {
    val app = embeddedServer(Netty, port = 8080) {
        install(Kandra) {
            localDatacenter = "us-east-1"
            keyspace = "coinx"
            consistency {
                defaultRead = KandraConsistency.LOCAL_QUORUM
                defaultWrite = KandraConsistency.EACH_QUORUM
            }
            loadBalancing {
                dcAwareFailover = true
                allowedRemoteDcs = listOf("eu-west-1", "ap-southeast-1")
            }
            failover { onLocalDcUnavailable = FailoverPolicy.RETRY_REMOTE_DC }
            speculativeExecution { enabled = true; delayMillis = 100; maxAttempts = 2 }
        }
    }
    // Somewhere during startup, after the KandraConfig is built:
    log.info(KandraMultiDc.describe(kandraConfig))
}
Kandra Multi-DC Configuration:
  Local DC: us-east-1
  Read consistency: LOCAL_QUORUM
  Write consistency: EACH_QUORUM
  Serial consistency: LOCAL_SERIAL
  Token-aware LB: true
  DC failover: enabled → [eu-west-1, ap-southeast-1]
  Speculative execution: 100ms delay, 2 max attempts
  Failover policy: RETRY_REMOTE_DC
describe() validates nothing, despite what its own KDoc says

The KDoc on describe() calls it "Validates a multi-DC configuration and returns a description" — the implementation performs no validation whatsoever: no check that allowedRemoteDcs is non-empty when dcAwareFailover = true, no reachability check, no exceptions thrown. It's a pure string formatter. (The real validation — dcAwareFailover = true or failover.onLocalDcUnavailable = RETRY_REMOTE_DC requiring a non-empty allowedRemoteDcs — does happen, but eagerly at session-build time inside kandra-ktor, independent of whether you ever call describe() at all.) Don't rely on this function to catch a misconfigured multi-DC setup; it only describes whatever you already configured, correct or not.

The last line always prints, unlike the two above it

Six lines print unconditionally: the header, Local DC, the three consistency levels, Token-aware LB — and the trailing "Failover policy" line, always, even at the default FailoverPolicy.THROW with no DC failover configured at all. That's inconsistent with the two lines directly above it ("DC failover" and "Speculative execution"), which only appear when their respective enabled/ dcAwareFailover flag is true. With a bare single-DC config, describe() still prints exactly 6 lines, ending in Failover policy: THROW — don't assume every line in the output implies that feature is actively "on."

describe() is a pure function — no I/O, no logging side effect of its own. You call your own logger with the result.

The real multi-DC config surface — it's all in kandra-ktor

install(Kandra) {
    localDatacenter = "us-east-1"      // top-level KandraConfig property
    consistency { ... }                 // ConsistencyConfig
    loadBalancing { ... }               // LoadBalancingConfig
    failover { ... }                    // FailoverConfig
    speculativeExecution { ... }        // SpeculativeExecutionConfig
}

See /modules/kandra-ktor for the exact fields, defaults, and gotchas of each block — they're documented there since that's where they actually live, not duplicated here.

dcAwareFailover and RETRY_REMOTE_DC are two knobs you must set together

loadBalancing { dcAwareFailover = true; allowedRemoteDcs = [...] } only makes remote-DC replicas visible to the driver. failover { onLocalDcUnavailable = FailoverPolicy.RETRY_REMOTE_DC } is what actually triggers a retry into them when the local DC goes down. Left at the THROW default, a local-DC outage still throws NoNodeAvailableException immediately even with remote DCs listed and dcAwareFailover enabled — setting one knob without the other leaves failover half-configured, with nothing in describe()'s output flagging the mismatch (see above).

Consistency level decision guide

KandraConsistency and the @ReadConsistency/@WriteConsistency resolution order are documented in full on /modules/kandra-core and /foundations. For multi-DC specifically:

ScenariodefaultReaddefaultWritedefaultSerialConsistency
Single DC (library default)LOCAL_ONELOCAL_QUORUMLOCAL_SERIAL
Multi-DC active-activeLOCAL_QUORUMEACH_QUORUMLOCAL_SERIAL (region-scoped uniqueness) or SERIAL (global uniqueness)
Strong globalQUORUMQUORUMSERIAL
  • Single DC: LOCAL_* and cluster-wide levels behave identically since there's no cross-DC coordination to speak of — use the LOCAL_* forms anyway for consistency with multi-DC-ready code.
  • Multi-DC active-active: bump reads to LOCAL_QUORUM (avoids reading stale data from a lagging local replica) and writes to EACH_QUORUM (every DC must see the write, preventing a not-yet-caught-up DC from serving stale reads under plain LOCAL_QUORUM).
  • Strong global: QUORUM/QUORUM for both — majority across the whole cluster regardless of DC, at the cost of cross-DC round-trip latency on every operation.
// Global username uniqueness — Paxos consensus across ALL DCs, not just the local one
val registered = userRepo.saveIfNotExists(user, serialConsistency = KandraConsistency.SERIAL)
LOCAL_SERIAL vs SERIAL is the recurring multi-DC footgun

saveIfNotExists defaults to LOCAL_SERIAL, which only guarantees uniqueness within the local DC — a second DC can independently accept a conflicting "unique" row unless you explicitly pass serialConsistency = KandraConsistency.SERIAL. This is easy to miss because LOCAL_SERIAL works correctly in every single-DC test environment; the bug only shows up once a second DC is actually live.

Full API reference

kandra-multidc on Dokka → · KandraMultiDc

Where this shows up elsewhere on this site