Changelog
Current version: v0.4.6 (gradle.properties), matching the latest entry below. One
file per version, newest first. Every fix here links to the docs/issues/ISS-NNN-*.md writeup with
the actual root cause — this page summarizes, it doesn't replace those. See
/battle-scars for the narrative version of the bugs below.
0.4.6
@GeneratedUuid(strategy: UuidStrategy = TIME_ORDERED) auto-populates a UUID field on every
INSERT, the same way @CreatedAt is auto-populated. Unlike every fix above, this isn't a bug
writeup — it's a proactive design decision closing off a real, silent-data-loss-shaped risk before
it produced one: Cassandra/ScyllaDB writes are upserts on partition key + clustering key, so a
clustering key derived from Instant.now() collides silently if two rows in the same partition land
in the same millisecond — the second save() just overwrites the first, no exception, no warning.
TIME_ORDERED(the default) generates a UUIDv7 via the newKandraUuid.timeOrdered(). UUIDv7's 48-bit millisecond timestamp sits in the leading bytes, so plain lexicographic comparison — exactly how Cassandra'sUUIDTypecomparator orders a genericuuidcolumn — already sorts chronologically, with no dependency on Cassandra's separatetimeuuidCQL type, which Kandra doesn't map to at all.RANDOMgenerates a UUIDv4 instead, for ids that must not leak their creation time.- Validated at schema-registration time, like
@Version: applying it to a non-UUIDfield throwsKandraSchemaExceptionimmediately, not at first insert. Never touchesupdate()/updateForce()— primary key components are immutable in Cassandra.
KandraUuid (kandra-core) is the UUIDv7/v4 generator backing the annotation, also usable directly
wherever application code needs a time-ordered or random id outside the entity insert lifecycle.
Full writeup: PR #9. See
/modules/kandra-core for the full annotation reference,
/battle-scars/time-ordered-clustering-keys for the
collision this exists to avoid, and /recipes/time-series for using it as a
clustering key in practice.
Verification: confirmed at the unit level (version/variant bits, cross-call chronological sort
order, uniqueness; strategy collection and wrong-type validation at schema registration) and live
against a real 3-node ScyllaDB cluster — a @GeneratedUuid @ClusteringKey column populates correctly
on save, ignoring any caller-supplied placeholder, and two rows written to the same partition
back-to-back land as two distinct, chronologically-ordered rows rather than one overwriting the
other.
Also changed: testcontainers bumped 1.19.8 → 1.21.4 — 1.19.8 hardcodes its Docker API-version
probe at 1.32, and current Docker Engine releases (MinAPIVersion 1.40+) reject that probe
outright, failing every kandra-test integration test at container startup, before any code under
test even runs. Dev/test dependency only — no runtime impact for consumers.
0.4.5
The query DSL's + unary-plus predicate registration is gone.
repo.find { +UserTable.email.eq("x") } becomes repo.find { UserTable.email eq "x" } —
drop the leading +, everything else about the call is unchanged (same operator names, same
infix-call shape).
eq/gt/gte/lt/lte/isIn are now QueryContext member extensions instead of
KandraColumnRef methods, so the comparison itself performs the predicate registration — there's
no intermediate KandraPredicate value left over for a caller to forget to prefix with +.
This wasn't a style cleanup. The old +-prefixed form had a real, silent failure mode: forgetting
+ on one predicate among several compiled cleanly and silently dropped that predicate from
the query, with no way to detect it at runtime — the discarded KandraPredicate never reached
QueryContext. Two independent rounds of external code review flagged this as the DSL's biggest
footgun before it was fixed. Full writeup:
GitHub issue #4.
Verification: confirmed against a real 3-node ScyllaDB cluster, including a dedicated
multi-predicate test (eq on a partition key + gt on a clustering key in the same query)
proving the silent-predicate-drop failure mode is now structurally impossible, not just less
likely.
Also fixed: README.md's "LIMIT and ALLOW FILTERING" example called allowFiltering(), a
method that has never existed on QueryContext — Kandra deliberately does not support ALLOW FILTERING (see ISS-021).
Replaced with the real @SecondaryIndex-based example, verified live against a real cluster.
0.4.4
Follow-up fixes discovered while re-verifying 0.4.3's fixes end-to-end against a real cluster, per
the docs/test-plan:1.1/ resume plan. Two of the 0.4.3 fixes (ISS-025 in particular) had scope
gaps that surfaced as new regressions once re-tested beyond the original direct-repository unit
tests; one further pre-existing bug (ISS-030) became reachable for the first time once ISS-029 was
fixed. Each of the three fixes below was confirmed individually — build, permanent Testcontainers
regression test, and a live run against a real 3-node ScyllaDB cluster — rather than as part of a
complete, formally recorded plan execution (that report was still outstanding at release time).
Fixed:
- ISS-028 —
cache invalidation on
save/update/updateForce/etc. used a partition-key-only key that silently never matchedfindById's real (full-key) cache key for any clustering-keyed@CacheResultentity, so writes never actually invalidated the stale cached row. - ISS-029 —
@LookupIndexresolution (find/findAll/findPage/exists/deleteByvia a lookup predicate) reconstructed only the primary table's partition key from the lookup row, never its clustering key, so it broke entirely (or, forfindPage, silently scattered across the whole partition) for any entity combining@LookupIndexwith a clustering key. - ISS-030 —
BatchEngine's soft-delete unconditionally deleted@LookupIndexrows, contradicting the documented "soft delete does not remove lookup rows" behavior — a soft-deleted row still "exists" until its TTL expires, so it should remain resolvable via its lookup index too.
0.4.3
All 5 Critical/High bugs found during the first real-cluster test plan execution
(docs/report:1.0/) are fixed in this release, plus one closely-related bug discovered while
fixing them. Every fix is verified against a real 3-node ScyllaDB cluster, not just unit tests.
KandraBatchScope's save/delete/saveIfNotExists are renamed to saveInBatch/
deleteInBatch/saveIfNotExistsInBatch. This is @ExperimentalKandraApi, and the rename is
the fix, not a cosmetic change — the old names could never have worked correctly under the
documented calling convention. Kotlin always resolves a same-named repository member function
over an extension function, even one added by the batch scope itself, so a batch-scoped save()
compiled, looked correct, and silently never executed inside the batch. See
/battle-scars/batch-scope-shadowing for the full story.
Fixed:
- ISS-022 —
kandra-codegengenerated invalid Kotlin (raw generic types) for anySet/Mapcolumn, failing compilation of the entire consuming module. - ISS-023 —
KandraCachecrashed withIllegalAccessExceptionon every real-Caffeine cache operation, making@CacheResultunusable with a real cache dependency present. - ISS-024 —
non-nullable
Set/Mapcolumns left at their naturalemptySet()/emptyMap()default became permanently unreadable the moment they were saved. - ISS-025 —
findById,deleteById,append,remove,put,increment,decrement,update(@Version),updateForce, and the soft-delete rewrite all omitted clustering keys from their WHERE clause —deleteByIdcould delete an entire partition instead of one row, and the mutation methods hard-crashed on any clustering-keyed entity. - ISS-026 —
KandraBatchScope'ssave/deletewere structurally unreachable through any documented calling convention, sobatch { }/batchBlocking { }silently provided zero cross-table atomicity. - ISS-027 —
KandraBatchScope'ssaveIfNotExistsLWT guard had the identical unreachability bug and never actually threw.
Also changed:
StatementBuilder.appendToCollection/removeFromCollectionnow require the full primary key (partition + clustering columns) in their internalkeyValueslist, not partition keys alone. This only affects directStatementBuildercallers (@InternalKandraApi) — the publicKandraRepository/KandraSuspendRepositoryAPI is unaffected, sinceappend/remove/putderive the full key from the entity automatically.findById/deleteByIdnow throwKandraSchemaExceptionif fewer key values are supplied than the entity's full primary key requires, instead of silently truncating to the partition key — on a clustering-keyed entity,findById(id)(partition key only) now fails loudly instead of silently returning an arbitrary row from the partition; usefindById(id, clusteringKeyValue).
0.4.2
Supersedes the v0.4.1 tag, which built and published under the wrong version string
(gradle.properties — the actual project.version source — hadn't been updated, only the unused
kandra key in gradle/libs.versions.toml had). No functional changes from what would have been
0.4.1.
Added — critical:
- True async driver usage —
executeSuspend/executeSuspendAll/prepareSuspendextensions onCqlSession; all suspend write paths inBatchEngineand all suspend read paths inQueryExecutor(findByIdSuspend,findAllSuspend,findPageSuspend,existsSuspend,rawSuspend,rawQuerySuspend) use true async viaexecuteAsync().await()— no coroutine dispatcher thread is ever blocked on a network round-trip (ISS-014). - UNSET vs NULL —
KandraUnsetsentinel;encode()returnsKandraUnsetfor null nullable fields →stmt.unset(idx)ininsertPrimary(no tombstone);saveWithNulls()for intentional tombstone writes. - Optimistic locking —
@VersiononLong/Instantfields;save()sets version to1L/Instant.now();update()uses LWTIF version = ?, throwsKandraOptimisticLockExceptionon conflict;updateForce()bypasses the check. - Tombstone awareness —
@SoftDelete(ttlSeconds)→delete()sets TTL instead of DELETE;@SoftDelete(markerProperty = "...")adds an opt-in permanent marker column enablingfindActive();@ScyllaTable(gcGraceSeconds=N)→ DDL includesWITH gc_grace_seconds = N. - Batch size guard —
batchWarnThresholdKb,batchMaxChunkSize,batchAutoChunkconfig;saveAll()auto-chunks large batches. @Sensitivelog masking —ColumnSchema.isSensitive;KandraEntityLogger.safeToString()masks sensitive fields with***.
Added — should-have:
- Health check —
GET /kandra/healthroute; enabled viaKandraConfig.healthCheck = true. - Graceful shutdown —
ShutdownConfig(drainTimeoutMs, graceful);KandraRuntime.isShuttingDowninFlightCount;ApplicationStoppingevent drains in-flight queries before close.
- Validation hook —
KandraValidator<T>,KandraValidationError,KandraValidationException;validate<User> {}in plugin config; runs before every save/update. SchemaMode.AUTO_MIGRATE—CREATE TABLE IF NOT EXISTS+ diff againstsystem_schema.columns+ALTER TABLE ADDfor new columns; logs WARN for unmapped Scylla columns.- Micrometer metrics —
MetricsConfiginKandraConfig(disabled by default). - CQL injection guard —
raw()logs WARN when called with no params but CQL contains string literals;KandraRawQuerybuilder for safe parameterised queries;rawQuery()on both repos. - Schema migration runner —
kandra-migratemodule:KandraMigration,KandraMigrationRunner(versioned, checksum-validated, claim-locked),MigrationHistory. - Query caching —
@CacheResult(ttlSeconds, maxSize)→ Caffeine-backedfindByIdcache, invalidated on save/update/delete.
Added — publishing & ecosystem:
gradle/libs.versions.tomlversion catalog wired into all module build files..github/workflows/ci.ymldocsjob runsdokkaHtmlMultiModuleon tag push, deploys to GitHub Pages.kandra-jakartamodule —JakartaKandraValidator<T>adaptsjakarta.validation.ValidatorintoKandraValidator<T>.- Real-cluster integration tests —
kandra-test/src/test/.../KandraIntegrationTest.ktand re-enabledkandra-ktorplugin tests, run viaKandraTestcontainersagainst real Cassandra.
Fixed (found during a pre-real-database-testing audit):
- ISS-014 —
QueryExecutorblocked the calling coroutine dispatcher on every suspend read. - ISS-015 —
KandraRepository.deleteByIdbypassed@SoftDelete, unlike every other delete path. - ISS-017 — migration checksum didn't actually hash the migration body.
- ISS-018 — concurrent migration runners could double-apply a migration.
- ISS-019 — collection/counter statements ignored configured consistency levels.
- ISS-021 —
an error message referenced a non-existent
allowFiltering()DSL method.
Changed:
StatementBuilder.appendToCollection,removeFromCollection,counterUpdategained an optionalconsistency: KandraConsistency?parameter, threaded throughKandraRepository/KandraSuspendRepository'sappend/remove/put/increment/decrement.
0.3.0
Added — gap fixes:
- Retry policy — configurable linear backoff (
retry { maxAttempts = 3; backoffMillis = 100 }); retries onWriteTimeoutException,ReadTimeoutException,NoNodeAvailableExceptionby default; suspend variant usesdelay()notThread.sleep(). - Idempotency tokens — all statements now carry correct
setIdempotent()flags: SELECT + DELETE + LWT INSERT = idempotent; plain INSERT + collection mutations + counter updates = non-idempotent. INclause on partition keys —isIn(listOf(id1, id2, id3))generatesWHERE pk IN ?; empty list short-circuits to empty result; non-PKINwithout a secondary index throwsKandraQueryException.- Nullable codec contract —
decode()checksrow.isNull()before any typed getter; non-nullable Kotlin type on a null column throwsKandraQueryExceptionwith a clear message instead of an NPE. - Caller-controlled batch (
KandraBatchScope) — the original (pre-rename, later found unreachable — see 0.4.3 above)application.kandra.batch { userRepo.save(user); ... }. @SecondaryIndexannotation —CREATE INDEX IF NOT EXISTSDDL; queries on@SecondaryIndexcolumns work withoutALLOW FILTERING; WARN logged on every use.- Prepared statement LRU cache — replaces unbounded
ConcurrentHashMap; configurable viapreparedStatementCacheSize. USING TIMESTAMP—save(entity, timestampMicros = ...)for idempotent event replay.- Query debug logging —
debug { logQueries = true; logSlowQueriesMs = 500; logBatches = true }. - Test keyspace isolation —
KandraTestcontainers.freshKeyspace(User::class). - API stability markers —
@InternalKandraApi(ERROR-level opt-in) onBatchEngine,StatementBuilder,SchemaRegistry,DdlGenerator;@ExperimentalKandraApi(WARNING-level opt-in) onKandraCodeccustom registration,KandraEventListener,KandraBatchScope,KandraAuth.
Added — multi-datacenter support:
- New
kandra-multidcmodule. - Load balancing config —
loadBalancing { tokenAware = true; dcAwareFailover = true; allowedRemoteDcs = listOf("eu-west") }. - Consistency level per operation —
KandraConsistencyenum inkandra-core;@ReadConsistency/@WriteConsistencyclass annotations; resolution order: per-op > annotationConsistencyConfigdefault. - LWT serial consistency —
saveIfNotExists(entity, serialConsistency = KandraConsistency.SERIAL). - DC failover policy —
failover { onLocalDcUnavailable = FailoverPolicy.RETRY_REMOTE_DC }. - Speculative execution —
speculativeExecution { enabled = true; delayMillis = 100; maxAttempts = 2 }; only fires for idempotent statements.
Added — authentication & security:
KandraAuthProviderabstraction —KandraAuth.fromEnv()(recommended default),fromFile()(k8s secrets),static()(dev only),custom { }(Vault/AWS SM); replaces hardcodedusername/passwordin config.- Credential rotation —
auth { refreshIntervalSeconds = 3600 }. - SSL/TLS —
ssl { enabled = true; trustStorePath = "..."; hostnameVerification = true }; mutual TLS support. - Keyspace permission validation — queries
system_auth.role_permissionsat startup; throwsKandraAuthExceptionifSELECT/MODIFYmissing; disable withvalidatePermissions = false.
Changed:
KandraEventListenerchanged fromfun interfacetointerface— existing single-method implementations remain compatible.auth.providerdefault changed from plaintextusername/passwordtoKandraAuth.fromEnv().StatementBuilderandBatchEnginenow require@OptIn(InternalKandraApi::class).
0.2.0
Added:
- Composite partition keys (
@PartitionKey(index)). @Ttl(seconds)+ per-save TTL override (save(entity, ttlSeconds = 120)).- Keyspace auto-creation with
SimpleStrategy/NetworkTopologyStrategy. - Local datacenter config (required for DataStax Java driver 4.x).
- Lightweight transactions —
saveIfNotExists()with[applied]check. - Cursor pagination —
findPage(pageSize, pageToken)using DataStax ByteBuffer paging state. EVENTUALwrite failure listener (KandraEventListener).- Schema validation mode (
SchemaModeenum:AUTO_CREATE,VALIDATE,NONE). LIMITandALLOW FILTERINGin query context (with WARN log onALLOW FILTERING— later removed entirely as a DSL capability; see/whyfor why).deleteById()anddeleteBy { }on repositories.- Collection mutations (
append,remove,put). - Counter table support (
@Counter,increment(),decrement()). - Extensible codec +
BigDecimal→ CQLDECIMALsupport. @CreatedAt/@UpdatedAtauto-fill via data classcopy()reflection.saveAll(entities, useBatch)batch insert.exists { }check viaSELECT pk LIMIT 1.- Connection pool configuration (
PoolConfig). - Bill of Materials (
kandra-bom).
0.1.0
Initial release.
Added:
@ScyllaTable,@PartitionKey,@ClusteringKey,@LookupIndex,@Column,@Transientannotations.SchemaRegistrywith DDL generation.BATCHandEVENTUALlookup consistency modes.KandraRepository(blocking) andKandraSuspendRepository(coroutine).- Type-safe query DSL (
KandraColumnRef,KandraPredicate,QueryContext) — this is the original+-prefixed DSL later replaced in 0.4.5; see that entry above for why. - Ktor
ApplicationPluginintegration. - Kodein-DI auto-binding (
kandra-kodein) and Koin auto-binding (kandra-koin). - KSP codegen generating type-safe
*Tableobjects. FakeKandraSessionfor unit tests.
For the reasoning behind individual fixes, see /battle-scars and the linked
ISS-NNN writeups above. For today's feature surface rather than its history, see
/foundations, /modules, and /reference.