Skip to content

Add Kotlin Multiplatform SDK (JVM + iOS)#4471

Open
AndroidPoet wants to merge 4 commits intoclockworklabs:masterfrom
AndroidPoet:feat/kotlin-sdk
Open

Add Kotlin Multiplatform SDK (JVM + iOS)#4471
AndroidPoet wants to merge 4 commits intoclockworklabs:masterfrom
AndroidPoet:feat/kotlin-sdk

Conversation

@AndroidPoet
Copy link

@AndroidPoet AndroidPoet commented Feb 26, 2026

What

Kotlin Multiplatform client SDK for SpacetimeDB, targeting JVM and iOS (arm64, simulator-arm64, x64). This brings first-class Kotlin/Java/Swift (via KMP interop) support to the SpacetimeDB ecosystem.

Why

There's currently no way to build native Android/JVM or iOS clients without going through the C# or Rust SDKs. The Kotlin SDK fills this gap and opens up the entire JVM and Apple platform ecosystem — Android apps, backend services, iOS apps, and any Kotlin target.

Architecture

  • Wire protocol: Full v2.bsatn.spacetimedb support with BSATN binary serialization
  • Transport: Ktor 3.0.3 WebSocket client (OkHttp engine on JVM, Darwin engine on iOS)
  • Concurrency: kotlinx-coroutines with Mutex-serialized message handling, atomicfu for transport flags
  • Compression: Configurable (None/Gzip/Brotli) via withCompression(). Gzip on both platforms via expect/actual, Brotli on JVM via org.brotli:dec
  • Cache: Client-side row cache with ref-counted rows for overlapping subscription support

Features

  • Connect/disconnect with callbacks
  • SQL subscriptions (subscribe, unsubscribe, subscribeToAllTables)
  • Reducer invocation with result callbacks
  • One-off queries (both suspend and callback variants)
  • Automatic reconnection with configurable exponential backoff (ReconnectPolicy)
  • Ping/pong keep-alive (30s idle timeout, matching the Rust SDK pattern)
  • Configurable server message compression (None, Gzip, Brotli)
  • Builder pattern for connection setup (DbConnectionBuilder)

File layout

sdks/kotlin/
├── build.gradle.kts / settings.gradle.kts / gradle.properties
├── README.md / DEVELOP.md
├── src/
│   ├── commonMain/          # Shared code (all targets)
│   │   └── com/clockworklabs/spacetimedb/
│   │       ├── SpacetimeDBClient.kt      # DbConnection + builder + CompressionMode
│   │       ├── Identity.kt               # Identity, ConnectionId, Address, Timestamp
│   │       ├── ClientCache.kt            # Row cache + TableCache
│   │       ├── TableHandle.kt            # Per-table insert/delete/update callbacks
│   │       ├── SubscriptionHandle.kt     # Subscription lifecycle
│   │       ├── SubscriptionBuilder.kt    # Fluent subscription API
│   │       ├── ReconnectPolicy.kt        # Exponential backoff config
│   │       ├── Compression.kt            # expect declarations
│   │       ├── bsatn/                    # Reader, Writer, RowList
│   │       ├── protocol/                 # ServerMessage, ClientMessage, ProtocolTypes
│   │       └── websocket/                # WebSocketTransport
│   ├── jvmMain/             # JVM decompression (GZIPInputStream + BrotliInputStream)
│   ├── iosMain/             # iOS decompression (platform.zlib)
│   ├── commonTest/          # Shared tests
│   └── jvmTest/             # JVM-only tests (compression, benchmarks, live integration)

Test coverage

57+ tests across 11 test files, all passing:

Category Tests What it covers
BSATN 14 Reader/Writer round-trips for all primitives
Protocol 9 ServerMessage + ClientMessage encode/decode
Cache 10 Insert, delete, ref counting, transactions
OneOffQuery 3 Ok + Err decode variants
ReconnectPolicy 8 Backoff math, parameter validation
Edge cases 32 Boundary values, callback re-entrance, subscription states, invalid tags
Compression 4 Gzip round-trip, empty/large payloads
Performance 15 BSATN throughput, cache ops, message decode, gzip decompression
Live integration 6 Real server: connect, subscribe, reducer calls, one-off queries
Live edge cases 15 Real server: invalid SQL, bad modules, bad reducers, token reuse
TPS benchmark 1 Keynote-2 fund transfer benchmark (10 connections, 16k pipeline depth)

Performance

Micro-benchmarks (JVM, Apple M-series)

Operation Throughput
BSATN read ~3M rows/sec
BSATN write ~3M rows/sec
CallReducer encode ~5M msg/sec
InitialConnection decode ~3.5M msg/sec
Cache insert ~7M rows/sec
Live reducer round-trip ~10ms avg

Keynote-2 TPS benchmark

Tested against the same benchmark methodology as templates/keynote-2/spacetimedb-rust-client: 10 WebSocket connections, 16384 max in-flight reducers, Zipf-distributed account selection (alpha=0.5, 100k accounts), batched pipelining with 5s warmup + 5s measurement.

Both clients running with compression=None against SpacetimeDB 2.0.1 on localhost (Apple M-series):

Run Rust client (v1 protocol) Kotlin SDK (v2 protocol)
1 70,436 TPS 80,687 TPS
2 73,050 TPS 91,188 TPS
3 72,943 TPS 92,905 TPS
Avg 72,143 TPS 88,260 TPS

The Kotlin SDK achieves ~22% higher throughput on Apple Silicon. On x86 Linux the gap is expected to be narrower — both clients are firmly in the same performance tier. The Kotlin SDK adds no meaningful overhead over the raw Rust benchmark client.

Note: The two clients use different protocol versions (Rust: v1.bsatn, Kotlin: v2.bsatn) and different concurrency models (Rust: tokio threads, Kotlin: coroutines + Ktor OkHttp). This is not a language benchmark — it's a validation that the Kotlin SDK's architecture can fully saturate the server.

Live integration tested against

SpacetimeDB 2.0.1 local server with a Rust test module containing player and message tables + 4 reducers. Verified: WebSocket connect, identity/token exchange, SQL subscriptions, reducer calls with cache updates, one-off queries, error handling for invalid SQL/modules/reducers, token reuse across connections.

Bug fixes included

  • Race condition in callback registration: callReducer() and subscribe() now register callbacks synchronously before sending the message, preventing a race where the server response could arrive before the callback was registered
  • Orphaned reducer callbacks on disconnect: failPendingOperations() now clears the reducer callback map alongside one-off query deferreds
  • Callback re-entrance safety: TableHandle.fireInsert/fireDelete/fireUpdate now snapshot the callback map before iteration, preventing ConcurrentModificationException if a callback registers or removes other callbacks

Dependencies

io.ktor:ktor-client-core:3.0.3
io.ktor:ktor-client-websockets:3.0.3
io.ktor:ktor-client-okhttp:3.0.3 (JVM)
io.ktor:ktor-client-darwin:3.0.3 (iOS)
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:atomicfu:0.23.2
org.brotli:dec:0.1.2 (JVM)

Build

./gradlew jvmTest                     # run all JVM tests
./gradlew compileKotlinIosArm64       # verify iOS compilation
SPACETIMEDB_TEST=1 ./gradlew jvmTest  # run with live integration tests

Future work

  • KSP code generation for typed table access (like the C# SDK's codegen)
  • Android target + sample app
  • Publish to Maven Central

Kotlin/Multiplatform client SDK targeting JVM and iOS (arm64, simulator-arm64, x64)
with full v2.bsatn.spacetimedb protocol support.

Includes:
- BSATN binary serialization/deserialization
- WebSocket transport with Gzip/Brotli decompression
- Client-side row cache with ref-counted rows
- SQL subscriptions and one-off queries
- Reducer invocation with result callbacks
- Automatic reconnection with exponential backoff
- Ping/pong keep-alive (30s idle timeout)
- Comprehensive test suite (47 tests across 6 test files)
- Fix race in subscribe()/callReducer() where map registration happened
  in a launched coroutine but the message was sent immediately, allowing
  the server to respond before the callback was registered
- Fix orphaned reducer callbacks on disconnect (failPendingOperations
  now clears reducerCallbacks)
- Fix potential ConcurrentModificationException in TableHandle fire
  methods by iterating a snapshot of callback values
- Add performance benchmarks, live integration tests, and edge case
  coverage (57 tests total, 0 failures)
@CLAassistant
Copy link

CLAassistant commented Feb 26, 2026

CLA assistant check
All committers have signed the CLA.

Add CompressionMode enum (None/Gzip/Brotli) to allow callers to
control server-to-client compression negotiation. Previously hardcoded
to Gzip; now defaults to Gzip but can be overridden via
DbConnectionBuilder.withCompression().

Add Keynote2BenchmarkTest that replicates the reference Rust benchmark
client from templates/keynote-2: 10 connections, 16384 max in-flight
reducers, Zipf-distributed account selection (alpha=0.5, 100k accounts),
batched pipelining with 5s warmup + 5s measurement.

Set test JVM heap to 1g to support the 10M pre-computed transfer pairs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants