KandraMigrationRunner
Applies versioned KandraMigrations to a ScyllaDB keyspace.
Maintains a kandra_migrations table to track which migrations have been applied. Migrations are executed in ascending version order. Already-applied migrations are skipped. If a previously-applied migration's checksum no longer matches, KandraMigrationException is thrown.
fun Application.configureMigrations() {
val runner = KandraMigrationRunner(kandraSession)
runner.run(V1_CreateUsers, V2_AddPhoneToUsers)
}
// Call BEFORE install(Kandra) with schemaMode = NONE for migration-managed schemasCrash safety (GH-26)
Each row in kandra_migrations carries a MigrationRowStatus. A version is written as MigrationRowStatus.CLAIMED before KandraMigration.up runs (via an IF NOT EXISTS LWT, so two instances racing on the same never-before-seen version can't both run it), and is only flipped to MigrationRowStatus.APPLIED after up() returns successfully. Only APPLIED rows (and legacy rows with no status at all, treated as applied for backward compatibility) count as "done" -- a CLAIMED row proves nothing except that someone started the migration.
run() never guesses about an unresolved CLAIMED row: there is no lease or heartbeat here, so a CLAIMED row belonging to a live, still-running instance is indistinguishable from one left behind by a process that crashed (OOM, SIGKILL, an uncaught Error, a Kubernetes eviction) between claiming a version and finishing it. Instead:
A recently claimed row (within staleClaimThreshold) logs a
WARNand halts the rest of thisrun()call -- it does not skip ahead to later migrations, which may depend on DDL the claimant hasn't finished writing yet.A row claimed longer ago than staleClaimThreshold throws KandraMigrationException, telling the operator to inspect
kandra_migrationsand resolve it manually.
Clock source for staleness (GH-64)
"How long ago was this claimed?" is deliberately measured entirely by the ScyllaDB/Cassandra cluster's own clock, never by comparing two application instances' wall clocks. claim writes claimed_at using the CQL toTimestamp(now()) function -- evaluated by the coordinator that processes the claim, not bound as a JVM-generated Instant -- and handleUnresolvedClaim reads "now" the same way, via a fresh SELECT toTimestamp(now()) against the coordinator serving that read. Both sides of the Duration.between comparison therefore come from the cluster, not from Instant.now() on whichever apps happen to be claiming or checking. This removes the failure mode of an earlier version of this class, where claimed_at was an app-generated Instant and staleness was Duration.between(claimedAt, Instant.now()) across two independently-drifting machine clocks: a claiming instance whose clock ran fast (or a checking instance whose clock ran slow) could make a genuinely crashed migration look perpetually fresh, while the reverse skew could make an in-progress migration look falsely stale and abort a deployment. The remaining assumption -- that nodes within one Scylla/ Cassandra cluster have reasonably synced clocks with each other -- is far narrower than "every application instance is NTP-synced with every other," and is already an existing operational precondition for correct LWT/Paxos behavior in these systems.
Parameters
how long a MigrationRowStatus.CLAIMED row is given the benefit of the doubt (treated as possibly still in progress elsewhere) before run() refuses to proceed and throws instead. Compared against an age measured entirely by the database cluster's own clock (see "Clock source for staleness" above). Defaults to 10 minutes.
DDL bootstrap coordination (GH #79/ISS-071)
The CREATE TABLE IF NOT EXISTS kandra_migrations + legacy-column ALTER TABLEs below used to run unconditionally, every time a KandraMigrationRunner was constructed -- if several application instances construct one concurrently against a keyspace that doesn't have the table yet (the normal shape of a rolling multi-replica deploy), they raced the same DDL against the cluster. This is now guarded by claimAndRunDdlBootstrap the same way kandra-ktor's SchemaMode.AUTO_CREATE/AUTO_MIGRATE DDL is guarded: one instance wins an INSERT ... IF NOT EXISTS LWT claim and runs the bootstrap, the rest poll until it reports done. If the claim holder goes silent for longer than two minutes (measured by the cluster's own clock, not any application instance's), a waiting instance presumes it crashed mid-bootstrap and reclaims it. See claimAndRunDdlBootstrap's own KDoc, further down this file, for the full mechanism -- it's the same design as this class's own per-migration claim above, just applied one level earlier, to the bookkeeping table's own creation.