From f250a6951d52447ba6977b66b15c5aa98311e42f Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sat, 22 Aug 2026 17:45:51 +0100 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20write-behind=20capture=20?= =?UTF-8?q?=E2=80=94=20events=20survive=20process=20death=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of the flight-recorder epic. Events now flow off the in-memory ring buffer into the on-device DB, so logs survive process death. - SharinganStore gains an internal `onRecord` seam, invoked after the unchanged lock-free CAS append (only while recording). No public change. - PersistenceController owns CoroutineScope(SupervisorJob()+Dispatchers.Default), wires `onRecord = { channel.trySend(it) }` on a bounded channel, and drains it with a single flusher coroutine that writes each batch in one SQLDelight transaction (size ~50 / 250 ms, whichever first). - Lazy session row created on the first flushed event of the launch. - internal @Serializable EventDto (Http/Mqtt/Ble) with fromEvent() encode-only; public event ABI untouched. Stored as the event payload_json blob. - Persisted event PK is globally unique across sessions: session id is timestamp+random, and the event row id prefixes the raw EventIds value with the session id (EventIds resets each launch, so the raw value is not safe as a cross-session PK). - Hands-free on Android via the existing manifest ContentProvider. - Tests: burst > ring capacity all persisted, batching (not one-write-per-event), record() unchanged when persistence off, cross-session id uniqueness. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sharingan/internal/SharinganAndroid.kt | 2 + .../persistence/DriverFactory.android.kt | 2 +- .../kotlin/dev/sharingan/SharinganStore.kt | 9 ++ .../sharingan/persistence/DriverFactory.kt | 2 +- .../dev/sharingan/persistence/EventDto.kt | 116 +++++++++++++++ .../dev/sharingan/persistence/Persistence.kt | 19 +++ .../persistence/PersistenceController.kt | 137 ++++++++++++++++++ .../persistence/SharinganDatabase.sq | 6 + .../persistence/PersistenceControllerTest.kt | 113 +++++++++++++++ .../persistence/DriverFactory.ios.kt | 2 +- 10 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt create mode 100644 sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt create mode 100644 sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt create mode 100644 sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt index 5473e27..7ec4d85 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt @@ -6,6 +6,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import android.net.Uri +import dev.sharingan.persistence.Persistence /** * Holds the application context Sharingan needs for the clipboard, the share @@ -28,6 +29,7 @@ public class SharinganInitProvider : ContentProvider() { val appContext = context?.applicationContext ?: return true SharinganAndroid.appContext = appContext CaptureNotification.start(appContext) + Persistence.start() return true } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt index e8d1fc6..ce7e8cf 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt @@ -4,7 +4,7 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import dev.sharingan.internal.SharinganAndroid -internal actual class DriverFactory { +internal actual class DriverFactory actual constructor() { actual fun create(): SqlDriver { val context = SharinganAndroid.appContext diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 5eca7dd..7dfbba1 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -22,6 +22,14 @@ public class SharinganStore( private val _events = MutableStateFlow>(emptyList()) private val _isRecording = MutableStateFlow(true) + /** + * Internal persistence seam: invoked with each accepted event after the + * CAS append. `null` unless the flight-recorder persistence is wired up + * (see `dev.sharingan.persistence.PersistenceController`). Internal — never + * part of the public API, so :sharingan-noop mirrors nothing here. + */ + internal var onRecord: ((SharinganEvent) -> Unit)? = null + /** All retained events, oldest first. */ public val events: StateFlow> = _events.asStateFlow() @@ -39,6 +47,7 @@ public class SharinganStore( appended } } + onRecord?.invoke(event) } /** Pauses (`false`) or resumes (`true`) capture. Paused events are dropped, not queued. */ diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt index 5acbcfd..2ad1628 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt @@ -7,6 +7,6 @@ import app.cash.sqldelight.db.SqlDriver * database. Internal: persistence is a debug-only concern and must not leak * into the public API (or the no-op twin). */ -internal expect class DriverFactory { +internal expect class DriverFactory() { fun create(): SqlDriver } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt new file mode 100644 index 0000000..71dd92a --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt @@ -0,0 +1,116 @@ +package dev.sharingan.persistence + +import dev.sharingan.BleEvent +import dev.sharingan.HttpEvent +import dev.sharingan.MqttEvent +import dev.sharingan.SharinganEvent +import kotlinx.serialization.Serializable + +/** + * Serializable mirror of [SharinganEvent], stored as the `event.payload_json` + * blob. A SEPARATE internal type rather than `@Serializable` on the public + * events — the #15 ABI freeze forbids touching `HttpEvent`/`MqttEvent`/`BleEvent`. + * + * Encode-only this slice: [fromEvent] maps a public event to its DTO. The + * `toEvent()` decoder lands in slice 3 (in-viewer history), which is why every + * public field has a mirrored counterpart and enum values are carried by name. + */ +@Serializable +internal sealed interface EventDto { + val id: String + val timestampMillis: Long + val error: String? + + companion object { + fun fromEvent(event: SharinganEvent): EventDto = when (event) { + is HttpEvent -> HttpDto( + id = event.id, + timestampMillis = event.timestampMillis, + method = event.method, + url = event.url, + statusCode = event.statusCode, + durationMillis = event.durationMillis, + requestHeaders = event.requestHeaders.map { (name, value) -> HeaderDto(name, value) }, + responseHeaders = event.responseHeaders.map { (name, value) -> HeaderDto(name, value) }, + requestBody = event.requestBody, + responseBody = event.responseBody, + contentType = event.contentType, + responseSizeBytes = event.responseSizeBytes, + timing = event.timing.map { TimingPhaseDto(it.label, it.millis) }, + error = event.error, + ) + is MqttEvent -> MqttDto( + id = event.id, + timestampMillis = event.timestampMillis, + direction = event.direction.name, + topic = event.topic, + qos = event.qos, + retained = event.retained, + payload = event.payload, + payloadSizeBytes = event.payloadSizeBytes, + error = event.error, + ) + is BleEvent -> BleDto( + id = event.id, + timestampMillis = event.timestampMillis, + operation = event.operation.name, + device = event.device, + characteristic = event.characteristic, + uuid = event.uuid, + payload = event.payload, + sizeBytes = event.sizeBytes, + error = event.error, + ) + } + } +} + +@Serializable +internal data class HeaderDto(val name: String, val value: String) + +@Serializable +internal data class TimingPhaseDto(val label: String, val millis: Long) + +@Serializable +internal data class HttpDto( + override val id: String, + override val timestampMillis: Long, + val method: String, + val url: String, + val statusCode: Int? = null, + val durationMillis: Long? = null, + val requestHeaders: List = emptyList(), + val responseHeaders: List = emptyList(), + val requestBody: String? = null, + val responseBody: String? = null, + val contentType: String? = null, + val responseSizeBytes: Long? = null, + val timing: List = emptyList(), + override val error: String? = null, +) : EventDto + +@Serializable +internal data class MqttDto( + override val id: String, + override val timestampMillis: Long, + val direction: String, + val topic: String, + val qos: Int = 0, + val retained: Boolean = false, + val payload: String? = null, + val payloadSizeBytes: Long? = null, + override val error: String? = null, +) : EventDto + +@Serializable +internal data class BleDto( + override val id: String, + override val timestampMillis: Long, + val operation: String, + val device: String, + val characteristic: String? = null, + val uuid: String? = null, + val payload: String? = null, + val sizeBytes: Long? = null, + override val error: String? = null, +) : EventDto diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt new file mode 100644 index 0000000..e7b7180 --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt @@ -0,0 +1,19 @@ +package dev.sharingan.persistence + +import dev.sharingan.Sharingan +import dev.sharingan.SharinganStore + +/** + * Hands-free persistence bootstrap. Starts the [PersistenceController] over the + * shared [Sharingan.store] exactly once per process. Internal — the public + * `Sharingan.configure()` entry point (and its no-op mirror) lands in a later + * slice; until then Android's manifest-merged ContentProvider calls [start]. + */ +internal object Persistence { + private var controller: PersistenceController? = null + + fun start(store: SharinganStore = Sharingan.store) { + if (controller != null) return + controller = PersistenceController(store, DriverFactory().create()).also { it.start() } + } +} diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt new file mode 100644 index 0000000..f1d9500 --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt @@ -0,0 +1,137 @@ +package dev.sharingan.persistence + +import app.cash.sqldelight.db.SqlDriver +import dev.sharingan.BleEvent +import dev.sharingan.HttpEvent +import dev.sharingan.MqttEvent +import dev.sharingan.SharinganEvent +import dev.sharingan.SharinganStore +import dev.sharingan.internal.currentTimeMillis +import kotlin.random.Random +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json + +/** + * Write-behind persistence: drains events off the store's internal `onRecord` + * seam into an on-device SQLDelight database so logs survive process death. + * + * `record()` keeps its lock-free CAS append and never blocks; [start] hangs a + * non-blocking `channel.trySend` off the seam, and a single flusher coroutine + * on `Dispatchers.Default` drains the channel, batching writes by size or time + * (whichever first) into one `transaction {}` per batch. One session row is + * created lazily on the first flushed event of the process launch. + */ +internal class PersistenceController( + private val store: SharinganStore, + private val driver: SqlDriver, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val batchSize: Int = DEFAULT_BATCH_SIZE, + private val flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, +) { + private val database = SharinganDatabase(driver) + private val channel = Channel(capacity = CHANNEL_CAPACITY) + + private var sessionId: String? = null + + /** Test seam: invoked with the batch size after each committed transaction. */ + internal var onBatchFlushed: ((Int) -> Unit)? = null + + /** Wires the store seam and starts the flusher. Idempotent per controller. */ + fun start() { + store.onRecord = { event -> channel.trySend(event) } + scope.launch { runFlusher() } + } + + /** Unwires the store seam and stops the flusher. */ + fun stop() { + store.onRecord = null + scope.cancel() + } + + private suspend fun runFlusher() { + val batch = mutableListOf() + while (true) { + val event = if (batch.isEmpty()) { + channel.receive() + } else { + withTimeoutOrNull(flushIntervalMillis) { channel.receive() } + } + if (event == null) { + flush(batch) // time-based + } else { + batch.add(event) + if (batch.size >= batchSize) flush(batch) // size-based + } + } + } + + private fun flush(batch: MutableList) { + if (batch.isEmpty()) return + val snapshot = batch.toList() + batch.clear() + database.transaction { + snapshot.forEach { persist(it) } + } + onBatchFlushed?.invoke(snapshot.size) + } + + private fun persist(event: SharinganEvent) { + val session = ensureSession() + database.sharinganDatabaseQueries.insertEvent( + id = "$session-${event.id}", + session_id = session, + timestamp = event.timestampMillis, + type = event.typeName(), + is_failure = if (event.isFailure) 1L else 0L, + host_or_topic = event.hostOrTopic(), + payload_json = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event)), + ) + } + + private fun ensureSession(): String { + sessionId?.let { return it } + val id = newSessionId() + database.sharinganDatabaseQueries.insertSession( + id = id, + started_at = currentTimeMillis(), + app_id = null, + build = null, + os = null, + device_model = null, + ) + sessionId = id + return id + } + + internal companion object { + const val DEFAULT_BATCH_SIZE: Int = 50 + const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 250L + const val CHANNEL_CAPACITY: Int = 1024 + } +} + +private val json = Json { encodeDefaults = true } + +private fun SharinganEvent.typeName(): String = when (this) { + is HttpEvent -> "HTTP" + is MqttEvent -> "MQTT" + is BleEvent -> "BLE" +} + +private fun SharinganEvent.hostOrTopic(): String? = when (this) { + is HttpEvent -> host + is MqttEvent -> topic + is BleEvent -> device +} + +// A session id only needs to be unique within this device's database. A +// timestamp plus a random component makes two process launches that begin in +// the same millisecond (and two test controllers) effectively collision-free. +private fun newSessionId(): String = + "session-${currentTimeMillis()}-${Random.nextLong().toULong().toString(16)}" diff --git a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq b/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq index a20dcff..9ff9788 100644 --- a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq +++ b/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq @@ -33,3 +33,9 @@ SELECT * FROM session WHERE id = ?; selectEventsForSession: SELECT * FROM event WHERE session_id = ? ORDER BY timestamp ASC; + +selectAllSessions: +SELECT * FROM session ORDER BY started_at ASC; + +selectAllEvents: +SELECT * FROM event ORDER BY timestamp ASC; diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt new file mode 100644 index 0000000..6c2fb44 --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt @@ -0,0 +1,113 @@ +package dev.sharingan.persistence + +import dev.sharingan.HttpEvent +import dev.sharingan.SharinganEvent +import dev.sharingan.SharinganStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PersistenceControllerTest { + + private fun event(id: String): SharinganEvent = HttpEvent( + id = id, + timestampMillis = 0L, + method = "GET", + url = "https://api.example.com/$id", + ) + + @Test + fun `Given a burst larger than the ring buffer When flushed Then every event is persisted`() = runBlocking { + val total = 500 + val driver = createTestDriver() + val store = SharinganStore(capacity = 300) + val controller = PersistenceController(store, driver) + + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() + + repeat(total) { i -> store.record(event("e$i")) } + + withTimeout(10_000) { done.await() } + assertEquals(total, flushed) + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(total, rows.size, "all $total events should reach the DB despite a ${store.capacity} ring buffer") + assertEquals(total, rows.map { it.id }.toSet().size, "persisted event ids must be unique") + } + + @Test + fun `Given many events When flushed Then they are written in batches not one per event`() = runBlocking { + val total = 200 + val batchSize = 50 + val driver = createTestDriver() + val store = SharinganStore(capacity = 10_000) + val controller = PersistenceController(store, driver, batchSize = batchSize, flushIntervalMillis = 60_000) + + val batches = mutableListOf() + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + batches += size + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() + + repeat(total) { i -> store.record(event("e$i")) } + + withTimeout(10_000) { done.await() } + assertEquals(total, batches.sum()) + assertTrue(batches.size < total, "expected batching, got ${batches.size} writes for $total events") + assertTrue(batches.all { it <= batchSize }, "no batch should exceed $batchSize, got $batches") + } + + @Test + fun `When persistence is off Then record keeps its behavior and onRecord stays null`() { + val store = SharinganStore(capacity = 10) + assertNull(store.onRecord) + store.record(event("a")) + store.record(event("b")) + assertEquals(listOf("a", "b"), store.events.value.map { it.id }) + assertNull(store.onRecord) + } + + @Test + fun `Given two sessions When events reuse a raw id Then persisted ids do not collide`() = runBlocking { + val driver = createTestDriver() + + val store1 = SharinganStore(capacity = 10) + val controller1 = PersistenceController(store1, driver) + val done1 = CompletableDeferred() + controller1.onBatchFlushed = { done1.complete(Unit) } + controller1.start() + store1.record(event("http-1")) + withTimeout(10_000) { done1.await() } + controller1.stop() + + val store2 = SharinganStore(capacity = 10) + val controller2 = PersistenceController(store2, driver) + val done2 = CompletableDeferred() + controller2.onBatchFlushed = { done2.complete(Unit) } + controller2.start() + store2.record(event("http-1")) + withTimeout(10_000) { done2.await() } + controller2.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(2, rows.map { it.id }.toSet().size, "cross-session event ids must be globally unique") + + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(2, sessions.size) + } +} diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt index 880a0f9..e63666c 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt @@ -3,6 +3,6 @@ package dev.sharingan.persistence import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver -internal actual class DriverFactory { +internal actual class DriverFactory actual constructor() { actual fun create(): SqlDriver = NativeSqliteDriver(SharinganDatabase.Schema, "sharingan.db") } From bf3b804e2638990d28b5a0da140286f2969038fe Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sat, 22 Aug 2026 18:06:36 +0100 Subject: [PATCH 02/28] =?UTF-8?q?fix:=20review=20follow-ups=20=E2=80=94=20?= =?UTF-8?q?DROP=5FOLDEST,=20idempotent=20start,=20drain-on-stop,=20session?= =?UTF-8?q?=20commit=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both reviewers' REQUEST-CHANGES on #56. - Channel now uses BufferOverflow.DROP_OLDEST so the crash-tail (newest events) survives backpressure, never the oldest. Pinned by a direct-channel test. - start() guarded by an AtomicBoolean (idempotent — no double flusher). - Persistence bootstrap made thread-safe with an AtomicBoolean CAS (`synchronized` is JVM-only, unavailable in commonMain). - stop() drains the pending channel + in-flight batch (channel.close() then join), then cancels the scope and closes the SqlDriver. - sessionId memoized only AFTER the batch transaction commits; reset on rollback so a later batch re-derives a rolled-back session row. - Per-batch transaction wrapped in try/catch so one failure never kills the flusher coroutine. - Tests: DROP_OLDEST overflow semantics, stop() drains pending batch; two-session test no longer stop()s (stop now closes the shared driver). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/persistence/Persistence.kt | 6 +- .../persistence/PersistenceController.kt | 93 +++++++++++++------ .../persistence/PersistenceControllerTest.kt | 30 +++++- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt index e7b7180..0b7a873 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt @@ -2,6 +2,8 @@ package dev.sharingan.persistence import dev.sharingan.Sharingan import dev.sharingan.SharinganStore +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Hands-free persistence bootstrap. Starts the [PersistenceController] over the @@ -9,11 +11,13 @@ import dev.sharingan.SharinganStore * `Sharingan.configure()` entry point (and its no-op mirror) lands in a later * slice; until then Android's manifest-merged ContentProvider calls [start]. */ +@OptIn(ExperimentalAtomicApi::class) internal object Persistence { + private val started = AtomicBoolean(false) private var controller: PersistenceController? = null fun start(store: SharinganStore = Sharingan.store) { - if (controller != null) return + if (!started.compareAndSet(false, true)) return controller = PersistenceController(store, DriverFactory().create()).also { it.start() } } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt index f1d9500..6d28307 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt @@ -7,12 +7,17 @@ import dev.sharingan.MqttEvent import dev.sharingan.SharinganEvent import dev.sharingan.SharinganStore import dev.sharingan.internal.currentTimeMillis +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.random.Random import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json @@ -27,6 +32,7 @@ import kotlinx.serialization.json.Json * (whichever first) into one `transaction {}` per batch. One session row is * created lazily on the first flushed event of the process launch. */ +@OptIn(ExperimentalAtomicApi::class) internal class PersistenceController( private val store: SharinganStore, private val driver: SqlDriver, @@ -35,32 +41,48 @@ internal class PersistenceController( private val flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, ) { private val database = SharinganDatabase(driver) - private val channel = Channel(capacity = CHANNEL_CAPACITY) + // DROP_OLDEST, not DROP_LATEST: a flight recorder must keep the crash-tail, + // so under backpressure the oldest events are evicted, never the newest. + private val channel = newEventChannel(CHANNEL_CAPACITY) + + private val started = AtomicBoolean(false) + + private var flusherJob: Job? = null + + // Only the flusher coroutine touches this. Memoized across flushes, but + // reset if a batch's transaction rolls back so a half-written session row + // is never referenced by a later batch. private var sessionId: String? = null /** Test seam: invoked with the batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null - /** Wires the store seam and starts the flusher. Idempotent per controller. */ + /** Wires the store seam and starts the flusher. Idempotent. */ fun start() { + if (!started.compareAndSet(false, true)) return store.onRecord = { event -> channel.trySend(event) } - scope.launch { runFlusher() } + flusherJob = scope.launch { runFlusher() } } - /** Unwires the store seam and stops the flusher. */ - fun stop() { + /** Drains and flushes the pending events, then closes the driver. */ + suspend fun stop() { + if (!started.compareAndSet(true, false)) return store.onRecord = null + channel.close() + flusherJob?.join() scope.cancel() + driver.close() } private suspend fun runFlusher() { val batch = mutableListOf() while (true) { - val event = if (batch.isEmpty()) { - channel.receive() - } else { - withTimeoutOrNull(flushIntervalMillis) { channel.receive() } + val event = try { + if (batch.isEmpty()) channel.receive() + else withTimeoutOrNull(flushIntervalMillis) { channel.receive() } + } catch (e: ClosedReceiveChannelException) { + break } if (event == null) { flush(batch) // time-based @@ -69,20 +91,43 @@ internal class PersistenceController( if (batch.size >= batchSize) flush(batch) // size-based } } + flush(batch) // drain the in-flight batch after the channel is closed } private fun flush(batch: MutableList) { if (batch.isEmpty()) return val snapshot = batch.toList() batch.clear() - database.transaction { - snapshot.forEach { persist(it) } + val isNewSession = sessionId == null + val session = sessionId ?: newSessionId() + try { + database.transaction { + if (isNewSession) insertSession(session) + snapshot.forEach { persist(it, session) } + } + if (isNewSession) sessionId = session + onBatchFlushed?.invoke(snapshot.size) + } catch (t: Throwable) { + // One failing batch must not kill the flusher. Drop it and keep + // draining; re-derive the session on the next batch in case its row + // was rolled back with this transaction. + if (isNewSession) sessionId = null + println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") } - onBatchFlushed?.invoke(snapshot.size) } - private fun persist(event: SharinganEvent) { - val session = ensureSession() + private fun insertSession(id: String) { + database.sharinganDatabaseQueries.insertSession( + id = id, + started_at = currentTimeMillis(), + app_id = null, + build = null, + os = null, + device_model = null, + ) + } + + private fun persist(event: SharinganEvent, session: String) { database.sharinganDatabaseQueries.insertEvent( id = "$session-${event.id}", session_id = session, @@ -94,21 +139,6 @@ internal class PersistenceController( ) } - private fun ensureSession(): String { - sessionId?.let { return it } - val id = newSessionId() - database.sharinganDatabaseQueries.insertSession( - id = id, - started_at = currentTimeMillis(), - app_id = null, - build = null, - os = null, - device_model = null, - ) - sessionId = id - return id - } - internal companion object { const val DEFAULT_BATCH_SIZE: Int = 50 const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 250L @@ -130,6 +160,11 @@ private fun SharinganEvent.hostOrTopic(): String? = when (this) { is BleEvent -> device } +// DROP_OLDEST: a flight recorder must keep the crash-tail, so when the channel +// overflows the oldest events are evicted, never the newest. +internal fun newEventChannel(capacity: Int): Channel = + Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) + // A session id only needs to be unique within this device's database. A // timestamp plus a random component makes two process launches that begin in // the same millisecond (and two test controllers) effectively collision-free. diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt index 6c2fb44..86a93eb 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt @@ -92,7 +92,6 @@ internal class PersistenceControllerTest { controller1.start() store1.record(event("http-1")) withTimeout(10_000) { done1.await() } - controller1.stop() val store2 = SharinganStore(capacity = 10) val controller2 = PersistenceController(store2, driver) @@ -101,7 +100,6 @@ internal class PersistenceControllerTest { controller2.start() store2.record(event("http-1")) withTimeout(10_000) { done2.await() } - controller2.stop() val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() assertEquals(2, rows.size) @@ -110,4 +108,32 @@ internal class PersistenceControllerTest { val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() assertEquals(2, sessions.size) } + + @Test + fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = runBlocking { + val channel = newEventChannel(capacity = 4) + (1..10).map { event("e$it") }.forEach { channel.trySend(it) } + channel.close() + + val received = mutableListOf() + for (e in channel) received += e + + assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.id }) + } + + @Test + fun `Given buffered events When stopped Then the pending batch is drained before close`() = runBlocking { + val driver = createTestDriver() + val store = SharinganStore(capacity = 1_000) + val controller = PersistenceController(store, driver, batchSize = 100, flushIntervalMillis = 60_000) + val batches = mutableListOf() + controller.onBatchFlushed = { size -> batches += size } + controller.start() + + repeat(30) { i -> store.record(event("e$i")) } + + controller.stop() + + assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") + } } From 5cc14cb19bd1142ec26e9ecdcf0abcf5e4ab974b Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sat, 22 Aug 2026 18:43:10 +0100 Subject: [PATCH 03/28] fix: use a real UUID for session ids (#50) timestamp+Random collided on Kotlin/Native (same millis + Native Random.Default returning the same value on rapid successive calls), so the two sessions in the test produced one PK and the second insert hit a UNIQUE violation (swallowed by the flusher's try/catch). kotlin.uuid.Uuid.random() is collision-free on every target; started_at stays currentTimeMillis(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sharingan/persistence/PersistenceController.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt index 6d28307..a5752c1 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt @@ -9,7 +9,8 @@ import dev.sharingan.SharinganStore import dev.sharingan.internal.currentTimeMillis import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.random.Random +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -165,8 +166,10 @@ private fun SharinganEvent.hostOrTopic(): String? = when (this) { internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) -// A session id only needs to be unique within this device's database. A -// timestamp plus a random component makes two process launches that begin in -// the same millisecond (and two test controllers) effectively collision-free. +// A session id only needs to be unique within this device's database, but it +// must be collision-free even for two launches in the same millisecond (and two +// test controllers) — a real UUID, not timestamp+Random which collide on +// Kotlin/Native's Default random under rapid successive calls. +@OptIn(ExperimentalUuidApi::class) private fun newSessionId(): String = - "session-${currentTimeMillis()}-${Random.nextLong().toULong().toString(16)}" + "session-${Uuid.random()}" From 1018167642469338c01abf2d470e7c71385b558a Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sat, 22 Aug 2026 19:34:19 +0100 Subject: [PATCH 04/28] test(ios): unique per-driver in-memory DB name (#50) The constant "sharingan-test.db" with inMemory=true mapped to a single file:...?cache=shared in-memory DB on Kotlin/Native, so every createTestDriver() in the test binary shared one database and earlier tests leaked ~700 rows into the two-sessions test. A per-driver UUID name gives each test a private DB. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt b/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt index 966eadb..515d69a 100644 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt +++ b/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt @@ -2,10 +2,13 @@ package dev.sharingan.persistence import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) internal actual fun createTestDriver(): SqlDriver = NativeSqliteDriver( schema = SharinganDatabase.Schema, - name = "sharingan-test.db", + name = "sharingan-test-${Uuid.random()}.db", onConfiguration = { it.copy(inMemory = true) }, ) From cea0cefa4cf3a7e551ad1cd397743f38e6cde264 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sun, 23 Aug 2026 22:42:19 +0100 Subject: [PATCH 05/28] fix: enforce foreign keys so session deletes cascade (#50) Enable PRAGMA foreign_keys / setForeignKeyConstraintsEnabled on Android, iOS driver configuration, and JVM test drivers. Add deleteSession query and a cascade test that fails when FKs are off. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../persistence/DriverFactory.android.kt | 14 ++++++++- .../persistence/TestSqlDriver.android.kt | 1 + .../persistence/SharinganDatabase.sq | 3 ++ .../SharinganDatabaseRoundTripTest.kt | 31 +++++++++++++++++++ .../persistence/DriverFactory.ios.kt | 7 ++++- .../persistence/TestSqlDriver.ios.kt | 7 ++++- 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt index ce7e8cf..aa10215 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt @@ -1,5 +1,6 @@ package dev.sharingan.persistence +import androidx.sqlite.db.SupportSQLiteDatabase import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import dev.sharingan.internal.SharinganAndroid @@ -9,6 +10,17 @@ internal actual class DriverFactory actual constructor() { val context = SharinganAndroid.appContext ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") - return AndroidSqliteDriver(SharinganDatabase.Schema, context, "sharingan.db") + return AndroidSqliteDriver( + schema = SharinganDatabase.Schema, + context = context, + name = "sharingan.db", + callback = + object : AndroidSqliteDriver.Callback(SharinganDatabase.Schema) { + override fun onConfigure(db: SupportSQLiteDatabase) { + super.onConfigure(db) + db.setForeignKeyConstraintsEnabled(true) + } + }, + ) } } diff --git a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt b/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt index 2ae33a0..4bb3b49 100644 --- a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt +++ b/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt @@ -6,4 +6,5 @@ import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver internal actual fun createTestDriver(): SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply { SharinganDatabase.Schema.create(this) + execute(null, "PRAGMA foreign_keys=ON;", 0) } diff --git a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq b/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq index 9ff9788..8459a58 100644 --- a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq +++ b/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq @@ -39,3 +39,6 @@ SELECT * FROM session ORDER BY started_at ASC; selectAllEvents: SELECT * FROM event ORDER BY timestamp ASC; + +deleteSession: +DELETE FROM session WHERE id = ?; diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt index 3dfe7dd..a4d5f0c 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt @@ -45,4 +45,35 @@ internal class SharinganDatabaseRoundTripTest { assertEquals("api.example.com", event.host_or_topic) assertEquals("""{"method":"GET"}""", event.payload_json) } + + @Test + fun `Given events for a session When the session is deleted Then its events cascade away`() { + val db = SharinganDatabase(createTestDriver()) + + db.sharinganDatabaseQueries.insertSession( + id = "session-1", + started_at = 1_700_000_000_000L, + app_id = null, + build = null, + os = null, + device_model = null, + ) + repeat(3) { index -> + db.sharinganDatabaseQueries.insertEvent( + id = "event-$index", + session_id = "session-1", + timestamp = 1_700_000_001_000L + index, + type = "HTTP", + is_failure = 0L, + host_or_topic = "api.example.com", + payload_json = """{"n":$index}""", + ) + } + + assertEquals(3, db.sharinganDatabaseQueries.selectAllEvents().executeAsList().size) + + db.sharinganDatabaseQueries.deleteSession("session-1") + + assertEquals(emptyList(), db.sharinganDatabaseQueries.selectAllEvents().executeAsList()) + } } diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt index e63666c..91ffe85 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt @@ -4,5 +4,10 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver internal actual class DriverFactory actual constructor() { - actual fun create(): SqlDriver = NativeSqliteDriver(SharinganDatabase.Schema, "sharingan.db") + actual fun create(): SqlDriver = + NativeSqliteDriver( + schema = SharinganDatabase.Schema, + name = "sharingan.db", + onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(foreignKeyConstraints = true)) }, + ) } diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt b/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt index 515d69a..0cdfff8 100644 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt +++ b/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt @@ -10,5 +10,10 @@ internal actual fun createTestDriver(): SqlDriver = NativeSqliteDriver( schema = SharinganDatabase.Schema, name = "sharingan-test-${Uuid.random()}.db", - onConfiguration = { it.copy(inMemory = true) }, + onConfiguration = { + it.copy( + inMemory = true, + extendedConfig = it.extendedConfig.copy(foreignKeyConstraints = true), + ) + }, ) From 73a474a2f44ae89ddc9085f2a648f7d68754c1ca Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sun, 23 Aug 2026 22:46:58 +0100 Subject: [PATCH 06/28] fix: flush on a real latency deadline not an idle timeout (#50) Stamp a deadline when the first event enters an empty batch and use the remaining time in withTimeoutOrNull. A slow steady stream now flushes within the configured interval instead of waiting for the batch size. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../persistence/PersistenceController.kt | 8 +++-- .../persistence/PersistenceControllerTest.kt | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt index a5752c1..df2079f 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt @@ -78,16 +78,20 @@ internal class PersistenceController( private suspend fun runFlusher() { val batch = mutableListOf() + var deadline = 0L while (true) { val event = try { if (batch.isEmpty()) channel.receive() - else withTimeoutOrNull(flushIntervalMillis) { channel.receive() } + // Non-positive timeout returns null immediately; that means the deadline has + // already passed and we should flush right away. + else withTimeoutOrNull(deadline - currentTimeMillis()) { channel.receive() } } catch (e: ClosedReceiveChannelException) { break } if (event == null) { - flush(batch) // time-based + flush(batch) // deadline reached } else { + if (batch.isEmpty()) deadline = currentTimeMillis() + flushIntervalMillis batch.add(event) if (batch.size >= batchSize) flush(batch) // size-based } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt index 86a93eb..b081c6d 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt @@ -3,7 +3,9 @@ package dev.sharingan.persistence import dev.sharingan.HttpEvent import dev.sharingan.SharinganEvent import dev.sharingan.SharinganStore +import dev.sharingan.internal.currentTimeMillis import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlin.test.Test @@ -136,4 +138,36 @@ internal class PersistenceControllerTest { assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") } + + @Test + fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = runBlocking { + val driver = createTestDriver() + val store = SharinganStore(capacity = 10) + val controller = PersistenceController(store, driver, batchSize = 50, flushIntervalMillis = 250) + + var firstFlushAt = 0L + var firstBatchSize = 0 + val flushed = CompletableDeferred() + controller.onBatchFlushed = { size -> + if (firstFlushAt == 0L) { + firstBatchSize = size + firstFlushAt = currentTimeMillis() + flushed.complete(Unit) + } + } + controller.start() + + val startedAt = currentTimeMillis() + repeat(10) { i -> + store.record(event("e$i")) + delay(100) + } + + withTimeout(10_000) { flushed.await() } + val elapsed = firstFlushAt - startedAt + assertTrue(firstBatchSize < 10, "deadline flush should fire mid-stream, got a batch of $firstBatchSize") + assertTrue(elapsed < 600, "expected flush within interval, took $elapsed ms") + + controller.stop() + } } From 623350cef18c2ed3ae984797e2fd312298d90613 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sun, 23 Aug 2026 22:52:42 +0100 Subject: [PATCH 07/28] refactor: controller owns the driver it opens (#50) Give PersistenceController a three-method lifecycle: start() wires the seam, stop() drains and leaves the driver open for readers, close() stop()s then cancels the scope and closes the driver. Tests inject the driver through an internal constructor; production uses the no-driver constructor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/persistence/Persistence.kt | 2 +- .../persistence/PersistenceController.kt | 23 +++++++++++++++++-- .../persistence/PersistenceControllerTest.kt | 4 +++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt index 0b7a873..0ebfa13 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt @@ -18,6 +18,6 @@ internal object Persistence { fun start(store: SharinganStore = Sharingan.store) { if (!started.compareAndSet(false, true)) return - controller = PersistenceController(store, DriverFactory().create()).also { it.start() } + controller = PersistenceController(store).also { it.start() } } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt index df2079f..74b580f 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt @@ -34,13 +34,24 @@ import kotlinx.serialization.json.Json * created lazily on the first flushed event of the process launch. */ @OptIn(ExperimentalAtomicApi::class) -internal class PersistenceController( +internal class PersistenceController internal constructor( private val store: SharinganStore, private val driver: SqlDriver, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val batchSize: Int = DEFAULT_BATCH_SIZE, private val flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, ) { + internal constructor( + store: SharinganStore, + batchSize: Int = DEFAULT_BATCH_SIZE, + flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + ) : this( + store = store, + driver = DriverFactory().create(), + batchSize = batchSize, + flushIntervalMillis = flushIntervalMillis, + ) + private val database = SharinganDatabase(driver) // DROP_OLDEST, not DROP_LATEST: a flight recorder must keep the crash-tail, @@ -66,12 +77,20 @@ internal class PersistenceController( flusherJob = scope.launch { runFlusher() } } - /** Drains and flushes the pending events, then closes the driver. */ + /** + * Drains and flushes the pending events, then stops the flusher. + * The driver stays open so other readers can keep using the database. + */ suspend fun stop() { if (!started.compareAndSet(true, false)) return store.onRecord = null channel.close() flusherJob?.join() + } + + /** Stops the flusher, cancels the scope, and closes the driver. */ + suspend fun close() { + stop() scope.cancel() driver.close() } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt index b081c6d..cdb3072 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt @@ -94,6 +94,7 @@ internal class PersistenceControllerTest { controller1.start() store1.record(event("http-1")) withTimeout(10_000) { done1.await() } + controller1.stop() val store2 = SharinganStore(capacity = 10) val controller2 = PersistenceController(store2, driver) @@ -102,6 +103,7 @@ internal class PersistenceControllerTest { controller2.start() store2.record(event("http-1")) withTimeout(10_000) { done2.await() } + controller2.stop() val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() assertEquals(2, rows.size) @@ -168,6 +170,6 @@ internal class PersistenceControllerTest { assertTrue(firstBatchSize < 10, "deadline flush should fire mid-stream, got a batch of $firstBatchSize") assertTrue(elapsed < 600, "expected flush within interval, took $elapsed ms") - controller.stop() + controller.close() } } From 9c72cd4e26207cc1c6352ac3d7b1020a319a1ae3 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Sun, 23 Aug 2026 23:15:09 +0100 Subject: [PATCH 08/28] refactor: extract :sharingan-db so persistence leaves the public ABI (#50) Move SQLDelight schema, drivers, persistence controller and tests into a new :sharingan-db module. The controller becomes a generic PersistenceController that receives a (T) -> EventRow mapping, keeping JSON encoding off the hot record() path. :sharingan consumes :sharingan-db as an implementation dependency, so the SQLDelight-generated types are no longer exported to the iOS framework header. - EventDto and toRow stay in :sharingan, package dev.sharingan.internal - Add SharinganDbContext for the Android context seam - Add Now.* equivalents for :sharingan-db's own time source - Update CI, BCV config, and release docs for the third artifact Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 4 +- .github/workflows/publish.yml | 2 +- build.gradle.kts | 9 +- docs/RELEASING.md | 24 ++-- docs/api-parity.md | 12 ++ settings.gradle.kts | 1 + sharingan-db/build.gradle.kts | 86 ++++++++++++ .../sharingan/db}/DriverFactory.android.kt | 8 +- .../kotlin/dev/sharingan/db/Now.android.kt | 3 + .../dev/sharingan/db/SharinganDbContext.kt | 8 ++ .../sharingan/db}/TestSqlDriver.android.kt | 2 +- .../kotlin/dev/sharingan/db}/DriverFactory.kt | 2 +- .../kotlin/dev/sharingan/db/EventRow.kt | 15 ++ .../commonMain/kotlin/dev/sharingan/db/Now.kt | 4 + .../sharingan/db}/PersistenceController.kt | 131 +++++++++--------- .../dev/sharingan/db}/SharinganDatabase.sq | 0 .../db}/PersistenceControllerTest.kt | 91 ++++++------ .../db}/SharinganDatabaseRoundTripTest.kt | 2 +- .../kotlin/dev/sharingan/db}/TestSqlDriver.kt | 2 +- .../dev/sharingan/db}/DriverFactory.ios.kt | 2 +- .../kotlin/dev/sharingan/db/Now.ios.kt | 7 + .../dev/sharingan/db}/TestSqlDriver.ios.kt | 2 +- sharingan/api/sharingan.klib.api | 10 ++ sharingan/build.gradle.kts | 16 +-- .../sharingan/internal/SharinganAndroid.kt | 3 +- .../kotlin/dev/sharingan/SharinganStore.kt | 2 +- .../{persistence => internal}/EventDto.kt | 2 +- .../dev/sharingan/internal/Persistence.kt | 56 ++++++++ .../dev/sharingan/persistence/Persistence.kt | 23 --- .../dev/sharingan/SharinganStoreTest.kt | 13 ++ 30 files changed, 365 insertions(+), 177 deletions(-) create mode 100644 sharingan-db/build.gradle.kts rename {sharingan/src/androidMain/kotlin/dev/sharingan/persistence => sharingan-db/src/androidMain/kotlin/dev/sharingan/db}/DriverFactory.android.kt (73%) create mode 100644 sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt create mode 100644 sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt rename {sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence => sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db}/TestSqlDriver.android.kt (90%) rename {sharingan/src/commonMain/kotlin/dev/sharingan/persistence => sharingan-db/src/commonMain/kotlin/dev/sharingan/db}/DriverFactory.kt (90%) create mode 100644 sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt create mode 100644 sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt rename {sharingan/src/commonMain/kotlin/dev/sharingan/persistence => sharingan-db/src/commonMain/kotlin/dev/sharingan/db}/PersistenceController.kt (60%) rename {sharingan/src/commonMain/sqldelight/dev/sharingan/persistence => sharingan-db/src/commonMain/sqldelight/dev/sharingan/db}/SharinganDatabase.sq (100%) rename {sharingan/src/commonTest/kotlin/dev/sharingan/persistence => sharingan-db/src/commonTest/kotlin/dev/sharingan/db}/PersistenceControllerTest.kt (68%) rename {sharingan/src/commonTest/kotlin/dev/sharingan/persistence => sharingan-db/src/commonTest/kotlin/dev/sharingan/db}/SharinganDatabaseRoundTripTest.kt (98%) rename {sharingan/src/commonTest/kotlin/dev/sharingan/persistence => sharingan-db/src/commonTest/kotlin/dev/sharingan/db}/TestSqlDriver.kt (73%) rename {sharingan/src/iosMain/kotlin/dev/sharingan/persistence => sharingan-db/src/iosMain/kotlin/dev/sharingan/db}/DriverFactory.ios.kt (92%) create mode 100644 sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt rename {sharingan/src/iosTest/kotlin/dev/sharingan/persistence => sharingan-db/src/iosTest/kotlin/dev/sharingan/db}/TestSqlDriver.ios.kt (94%) rename sharingan/src/commonMain/kotlin/dev/sharingan/{persistence => internal}/EventDto.kt (99%) create mode 100644 sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt delete mode 100644 sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2477449..808b1fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,7 +66,7 @@ jobs: # commonTest is executed by the Android debug unit-test task. A failing # test here blocks the PR. - name: Run JVM/common unit tests - run: ./gradlew :sharingan:testDebugUnitTest :sharingan-noop:testDebugUnitTest + run: ./gradlew :sharingan:testDebugUnitTest :sharingan-db:testDebugUnitTest :sharingan-noop:testDebugUnitTest # Full multiplatform build + iOS simulator unit tests on macOS — the only # host that can link the Kotlin/Native iOS frameworks. This job (a) runs @@ -107,7 +107,7 @@ jobs: # default. If a runtime lacks the device the step fails fast (no silent # skip) — a deviceId follow-up, not a hidden gap. - name: Run iOS simulator unit tests - run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test + run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-db:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test # Android instrumented (connected) tests on a Linux-hosted emulator. This is # the only job that exercises the device suites — :sharingan's Compose UI diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index be9df96..cd3ac23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,6 +84,6 @@ jobs: echo "::notice title=Staged to Central Portal::Artifacts uploaded to a NEW deployment on the Central Portal and are NOT yet public." echo "Next: a maintainer must verify and release manually. See docs/RELEASING.md." echo " 1. Open https://central.sonatype.com/publishing/deployments" - echo " 2. Confirm io.github.mibrahimdev:sharingan and io.github.mibrahimdev:sharingan-noop are present, validated, and signed." + echo " 2. Confirm io.github.mibrahimdev:sharingan, io.github.mibrahimdev:sharingan-db, and io.github.mibrahimdev:sharingan-noop are present, validated, and signed." echo " 3. Click Publish on the deployment to release it to Central." echo " 4. Cut the matching GitHub Release for this tag." diff --git a/build.gradle.kts b/build.gradle.kts index 1e2e9e4..491461c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,10 @@ apiValidation { // excluded from the guarded public surface. Wrapped behind `internal` // DriverFactory/controller symbols; never mirrored in :sharingan-noop. ignoredPackages += "dev.sharingan.persistence" + // `:sharingan-db` is a debug-only implementation detail of `:sharingan`. + // Its surface is SQLDelight-generated and changes on every schema edit in + // slices 3-5; no consumer codes against it, so it is not BCV-guarded. + ignoredProjects += "sharingan-db" // KMP: also dump/verify the Kotlin/Native (iOS) ABI, not just the JVM/Android // surface, so iosMain-only declarations (SharinganViewController, @@ -78,13 +82,11 @@ tasks.register("checkApiParity") { // directly, so :sharingan-noop correctly omits them. Excluded from parity: // - dev/sharingan/ui/** : the in-app Compose viewer screens // - dev/sharingan/internal/** : init ContentProvider + notification receiver - // - dev/sharingan/persistence/** : SQLDelight-generated flight-recorder DB (public in bytecode only) // - dev/sharingan/SharinganActivity : the Android viewer Activity // - any ComposableSingletons$* : Compose-compiler lambda synthetics fun isExcludedJvmClass(name: String): Boolean = name.startsWith("dev/sharingan/ui/") || name.startsWith("dev/sharingan/internal/") || - name.startsWith("dev/sharingan/persistence/") || name == "dev/sharingan/SharinganActivity" || name.contains("ComposableSingletons\$") @@ -135,13 +137,12 @@ tasks.register("checkApiParity") { // - `$stableprop` / `$stableprop_getter`: the native equivalent of the JVM // `$stable` field, present only in the Compose-carrying real module; // - `dev.sharingan.ui/SharinganScreen`: the top-level UI composable (ui/**). - // - `dev.sharingan.persistence/`: SQLDelight-generated flight-recorder DB (ui-adjacent internal seam). fun nativeContractEntries(klibFile: File): Set { val entries = linkedSetOf() for (raw in klibFile.readLines()) { val t = raw.trim() if (t.isEmpty() || t == "}" || t.startsWith("//")) continue - if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw || "dev.sharingan.persistence/" in raw) continue + if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw) continue entries.add(t) } return entries diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6169873..7709aa5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,11 +1,12 @@ # Releasing Sharingan -Sharingan ships two artifacts to Maven Central under `io.github.mibrahimdev`: +Sharingan ships three artifacts to Maven Central under `io.github.mibrahimdev`: -| Coordinate | Module | -| ------------------------------------- | ----------------- | -| `io.github.mibrahimdev:sharingan` | `:sharingan` | -| `io.github.mibrahimdev:sharingan-noop`| `:sharingan-noop` | +| Coordinate | Module | +| -------------------------------------- | ----------------- | +| `io.github.mibrahimdev:sharingan` | `:sharingan` | +| `io.github.mibrahimdev:sharingan-db` | `:sharingan-db` | +| `io.github.mibrahimdev:sharingan-noop` | `:sharingan-noop` | Releases are **two steps by deliberate design**: CI *stages* a deployment to the Central Portal, then a human *verifies and releases* it. We do **not** @@ -37,7 +38,7 @@ push tag v ──▶ CI stages to Central Portal ──▶ STOP (aw (`scripts/check-version-tag.sh`). 3. The `Publish to Maven Central` workflow runs `./gradlew publishToMavenCentral`. - This **uploads both modules to a new deployment on the Central Portal and + This **uploads all three modules to a new deployment on the Central Portal and stops** — nothing is public yet. The job's `Staged - next steps` summary links straight back to this checklist. @@ -48,12 +49,13 @@ push tag v ──▶ CI stages to Central Portal ──▶ STOP (aw 2. Find the new deployment from the CI run. Confirm **all** of: - **Status** is `VALIDATED` (not `FAILED`). If it failed, expand it to read why — do **not** publish a failed deployment; fix and re-stage instead. - - **Both** components are present under group `io.github.mibrahimdev`: - - `io.github.mibrahimdev:sharingan:` - - `io.github.mibrahimdev:sharingan-noop:` + - **All three** components are present under group `io.github.mibrahimdev`: + - `io.github.mibrahimdev:sharingan:` + - `io.github.mibrahimdev:sharingan-db:` + - `io.github.mibrahimdev:sharingan-noop:` - If only one module is there, something is wrong — drop the deployment and - re-stage. Never release a half-uploaded version. + If any module is missing, something is wrong — drop the deployment and + re-stage. Never release a half-uploaded version. - The **version** matches the tag (``, no stray `-SNAPSHOT`). - Each component carries its **`.pom`**, the main artifact, **`-sources`** and **`-javadoc`** jars, and a matching **`.asc` signature** for every diff --git a/docs/api-parity.md b/docs/api-parity.md index 7335909..f2e42ce 100644 --- a/docs/api-parity.md +++ b/docs/api-parity.md @@ -48,6 +48,18 @@ Kotlin/Native link, no Android or iOS SDK — so it runs in a couple of seconds. ./gradlew checkApiParity ``` +### What BCV itself excludes + +`apiCheck`/`apiDump` are also configured in the root `build.gradle.kts`. The +following modules are intentionally **not** BCV-guarded, because they are not +consumer-facing public API: + +- `:sample:composeApp` — the sample application, not a published library. +- `:sharingan-db` — the on-device SQLDelight database is an implementation +detail of `:sharingan`. Its generated surface changes with every schema edit in +slices 3–5, and no consumer codes against it. It is published only as a +required runtime dependency of `:sharingan`. + In CI it runs as the `api-parity` job in [`.github/workflows/api-check.yml`](../.github/workflows/api-check.yml), on a cheap `ubuntu` runner (it needs no macOS host). diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a1f064..5c6af11 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,5 @@ rootProject.name = "sharingan" include(":sharingan") include(":sharingan-noop") include(":ktlint-rules") +include(":sharingan-db") include(":sample:composeApp") diff --git a/sharingan-db/build.gradle.kts b/sharingan-db/build.gradle.kts new file mode 100644 index 0000000..6569429 --- /dev/null +++ b/sharingan-db/build.gradle.kts @@ -0,0 +1,86 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.sqldelight) + alias(libs.plugins.mavenPublish) +} + +group = "io.github.mibrahimdev" +version = libs.versions.sharingan.get() + +sqldelight { + databases { + create("SharinganDatabase") { packageName.set("dev.sharingan.db") } + } +} + +kotlin { + explicitApi() + + androidTarget { + publishLibraryVariants("release") + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + + iosArm64() + iosSimulatorArm64 { + // Same pin as :sharingan — the default KGP simulator id may not exist. + testRuns.configureEach { deviceId = "iPhone 17 Pro" } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.sqldelight.runtime) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + androidMain.dependencies { implementation(libs.sqldelight.android.driver) } + iosMain.dependencies { implementation(libs.sqldelight.native.driver) } + androidUnitTest.dependencies{ implementation(libs.sqldelight.sqlite.driver) } + iosTest.dependencies { implementation(libs.sqldelight.native.driver) } + } +} + +android { + namespace = "dev.sharingan.db" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +mavenPublishing { + publishToMavenCentral() + if (!providers.gradleProperty("localPublishNoSign").isPresent) { signAllPublications() } + coordinates(group.toString(), "sharingan-db", version.toString()) + pom { + name.set("Sharingan DB") + description.set("On-device flight-recorder database for Sharingan. Implementation detail of the `sharingan` artifact.") + url.set("https://mibrahimdev.github.io/Sharingan/") + licenses { + license { + name.set("Apache-2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("mibrahimdev") + name.set("Mohamed Ibrahim") + email.set("mibrahim.dev@gmail.com") + } + } + scm { + connection.set("scm:git:https://github.com/mibrahimdev/Sharingan.git") + developerConnection.set("scm:git:ssh://git@github.com/mibrahimdev/Sharingan.git") + url.set("https://github.com/mibrahimdev/Sharingan") + } + } +} diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt similarity index 73% rename from sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt rename to sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt index aa10215..03dd69b 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt @@ -1,15 +1,13 @@ -package dev.sharingan.persistence +package dev.sharingan.db import androidx.sqlite.db.SupportSQLiteDatabase import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver -import dev.sharingan.internal.SharinganAndroid internal actual class DriverFactory actual constructor() { actual fun create(): SqlDriver { - val context = - SharinganAndroid.appContext - ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") + val context = SharinganDbContext.appContext + ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") return AndroidSqliteDriver( schema = SharinganDatabase.Schema, context = context, diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt new file mode 100644 index 0000000..1154080 --- /dev/null +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt @@ -0,0 +1,3 @@ +package dev.sharingan.db + +internal actual fun nowMillis(): Long = System.currentTimeMillis() diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt new file mode 100644 index 0000000..a95dbd1 --- /dev/null +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt @@ -0,0 +1,8 @@ +package dev.sharingan.db + +import android.content.Context + +public object SharinganDbContext { + @Volatile internal var appContext: Context? = null + public fun install(context: Context) { appContext = context.applicationContext } +} diff --git a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt b/sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt similarity index 90% rename from sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt rename to sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt index 4bb3b49..3b3249e 100644 --- a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt +++ b/sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt similarity index 90% rename from sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt rename to sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt index 2ad1628..171ce79 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt new file mode 100644 index 0000000..f92129f --- /dev/null +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt @@ -0,0 +1,15 @@ +package dev.sharingan.db + +/** + * Plain row handed to the persistence controller. The caller's [toRow] mapping + * runs on the flusher coroutine so the hot record() path stays a lock-free + * channel send. + */ +public data class EventRow( + val rawId: String, + val timestampMillis: Long, + val type: String, + val isFailure: Boolean, + val hostOrTopic: String?, + val payloadJson: String, +) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt new file mode 100644 index 0000000..da4667d --- /dev/null +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt @@ -0,0 +1,4 @@ +package dev.sharingan.db + +/** Current wall-clock time in epoch milliseconds. */ +internal expect fun nowMillis(): Long diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt similarity index 60% rename from sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt rename to sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 74b580f..09b716c 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -1,12 +1,6 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver -import dev.sharingan.BleEvent -import dev.sharingan.HttpEvent -import dev.sharingan.MqttEvent -import dev.sharingan.SharinganEvent -import dev.sharingan.SharinganStore -import dev.sharingan.internal.currentTimeMillis import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.uuid.ExperimentalUuidApi @@ -21,33 +15,49 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.Json /** - * Write-behind persistence: drains events off the store's internal `onRecord` - * seam into an on-device SQLDelight database so logs survive process death. + * Write-behind persistence: drains events off the caller's seam into an + * on-device SQLDelight database so logs survive process death. * - * `record()` keeps its lock-free CAS append and never blocks; [start] hangs a - * non-blocking `channel.trySend` off the seam, and a single flusher coroutine - * on `Dispatchers.Default` drains the channel, batching writes by size or time - * (whichever first) into one `transaction {}` per batch. One session row is - * created lazily on the first flushed event of the process launch. + * [submit] is a single non-blocking [Channel.trySend]; the flusher coroutine on + * [Dispatchers.Default] batches writes by size or time (whichever first) into + * one transaction per batch. The caller-supplied [toRow] mapping runs on the + * flusher, never on the hot path. */ @OptIn(ExperimentalAtomicApi::class) -internal class PersistenceController internal constructor( - private val store: SharinganStore, +public class PersistenceController private constructor( + private val toRow: (T) -> EventRow, private val driver: SqlDriver, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - private val batchSize: Int = DEFAULT_BATCH_SIZE, - private val flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + private val ownsDriver: Boolean, + private val scope: CoroutineScope, + private val batchSize: Int, + private val flushIntervalMillis: Long, ) { - internal constructor( - store: SharinganStore, + public constructor( + toRow: (T) -> EventRow, batchSize: Int = DEFAULT_BATCH_SIZE, flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, ) : this( - store = store, + toRow = toRow, driver = DriverFactory().create(), + ownsDriver = true, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + batchSize = batchSize, + flushIntervalMillis = flushIntervalMillis, + ) + + internal constructor( + toRow: (T) -> EventRow, + driver: SqlDriver, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + batchSize: Int = DEFAULT_BATCH_SIZE, + flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + ) : this( + toRow = toRow, + driver = driver, + ownsDriver = false, + scope = scope, batchSize = batchSize, flushIntervalMillis = flushIntervalMillis, ) @@ -56,7 +66,7 @@ internal class PersistenceController internal constructor( // DROP_OLDEST, not DROP_LATEST: a flight recorder must keep the crash-tail, // so under backpressure the oldest events are evicted, never the newest. - private val channel = newEventChannel(CHANNEL_CAPACITY) + private val channel = newEventChannel(CHANNEL_CAPACITY) private val started = AtomicBoolean(false) @@ -70,47 +80,50 @@ internal class PersistenceController internal constructor( /** Test seam: invoked with the batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null - /** Wires the store seam and starts the flusher. Idempotent. */ - fun start() { + /** Wires the seam and starts the flusher. Idempotent. */ + public fun start() { if (!started.compareAndSet(false, true)) return - store.onRecord = { event -> channel.trySend(event) } flusherJob = scope.launch { runFlusher() } } + /** Submits [event] to the flusher. Non-blocking and allocation-free. */ + public fun submit(event: T) { + channel.trySend(event) + } + /** * Drains and flushes the pending events, then stops the flusher. * The driver stays open so other readers can keep using the database. */ - suspend fun stop() { + public suspend fun stop() { if (!started.compareAndSet(true, false)) return - store.onRecord = null channel.close() flusherJob?.join() } /** Stops the flusher, cancels the scope, and closes the driver. */ - suspend fun close() { + public suspend fun close() { stop() scope.cancel() - driver.close() + if (ownsDriver) driver.close() } private suspend fun runFlusher() { - val batch = mutableListOf() + val batch = mutableListOf() var deadline = 0L while (true) { val event = try { if (batch.isEmpty()) channel.receive() - // Non-positive timeout returns null immediately; that means the deadline has + // Non-positive timeout returns null immediately; the deadline has // already passed and we should flush right away. - else withTimeoutOrNull(deadline - currentTimeMillis()) { channel.receive() } + else withTimeoutOrNull(deadline - nowMillis()) { channel.receive() } } catch (e: ClosedReceiveChannelException) { break } if (event == null) { flush(batch) // deadline reached } else { - if (batch.isEmpty()) deadline = currentTimeMillis() + flushIntervalMillis + if (batch.isEmpty()) deadline = nowMillis() + flushIntervalMillis batch.add(event) if (batch.size >= batchSize) flush(batch) // size-based } @@ -118,19 +131,20 @@ internal class PersistenceController internal constructor( flush(batch) // drain the in-flight batch after the channel is closed } - private fun flush(batch: MutableList) { + private fun flush(batch: MutableList) { if (batch.isEmpty()) return val snapshot = batch.toList() batch.clear() + val rows = snapshot.map(toRow) val isNewSession = sessionId == null val session = sessionId ?: newSessionId() try { database.transaction { if (isNewSession) insertSession(session) - snapshot.forEach { persist(it, session) } + rows.forEach { persist(it, session) } } if (isNewSession) sessionId = session - onBatchFlushed?.invoke(snapshot.size) + onBatchFlushed?.invoke(rows.size) } catch (t: Throwable) { // One failing batch must not kill the flusher. Drop it and keep // draining; re-derive the session on the next batch in case its row @@ -143,7 +157,7 @@ internal class PersistenceController internal constructor( private fun insertSession(id: String) { database.sharinganDatabaseQueries.insertSession( id = id, - started_at = currentTimeMillis(), + started_at = nowMillis(), app_id = null, build = null, os = null, @@ -151,42 +165,28 @@ internal class PersistenceController internal constructor( ) } - private fun persist(event: SharinganEvent, session: String) { + private fun persist(row: EventRow, session: String) { database.sharinganDatabaseQueries.insertEvent( - id = "$session-${event.id}", + id = "$session-${row.rawId}", session_id = session, - timestamp = event.timestampMillis, - type = event.typeName(), - is_failure = if (event.isFailure) 1L else 0L, - host_or_topic = event.hostOrTopic(), - payload_json = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event)), + timestamp = row.timestampMillis, + type = row.type, + is_failure = if (row.isFailure) 1L else 0L, + host_or_topic = row.hostOrTopic, + payload_json = row.payloadJson, ) } internal companion object { - const val DEFAULT_BATCH_SIZE: Int = 50 - const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 250L - const val CHANNEL_CAPACITY: Int = 1024 + internal const val DEFAULT_BATCH_SIZE: Int = 50 + internal const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 250L + internal const val CHANNEL_CAPACITY: Int = 1024 } } -private val json = Json { encodeDefaults = true } - -private fun SharinganEvent.typeName(): String = when (this) { - is HttpEvent -> "HTTP" - is MqttEvent -> "MQTT" - is BleEvent -> "BLE" -} - -private fun SharinganEvent.hostOrTopic(): String? = when (this) { - is HttpEvent -> host - is MqttEvent -> topic - is BleEvent -> device -} - // DROP_OLDEST: a flight recorder must keep the crash-tail, so when the channel // overflows the oldest events are evicted, never the newest. -internal fun newEventChannel(capacity: Int): Channel = +internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) // A session id only needs to be unique within this device's database, but it @@ -194,5 +194,4 @@ internal fun newEventChannel(capacity: Int): Channel = // test controllers) — a real UUID, not timestamp+Random which collide on // Kotlin/Native's Default random under rapid successive calls. @OptIn(ExperimentalUuidApi::class) -private fun newSessionId(): String = - "session-${Uuid.random()}" +private fun newSessionId(): String = "session-${Uuid.random()}" diff --git a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq b/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq similarity index 100% rename from sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq rename to sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt similarity index 68% rename from sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt rename to sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index cdb3072..a274881 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -1,33 +1,29 @@ -package dev.sharingan.persistence +package dev.sharingan.db -import dev.sharingan.HttpEvent -import dev.sharingan.SharinganEvent -import dev.sharingan.SharinganStore -import dev.sharingan.internal.currentTimeMillis import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull import kotlin.test.assertTrue internal class PersistenceControllerTest { - private fun event(id: String): SharinganEvent = HttpEvent( - id = id, + private fun event(id: String): EventRow = EventRow( + rawId = id, timestampMillis = 0L, - method = "GET", - url = "https://api.example.com/$id", + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"method":"GET"}""", ) @Test fun `Given a burst larger than the ring buffer When flushed Then every event is persisted`() = runBlocking { val total = 500 val driver = createTestDriver() - val store = SharinganStore(capacity = 300) - val controller = PersistenceController(store, driver) + val controller = PersistenceController(toRow = { it }, driver = driver) val done = CompletableDeferred() var flushed = 0 @@ -37,14 +33,16 @@ internal class PersistenceControllerTest { } controller.start() - repeat(total) { i -> store.record(event("e$i")) } + repeat(total) { i -> controller.submit(event("e$i")) } withTimeout(10_000) { done.await() } assertEquals(total, flushed) val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(total, rows.size, "all $total events should reach the DB despite a ${store.capacity} ring buffer") + assertEquals(total, rows.size, "all $total events should reach the DB") assertEquals(total, rows.map { it.id }.toSet().size, "persisted event ids must be unique") + + controller.close() } @Test @@ -52,8 +50,12 @@ internal class PersistenceControllerTest { val total = 200 val batchSize = 50 val driver = createTestDriver() - val store = SharinganStore(capacity = 10_000) - val controller = PersistenceController(store, driver, batchSize = batchSize, flushIntervalMillis = 60_000) + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = batchSize, + flushIntervalMillis = 60_000, + ) val batches = mutableListOf() val done = CompletableDeferred() @@ -65,43 +67,33 @@ internal class PersistenceControllerTest { } controller.start() - repeat(total) { i -> store.record(event("e$i")) } + repeat(total) { i -> controller.submit(event("e$i")) } withTimeout(10_000) { done.await() } assertEquals(total, batches.sum()) assertTrue(batches.size < total, "expected batching, got ${batches.size} writes for $total events") assertTrue(batches.all { it <= batchSize }, "no batch should exceed $batchSize, got $batches") - } - @Test - fun `When persistence is off Then record keeps its behavior and onRecord stays null`() { - val store = SharinganStore(capacity = 10) - assertNull(store.onRecord) - store.record(event("a")) - store.record(event("b")) - assertEquals(listOf("a", "b"), store.events.value.map { it.id }) - assertNull(store.onRecord) + controller.close() } @Test fun `Given two sessions When events reuse a raw id Then persisted ids do not collide`() = runBlocking { val driver = createTestDriver() - val store1 = SharinganStore(capacity = 10) - val controller1 = PersistenceController(store1, driver) + val controller1 = PersistenceController(toRow = { it }, driver = driver) val done1 = CompletableDeferred() controller1.onBatchFlushed = { done1.complete(Unit) } controller1.start() - store1.record(event("http-1")) + controller1.submit(event("http-1")) withTimeout(10_000) { done1.await() } controller1.stop() - val store2 = SharinganStore(capacity = 10) - val controller2 = PersistenceController(store2, driver) + val controller2 = PersistenceController(toRow = { it }, driver = driver) val done2 = CompletableDeferred() controller2.onBatchFlushed = { done2.complete(Unit) } controller2.start() - store2.record(event("http-1")) + controller2.submit(event("http-1")) withTimeout(10_000) { done2.await() } controller2.stop() @@ -111,41 +103,54 @@ internal class PersistenceControllerTest { val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() assertEquals(2, sessions.size) + + controller1.close() + controller2.close() } @Test fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = runBlocking { - val channel = newEventChannel(capacity = 4) + val channel = newEventChannel(capacity = 4) (1..10).map { event("e$it") }.forEach { channel.trySend(it) } channel.close() - val received = mutableListOf() + val received = mutableListOf() for (e in channel) received += e - assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.id }) + assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.rawId }) } @Test fun `Given buffered events When stopped Then the pending batch is drained before close`() = runBlocking { val driver = createTestDriver() - val store = SharinganStore(capacity = 1_000) - val controller = PersistenceController(store, driver, batchSize = 100, flushIntervalMillis = 60_000) + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) val batches = mutableListOf() controller.onBatchFlushed = { size -> batches += size } controller.start() - repeat(30) { i -> store.record(event("e$i")) } + repeat(30) { i -> controller.submit(event("e$i")) } controller.stop() assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") + + controller.close() } @Test fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = runBlocking { val driver = createTestDriver() - val store = SharinganStore(capacity = 10) - val controller = PersistenceController(store, driver, batchSize = 50, flushIntervalMillis = 250) + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) var firstFlushAt = 0L var firstBatchSize = 0 @@ -153,15 +158,15 @@ internal class PersistenceControllerTest { controller.onBatchFlushed = { size -> if (firstFlushAt == 0L) { firstBatchSize = size - firstFlushAt = currentTimeMillis() + firstFlushAt = nowMillis() flushed.complete(Unit) } } controller.start() - val startedAt = currentTimeMillis() + val startedAt = nowMillis() repeat(10) { i -> - store.record(event("e$i")) + controller.submit(event("e$i")) delay(100) } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt similarity index 98% rename from sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt rename to sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt index a4d5f0c..0f65e9c 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import kotlin.test.Test import kotlin.test.assertEquals diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt similarity index 73% rename from sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt rename to sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt index e794801..97023ba 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt similarity index 92% rename from sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt rename to sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt index 91ffe85..0d4d581 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt +++ b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver diff --git a/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt new file mode 100644 index 0000000..6654b8c --- /dev/null +++ b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt @@ -0,0 +1,7 @@ +package dev.sharingan.db + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +internal actual fun nowMillis(): Long = + (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt b/sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt similarity index 94% rename from sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt rename to sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt index 0cdfff8..4f3adc7 100644 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt +++ b/sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver diff --git a/sharingan/api/sharingan.klib.api b/sharingan/api/sharingan.klib.api index ddc83fe..d312b33 100644 --- a/sharingan/api/sharingan.klib.api +++ b/sharingan/api/sharingan.klib.api @@ -284,6 +284,11 @@ final object dev.sharingan/SharinganExport { // dev.sharingan/SharinganExport|nu final fun summary(kotlin.collections/List): kotlin/String // dev.sharingan/SharinganExport.summary|summary(kotlin.collections.List){}[0] } +final val dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop|#static{}dev_sharingan_internal_BleDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop|#static{}dev_sharingan_internal_HeaderDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop|#static{}dev_sharingan_internal_HttpDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop|#static{}dev_sharingan_internal_MqttDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop|#static{}dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop[0] final val dev.sharingan.ktor/SharinganKtor // dev.sharingan.ktor/SharinganKtor|{}SharinganKtor[0] final fun (): io.ktor.client.plugins.api/ClientPlugin // dev.sharingan.ktor/SharinganKtor.|(){}[0] final val dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop // dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop|#static{}dev_sharingan_ktor_SharinganKtorConfig$stableprop[0] @@ -298,6 +303,11 @@ final val dev.sharingan/dev_sharingan_SharinganExport$stableprop // dev.sharinga final val dev.sharingan/dev_sharingan_SharinganStore$stableprop // dev.sharingan/dev_sharingan_SharinganStore$stableprop|#static{}dev_sharingan_SharinganStore$stableprop[0] final val dev.sharingan/dev_sharingan_TimingPhase$stableprop // dev.sharingan/dev_sharingan_TimingPhase$stableprop|#static{}dev_sharingan_TimingPhase$stableprop[0] +final fun dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop_getter|dev_sharingan_internal_BleDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter|dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop_getter|dev_sharingan_internal_HttpDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop_getter|dev_sharingan_internal_MqttDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter|dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter(){}[0] final fun dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter(): kotlin/Int // dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter|dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter(){}[0] final fun dev.sharingan.ui/SharinganScreen(androidx.compose.ui/Modifier?, kotlin/Boolean, dev.sharingan/SharinganStore?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // dev.sharingan.ui/SharinganScreen|SharinganScreen(androidx.compose.ui.Modifier?;kotlin.Boolean;dev.sharingan.SharinganStore?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun dev.sharingan/SharinganViewController(): platform.UIKit/UIViewController // dev.sharingan/SharinganViewController|SharinganViewController(){}[0] diff --git a/sharingan/build.gradle.kts b/sharingan/build.gradle.kts index 2b8e57a..26535da 100644 --- a/sharingan/build.gradle.kts +++ b/sharingan/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) - alias(libs.plugins.sqldelight) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.mavenPublish) alias(libs.plugins.ktlint) @@ -17,14 +16,6 @@ plugins { group = "io.github.mibrahimdev" version = libs.versions.sharingan.get() -sqldelight { - databases { - create("SharinganDatabase") { - packageName.set("dev.sharingan.persistence") - } - } -} - kotlin { explicitApi() @@ -58,6 +49,9 @@ kotlin { implementation(libs.ktor.client.core) // Flight-recorder persistence (issue #27 / #49): DTO JSON codec. implementation(libs.kotlinx.serialization.json) + // On-device SQLDelight database; implementation dependency so its + // generated public types are not exported to consumers. + implementation(project(":sharingan-db")) implementation(compose.runtime) implementation(compose.foundation) @@ -72,16 +66,12 @@ kotlin { } androidMain.dependencies { implementation(libs.androidx.activity.compose) - implementation(libs.sqldelight.android.driver) } iosMain.dependencies { - implementation(libs.sqldelight.native.driver) } androidUnitTest.dependencies { - implementation(libs.sqldelight.sqlite.driver) } iosTest.dependencies { - implementation(libs.sqldelight.native.driver) } // On-device UI tests (CMP test API): ./gradlew :sharingan:connectedDebugAndroidTest androidInstrumentedTest.dependencies { diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt index 7ec4d85..d81f20f 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt @@ -6,7 +6,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import android.net.Uri -import dev.sharingan.persistence.Persistence +import dev.sharingan.db.SharinganDbContext /** * Holds the application context Sharingan needs for the clipboard, the share @@ -28,6 +28,7 @@ public class SharinganInitProvider : ContentProvider() { override fun onCreate(): Boolean { val appContext = context?.applicationContext ?: return true SharinganAndroid.appContext = appContext + SharinganDbContext.install(appContext) CaptureNotification.start(appContext) Persistence.start() return true diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 7dfbba1..6745ce5 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -25,7 +25,7 @@ public class SharinganStore( /** * Internal persistence seam: invoked with each accepted event after the * CAS append. `null` unless the flight-recorder persistence is wired up - * (see `dev.sharingan.persistence.PersistenceController`). Internal — never + * (see `dev.sharingan.db.PersistenceController`). Internal — never * part of the public API, so :sharingan-noop mirrors nothing here. */ internal var onRecord: ((SharinganEvent) -> Unit)? = null diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt similarity index 99% rename from sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt rename to sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt index 71dd92a..38cc6f7 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/EventDto.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.internal import dev.sharingan.BleEvent import dev.sharingan.HttpEvent diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt new file mode 100644 index 0000000..3f7f9c1 --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -0,0 +1,56 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.HttpEvent +import dev.sharingan.MqttEvent +import dev.sharingan.Sharingan +import dev.sharingan.SharinganEvent +import dev.sharingan.SharinganStore +import dev.sharingan.db.EventRow +import dev.sharingan.db.PersistenceController +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.serialization.json.Json + +/** + * Hands-free persistence bootstrap. Starts the [PersistenceController] over the + * shared [Sharingan.store] exactly once per process. Internal — the public + * `Sharingan.configure()` entry point (and its no-op mirror) lands in a later + * slice; until then Android's manifest-merged ContentProvider calls [start]. + */ +@OptIn(ExperimentalAtomicApi::class) +internal object Persistence { + private val started = AtomicBoolean(false) + private var controller: PersistenceController? = null + + fun start(store: SharinganStore = Sharingan.store) { + if (!started.compareAndSet(false, true)) return + controller = PersistenceController(::toRow).also { + it.start() + store.onRecord = { event -> it.submit(event) } + } + } +} + +private val json = Json { encodeDefaults = true } + +internal fun toRow(event: SharinganEvent): EventRow = EventRow( + rawId = event.id, + timestampMillis = event.timestampMillis, + type = event.typeName(), + isFailure = event.isFailure, + hostOrTopic = event.hostOrTopic(), + payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event)), +) + +private fun SharinganEvent.typeName(): String = when (this) { + is HttpEvent -> "HTTP" + is MqttEvent -> "MQTT" + is BleEvent -> "BLE" +} + +private fun SharinganEvent.hostOrTopic(): String? = when (this) { + is HttpEvent -> host + is MqttEvent -> topic + is BleEvent -> device +} diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt deleted file mode 100644 index 0ebfa13..0000000 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/Persistence.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.sharingan.persistence - -import dev.sharingan.Sharingan -import dev.sharingan.SharinganStore -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Hands-free persistence bootstrap. Starts the [PersistenceController] over the - * shared [Sharingan.store] exactly once per process. Internal — the public - * `Sharingan.configure()` entry point (and its no-op mirror) lands in a later - * slice; until then Android's manifest-merged ContentProvider calls [start]. - */ -@OptIn(ExperimentalAtomicApi::class) -internal object Persistence { - private val started = AtomicBoolean(false) - private var controller: PersistenceController? = null - - fun start(store: SharinganStore = Sharingan.store) { - if (!started.compareAndSet(false, true)) return - controller = PersistenceController(store).also { it.start() } - } -} diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index 008f130..f9eb123 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue internal class SharinganStoreTest { @@ -119,4 +120,16 @@ internal class SharinganStoreTest { } assertEquals(expected, ids.toSet()) } + assertEquals(expected, ids.toSet()) + } + + @Test + fun `When persistence is off Then record keeps its behavior and onRecord stays null`() { + val store = SharinganStore(capacity = 10) + assertNull(store.onRecord) + store.record(event("a")) + store.record(event("b")) + assertEquals(listOf("a", "b"), store.events.value.map { it.id }) + assertNull(store.onRecord) + } } From abbb2892840747701216b96e84fb3fb6a585d1dd Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 17:46:12 +0100 Subject: [PATCH 09/28] docs: warn that PersistenceController.stop() does not detach the seam (#50) Add KDoc warnings to stop() and close() so slice 4's configure()/shutdown path knows that events submitted after stop() are silently dropped unless the caller detaches store.onRecord first. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/dev/sharingan/db/PersistenceController.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 09b716c..b9ff175 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -94,6 +94,10 @@ public class PersistenceController private constructor( /** * Drains and flushes the pending events, then stops the flusher. * The driver stays open so other readers can keep using the database. + * + * WARNING: this does NOT detach the caller's `store.onRecord` seam. Events + * submitted after `stop()` are trySend'd to a closed channel and silently + * dropped. Full teardown (detaching the seam) is the caller's responsibility. */ public suspend fun stop() { if (!started.compareAndSet(true, false)) return @@ -101,7 +105,12 @@ public class PersistenceController private constructor( flusherJob?.join() } - /** Stops the flusher, cancels the scope, and closes the driver. */ + /** + * Stops the flusher, cancels the scope, and closes the driver. + * + * NOTE: this also does not detach the caller's seam; do that before calling + * `close()` if you need to stop event submission. + */ public suspend fun close() { stop() scope.cancel() From 05b646a94b8a8ea433a7a11b0a85871507a12c30 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 17:53:14 +0100 Subject: [PATCH 10/28] test: add EventDto encode coverage (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the JSON encoding of HttpEvent / MqttEvent / BleEvent via EventDto in :sharingan, including the redacted-header value. Uses the same json instance that toRow() uses (now internal for test visibility). No decode round-trip — toEvent() is slice 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/internal/Persistence.kt | 2 +- .../dev/sharingan/internal/EventDtoTest.kt | 125 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt index 3f7f9c1..d5fdb13 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -32,7 +32,7 @@ internal object Persistence { } } -private val json = Json { encodeDefaults = true } +internal val json = Json { encodeDefaults = true } internal fun toRow(event: SharinganEvent): EventRow = EventRow( rawId = event.id, diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt new file mode 100644 index 0000000..71cd8b8 --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt @@ -0,0 +1,125 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.BleOperation +import dev.sharingan.HttpEvent +import dev.sharingan.HttpLogger +import dev.sharingan.MqttDirection +import dev.sharingan.MqttEvent +import kotlin.test.Test +import kotlin.test.assertTrue + +internal class EventDtoTest { + + @Test + fun `Given an HTTP event When encoded Then method url status and redacted header are present`() { + val event = HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + durationMillis = 42L, + requestHeaders = listOf("Authorization" to HttpLogger.REDACTED_VALUE, "Content-Type" to "application/json"), + responseHeaders = listOf("X-Request-Id" to "abc"), + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + contentType = "application/json", + responseSizeBytes = 128L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"method\":\"POST\"" in encoded) + assertTrue("\"url\":\"https://api.example.com/v1/users\"" in encoded) + assertTrue("\"statusCode\":201" in encoded) + assertTrue("\"Authorization\"" in encoded) + assertTrue("\"value\":\"${HttpLogger.REDACTED_VALUE}\"" in encoded) + assertTrue("\"Content-Type\"" in encoded) + assertTrue("\"application/json\"" in encoded) + } + + @Test + fun `Given an MQTT publish event When encoded Then topic direction qos and retained flag are present`() { + val event = MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + qos = 1, + retained = true, + payload = """{"online":true}""", + payloadSizeBytes = 64L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"direction\":\"PUBLISH\"" in encoded) + assertTrue("\"topic\":\"devices/001/status\"" in encoded) + assertTrue("\"qos\":1" in encoded) + assertTrue("\"retained\":true" in encoded) + assertTrue("\"payload\":\"{\\\"online\\\":true}\"" in encoded) + } + + @Test + fun `Given an MQTT receive event When encoded Then direction is RECEIVE`() { + val event = MqttEvent( + id = "mqtt-2", + timestampMillis = 1_700_000_000_001L, + direction = MqttDirection.RECEIVE, + topic = "alerts/critical", + qos = 2, + retained = false, + payload = "alert", + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"direction\":\"RECEIVE\"" in encoded) + assertTrue("\"topic\":\"alerts/critical\"" in encoded) + assertTrue("\"qos\":2" in encoded) + } + + @Test + fun `Given a BLE read event When encoded Then operation device and characteristic are present`() { + val event = BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + characteristic = "Heart Rate Measurement", + uuid = "00002a37-0000-1000-8000-00805f9b34fb", + payload = "{\"bpm\":72}", + sizeBytes = 32L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"operation\":\"READ\"" in encoded) + assertTrue("\"device\":\"HR-Monitor-A1\"" in encoded) + assertTrue("\"characteristic\":\"Heart Rate Measurement\"" in encoded) + assertTrue("\"uuid\":\"00002a37-0000-1000-8000-00805f9b34fb\"" in encoded) + } + + @Test + fun `Given a BLE error event When encoded Then error flag is present`() { + val event = BleEvent( + id = "ble-2", + timestampMillis = 1_700_000_000_001L, + operation = BleOperation.CONNECT, + device = "Scale-B2", + error = "GATT 133", + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"operation\":\"CONNECT\"" in encoded) + assertTrue("\"device\":\"Scale-B2\"" in encoded) + assertTrue("\"error\":\"GATT 133\"" in encoded) + } +} From af9ae5b874cdbd892f248b24ece975f48d28aabb Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 21:34:22 +0100 Subject: [PATCH 11/28] fix: swallow toRow mapper throws inside flush so the flusher survives (#50) The caller-supplied (T) -> EventRow mapping can throw (e.g. JSON encode of an unbounded body). Move it inside the existing batch try/catch so the exception drops one batch instead of escaping runFlusher and crashing the host process. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 5 ++- .../sharingan/db/PersistenceControllerTest.kt | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index b9ff175..cc3458c 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -144,10 +144,13 @@ public class PersistenceController private constructor( if (batch.isEmpty()) return val snapshot = batch.toList() batch.clear() - val rows = snapshot.map(toRow) val isNewSession = sessionId == null val session = sessionId ?: newSessionId() try { + // The caller-supplied mapper runs INSIDE the try so a thrown + // exception is treated like a failed transaction: drop the batch + // and keep the flusher alive. + val rows = snapshot.map(toRow) database.transaction { if (isNewSession) insertSession(session) rows.forEach { persist(it, session) } diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index a274881..253de70 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -45,6 +45,42 @@ internal class PersistenceControllerTest { controller.close() } + @Test + fun `Given a toRow mapper that throws on one event When flushed Then the flusher survives and other events persist`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController( + toRow = { row -> + if (row.rawId == "boom") error("mapper failure") + row + }, + driver = driver, + batchSize = 1, + flushIntervalMillis = 60_000, + ) + controller.start() + + fun row(id: String) = EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) + + controller.submit(row("before")) + controller.submit(row("boom")) + controller.submit(row("after")) + + controller.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(setOf("""{"id":"before"}""", """{"id":"after"}"""), rows.map { it.payload_json }.toSet()) + + controller.close() + } + @Test fun `Given many events When flushed Then they are written in batches not one per event`() = runBlocking { val total = 200 From 880496131931ce171341a25befae390a7673c43c Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 21:36:44 +0100 Subject: [PATCH 12/28] fix: use atomic select instead of withTimeoutOrNull in flusher (#50) withTimeoutOrNull can prompt-cancel after taking an element from the channel, losing events under backpressure. Replace the timeout path with select { channel.onReceiveCatching { ... }; onTimeout { ... } } so the receive and timeout are atomic. The race itself is not deterministic in a unit test; the new focused test pins the deadline-flush path that the select exercises. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 37 ++++++++++++++----- .../sharingan/db/PersistenceControllerTest.kt | 24 ++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index cc3458c..d1bfa73 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -14,7 +14,8 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select /** * Write-behind persistence: drains events off the caller's seam into an @@ -25,7 +26,7 @@ import kotlinx.coroutines.withTimeoutOrNull * one transaction per batch. The caller-supplied [toRow] mapping runs on the * flusher, never on the hot path. */ -@OptIn(ExperimentalAtomicApi::class) +@OptIn(ExperimentalAtomicApi::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) public class PersistenceController private constructor( private val toRow: (T) -> EventRow, private val driver: SqlDriver, @@ -121,13 +122,31 @@ public class PersistenceController private constructor( val batch = mutableListOf() var deadline = 0L while (true) { - val event = try { - if (batch.isEmpty()) channel.receive() - // Non-positive timeout returns null immediately; the deadline has - // already passed and we should flush right away. - else withTimeoutOrNull(deadline - nowMillis()) { channel.receive() } - } catch (e: ClosedReceiveChannelException) { - break + val event = if (batch.isEmpty()) { + try { + channel.receive() + } catch (e: ClosedReceiveChannelException) { + break + } + } else { + // Use select+onReceiveCatching instead of withTimeoutOrNull so + // the receive and timeout are atomic; this prevents prompt + // cancellation from dropping an element that was already taken + // from the channel. + var closed = false + select { + channel.onReceiveCatching { result -> + if (result.isClosed) { + closed = true + null + } else { + result.getOrNull() + } + } + // Non-positive timeout returns null immediately; the deadline + // has already passed and we should flush right away. + onTimeout(deadline - nowMillis()) { null } + }.also { if (closed) break } } if (event == null) { flush(batch) // deadline reached diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 253de70..97d194c 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -178,6 +178,30 @@ internal class PersistenceControllerTest { controller.close() } + @Test + fun `Given events under the batch size When the deadline passes Then a deadline flush lands`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) + val flushed = CompletableDeferred() + controller.onBatchFlushed = { flushed.complete(Unit) } + controller.start() + + controller.submit(event("a")) + controller.submit(event("b")) + + withTimeout(10_000) { flushed.await() } + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + + controller.close() + } + @Test fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = runBlocking { val driver = createTestDriver() From 15e08fbb344cfaf1c590390b63c6d99911f55322 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 21:40:14 +0100 Subject: [PATCH 13/28] fix: hold flusherJob in an AtomicReference to avoid a null race with stop (#50) A concurrent stop() could read flusherJob before start() assigned it, skip the join, and tear down the scope while a transaction was in flight. Replace the AtomicBoolean + var pair with a single AtomicReference used as the start/stop gate; stop atomically reads-and-clears it before joining. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 27 +++++++++---- .../sharingan/db/PersistenceControllerTest.kt | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index d1bfa73..925e834 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -1,7 +1,7 @@ package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver -import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -69,9 +69,9 @@ public class PersistenceController private constructor( // so under backpressure the oldest events are evicted, never the newest. private val channel = newEventChannel(CHANNEL_CAPACITY) - private val started = AtomicBoolean(false) - - private var flusherJob: Job? = null + // Stored in an AtomicReference so a concurrent stop() cannot read a stale + // null and skip the join while a transaction is in flight. + private val flusherJob = AtomicReference(null) // Only the flusher coroutine touches this. Memoized across flushes, but // reset if a batch's transaction rolls back so a half-written session row @@ -83,8 +83,11 @@ public class PersistenceController private constructor( /** Wires the seam and starts the flusher. Idempotent. */ public fun start() { - if (!started.compareAndSet(false, true)) return - flusherJob = scope.launch { runFlusher() } + val job = scope.launch { runFlusher() } + if (!flusherJob.compareAndSet(null, job)) { + // Already started (or stopped concurrently); drop the spare job. + job.cancel() + } } /** Submits [event] to the flusher. Non-blocking and allocation-free. */ @@ -101,9 +104,17 @@ public class PersistenceController private constructor( * dropped. Full teardown (detaching the seam) is the caller's responsibility. */ public suspend fun stop() { - if (!started.compareAndSet(true, false)) return + val job = readAndClearFlusherJob() ?: return channel.close() - flusherJob?.join() + job.join() + } + + private fun readAndClearFlusherJob(): Job? { + while (true) { + val job = flusherJob.load() + if (job == null) return null + if (flusherJob.compareAndSet(job, null)) return job + } } /** diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 97d194c..b557dc1 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -156,6 +156,46 @@ internal class PersistenceControllerTest { assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.rawId }) } + @Test + fun `Given stop is called twice Then it is idempotent and does not crash`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) + controller.start() + repeat(10) { i -> controller.submit(event("e$i")) } + + controller.stop() + controller.stop() // second call must be a no-op + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(10, rows.size) + + controller.close() + } + + @Test + fun `Given repeated start stop cycles Then the controller remains usable`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) + + repeat(20) { cycle -> + controller.start() + controller.submit(event("cycle-$cycle")) + controller.stop() + } + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + // Each cycle's event must have either been flushed or dropped by a race; + // the only hard contract is that no crash occurred and the DB is consistent. + assertTrue(rows.size in 0..20, "expected 0..20 rows after races, got ${rows.size}") + + controller.close() + } + @Test fun `Given buffered events When stopped Then the pending batch is drained before close`() = runBlocking { val driver = createTestDriver() From d20d7d3c57881ac9377a7bda1da47b47a299bf79 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 21:43:28 +0100 Subject: [PATCH 14/28] refactor: make channel capacity an internal constructor param and test DROP_OLDEST (#50) Expose channelCapacity through the internal constructor so tests can exercise backpressure without relying on production defaults. Rename the 500-event burst test to reflect that it stays within the channel, and add a focused DROP_OLDEST test that submits before start() so the flusher cannot drain during the burst. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 6 ++- .../sharingan/db/PersistenceControllerTest.kt | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 925e834..0d4b629 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -34,6 +34,7 @@ public class PersistenceController private constructor( private val scope: CoroutineScope, private val batchSize: Int, private val flushIntervalMillis: Long, + channelCapacity: Int, ) { public constructor( toRow: (T) -> EventRow, @@ -46,6 +47,7 @@ public class PersistenceController private constructor( scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), batchSize = batchSize, flushIntervalMillis = flushIntervalMillis, + channelCapacity = CHANNEL_CAPACITY, ) internal constructor( @@ -54,6 +56,7 @@ public class PersistenceController private constructor( scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), batchSize: Int = DEFAULT_BATCH_SIZE, flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + channelCapacity: Int = CHANNEL_CAPACITY, ) : this( toRow = toRow, driver = driver, @@ -61,13 +64,14 @@ public class PersistenceController private constructor( scope = scope, batchSize = batchSize, flushIntervalMillis = flushIntervalMillis, + channelCapacity = channelCapacity, ) private val database = SharinganDatabase(driver) // DROP_OLDEST, not DROP_LATEST: a flight recorder must keep the crash-tail, // so under backpressure the oldest events are evicted, never the newest. - private val channel = newEventChannel(CHANNEL_CAPACITY) + private val channel = newEventChannel(channelCapacity) // Stored in an AtomicReference so a concurrent stop() cannot read a stale // null and skip the join while a transaction is in flight. diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index b557dc1..9dae23f 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -20,7 +20,7 @@ internal class PersistenceControllerTest { ) @Test - fun `Given a burst larger than the ring buffer When flushed Then every event is persisted`() = runBlocking { + fun `Given a burst within the channel capacity When flushed Then every event is persisted`() = runBlocking { val total = 500 val driver = createTestDriver() val controller = PersistenceController(toRow = { it }, driver = driver) @@ -45,6 +45,46 @@ internal class PersistenceControllerTest { controller.close() } + @Test + fun `Given a burst beyond the channel capacity When flushed Then the newest events survive`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 4, + flushIntervalMillis = 60_000, + channelCapacity = 4, + ) + + fun row(id: String) = EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) + + // Submit before start() so the flusher cannot drain the channel while + // the burst is being written; DROP_OLDEST must retain the newest 4. + repeat(10) { i -> controller.submit(row("e$i")) } + + val done = CompletableDeferred() + controller.onBatchFlushed = { done.complete(Unit) } + controller.start() + + withTimeout(10_000) { done.await() } + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(4, rows.size, "channel should keep only the newest 4 events") + assertEquals( + setOf("""{"id":"e6"}""", """{"id":"e7"}""", """{"id":"e8"}""", """{"id":"e9"}"""), + rows.map { it.payload_json }.toSet(), + ) + + controller.close() + } + @Test fun `Given a toRow mapper that throws on one event When flushed Then the flusher survives and other events persist`() = runBlocking { val driver = createTestDriver() From bcfddb2285cf35041a41ed545361a89523499da2 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 21:44:42 +0100 Subject: [PATCH 15/28] test: document SharinganStore onRecord seam contract (#50) Add focused contract tests showing that the in-memory store forwards every accepted event to the internal persistence seam, skips forwarding while paused, and still forwards events that are later evicted by the ring buffer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/SharinganStoreTest.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index f9eb123..c15d6ad 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -132,4 +132,44 @@ internal class SharinganStoreTest { assertEquals(listOf("a", "b"), store.events.value.map { it.id }) assertNull(store.onRecord) } + + @Test + fun `Given an onRecord seam When events are recorded Then the seam receives each event`() { + val store = SharinganStore(capacity = 10) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + + store.record(event("a")) + store.record(event("b")) + + assertEquals(listOf("a", "b"), store.events.value.map { it.id }) + assertEquals(listOf("a", "b"), forwarded) + } + + @Test + fun `Given an onRecord seam When recording is paused Then the seam receives nothing`() { + val store = SharinganStore(capacity = 10) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + store.setRecording(false) + + store.record(event("a")) + store.record(event("b")) + + assertTrue(store.events.value.isEmpty()) + assertTrue(forwarded.isEmpty()) + } + + @Test + fun `Given an onRecord seam When the buffer evicts an event Then the evicted event was still forwarded`() { + val store = SharinganStore(capacity = 2) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + + listOf("a", "b", "c").forEach { store.record(event(it)) } + + assertEquals(listOf("b", "c"), store.events.value.map { it.id }) + assertEquals(listOf("a", "b", "c"), forwarded) + } + } From bf7d6ce26a902188d2614eed149dd3fcc8e0f542 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 22:41:39 +0100 Subject: [PATCH 16/28] fix: make PersistenceController single-use and start flusher LAZY (#50) Reviewers found that the finding-#3 AtomicReference rewrite reintroduced finding-#2 one method up: start() launched the flusher eagerly before the CAS, so a redundant start() cancelled a coroutine that could already be suspended in channel.receive(), losing events. - Fix A: launch the flusher with CoroutineStart.LAZY and call job.start() only inside the CAS-won branch. Drop the losing job's cancel() entirely. - Fix B: add a terminal `stopped` state. stop() sets it; start() after stop() throws IllegalStateException. Update KDoc on start()/stop() to state single-use semantics and remove the now-wrong 'Idempotent' label. - Fix C: document that the `select`/`onTimeout` usage is ExperimentalCoroutinesApi and load-bearing for the published module. - Optional nit: collapse readAndClearFlusherJob() to flusherJob.exchange(null). Add a focused TDD test proving a redundant start() dispatches exactly one flusher and loses no events, and rewrite the vacuous start/stop-cycle test into an assertion that start()-after-stop() throws. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 46 +++++++---- .../sharingan/db/PersistenceControllerTest.kt | 82 +++++++++++++++++-- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 0d4b629..901747c 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -1,6 +1,7 @@ package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver +import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.uuid.ExperimentalUuidApi @@ -13,6 +14,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select @@ -25,6 +27,10 @@ import kotlinx.coroutines.selects.select * [Dispatchers.Default] batches writes by size or time (whichever first) into * one transaction per batch. The caller-supplied [toRow] mapping runs on the * flusher, never on the hot path. + * + * The flusher uses `select` + `onTimeout` from `kotlinx.coroutines`'s + * [ExperimentalCoroutinesApi]. These are load-bearing for the published ABI: + * a coroutines version bump must be re-validated against the controller tests. */ @OptIn(ExperimentalAtomicApi::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) public class PersistenceController private constructor( @@ -77,6 +83,11 @@ public class PersistenceController private constructor( // null and skip the join while a transaction is in flight. private val flusherJob = AtomicReference(null) + // Terminal flag: once stop() has been called the controller is single-use + // and start() must throw rather than launch a zombie flusher on a closed + // channel. + private val stopped = AtomicBoolean(false) + // Only the flusher coroutine touches this. Memoized across flushes, but // reset if a batch's transaction rolls back so a half-written session row // is never referenced by a later batch. @@ -85,13 +96,25 @@ public class PersistenceController private constructor( /** Test seam: invoked with the batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null - /** Wires the seam and starts the flusher. Idempotent. */ + /** + * Wires the seam and starts the flusher. + * + * Calling [start] more than once before [stop] is idempotent — only one + * flusher coroutine is ever dispatched. After [stop] has been called the + * controller is single-use and further [start] calls throw + * [IllegalStateException]. + */ public fun start() { - val job = scope.launch { runFlusher() } - if (!flusherJob.compareAndSet(null, job)) { - // Already started (or stopped concurrently); drop the spare job. - job.cancel() + if (stopped.load()) throw IllegalStateException("PersistenceController is single-use; already stopped") + // LAZY: the coroutine is only dispatched if this call wins the CAS. + // An eager launch + cancel on the losing branch could prompt-cancel a + // receive and drop an element from the channel. + val job = scope.launch(start = CoroutineStart.LAZY) { runFlusher() } + if (flusherJob.compareAndSet(null, job)) { + job.start() } + // Losing CAS: another start() (or a racing stop()) already owns the + // flusher slot. The spare LAZY job never runs and needs no cancel. } /** Submits [event] to the flusher. Non-blocking and allocation-free. */ @@ -103,24 +126,19 @@ public class PersistenceController private constructor( * Drains and flushes the pending events, then stops the flusher. * The driver stays open so other readers can keep using the database. * + * This controller is single-use: after [stop], [start] will throw. + * * WARNING: this does NOT detach the caller's `store.onRecord` seam. Events * submitted after `stop()` are trySend'd to a closed channel and silently * dropped. Full teardown (detaching the seam) is the caller's responsibility. */ public suspend fun stop() { - val job = readAndClearFlusherJob() ?: return + stopped.store(true) + val job = flusherJob.exchange(null) ?: return channel.close() job.join() } - private fun readAndClearFlusherJob(): Job? { - while (true) { - val job = flusherJob.load() - if (job == null) return null - if (flusherJob.compareAndSet(job, null)) return job - } - } - /** * Stops the flusher, cancels the scope, and closes the driver. * diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 9dae23f..678e558 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -1,11 +1,18 @@ package dev.sharingan.db import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue internal class PersistenceControllerTest { @@ -218,20 +225,57 @@ internal class PersistenceControllerTest { } @Test - fun `Given repeated start stop cycles Then the controller remains usable`() = runBlocking { + fun `Given start is called twice without stop Then only one flusher runs and every event is persisted`() = runBlocking { + val total = 5 val driver = createTestDriver() - val controller = PersistenceController(toRow = { it }, driver = driver) + val dispatcher = CountingDispatcher(Dispatchers.Default) + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val controller = PersistenceController( + toRow = { it }, + driver = driver, + scope = scope, + batchSize = total, + flushIntervalMillis = 60_000, + ) - repeat(20) { cycle -> - controller.start() - controller.submit(event("cycle-$cycle")) - controller.stop() + val flushed = CompletableDeferred() + var flusherDispatchesAtFlush = 0 + controller.onBatchFlushed = { size -> + flusherDispatchesAtFlush = dispatcher.dispatchedCount() + flushed.complete(Unit) } + controller.start() + controller.start() // redundant start must not spin up a second flusher + + repeat(total) { i -> controller.submit(event("e$i")) } + + withTimeout(10_000) { flushed.await() } + + // An eager redundant launch dispatches a coroutine before losing the CAS + // and cancelling it; a LAZY launch only dispatches the CAS winner. + assertEquals(1, flusherDispatchesAtFlush, "expected 1 dispatched flusher, got $flusherDispatchesAtFlush") + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - // Each cycle's event must have either been flushed or dropped by a race; - // the only hard contract is that no crash occurred and the DB is consistent. - assertTrue(rows.size in 0..20, "expected 0..20 rows after races, got ${rows.size}") + assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") + + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(1, sessions.size, "only one flusher/session must be created") + + controller.close() + } + + @Test + fun `Given stop has been called When start is called again Then it throws IllegalStateException`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) + controller.start() + controller.submit(event("a")) + controller.stop() + + assertFailsWith { + controller.start() + } controller.close() } @@ -318,3 +362,23 @@ internal class PersistenceControllerTest { controller.close() } } + +@OptIn(ExperimentalAtomicApi::class) +private class CountingDispatcher(private val delegate: CoroutineDispatcher) : CoroutineDispatcher() { + private val count = AtomicInt(0) + + override fun dispatch(context: kotlin.coroutines.CoroutineContext, block: Runnable) { + // Only count dispatches that start a new coroutine (DispatchedContinuation). + // Channel close also dispatches resumptions (CancellableContinuationImpl), + // which must not be counted as flusher starts. + if (block::class.simpleName == "DispatchedContinuation") { + while (true) { + val cur = count.load() + if (count.compareAndSet(cur, cur + 1)) break + } + } + delegate.dispatch(context, block) + } + + fun dispatchedCount(): Int = count.load() +} From af725daff169becf8c046a7a79b069c0d768388c Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 22:54:37 +0100 Subject: [PATCH 17/28] fix: PersistenceController native compile blocker, stop() race, and deterministic test (#50) - Fix 1 (iOS CI blocker): replace the JVM-only CountingDispatcher/Runnable approach in PersistenceControllerTest with an internal `flusherStartCount()` seam backed by an AtomicInt. This removes the `java.lang.Runnable` reference that broke Kotlin/Native compilation. - Fix 2 (single-use hole in stop()): always close the channel before reading and joining the flusher job. A concurrent start() that wins the CAS after stop() sets the terminal flag now starts on a closed channel and exits cleanly instead of leaving a live flusher behind. - Fix 3 (deterministic double-start test): assert `flusherStartCount() == 1` after two start() calls instead of string-matching a kotlinx.coroutines internal class name. - Optional nit: use `error(...)` for the single-use guard (still throws IllegalStateException). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 17 +++++++- .../sharingan/db/PersistenceControllerTest.kt | 39 ++----------------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 901747c..cfa1fb4 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -2,8 +2,10 @@ package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope @@ -96,6 +98,13 @@ public class PersistenceController private constructor( /** Test seam: invoked with the batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null + // Counter incremented exactly when the flusher coroutine body begins. + // Internal test seam — not part of the public ABI. + private val flusherStarts = AtomicInt(0) + + /** Internal test seam: number of flusher coroutines that actually started. */ + internal fun flusherStartCount(): Int = flusherStarts.load() + /** * Wires the seam and starts the flusher. * @@ -105,7 +114,7 @@ public class PersistenceController private constructor( * [IllegalStateException]. */ public fun start() { - if (stopped.load()) throw IllegalStateException("PersistenceController is single-use; already stopped") + if (stopped.load()) error("PersistenceController is single-use; already stopped") // LAZY: the coroutine is only dispatched if this call wins the CAS. // An eager launch + cancel on the losing branch could prompt-cancel a // receive and drop an element from the channel. @@ -134,8 +143,11 @@ public class PersistenceController private constructor( */ public suspend fun stop() { stopped.store(true) - val job = flusherJob.exchange(null) ?: return + // Always close the channel first. A concurrent start() that won the CAS + // after we set stopped=true will start a flusher on the closed channel, + // which exits immediately; we must not leave it open. channel.close() + val job = flusherJob.exchange(null) ?: return job.join() } @@ -152,6 +164,7 @@ public class PersistenceController private constructor( } private suspend fun runFlusher() { + flusherStarts.incrementAndFetch() val batch = mutableListOf() var deadline = 0L while (true) { diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 678e558..dbba008 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -1,15 +1,9 @@ package dev.sharingan.db import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout -import kotlin.concurrent.atomics.AtomicInt -import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -228,34 +222,25 @@ internal class PersistenceControllerTest { fun `Given start is called twice without stop Then only one flusher runs and every event is persisted`() = runBlocking { val total = 5 val driver = createTestDriver() - val dispatcher = CountingDispatcher(Dispatchers.Default) - val scope = CoroutineScope(SupervisorJob() + dispatcher) val controller = PersistenceController( toRow = { it }, driver = driver, - scope = scope, batchSize = total, flushIntervalMillis = 60_000, ) val flushed = CompletableDeferred() - var flusherDispatchesAtFlush = 0 - controller.onBatchFlushed = { size -> - flusherDispatchesAtFlush = dispatcher.dispatchedCount() - flushed.complete(Unit) - } + controller.onBatchFlushed = { flushed.complete(Unit) } controller.start() controller.start() // redundant start must not spin up a second flusher + assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") + repeat(total) { i -> controller.submit(event("e$i")) } withTimeout(10_000) { flushed.await() } - // An eager redundant launch dispatches a coroutine before losing the CAS - // and cancelling it; a LAZY launch only dispatches the CAS winner. - assertEquals(1, flusherDispatchesAtFlush, "expected 1 dispatched flusher, got $flusherDispatchesAtFlush") - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") @@ -363,22 +348,4 @@ internal class PersistenceControllerTest { } } -@OptIn(ExperimentalAtomicApi::class) -private class CountingDispatcher(private val delegate: CoroutineDispatcher) : CoroutineDispatcher() { - private val count = AtomicInt(0) - - override fun dispatch(context: kotlin.coroutines.CoroutineContext, block: Runnable) { - // Only count dispatches that start a new coroutine (DispatchedContinuation). - // Channel close also dispatches resumptions (CancellableContinuationImpl), - // which must not be counted as flusher starts. - if (block::class.simpleName == "DispatchedContinuation") { - while (true) { - val cur = count.load() - if (count.compareAndSet(cur, cur + 1)) break - } - } - delegate.dispatch(context, block) - } - fun dispatchedCount(): Int = count.load() -} From 5546477c70a09da3232d8958f6d840de72354af1 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 24 Aug 2026 23:01:18 +0100 Subject: [PATCH 18/28] test: move flusherStartCount assertion after flush to remove race (#50) The assertion that only one flusher started was read before the flusher had demonstrably entered its body. Move it after the flushed.await() so the count is deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/dev/sharingan/db/PersistenceControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index dbba008..a53ce0e 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -235,12 +235,12 @@ internal class PersistenceControllerTest { controller.start() controller.start() // redundant start must not spin up a second flusher - assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") - repeat(total) { i -> controller.submit(event("e$i")) } withTimeout(10_000) { flushed.await() } + assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") From b542b2ec419a10c16c66bec3709c25bff8e8f703 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Tue, 25 Aug 2026 15:40:03 +0100 Subject: [PATCH 19/28] docs: trim over-explained comments in the persistence layer (#50) Cut comments that restated the code or narrated review history; kept the non-obvious why-notes (LAZY start, atomic select, map-inside-try, DROP_OLDEST, UUID-not-Random, the stop() seam WARNING). Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../dev/sharingan/db/PersistenceController.kt | 99 ++++++------------- .../kotlin/dev/sharingan/internal/EventDto.kt | 9 +- 2 files changed, 33 insertions(+), 75 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index cfa1fb4..6ad274c 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -25,14 +25,12 @@ import kotlinx.coroutines.selects.select * Write-behind persistence: drains events off the caller's seam into an * on-device SQLDelight database so logs survive process death. * - * [submit] is a single non-blocking [Channel.trySend]; the flusher coroutine on - * [Dispatchers.Default] batches writes by size or time (whichever first) into - * one transaction per batch. The caller-supplied [toRow] mapping runs on the - * flusher, never on the hot path. + * [submit] is a non-blocking [Channel.trySend]; a flusher coroutine batches + * writes by size or time into one transaction per batch, running [toRow] off + * the hot path. * - * The flusher uses `select` + `onTimeout` from `kotlinx.coroutines`'s - * [ExperimentalCoroutinesApi]. These are load-bearing for the published ABI: - * a coroutines version bump must be re-validated against the controller tests. + * Uses `select`/`onTimeout` ([ExperimentalCoroutinesApi]) — re-validate against + * the controller tests on a coroutines bump. */ @OptIn(ExperimentalAtomicApi::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) public class PersistenceController private constructor( @@ -76,54 +74,35 @@ public class PersistenceController private constructor( ) private val database = SharinganDatabase(driver) - - // DROP_OLDEST, not DROP_LATEST: a flight recorder must keep the crash-tail, - // so under backpressure the oldest events are evicted, never the newest. private val channel = newEventChannel(channelCapacity) - // Stored in an AtomicReference so a concurrent stop() cannot read a stale - // null and skip the join while a transaction is in flight. + // AtomicReference so a racing stop() can't read a stale null and skip the join. private val flusherJob = AtomicReference(null) - // Terminal flag: once stop() has been called the controller is single-use - // and start() must throw rather than launch a zombie flusher on a closed - // channel. + // Terminal flag; the controller is single-use once stopped. private val stopped = AtomicBoolean(false) - // Only the flusher coroutine touches this. Memoized across flushes, but - // reset if a batch's transaction rolls back so a half-written session row - // is never referenced by a later batch. + // Flusher-thread only; reset on rollback so a rolled-back session row isn't reused. private var sessionId: String? = null - /** Test seam: invoked with the batch size after each committed transaction. */ + /** Test seam: batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null - // Counter incremented exactly when the flusher coroutine body begins. - // Internal test seam — not part of the public ABI. private val flusherStarts = AtomicInt(0) - /** Internal test seam: number of flusher coroutines that actually started. */ + /** Test seam: flusher coroutines that actually started. */ internal fun flusherStartCount(): Int = flusherStarts.load() /** - * Wires the seam and starts the flusher. - * - * Calling [start] more than once before [stop] is idempotent — only one - * flusher coroutine is ever dispatched. After [stop] has been called the - * controller is single-use and further [start] calls throw - * [IllegalStateException]. + * Wires the seam and starts the flusher. Idempotent before [stop]; after + * [stop] the controller is single-use and [start] throws. */ public fun start() { if (stopped.load()) error("PersistenceController is single-use; already stopped") - // LAZY: the coroutine is only dispatched if this call wins the CAS. - // An eager launch + cancel on the losing branch could prompt-cancel a - // receive and drop an element from the channel. + // LAZY + start only on the CAS win: an eager launch would need a cancel() + // on the losing branch, which could prompt-cancel a receive and drop an event. val job = scope.launch(start = CoroutineStart.LAZY) { runFlusher() } - if (flusherJob.compareAndSet(null, job)) { - job.start() - } - // Losing CAS: another start() (or a racing stop()) already owns the - // flusher slot. The spare LAZY job never runs and needs no cancel. + if (flusherJob.compareAndSet(null, job)) job.start() } /** Submits [event] to the flusher. Non-blocking and allocation-free. */ @@ -132,31 +111,23 @@ public class PersistenceController private constructor( } /** - * Drains and flushes the pending events, then stops the flusher. - * The driver stays open so other readers can keep using the database. - * - * This controller is single-use: after [stop], [start] will throw. + * Drains pending events, then stops the flusher. The driver stays open. + * Single-use: after [stop], [start] throws. * - * WARNING: this does NOT detach the caller's `store.onRecord` seam. Events - * submitted after `stop()` are trySend'd to a closed channel and silently - * dropped. Full teardown (detaching the seam) is the caller's responsibility. + * WARNING: does NOT detach the caller's `store.onRecord` seam — events + * submitted after [stop] hit a closed channel and are dropped. Detaching the + * seam is the caller's responsibility. */ public suspend fun stop() { stopped.store(true) - // Always close the channel first. A concurrent start() that won the CAS - // after we set stopped=true will start a flusher on the closed channel, - // which exits immediately; we must not leave it open. + // Close first: a start() that won the CAS after stopped=true then lands + // on a closed channel and exits at once. channel.close() val job = flusherJob.exchange(null) ?: return job.join() } - /** - * Stops the flusher, cancels the scope, and closes the driver. - * - * NOTE: this also does not detach the caller's seam; do that before calling - * `close()` if you need to stop event submission. - */ + /** Stops the flusher, cancels the scope, and closes the driver. Also does not detach the seam. */ public suspend fun close() { stop() scope.cancel() @@ -175,10 +146,8 @@ public class PersistenceController private constructor( break } } else { - // Use select+onReceiveCatching instead of withTimeoutOrNull so - // the receive and timeout are atomic; this prevents prompt - // cancellation from dropping an element that was already taken - // from the channel. + // Atomic receive-or-timeout; withTimeoutOrNull could prompt-cancel + // an already-taken element and lose it. var closed = false select { channel.onReceiveCatching { result -> @@ -189,8 +158,6 @@ public class PersistenceController private constructor( result.getOrNull() } } - // Non-positive timeout returns null immediately; the deadline - // has already passed and we should flush right away. onTimeout(deadline - nowMillis()) { null } }.also { if (closed) break } } @@ -212,9 +179,7 @@ public class PersistenceController private constructor( val isNewSession = sessionId == null val session = sessionId ?: newSessionId() try { - // The caller-supplied mapper runs INSIDE the try so a thrown - // exception is treated like a failed transaction: drop the batch - // and keep the flusher alive. + // Map inside the try so a throwing mapper drops the batch, not the flusher. val rows = snapshot.map(toRow) database.transaction { if (isNewSession) insertSession(session) @@ -223,9 +188,7 @@ public class PersistenceController private constructor( if (isNewSession) sessionId = session onBatchFlushed?.invoke(rows.size) } catch (t: Throwable) { - // One failing batch must not kill the flusher. Drop it and keep - // draining; re-derive the session on the next batch in case its row - // was rolled back with this transaction. + // Drop the batch, keep draining; reset session in case its row rolled back. if (isNewSession) sessionId = null println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") } @@ -261,14 +224,10 @@ public class PersistenceController private constructor( } } -// DROP_OLDEST: a flight recorder must keep the crash-tail, so when the channel -// overflows the oldest events are evicted, never the newest. +// DROP_OLDEST keeps the crash-tail: on overflow the oldest events are evicted, not the newest. internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) -// A session id only needs to be unique within this device's database, but it -// must be collision-free even for two launches in the same millisecond (and two -// test controllers) — a real UUID, not timestamp+Random which collide on -// Kotlin/Native's Default random under rapid successive calls. +// UUID, not timestamp+Random: the latter collides on Kotlin/Native under rapid calls. @OptIn(ExperimentalUuidApi::class) private fun newSessionId(): String = "session-${Uuid.random()}" diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt index 38cc6f7..c4c8a89 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt @@ -8,12 +8,11 @@ import kotlinx.serialization.Serializable /** * Serializable mirror of [SharinganEvent], stored as the `event.payload_json` - * blob. A SEPARATE internal type rather than `@Serializable` on the public - * events — the #15 ABI freeze forbids touching `HttpEvent`/`MqttEvent`/`BleEvent`. + * blob. A separate internal type because the #15 ABI freeze forbids adding + * `@Serializable` to the public events. * - * Encode-only this slice: [fromEvent] maps a public event to its DTO. The - * `toEvent()` decoder lands in slice 3 (in-viewer history), which is why every - * public field has a mirrored counterpart and enum values are carried by name. + * Encode-only this slice ([fromEvent]); the decoder lands in slice 3, so every + * field is mirrored and enums are carried by name. */ @Serializable internal sealed interface EventDto { From 4cf00dd4df6072f6a409a452367593c41f923d68 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 26 Aug 2026 18:10:51 +0100 Subject: [PATCH 20/28] docs: apply reviewers clarity/minimalism cut-list (#50) Comment-only edits in PersistenceController, EventDto, Persistence, and PersistenceControllerTest per Vigil + Sage cut-list. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 13 ++++++------- .../dev/sharingan/db/PersistenceControllerTest.kt | 4 ++-- .../kotlin/dev/sharingan/internal/EventDto.kt | 4 ++-- .../kotlin/dev/sharingan/internal/Persistence.kt | 6 ++---- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 6ad274c..448c599 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -105,14 +105,13 @@ public class PersistenceController private constructor( if (flusherJob.compareAndSet(null, job)) job.start() } - /** Submits [event] to the flusher. Non-blocking and allocation-free. */ + /** Submits [event] to the flusher. Never blocks the caller. */ public fun submit(event: T) { channel.trySend(event) } /** * Drains pending events, then stops the flusher. The driver stays open. - * Single-use: after [stop], [start] throws. * * WARNING: does NOT detach the caller's `store.onRecord` seam — events * submitted after [stop] hit a closed channel and are dropped. Detaching the @@ -127,7 +126,7 @@ public class PersistenceController private constructor( job.join() } - /** Stops the flusher, cancels the scope, and closes the driver. Also does not detach the seam. */ + /** Closes the driver only if this controller opened it. Like [stop], does not detach the seam. */ public suspend fun close() { stop() scope.cancel() @@ -162,11 +161,11 @@ public class PersistenceController private constructor( }.also { if (closed) break } } if (event == null) { - flush(batch) // deadline reached + flush(batch) } else { if (batch.isEmpty()) deadline = nowMillis() + flushIntervalMillis batch.add(event) - if (batch.size >= batchSize) flush(batch) // size-based + if (batch.size >= batchSize) flush(batch) } } flush(batch) // drain the in-flight batch after the channel is closed @@ -188,7 +187,7 @@ public class PersistenceController private constructor( if (isNewSession) sessionId = session onBatchFlushed?.invoke(rows.size) } catch (t: Throwable) { - // Drop the batch, keep draining; reset session in case its row rolled back. + // Reset the session in case its row rolled back with this transaction. if (isNewSession) sessionId = null println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") } @@ -224,7 +223,7 @@ public class PersistenceController private constructor( } } -// DROP_OLDEST keeps the crash-tail: on overflow the oldest events are evicted, not the newest. +// DROP_OLDEST keeps the crash-tail — the newest events matter most here. internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index a53ce0e..85ca1bf 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -210,7 +210,7 @@ internal class PersistenceControllerTest { repeat(10) { i -> controller.submit(event("e$i")) } controller.stop() - controller.stop() // second call must be a no-op + controller.stop() val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() assertEquals(10, rows.size) @@ -233,7 +233,7 @@ internal class PersistenceControllerTest { controller.onBatchFlushed = { flushed.complete(Unit) } controller.start() - controller.start() // redundant start must not spin up a second flusher + controller.start() repeat(total) { i -> controller.submit(event("e$i")) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt index c4c8a89..3d9b66a 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt @@ -11,8 +11,8 @@ import kotlinx.serialization.Serializable * blob. A separate internal type because the #15 ABI freeze forbids adding * `@Serializable` to the public events. * - * Encode-only this slice ([fromEvent]); the decoder lands in slice 3, so every - * field is mirrored and enums are carried by name. + * Encode-only ([fromEvent]). Every field is mirrored and enums carried by name + * so a decoder can be added without a format change. */ @Serializable internal sealed interface EventDto { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt index d5fdb13..4be1769 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -13,10 +13,8 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlinx.serialization.json.Json /** - * Hands-free persistence bootstrap. Starts the [PersistenceController] over the - * shared [Sharingan.store] exactly once per process. Internal — the public - * `Sharingan.configure()` entry point (and its no-op mirror) lands in a later - * slice; until then Android's manifest-merged ContentProvider calls [start]. + * Starts persistence once per process. The only caller is Android's + * manifest-merged ContentProvider — there is no Kotlin call site to grep. */ @OptIn(ExperimentalAtomicApi::class) internal object Persistence { From 0dead7771c55bfdef489ab6d278043b56bd4d816 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 15:28:30 +0100 Subject: [PATCH 21/28] fix: BCV-guard :sharingan-db's published ABI (#50) :sharingan-db ships to Maven Central, so its public surface must be guarded by apiCheck like any other published module. Remove the ignoredProjects exemption, commit the generated api/*.api dumps, and verify apiCheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.gradle.kts | 11 +-- sharingan-db/api/sharingan-db.api | 104 ++++++++++++++++++++ sharingan-db/api/sharingan-db.klib.api | 131 +++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 sharingan-db/api/sharingan-db.api create mode 100644 sharingan-db/api/sharingan-db.klib.api diff --git a/build.gradle.kts b/build.gradle.kts index 491461c..f2951f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,16 +22,7 @@ apiValidation { // Custom ktlint ruleset (AGENTS.md comments policy) — internal tooling. ignoredProjects += "ktlint-rules" - // Flight-recorder persistence (issue #27/#49) is SQLDelight-generated code - // that the compiler emits `public` (SQLDelight offers no internal-generation - // option). It is a debug-only internal seam, not consumer API, so it is - // excluded from the guarded public surface. Wrapped behind `internal` - // DriverFactory/controller symbols; never mirrored in :sharingan-noop. - ignoredPackages += "dev.sharingan.persistence" - // `:sharingan-db` is a debug-only implementation detail of `:sharingan`. - // Its surface is SQLDelight-generated and changes on every schema edit in - // slices 3-5; no consumer codes against it, so it is not BCV-guarded. - ignoredProjects += "sharingan-db" + // `:sharingan-db` ships to Maven Central as a dependency of `:sharingan`, so its ABI is guarded like any published module. // KMP: also dump/verify the Kotlin/Native (iOS) ABI, not just the JVM/Android // surface, so iosMain-only declarations (SharinganViewController, diff --git a/sharingan-db/api/sharingan-db.api b/sharingan-db/api/sharingan-db.api new file mode 100644 index 0000000..caef775 --- /dev/null +++ b/sharingan-db/api/sharingan-db.api @@ -0,0 +1,104 @@ +public final class dev/sharingan/db/Event { + public fun (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/Event; + public static synthetic fun copy$default (Ldev/sharingan/db/Event;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/Event; + public fun equals (Ljava/lang/Object;)Z + public final fun getHost_or_topic ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getPayload_json ()Ljava/lang/String; + public final fun getSession_id ()Ljava/lang/String; + public final fun getTimestamp ()J + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun is_failure ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/sharingan/db/EventRow { + public fun (Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/EventRow; + public static synthetic fun copy$default (Ldev/sharingan/db/EventRow;Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/EventRow; + public fun equals (Ljava/lang/Object;)Z + public final fun getHostOrTopic ()Ljava/lang/String; + public final fun getPayloadJson ()Ljava/lang/String; + public final fun getRawId ()Ljava/lang/String; + public final fun getTimestampMillis ()J + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun isFailure ()Z + public fun toString ()Ljava/lang/String; +} + +public final class dev/sharingan/db/PersistenceController { + public fun (Lkotlin/jvm/functions/Function1;IJ)V + public synthetic fun (Lkotlin/jvm/functions/Function1;IJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun start ()V + public final fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun submit (Ljava/lang/Object;)V +} + +public final class dev/sharingan/db/Session { + public fun (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/Session; + public static synthetic fun copy$default (Ldev/sharingan/db/Session;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/Session; + public fun equals (Ljava/lang/Object;)Z + public final fun getApp_id ()Ljava/lang/String; + public final fun getBuild ()Ljava/lang/String; + public final fun getDevice_model ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getOs ()Ljava/lang/String; + public final fun getStarted_at ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/sharingan/db/SharinganDatabase : app/cash/sqldelight/Transacter { + public static final field Companion Ldev/sharingan/db/SharinganDatabase$Companion; + public abstract fun getSharinganDatabaseQueries ()Ldev/sharingan/db/SharinganDatabaseQueries; +} + +public final class dev/sharingan/db/SharinganDatabase$Companion { + public final fun getSchema ()Lapp/cash/sqldelight/db/SqlSchema; + public final fun invoke (Lapp/cash/sqldelight/db/SqlDriver;)Ldev/sharingan/db/SharinganDatabase; +} + +public final class dev/sharingan/db/SharinganDatabaseQueries : app/cash/sqldelight/TransacterImpl { + public fun (Lapp/cash/sqldelight/db/SqlDriver;)V + public final fun deleteSession (Ljava/lang/String;)V + public final fun insertEvent (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V + public final fun insertSession (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun selectAllEvents ()Lapp/cash/sqldelight/Query; + public final fun selectAllEvents (Lkotlin/jvm/functions/Function7;)Lapp/cash/sqldelight/Query; + public final fun selectAllSessions ()Lapp/cash/sqldelight/Query; + public final fun selectAllSessions (Lkotlin/jvm/functions/Function6;)Lapp/cash/sqldelight/Query; + public final fun selectEventsForSession (Ljava/lang/String;)Lapp/cash/sqldelight/Query; + public final fun selectEventsForSession (Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lapp/cash/sqldelight/Query; + public final fun selectSession (Ljava/lang/String;)Lapp/cash/sqldelight/Query; + public final fun selectSession (Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lapp/cash/sqldelight/Query; +} + +public final class dev/sharingan/db/SharinganDbContext { + public static final field INSTANCE Ldev/sharingan/db/SharinganDbContext; + public final fun install (Landroid/content/Context;)V +} + diff --git a/sharingan-db/api/sharingan-db.klib.api b/sharingan-db/api/sharingan-db.klib.api new file mode 100644 index 0000000..b61ff88 --- /dev/null +++ b/sharingan-db/api/sharingan-db.klib.api @@ -0,0 +1,131 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract interface dev.sharingan.db/SharinganDatabase : app.cash.sqldelight/Transacter { // dev.sharingan.db/SharinganDatabase|null[0] + abstract val sharinganDatabaseQueries // dev.sharingan.db/SharinganDatabase.sharinganDatabaseQueries|{}sharinganDatabaseQueries[0] + abstract fun (): dev.sharingan.db/SharinganDatabaseQueries // dev.sharingan.db/SharinganDatabase.sharinganDatabaseQueries.|(){}[0] + + final object Companion { // dev.sharingan.db/SharinganDatabase.Companion|null[0] + final val Schema // dev.sharingan.db/SharinganDatabase.Companion.Schema|{}Schema[0] + final fun (): app.cash.sqldelight.db/SqlSchema> // dev.sharingan.db/SharinganDatabase.Companion.Schema.|(){}[0] + + final fun invoke(app.cash.sqldelight.db/SqlDriver): dev.sharingan.db/SharinganDatabase // dev.sharingan.db/SharinganDatabase.Companion.invoke|invoke(app.cash.sqldelight.db.SqlDriver){}[0] + } +} + +final class <#A: kotlin/Any> dev.sharingan.db/PersistenceController { // dev.sharingan.db/PersistenceController|null[0] + constructor (kotlin/Function1<#A, dev.sharingan.db/EventRow>, kotlin/Int = ..., kotlin/Long = ...) // dev.sharingan.db/PersistenceController.|(kotlin.Function1<1:0,dev.sharingan.db.EventRow>;kotlin.Int;kotlin.Long){}[0] + + final fun start() // dev.sharingan.db/PersistenceController.start|start(){}[0] + final fun submit(#A) // dev.sharingan.db/PersistenceController.submit|submit(1:0){}[0] + final suspend fun close() // dev.sharingan.db/PersistenceController.close|close(){}[0] + final suspend fun stop() // dev.sharingan.db/PersistenceController.stop|stop(){}[0] +} + +final class dev.sharingan.db/Event { // dev.sharingan.db/Event|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Long, kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?) // dev.sharingan.db/Event.|(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + + final val host_or_topic // dev.sharingan.db/Event.host_or_topic|{}host_or_topic[0] + final fun (): kotlin/String? // dev.sharingan.db/Event.host_or_topic.|(){}[0] + final val id // dev.sharingan.db/Event.id|{}id[0] + final fun (): kotlin/String // dev.sharingan.db/Event.id.|(){}[0] + final val is_failure // dev.sharingan.db/Event.is_failure|{}is_failure[0] + final fun (): kotlin/Long // dev.sharingan.db/Event.is_failure.|(){}[0] + final val payload_json // dev.sharingan.db/Event.payload_json|{}payload_json[0] + final fun (): kotlin/String? // dev.sharingan.db/Event.payload_json.|(){}[0] + final val session_id // dev.sharingan.db/Event.session_id|{}session_id[0] + final fun (): kotlin/String // dev.sharingan.db/Event.session_id.|(){}[0] + final val timestamp // dev.sharingan.db/Event.timestamp|{}timestamp[0] + final fun (): kotlin/Long // dev.sharingan.db/Event.timestamp.|(){}[0] + final val type // dev.sharingan.db/Event.type|{}type[0] + final fun (): kotlin/String // dev.sharingan.db/Event.type.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/Event.component1|component1(){}[0] + final fun component2(): kotlin/String // dev.sharingan.db/Event.component2|component2(){}[0] + final fun component3(): kotlin/Long // dev.sharingan.db/Event.component3|component3(){}[0] + final fun component4(): kotlin/String // dev.sharingan.db/Event.component4|component4(){}[0] + final fun component5(): kotlin/Long // dev.sharingan.db/Event.component5|component5(){}[0] + final fun component6(): kotlin/String? // dev.sharingan.db/Event.component6|component6(){}[0] + final fun component7(): kotlin/String? // dev.sharingan.db/Event.component7|component7(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/String? = ..., kotlin/String? = ...): dev.sharingan.db/Event // dev.sharingan.db/Event.copy|copy(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/Event.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/Event.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/Event.toString|toString(){}[0] +} + +final class dev.sharingan.db/EventRow { // dev.sharingan.db/EventRow|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/String, kotlin/Boolean, kotlin/String?, kotlin/String) // dev.sharingan.db/EventRow.|(kotlin.String;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.String?;kotlin.String){}[0] + + final val hostOrTopic // dev.sharingan.db/EventRow.hostOrTopic|{}hostOrTopic[0] + final fun (): kotlin/String? // dev.sharingan.db/EventRow.hostOrTopic.|(){}[0] + final val isFailure // dev.sharingan.db/EventRow.isFailure|{}isFailure[0] + final fun (): kotlin/Boolean // dev.sharingan.db/EventRow.isFailure.|(){}[0] + final val payloadJson // dev.sharingan.db/EventRow.payloadJson|{}payloadJson[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.payloadJson.|(){}[0] + final val rawId // dev.sharingan.db/EventRow.rawId|{}rawId[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.rawId.|(){}[0] + final val timestampMillis // dev.sharingan.db/EventRow.timestampMillis|{}timestampMillis[0] + final fun (): kotlin/Long // dev.sharingan.db/EventRow.timestampMillis.|(){}[0] + final val type // dev.sharingan.db/EventRow.type|{}type[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.type.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/EventRow.component1|component1(){}[0] + final fun component2(): kotlin/Long // dev.sharingan.db/EventRow.component2|component2(){}[0] + final fun component3(): kotlin/String // dev.sharingan.db/EventRow.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // dev.sharingan.db/EventRow.component4|component4(){}[0] + final fun component5(): kotlin/String? // dev.sharingan.db/EventRow.component5|component5(){}[0] + final fun component6(): kotlin/String // dev.sharingan.db/EventRow.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/Long = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/String? = ..., kotlin/String = ...): dev.sharingan.db/EventRow // dev.sharingan.db/EventRow.copy|copy(kotlin.String;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.String?;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/EventRow.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/EventRow.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/EventRow.toString|toString(){}[0] +} + +final class dev.sharingan.db/Session { // dev.sharingan.db/Session|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/String?) // dev.sharingan.db/Session.|(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + + final val app_id // dev.sharingan.db/Session.app_id|{}app_id[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.app_id.|(){}[0] + final val build // dev.sharingan.db/Session.build|{}build[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.build.|(){}[0] + final val device_model // dev.sharingan.db/Session.device_model|{}device_model[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.device_model.|(){}[0] + final val id // dev.sharingan.db/Session.id|{}id[0] + final fun (): kotlin/String // dev.sharingan.db/Session.id.|(){}[0] + final val os // dev.sharingan.db/Session.os|{}os[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.os.|(){}[0] + final val started_at // dev.sharingan.db/Session.started_at|{}started_at[0] + final fun (): kotlin/Long // dev.sharingan.db/Session.started_at.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/Session.component1|component1(){}[0] + final fun component2(): kotlin/Long // dev.sharingan.db/Session.component2|component2(){}[0] + final fun component3(): kotlin/String? // dev.sharingan.db/Session.component3|component3(){}[0] + final fun component4(): kotlin/String? // dev.sharingan.db/Session.component4|component4(){}[0] + final fun component5(): kotlin/String? // dev.sharingan.db/Session.component5|component5(){}[0] + final fun component6(): kotlin/String? // dev.sharingan.db/Session.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/Long = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/String? = ...): dev.sharingan.db/Session // dev.sharingan.db/Session.copy|copy(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/Session.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/Session.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/Session.toString|toString(){}[0] +} + +final class dev.sharingan.db/SharinganDatabaseQueries : app.cash.sqldelight/TransacterImpl { // dev.sharingan.db/SharinganDatabaseQueries|null[0] + constructor (app.cash.sqldelight.db/SqlDriver) // dev.sharingan.db/SharinganDatabaseQueries.|(app.cash.sqldelight.db.SqlDriver){}[0] + + final fun <#A1: kotlin/Any> selectAllEvents(kotlin/Function7): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectAllEvents|selectAllEvents(kotlin.Function7){0§}[0] + final fun <#A1: kotlin/Any> selectAllSessions(kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectAllSessions|selectAllSessions(kotlin.Function6){0§}[0] + final fun <#A1: kotlin/Any> selectEventsForSession(kotlin/String, kotlin/Function7): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectEventsForSession|selectEventsForSession(kotlin.String;kotlin.Function7){0§}[0] + final fun <#A1: kotlin/Any> selectSession(kotlin/String, kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectSession|selectSession(kotlin.String;kotlin.Function6){0§}[0] + final fun deleteSession(kotlin/String) // dev.sharingan.db/SharinganDatabaseQueries.deleteSession|deleteSession(kotlin.String){}[0] + final fun insertEvent(kotlin/String, kotlin/String, kotlin/Long, kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertEvent|insertEvent(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + final fun insertSession(kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertSession|insertSession(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + final fun selectAllEvents(): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectAllEvents|selectAllEvents(){}[0] + final fun selectAllSessions(): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectAllSessions|selectAllSessions(){}[0] + final fun selectEventsForSession(kotlin/String): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectEventsForSession|selectEventsForSession(kotlin.String){}[0] + final fun selectSession(kotlin/String): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectSession|selectSession(kotlin.String){}[0] +} From e43b22714ed8318e7d43375aad2d6fa247e7a03e Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 15:30:21 +0100 Subject: [PATCH 22/28] docs: describe shipped persistence (flight recorder) accurately (#50) Three statements predate the flight recorder: ARCHITECTURE's overview claimed nothing is persisted, section 5.4 described persistence as hypothetical/opt-in, and CONTEXT.md's Store entry claimed memory-only. All three now describe what ships: an in-memory ring buffer (300) that is also mirrored to a SQLite flight recorder via the write-behind seam (SharinganStore.onRecord -> PersistenceController), on by default in debug, with request/response bodies never written to disk. CONTEXT.md gains Flight Recorder, Write-Behind Seam, and Run entries, and the Capture entry notes redaction applies before disk too. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 17 +++++++++++++++-- docs/ARCHITECTURE.md | 6 +++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5c794d4..2b2e358 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,17 +18,30 @@ The single home for everything Sharingan knows about one Protocol: its filter ch _Avoid_: handler, strategy, adapter (reserved for things satisfying an interface at a seam) **Capture**: -The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store. +The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store, and redaction applies before disk too. + _Avoid_: logging, tracking, interception +**Flight Recorder**: +The on-disk SQLite mirror of the Store, written via the Write-Behind Seam. Memory stays the source of truth; the Flight Recorder is a post-mortem log, not a database to query. Request/response bodies are never written to disk. +_Avoid_: database, cache + **Logger**: A per-Protocol capture entry point (`Sharingan.http`, `Sharingan.mqtt`, `Sharingan.ble`). Client-agnostic: host apps wire their own MQTT/BLE libraries into it. _Avoid_: tracker, recorder +**Run**: +One process lifetime — the Flight Recorder groups all Events captured in a process under a single session row. +_Avoid_: session (reserved for the v2 epic) + **Store**: -The in-memory ring buffer of Events (default 300). Memory-only by design — nothing is ever written to disk; process death clears it. +The in-memory ring buffer of Events (default 300). The source of truth while the process lives; each Event is mirrored to the Flight Recorder via the Write-Behind Seam. Process death clears the ring buffer, not the Flight Recorder. _Avoid_: database, cache, repository, buffer (alone) +**Write-Behind Seam**: +The internal seam (`SharinganStore.onRecord` → `PersistenceController`) that mirrors each accepted Event to the Flight Recorder after the ring-buffer append. It observes Capture, never blocks it. +_Avoid_: listener, hook + **Capture Notification**: The Android sticky notification showing per-Protocol counters, a three-event Ticker when expanded, and Pause/Resume. Silent, updated in place, and must never crash the host app. _Avoid_: status bar entry, foreground notification (it isn't a foreground service) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8323ae8..652bf9a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ Companion docs: [README.md](../README.md) (user-facing), [AGENTS.md](../AGENTS.m ## 1. System overview -Sharingan is an on-device debug logger for Kotlin Multiplatform (Android API 24+, iOS arm64 + simulator arm64). It captures HTTP, MQTT and BLE traffic into an in-memory ring buffer, renders a Compose Multiplatform log browser, and exports events as agent-friendly Markdown / cURL / JSON / digest text. Nothing is ever persisted. +Sharingan is an on-device debug logger for Kotlin Multiplatform (Android API 24+, iOS arm64 + simulator arm64). It captures HTTP, MQTT and BLE traffic into an in-memory ring buffer, renders a Compose Multiplatform log browser, and exports events as agent-friendly Markdown / cURL / JSON / digest text. Events live in an in-memory ring buffer (default 300) that is also mirrored to an on-device SQLite flight recorder via a write-behind seam; persistence is on by default in debug, and request/response bodies are never written to disk. ### Module map @@ -219,9 +219,9 @@ For **WebSocket capture** specifically: Ktor's `WebSockets` plugin offers no equ The design explored three densities — **Terminal / Comfortable / Badged** — and only Terminal shipped (`TerminalRow` in [ui/HomeScreen.kt](../sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt); its doc comment names it "the design's default 'Terminal' density"). To add a variant: new `ComfortableRow`/`BadgedRow` composable beside `TerminalRow` consuming the same `EventPresentation`, a density value in `HomeUiState`, and a row-style picker in `HomeHeader`. `EventPresentation` already carries everything (`sub`, `sizeLabel`, tints) the richer variants need. -### 5.4 Persist events +### 5.4 Flight recorder (persistence) -Today the buffer is deliberately memory-only. If persistence is ever wanted: keep `SharinganStore` as the hot interface and add an optional sink observing `store.events` (the pattern `CaptureNotification.start()` already uses); never make persistence the source of truth. Mind: redaction already happened at capture, but bodies may still hold PII — persisting changes the security posture, opt-in only. +The in-memory ring buffer stays the source of truth. Each accepted Event is also mirrored to an on-device SQLite database by a write-behind seam: `SharinganStore.onRecord` (internal callback, invoked after the ring-buffer CAS append) → `PersistenceController` ([sharingan-db](../sharingan-db), SQLDelight). Persistence ships ON by default in debug. The Flight Recorder is a post-mortem log, never queried back into the UI and never the source of truth; one session row groups the Events of each process lifetime (a Run). Redaction applies before disk, and request/response bodies are never written to disk — persisting bodies would change the security posture and is deliberately out of scope. ### 5.5 Sample iosApp Xcode project From 32bf19ae4b5da643ca8437e6e85efaaab23884b8 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 15:44:44 +0100 Subject: [PATCH 23/28] docs: fix remaining memory-only claims across README, AGENTS, llms.txt (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more places still claimed "memory-only / never persisted": the README feature bullet and SharinganStore note, ARCHITECTURE's design properties and known-limitations entries, and AGENTS.md lines 3 and 106. All now say what ships: the ring buffer stays memory-only and is still cleared on process death; events are additionally mirrored to the on-disk SQLite flight recorder (debug only), which survives, and bodies are never written to disk. AGENTS.md and llms.txt are a hand-maintained mirror — regenerated llms.txt from AGENTS.md and confirmed byte-identical before committing. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 ++-- README.md | 4 ++-- docs/ARCHITECTURE.md | 4 ++-- llms.txt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a624a63..89d4c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Sharingan — agent reference -On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events, never persisted) and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. +On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder — bodies are never written to disk — and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. ## Artifacts @@ -103,7 +103,7 @@ presentSharingan(animated: Boolean = true) // presents over topmost VC, any thr ## Facts agents commonly need - Zero-setup init on Android via manifest-merged ContentProvider; no Application code. -- Buffer is memory-only; process death clears it. Capacity via `SharinganStore(capacity)`. +- Ring buffer is memory-only; process death clears it (the on-disk flight recorder survives). Bodies are never written to disk. Capacity via `SharinganStore(capacity)`. - The Ktor plugin rethrows transport failures untouched after recording them (statusCode null, error set). - Thread-safe: store updates are atomic CAS; loggers callable from any thread. - Min targets: Android API 24, iOS arm64 + simulator arm64. Requires Ktor 3.x for the plugin. diff --git a/README.md b/README.md index a9726fa..401775a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ No init code — a manifest-merged ContentProvider starts Sharingan automaticall - **Detail view per event** — timing waterfall, request/response headers (secrets redacted at capture), syntax-colored JSON bodies, QoS/retain flags, GATT operations and decoded values. - **Share sheet built for agents** — "Copy for AI agent" produces structured Markdown an LLM parses reliably; cURL, raw JSON and the system share sheet are one tap away. - **Zero release impact** — swap in the `sharingan-noop` artifact: identical API, captures nothing, ships no UI. -- **Memory-only** — an in-memory ring buffer (default 300 events); nothing is ever written to disk. +- **Ring buffer, not a database** — events live in an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder (debug builds only); bodies are never written to disk. - **Minimal** — no image assets, no icon fonts, no bundled typefaces, no DI, no navigation library. The capture core depends only on coroutines (+ Ktor when you use the plugin). Requirements: Android API 24+, iOS arm64 + simulator arm64, Ktor 3.x for the HTTP plugin. @@ -336,7 +336,7 @@ Sharingan.clear() // drop everything Sharingan.setNotificationEnabled(false) // Android: opt out of the notification ``` -`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears it). Loggers are thread-safe, callable from any thread. +`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears the ring, not the flight recorder). Loggers are thread-safe, callable from any thread. ## Release builds: what "no effect" means diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 652bf9a..0ef5df0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -105,7 +105,7 @@ All deliberate, all verifiable: - **Streaming never consumed.** `shouldReadBody()` refuses `text/event-stream` and anything non-textual (`isTextual()` whitelist: `text/*`, `*json`, `*xml`, form-urlencoded) — SSE and binary downloads keep streaming for the caller. - **Transport failures recorded then rethrown untouched** (catch block in the plugin's `on(Send)`): the app's error handling is never altered. - **Notification failures swallowed.** `manager.notify()` is wrapped in `try/catch(Exception)` in `CaptureNotification.post()`. This is a scar, not paranoia: commit `8550bbd` records a real crash where the API-26 version guard left the builder chain (`.setSmallIcon` etc.) attached only to the `else` branch, so API 26+ posted an icon-less notification and the resulting `IllegalArgumentException` from a background flow collector killed the host app. Rule extracted: **a debug tool must never crash the host app.** -- **Memory-only ring buffer.** No disk, no network. Process death clears everything (a stated property, see §6). +- **Memory-only ring buffer, mirrored on disk.** The ring buffer never touches the network; each event is mirrored to the SQLite flight recorder (§5.4). Process death clears the ring, not the recorder (a stated property, see §6). ### 2.5 UI architecture @@ -234,7 +234,7 @@ Add `sample/iosApp/` (standard KMP template: `iosApp.xcodeproj` + SwiftUI `Conte - **`SharinganScreen()` exists only in the debug artifact.** It is the one asymmetry in the API surface (deliberate: a noop composable would drag Compose into the release artifact). Never reference it from code compiled against the noop; use `Sharingan.show(context)` / `SharinganViewController()`, which *are* mirrored. Documented in [AGENTS.md](../AGENTS.md) and README §"Release builds". - **DND hides the notification.** The channel is `IMPORTANCE_LOW` and the notification silent (`CaptureNotification.ensureChannel`), so Do Not Disturb suppresses it on most devices. `Sharingan.show(context)` always works. - **`POST_NOTIFICATIONS` must be requested by the host app** on Android 13+. The library only *declares* the permission ([sharingan/src/androidMain/AndroidManifest.xml](../sharingan/src/androidMain/AndroidManifest.xml)); the sample shows the request ([MainActivity.kt](../sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt)). Without the grant, capture still works (`areNotificationsEnabled()` check + swallowed `notify()` failures), there's just no notification. -- **Buffer lost on process death** — memory-only by design; capacity is per-store (`SharinganStore(capacity = …)`). +- **Ring buffer lost on process death** — memory-only by design, so the ring resets each process; the flight recorder survives (§5.4). Capacity is per-store (`SharinganStore(capacity = …)`). - **The notification observer starts once per process** (`CaptureNotification.start` guards on `scope != null`) and survives for the process lifetime; `setNotificationEnabled(false)` cancels the posted notification but keeps observing. - **`HttpEvent.host/path` come from a naive string split** (`splitUrl` in `HttpEvent.kt`), not a URL parser — fine for display, don't reuse it for anything semantic. - **Request-body capture only sees `OutgoingContent.ByteArrayContent`** (`outgoingBodyText` in `ktor/SharinganKtor.kt`); streamed/chunked request bodies are not captured (responses: textual-only whitelist, SSE never read). diff --git a/llms.txt b/llms.txt index b02d756..340f484 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Sharingan — agent reference -On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events, never persisted) and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. +On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder — bodies are never written to disk — and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. ## Artifacts @@ -103,7 +103,7 @@ presentSharingan(animated: Boolean = true) // presents over topmost VC, any thr ## Facts agents commonly need - Zero-setup init on Android via manifest-merged ContentProvider; no Application code. -- Buffer is memory-only; process death clears it. Capacity via `SharinganStore(capacity)`. +- Ring buffer is memory-only; process death clears it (the on-disk flight recorder survives). Bodies are never written to disk. Capacity via `SharinganStore(capacity)`. - The Ktor plugin rethrows transport failures untouched after recording them (statusCode null, error set). - Thread-safe: store updates are atomic CAS; loggers callable from any thread. - Min targets: Android API 24, iOS arm64 + simulator arm64. Requires Ktor 3.x for the plugin. From 4df07814471100ada68434971dbaf6f93bc56849 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 15:45:23 +0100 Subject: [PATCH 24/28] docs: keep CONTEXT glossary formatting uniform (#50) The Capture entry gained a stray blank line before its _Avoid_ line; every other entry has them adjacent. Backtick `session` in the Run entry so it reads as the schema table name rather than the term reserved for the v2 epic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UBhi6Qx8rdo1aX9EnmtEJS --- CONTEXT.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2b2e358..dfe34ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,7 +19,6 @@ _Avoid_: handler, strategy, adapter (reserved for things satisfying an interface **Capture**: The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store, and redaction applies before disk too. - _Avoid_: logging, tracking, interception **Flight Recorder**: @@ -31,7 +30,7 @@ A per-Protocol capture entry point (`Sharingan.http`, `Sharingan.mqtt`, `Sharing _Avoid_: tracker, recorder **Run**: -One process lifetime — the Flight Recorder groups all Events captured in a process under a single session row. +One process lifetime — the Flight Recorder groups all Events captured in a process under a single `session` row. _Avoid_: session (reserved for the v2 epic) **Store**: From bcf6a3339530150cb1f4072d18ca7a66c0b5a4e3 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:00:05 +0100 Subject: [PATCH 25/28] fix: clear() drops persisted rows and pending batch; bodies stay off disk (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: SharinganStore.clear() now invokes an onClear seam; Persistence wires it to a new PersistenceController.clear(). The clear travels as a command on the same channel the writes use, so the single flusher coroutine deletes rows at an exact point in the write order — a pending in-flight batch cannot resurrect events after a clear. B2: toRow() strips HttpDto request/response bodies and Mqtt/Ble payloads on the persistence path only (design default persistBodies = false); EventDto stays capable of carrying bodies for the slice-5 opt-in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/sharingan/db/PersistenceController.kt | 49 ++++++++++--- .../dev/sharingan/db/SharinganDatabase.sq | 3 + .../sharingan/db/PersistenceControllerTest.kt | 36 ++++++++++ .../kotlin/dev/sharingan/SharinganStore.kt | 10 +++ .../dev/sharingan/internal/Persistence.kt | 18 +++-- .../dev/sharingan/SharinganStoreTest.kt | 15 +++- .../internal/PersistenceToRowTest.kt | 68 +++++++++++++++++++ 7 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 448c599..0692eb4 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -74,7 +74,7 @@ public class PersistenceController private constructor( ) private val database = SharinganDatabase(driver) - private val channel = newEventChannel(channelCapacity) + private val channel = newEventChannel>(channelCapacity) // AtomicReference so a racing stop() can't read a stale null and skip the join. private val flusherJob = AtomicReference(null) @@ -88,6 +88,9 @@ public class PersistenceController private constructor( /** Test seam: batch size after each committed transaction. */ internal var onBatchFlushed: ((Int) -> Unit)? = null + /** Test seam: invoked after a clear's rows have been deleted. */ + internal var onCleared: (() -> Unit)? = null + private val flusherStarts = AtomicInt(0) /** Test seam: flusher coroutines that actually started. */ @@ -107,7 +110,19 @@ public class PersistenceController private constructor( /** Submits [event] to the flusher. Never blocks the caller. */ public fun submit(event: T) { - channel.trySend(event) + channel.trySend(Command.Record(event)) + } + + /** + * Deletes every persisted row and discards whatever is still queued or + * held in the flusher's in-flight batch. Enqueued as a command on the same + * channel the writes travel on, so the single flusher coroutine applies it + * at an exact point in the write order — events recorded before [clear] + * are flushed first and then deleted (or discarded), events recorded after + * survive. No locks, no resurrection window. + */ + public fun clear() { + channel.trySend(Command.ClearAll) } /** @@ -138,7 +153,7 @@ public class PersistenceController private constructor( val batch = mutableListOf() var deadline = 0L while (true) { - val event = if (batch.isEmpty()) { + val command: Command? = if (batch.isEmpty()) { try { channel.receive() } catch (e: ClosedReceiveChannelException) { @@ -148,7 +163,7 @@ public class PersistenceController private constructor( // Atomic receive-or-timeout; withTimeoutOrNull could prompt-cancel // an already-taken element and lose it. var closed = false - select { + select?> { channel.onReceiveCatching { result -> if (result.isClosed) { closed = true @@ -160,12 +175,19 @@ public class PersistenceController private constructor( onTimeout(deadline - nowMillis()) { null } }.also { if (closed) break } } - if (event == null) { - flush(batch) - } else { - if (batch.isEmpty()) deadline = nowMillis() + flushIntervalMillis - batch.add(event) - if (batch.size >= batchSize) flush(batch) + when (command) { + is Command.Record -> { + val event = command.event + if (batch.isEmpty()) deadline = nowMillis() + flushIntervalMillis + batch.add(event) + if (batch.size >= batchSize) flush(batch) + } + is Command.ClearAll -> { + batch.clear() + database.sharinganDatabaseQueries.deleteAllEvents() + onCleared?.invoke() + } + null -> flush(batch) } } flush(batch) // drain the in-flight batch after the channel is closed @@ -227,6 +249,13 @@ public class PersistenceController private constructor( internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) +// Everything the flusher does — writes and clears — travels on one channel, so +// the single flusher coroutine applies DB mutations in submission order. +private sealed interface Command { + data class Record(val event: T) : Command + data object ClearAll : Command +} + // UUID, not timestamp+Random: the latter collides on Kotlin/Native under rapid calls. @OptIn(ExperimentalUuidApi::class) private fun newSessionId(): String = "session-${Uuid.random()}" diff --git a/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq b/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq index 8459a58..9cda940 100644 --- a/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq +++ b/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq @@ -42,3 +42,6 @@ SELECT * FROM event ORDER BY timestamp ASC; deleteSession: DELETE FROM session WHERE id = ?; + +deleteAllEvents: +DELETE FROM event; diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 85ca1bf..90e6aa2 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -185,6 +185,42 @@ internal class PersistenceControllerTest { controller2.close() } + @Test + fun `Given a pending batch When clear is called Then no events are resurrected`() = runBlocking { + val driver = createTestDriver() + val controller = PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + // Short deadline so flushed-1 lands; the pending events are queued + // behind ClearAll microseconds later, far ahead of the next deadline. + flushIntervalMillis = 100, + ) + val firstFlush = CompletableDeferred() + controller.onBatchFlushed = { firstFlush.complete(Unit) } + controller.start() + + controller.submit(event("flushed-1")) + withTimeout(10_000) { firstFlush.await() } + + // Queued but never flushed: under the batch size and far from the deadline. + controller.submit(event("pending-1")) + controller.submit(event("pending-2")) + + val cleared = CompletableDeferred() + controller.onCleared = { cleared.complete(Unit) } + controller.clear() + withTimeout(10_000) { cleared.await() } + + // Drain the flusher: a resurrection would commit the pending batch now. + controller.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(0, rows.size, "clear must delete persisted rows and discard the pending batch") + + controller.close() + } + @Test fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = runBlocking { val channel = newEventChannel(capacity = 4) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 6745ce5..c949fe6 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -30,6 +30,15 @@ public class SharinganStore( */ internal var onRecord: ((SharinganEvent) -> Unit)? = null + /** + * Internal persistence seam: invoked from [clear] so the persistence layer + * can drop the mirrored rows on disk too. `null` unless the flight-recorder + * persistence is wired up (see `dev.sharingan.db.PersistenceController`). + * Internal — never part of the public API, so :sharingan-noop mirrors + * nothing here. + */ + internal var onClear: (() -> Unit)? = null + /** All retained events, oldest first. */ public val events: StateFlow> = _events.asStateFlow() @@ -58,6 +67,7 @@ public class SharinganStore( /** Removes all retained events. */ public fun clear() { _events.value = emptyList() + onClear?.invoke() } public companion object { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt index 4be1769..8ef857f 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -23,24 +23,34 @@ internal object Persistence { fun start(store: SharinganStore = Sharingan.store) { if (!started.compareAndSet(false, true)) return - controller = PersistenceController(::toRow).also { - it.start() - store.onRecord = { event -> it.submit(event) } + controller = PersistenceController(::toRow).also { ctrl -> + ctrl.start() + store.onRecord = { event -> ctrl.submit(event) } + store.onClear = { ctrl.clear() } } } } internal val json = Json { encodeDefaults = true } +// Bodies stay in memory only — the flight-recorder design defaults to +// persistBodies = false. EventDto keeps the fields so a later slice can add +// the opt-in flag on this path. internal fun toRow(event: SharinganEvent): EventRow = EventRow( rawId = event.id, timestampMillis = event.timestampMillis, type = event.typeName(), isFailure = event.isFailure, hostOrTopic = event.hostOrTopic(), - payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event)), + payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event).withoutBodies()), ) +private fun EventDto.withoutBodies(): EventDto = when (this) { + is HttpDto -> copy(requestBody = null, responseBody = null) + is MqttDto -> copy(payload = null) + is BleDto -> copy(payload = null) +} + private fun SharinganEvent.typeName(): String = when (this) { is HttpEvent -> "HTTP" is MqttEvent -> "MQTT" diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index c15d6ad..e620dc0 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -91,6 +91,19 @@ internal class SharinganStoreTest { assertEquals(1, events.count { it is BleEvent }) } + @Test + fun `Given an onClear seam When the store is cleared Then the seam is invoked`() { + val store = SharinganStore(capacity = 10) + var cleared = 0 + store.onClear = { cleared++ } + + store.record(event("a")) + store.clear() + + assertEquals(1, cleared) + assertTrue(store.events.value.isEmpty()) + } + @Test fun `Given many producers recording concurrently When all complete Then no event is lost or duplicated`() = runTest { @@ -120,8 +133,6 @@ internal class SharinganStoreTest { } assertEquals(expected, ids.toSet()) } - assertEquals(expected, ids.toSet()) - } @Test fun `When persistence is off Then record keeps its behavior and onRecord stays null`() { diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt new file mode 100644 index 0000000..751e6cf --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt @@ -0,0 +1,68 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.BleOperation +import dev.sharingan.HttpEvent +import dev.sharingan.MqttDirection +import dev.sharingan.MqttEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PersistenceToRowTest { + + @Test + fun `Given an HTTP event with bodies When it becomes a row Then the bodies are not persisted`() { + val event = HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is HttpDto) + assertNull(dto.requestBody, "request bodies must stay off disk (persistBodies = false)") + assertNull(dto.responseBody, "response bodies must stay off disk (persistBodies = false)") + assertEquals("POST", dto.method) + } + + @Test + fun `Given an MQTT event with a payload When it becomes a row Then the payload is not persisted`() { + val event = MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + payload = """{"online":true}""", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is MqttDto) + assertNull(dto.payload, "MQTT payloads must stay off disk (persistBodies = false)") + assertEquals("devices/001/status", dto.topic) + } + + @Test + fun `Given a BLE event with a payload When it becomes a row Then the payload is not persisted`() { + val event = BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + payload = "{\"bpm\":72}", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is BleDto) + assertNull(dto.payload, "BLE payloads must stay off disk (persistBodies = false)") + assertEquals("HR-Monitor-A1", dto.device) + } +} From 8c70940403c04023ec65b3d2fa4c815e0dcb7086 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:56:59 +0100 Subject: [PATCH 26/28] fix: give the write-behind seam an owner (#50) The seam had no lifecycle. Persistence.start() wired store.onRecord and forgot both the controller and the store, so nothing could unwire it and 'started' latched true for the process lifetime. - Wire the seams BEFORE starting the flusher. start() is LAZY so the old order was harmless, but the dependency is now encoded rather than implied. - Add Persistence.stop(): unwires onRecord/onClear, closes the controller, and resets 'started' so a later start() works. Retains the store it wired. - @Volatile on both seam properties (kotlin.concurrent.Volatile, so it applies on Native as well as the JVM). They are written at process start and read on the capture path. Left deliberately: the dropped-batch report stays a println, since the project has no logging facility and a debug-only recorder does not justify inventing one. Both simplifications carry ponytail: comments naming the ceiling. Not unit-tested, and the docblock says why: start() builds a real driver, and DriverFactory.create() on Android needs the ContentProvider-installed Context that a JVM unit test cannot supply. The alternative was Robolectric or a controller-injection seam, neither justified by straight-line wiring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UBhi6Qx8rdo1aX9EnmtEJS --- .../dev/sharingan/db/PersistenceController.kt | 3 ++ .../kotlin/dev/sharingan/SharinganStore.kt | 3 ++ .../dev/sharingan/internal/Persistence.kt | 33 ++++++++++++++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 0692eb4..484f041 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -211,6 +211,9 @@ public class PersistenceController private constructor( } catch (t: Throwable) { // Reset the session in case its row rolled back with this transaction. if (isNewSession) sessionId = null + // ponytail: println is the whole reporting story — the project has no + // logging facility, and a debug-only recorder does not justify inventing + // one. Route this through a real logger if the library ever grows one. println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index c949fe6..9163e2d 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -1,5 +1,6 @@ package dev.sharingan +import kotlin.concurrent.Volatile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +29,7 @@ public class SharinganStore( * (see `dev.sharingan.db.PersistenceController`). Internal — never * part of the public API, so :sharingan-noop mirrors nothing here. */ + @Volatile internal var onRecord: ((SharinganEvent) -> Unit)? = null /** @@ -37,6 +39,7 @@ public class SharinganStore( * Internal — never part of the public API, so :sharingan-noop mirrors * nothing here. */ + @Volatile internal var onClear: (() -> Unit)? = null /** All retained events, oldest first. */ diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt index 8ef857f..6999474 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -15,19 +15,42 @@ import kotlinx.serialization.json.Json /** * Starts persistence once per process. The only caller is Android's * manifest-merged ContentProvider — there is no Kotlin call site to grep. + * [stop] tears the wiring down so a new lifecycle can start again. + * + * ponytail: not unit-tested. [start] builds a real driver, and on Android + * DriverFactory.create() needs the ContentProvider-installed Context, which a + * JVM unit test has none of. Covering this would mean either Robolectric or a + * controller-injection seam; neither is worth it for straight-line wiring. + * The persistence behaviour it wires is tested in :sharingan-db. */ @OptIn(ExperimentalAtomicApi::class) internal object Persistence { private val started = AtomicBoolean(false) private var controller: PersistenceController? = null + private var store: SharinganStore? = null fun start(store: SharinganStore = Sharingan.store) { if (!started.compareAndSet(false, true)) return - controller = PersistenceController(::toRow).also { ctrl -> - ctrl.start() - store.onRecord = { event -> ctrl.submit(event) } - store.onClear = { ctrl.clear() } - } + val controller = PersistenceController(::toRow) + // Wire the seams BEFORE the flusher starts: by the time an event can be + // accepted, the sink is complete. start() is lazy, but the ordering + // dependency should be encoded, not implied. + store.onRecord = { event -> controller.submit(event) } + store.onClear = { controller.clear() } + this.store = store + this.controller = controller + controller.start() + } + + /** Unwires the seams and closes the controller; [start] may run again afterwards. */ + suspend fun stop() { + val controller = controller ?: return + store?.onRecord = null + store?.onClear = null + store = null + this.controller = null + controller.close() + started.store(false) } } From 8b0424476b8d15d064a4642fb3722b8059fd0db0 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:58:58 +0100 Subject: [PATCH 27/28] chore: refresh :sharingan-db API dump for clear() (#50) The BCV gate added in 87f9e71 caught the ABI widening from the clear() work: PersistenceController.clear() (called cross-module by :sharingan) and the SQLDelight-generated deleteAllEvents(). Both are intended. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UBhi6Qx8rdo1aX9EnmtEJS --- sharingan-db/api/sharingan-db.api | 2 ++ sharingan-db/api/sharingan-db.klib.api | 2 ++ 2 files changed, 4 insertions(+) diff --git a/sharingan-db/api/sharingan-db.api b/sharingan-db/api/sharingan-db.api index caef775..e4e00d8 100644 --- a/sharingan-db/api/sharingan-db.api +++ b/sharingan-db/api/sharingan-db.api @@ -45,6 +45,7 @@ public final class dev/sharingan/db/EventRow { public final class dev/sharingan/db/PersistenceController { public fun (Lkotlin/jvm/functions/Function1;IJ)V public synthetic fun (Lkotlin/jvm/functions/Function1;IJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun clear ()V public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun start ()V public final fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -84,6 +85,7 @@ public final class dev/sharingan/db/SharinganDatabase$Companion { public final class dev/sharingan/db/SharinganDatabaseQueries : app/cash/sqldelight/TransacterImpl { public fun (Lapp/cash/sqldelight/db/SqlDriver;)V + public final fun deleteAllEvents ()V public final fun deleteSession (Ljava/lang/String;)V public final fun insertEvent (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V public final fun insertSession (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V diff --git a/sharingan-db/api/sharingan-db.klib.api b/sharingan-db/api/sharingan-db.klib.api index b61ff88..726e9a8 100644 --- a/sharingan-db/api/sharingan-db.klib.api +++ b/sharingan-db/api/sharingan-db.klib.api @@ -21,6 +21,7 @@ abstract interface dev.sharingan.db/SharinganDatabase : app.cash.sqldelight/Tran final class <#A: kotlin/Any> dev.sharingan.db/PersistenceController { // dev.sharingan.db/PersistenceController|null[0] constructor (kotlin/Function1<#A, dev.sharingan.db/EventRow>, kotlin/Int = ..., kotlin/Long = ...) // dev.sharingan.db/PersistenceController.|(kotlin.Function1<1:0,dev.sharingan.db.EventRow>;kotlin.Int;kotlin.Long){}[0] + final fun clear() // dev.sharingan.db/PersistenceController.clear|clear(){}[0] final fun start() // dev.sharingan.db/PersistenceController.start|start(){}[0] final fun submit(#A) // dev.sharingan.db/PersistenceController.submit|submit(1:0){}[0] final suspend fun close() // dev.sharingan.db/PersistenceController.close|close(){}[0] @@ -121,6 +122,7 @@ final class dev.sharingan.db/SharinganDatabaseQueries : app.cash.sqldelight/Tran final fun <#A1: kotlin/Any> selectAllSessions(kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectAllSessions|selectAllSessions(kotlin.Function6){0§}[0] final fun <#A1: kotlin/Any> selectEventsForSession(kotlin/String, kotlin/Function7): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectEventsForSession|selectEventsForSession(kotlin.String;kotlin.Function7){0§}[0] final fun <#A1: kotlin/Any> selectSession(kotlin/String, kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectSession|selectSession(kotlin.String;kotlin.Function6){0§}[0] + final fun deleteAllEvents() // dev.sharingan.db/SharinganDatabaseQueries.deleteAllEvents|deleteAllEvents(){}[0] final fun deleteSession(kotlin/String) // dev.sharingan.db/SharinganDatabaseQueries.deleteSession|deleteSession(kotlin.String){}[0] final fun insertEvent(kotlin/String, kotlin/String, kotlin/Long, kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertEvent|insertEvent(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] final fun insertSession(kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertSession|insertSession(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] From 377e2bb879575101dcdf12bea9aebe937f65e403 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 19:46:10 +0100 Subject: [PATCH 28/28] style: conform to the ktlint comments policy from develop (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop gained a ktlint ruleset (#58) after this branch forked: standard formatting rules plus a custom no-multi-line-comment rule. Apply ktlintFormat and condense every multi-line // run this branch introduced into a single line, per the AGENTS.md comments policy. KDoc is exempt, so the Persistence lifecycle docblock is unchanged. Also applies the ktlint plugin to :sharingan-db. The module was created on this branch before the ruleset existed, so it was escaping the lint gate the same way it was escaping the BCV gate — adding it surfaced eight violations that were previously invisible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UBhi6Qx8rdo1aX9EnmtEJS --- sharingan-db/build.gradle.kts | 25 +- .../dev/sharingan/db/DriverFactory.android.kt | 5 +- .../dev/sharingan/db/SharinganDbContext.kt | 5 +- .../dev/sharingan/db/PersistenceController.kt | 84 +-- .../sharingan/db/PersistenceControllerTest.kt | 620 +++++++++--------- .../db/SharinganDatabaseRoundTripTest.kt | 8 +- .../kotlin/dev/sharingan/db/Now.ios.kt | 3 +- sharingan/build.gradle.kts | 3 +- .../kotlin/dev/sharingan/SharinganStore.kt | 2 +- .../kotlin/dev/sharingan/internal/EventDto.kt | 94 +-- .../dev/sharingan/internal/Persistence.kt | 60 +- .../dev/sharingan/SharinganStoreTest.kt | 1 - .../dev/sharingan/internal/EventDtoTest.kt | 106 +-- .../internal/PersistenceToRowTest.kt | 50 +- 14 files changed, 560 insertions(+), 506 deletions(-) diff --git a/sharingan-db/build.gradle.kts b/sharingan-db/build.gradle.kts index 6569429..56e5a1c 100644 --- a/sharingan-db/build.gradle.kts +++ b/sharingan-db/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.sqldelight) alias(libs.plugins.mavenPublish) + alias(libs.plugins.ktlint) } group = "io.github.mibrahimdev" @@ -39,17 +40,25 @@ kotlin { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) } - androidMain.dependencies { implementation(libs.sqldelight.android.driver) } - iosMain.dependencies { implementation(libs.sqldelight.native.driver) } - androidUnitTest.dependencies{ implementation(libs.sqldelight.sqlite.driver) } - iosTest.dependencies { implementation(libs.sqldelight.native.driver) } + androidMain.dependencies { implementation(libs.sqldelight.android.driver) } + iosMain.dependencies { implementation(libs.sqldelight.native.driver) } + androidUnitTest.dependencies { implementation(libs.sqldelight.sqlite.driver) } + iosTest.dependencies { implementation(libs.sqldelight.native.driver) } } } android { namespace = "dev.sharingan.db" - compileSdk = libs.versions.android.compileSdk.get().toInt() - defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -58,7 +67,9 @@ android { mavenPublishing { publishToMavenCentral() - if (!providers.gradleProperty("localPublishNoSign").isPresent) { signAllPublications() } + if (!providers.gradleProperty("localPublishNoSign").isPresent) { + signAllPublications() + } coordinates(group.toString(), "sharingan-db", version.toString()) pom { name.set("Sharingan DB") diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt index 03dd69b..1f1f2cd 100644 --- a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt @@ -6,8 +6,9 @@ import app.cash.sqldelight.driver.android.AndroidSqliteDriver internal actual class DriverFactory actual constructor() { actual fun create(): SqlDriver { - val context = SharinganDbContext.appContext - ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") + val context = + SharinganDbContext.appContext + ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") return AndroidSqliteDriver( schema = SharinganDatabase.Schema, context = context, diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt index a95dbd1..fab0677 100644 --- a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt @@ -4,5 +4,8 @@ import android.content.Context public object SharinganDbContext { @Volatile internal var appContext: Context? = null - public fun install(context: Context) { appContext = context.applicationContext } + + public fun install(context: Context) { + appContext = context.applicationContext + } } diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt index 484f041..94f6d27 100644 --- a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -1,14 +1,8 @@ package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.AtomicInt -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.atomics.incrementAndFetch -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -16,10 +10,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Write-behind persistence: drains events off the caller's seam into an @@ -102,8 +102,7 @@ public class PersistenceController private constructor( */ public fun start() { if (stopped.load()) error("PersistenceController is single-use; already stopped") - // LAZY + start only on the CAS win: an eager launch would need a cancel() - // on the losing branch, which could prompt-cancel a receive and drop an event. + // LAZY + start only on the CAS win: an eager launch would need a cancel() that could drop an event. val job = scope.launch(start = CoroutineStart.LAZY) { runFlusher() } if (flusherJob.compareAndSet(null, job)) job.start() } @@ -134,8 +133,7 @@ public class PersistenceController private constructor( */ public suspend fun stop() { stopped.store(true) - // Close first: a start() that won the CAS after stopped=true then lands - // on a closed channel and exits at once. + // Close first so a start() that won the CAS after stopped=true lands on a closed channel and exits at once. channel.close() val job = flusherJob.exchange(null) ?: return job.join() @@ -153,28 +151,28 @@ public class PersistenceController private constructor( val batch = mutableListOf() var deadline = 0L while (true) { - val command: Command? = if (batch.isEmpty()) { - try { - channel.receive() - } catch (e: ClosedReceiveChannelException) { - break - } - } else { - // Atomic receive-or-timeout; withTimeoutOrNull could prompt-cancel - // an already-taken element and lose it. - var closed = false - select?> { - channel.onReceiveCatching { result -> - if (result.isClosed) { - closed = true - null - } else { - result.getOrNull() - } + val command: Command? = + if (batch.isEmpty()) { + try { + channel.receive() + } catch (e: ClosedReceiveChannelException) { + break } - onTimeout(deadline - nowMillis()) { null } - }.also { if (closed) break } - } + } else { + // Atomic receive-or-timeout; withTimeoutOrNull could prompt-cancel an already-taken element. + var closed = false + select?> { + channel.onReceiveCatching { result -> + if (result.isClosed) { + closed = true + null + } else { + result.getOrNull() + } + } + onTimeout(deadline - nowMillis()) { null } + }.also { if (closed) break } + } when (command) { is Command.Record -> { val event = command.event @@ -211,9 +209,7 @@ public class PersistenceController private constructor( } catch (t: Throwable) { // Reset the session in case its row rolled back with this transaction. if (isNewSession) sessionId = null - // ponytail: println is the whole reporting story — the project has no - // logging facility, and a debug-only recorder does not justify inventing - // one. Route this through a real logger if the library ever grows one. + // ponytail: println is the whole story — no logging facility exists; route through a real one if it ever gains one. println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") } } @@ -229,7 +225,10 @@ public class PersistenceController private constructor( ) } - private fun persist(row: EventRow, session: String) { + private fun persist( + row: EventRow, + session: String, + ) { database.sharinganDatabaseQueries.insertEvent( id = "$session-${row.rawId}", session_id = session, @@ -249,13 +248,14 @@ public class PersistenceController private constructor( } // DROP_OLDEST keeps the crash-tail — the newest events matter most here. -internal fun newEventChannel(capacity: Int): Channel = - Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) +internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) -// Everything the flusher does — writes and clears — travels on one channel, so -// the single flusher coroutine applies DB mutations in submission order. +// Writes and clears travel on one channel, so the single flusher applies DB mutations in submission order. private sealed interface Command { - data class Record(val event: T) : Command + data class Record( + val event: T, + ) : Command + data object ClearAll : Command } diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt index 90e6aa2..4d66a1e 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -10,378 +10,398 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue internal class PersistenceControllerTest { - - private fun event(id: String): EventRow = EventRow( - rawId = id, - timestampMillis = 0L, - type = "HTTP", - isFailure = false, - hostOrTopic = "api.example.com", - payloadJson = """{"method":"GET"}""", - ) - - @Test - fun `Given a burst within the channel capacity When flushed Then every event is persisted`() = runBlocking { - val total = 500 - val driver = createTestDriver() - val controller = PersistenceController(toRow = { it }, driver = driver) - - val done = CompletableDeferred() - var flushed = 0 - controller.onBatchFlushed = { size -> - flushed += size - if (flushed >= total) done.complete(Unit) - } - controller.start() - - repeat(total) { i -> controller.submit(event("e$i")) } - - withTimeout(10_000) { done.await() } - assertEquals(total, flushed) - - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(total, rows.size, "all $total events should reach the DB") - assertEquals(total, rows.map { it.id }.toSet().size, "persisted event ids must be unique") - - controller.close() - } - - @Test - fun `Given a burst beyond the channel capacity When flushed Then the newest events survive`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 4, - flushIntervalMillis = 60_000, - channelCapacity = 4, - ) - - fun row(id: String) = EventRow( + private fun event(id: String): EventRow = + EventRow( rawId = id, timestampMillis = 0L, type = "HTTP", isFailure = false, hostOrTopic = "api.example.com", - payloadJson = """{"id":"$id"}""", + payloadJson = """{"method":"GET"}""", ) - // Submit before start() so the flusher cannot drain the channel while - // the burst is being written; DROP_OLDEST must retain the newest 4. - repeat(10) { i -> controller.submit(row("e$i")) } + @Test + fun `Given a burst within the channel capacity When flushed Then every event is persisted`() = + runBlocking { + val total = 500 + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) + + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() - val done = CompletableDeferred() - controller.onBatchFlushed = { done.complete(Unit) } - controller.start() + repeat(total) { i -> controller.submit(event("e$i")) } - withTimeout(10_000) { done.await() } + withTimeout(10_000) { done.await() } + assertEquals(total, flushed) - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(4, rows.size, "channel should keep only the newest 4 events") - assertEquals( - setOf("""{"id":"e6"}""", """{"id":"e7"}""", """{"id":"e8"}""", """{"id":"e9"}"""), - rows.map { it.payload_json }.toSet(), - ) + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(total, rows.size, "all $total events should reach the DB") + assertEquals(total, rows.map { it.id }.toSet().size, "persisted event ids must be unique") - controller.close() - } + controller.close() + } @Test - fun `Given a toRow mapper that throws on one event When flushed Then the flusher survives and other events persist`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { row -> - if (row.rawId == "boom") error("mapper failure") - row - }, - driver = driver, - batchSize = 1, - flushIntervalMillis = 60_000, - ) - controller.start() + fun `Given a burst beyond the channel capacity When flushed Then the newest events survive`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 4, + flushIntervalMillis = 60_000, + channelCapacity = 4, + ) + + fun row(id: String) = + EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) + + // Submit before start() so the flusher cannot drain mid-burst; DROP_OLDEST must retain the newest 4. + repeat(10) { i -> controller.submit(row("e$i")) } + + val done = CompletableDeferred() + controller.onBatchFlushed = { done.complete(Unit) } + controller.start() - fun row(id: String) = EventRow( - rawId = id, - timestampMillis = 0L, - type = "HTTP", - isFailure = false, - hostOrTopic = "api.example.com", - payloadJson = """{"id":"$id"}""", - ) + withTimeout(10_000) { done.await() } - controller.submit(row("before")) - controller.submit(row("boom")) - controller.submit(row("after")) + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(4, rows.size, "channel should keep only the newest 4 events") + assertEquals( + setOf("""{"id":"e6"}""", """{"id":"e7"}""", """{"id":"e8"}""", """{"id":"e9"}"""), + rows.map { it.payload_json }.toSet(), + ) - controller.stop() + controller.close() + } - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(2, rows.size) - assertEquals(setOf("""{"id":"before"}""", """{"id":"after"}"""), rows.map { it.payload_json }.toSet()) + @Test + fun `Given a toRow mapper that throws on one event When flushed Then the flusher survives and other events persist`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { row -> + if (row.rawId == "boom") error("mapper failure") + row + }, + driver = driver, + batchSize = 1, + flushIntervalMillis = 60_000, + ) + controller.start() - controller.close() - } + fun row(id: String) = + EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) - @Test - fun `Given many events When flushed Then they are written in batches not one per event`() = runBlocking { - val total = 200 - val batchSize = 50 - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = batchSize, - flushIntervalMillis = 60_000, - ) + controller.submit(row("before")) + controller.submit(row("boom")) + controller.submit(row("after")) + + controller.stop() - val batches = mutableListOf() - val done = CompletableDeferred() - var flushed = 0 - controller.onBatchFlushed = { size -> - batches += size - flushed += size - if (flushed >= total) done.complete(Unit) + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(setOf("""{"id":"before"}""", """{"id":"after"}"""), rows.map { it.payload_json }.toSet()) + + controller.close() } - controller.start() - repeat(total) { i -> controller.submit(event("e$i")) } + @Test + fun `Given many events When flushed Then they are written in batches not one per event`() = + runBlocking { + val total = 200 + val batchSize = 50 + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = batchSize, + flushIntervalMillis = 60_000, + ) + + val batches = mutableListOf() + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + batches += size + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() - withTimeout(10_000) { done.await() } - assertEquals(total, batches.sum()) - assertTrue(batches.size < total, "expected batching, got ${batches.size} writes for $total events") - assertTrue(batches.all { it <= batchSize }, "no batch should exceed $batchSize, got $batches") + repeat(total) { i -> controller.submit(event("e$i")) } - controller.close() - } + withTimeout(10_000) { done.await() } + assertEquals(total, batches.sum()) + assertTrue(batches.size < total, "expected batching, got ${batches.size} writes for $total events") + assertTrue(batches.all { it <= batchSize }, "no batch should exceed $batchSize, got $batches") + + controller.close() + } @Test - fun `Given two sessions When events reuse a raw id Then persisted ids do not collide`() = runBlocking { - val driver = createTestDriver() - - val controller1 = PersistenceController(toRow = { it }, driver = driver) - val done1 = CompletableDeferred() - controller1.onBatchFlushed = { done1.complete(Unit) } - controller1.start() - controller1.submit(event("http-1")) - withTimeout(10_000) { done1.await() } - controller1.stop() - - val controller2 = PersistenceController(toRow = { it }, driver = driver) - val done2 = CompletableDeferred() - controller2.onBatchFlushed = { done2.complete(Unit) } - controller2.start() - controller2.submit(event("http-1")) - withTimeout(10_000) { done2.await() } - controller2.stop() - - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(2, rows.size) - assertEquals(2, rows.map { it.id }.toSet().size, "cross-session event ids must be globally unique") - - val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() - assertEquals(2, sessions.size) - - controller1.close() - controller2.close() - } + fun `Given two sessions When events reuse a raw id Then persisted ids do not collide`() = + runBlocking { + val driver = createTestDriver() + + val controller1 = PersistenceController(toRow = { it }, driver = driver) + val done1 = CompletableDeferred() + controller1.onBatchFlushed = { done1.complete(Unit) } + controller1.start() + controller1.submit(event("http-1")) + withTimeout(10_000) { done1.await() } + controller1.stop() + + val controller2 = PersistenceController(toRow = { it }, driver = driver) + val done2 = CompletableDeferred() + controller2.onBatchFlushed = { done2.complete(Unit) } + controller2.start() + controller2.submit(event("http-1")) + withTimeout(10_000) { done2.await() } + controller2.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(2, rows.map { it.id }.toSet().size, "cross-session event ids must be globally unique") + + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(2, sessions.size) + + controller1.close() + controller2.close() + } @Test - fun `Given a pending batch When clear is called Then no events are resurrected`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 100, - // Short deadline so flushed-1 lands; the pending events are queued - // behind ClearAll microseconds later, far ahead of the next deadline. - flushIntervalMillis = 100, - ) - val firstFlush = CompletableDeferred() - controller.onBatchFlushed = { firstFlush.complete(Unit) } - controller.start() + fun `Given a pending batch When clear is called Then no events are resurrected`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + // Short deadline so flushed-1 lands; pending events queue behind ClearAll well before the next deadline. + flushIntervalMillis = 100, + ) + val firstFlush = CompletableDeferred() + controller.onBatchFlushed = { firstFlush.complete(Unit) } + controller.start() - controller.submit(event("flushed-1")) - withTimeout(10_000) { firstFlush.await() } + controller.submit(event("flushed-1")) + withTimeout(10_000) { firstFlush.await() } - // Queued but never flushed: under the batch size and far from the deadline. - controller.submit(event("pending-1")) - controller.submit(event("pending-2")) + // Queued but never flushed: under the batch size and far from the deadline. + controller.submit(event("pending-1")) + controller.submit(event("pending-2")) - val cleared = CompletableDeferred() - controller.onCleared = { cleared.complete(Unit) } - controller.clear() - withTimeout(10_000) { cleared.await() } + val cleared = CompletableDeferred() + controller.onCleared = { cleared.complete(Unit) } + controller.clear() + withTimeout(10_000) { cleared.await() } - // Drain the flusher: a resurrection would commit the pending batch now. - controller.stop() + // Drain the flusher: a resurrection would commit the pending batch now. + controller.stop() - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(0, rows.size, "clear must delete persisted rows and discard the pending batch") + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(0, rows.size, "clear must delete persisted rows and discard the pending batch") - controller.close() - } + controller.close() + } @Test - fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = runBlocking { - val channel = newEventChannel(capacity = 4) - (1..10).map { event("e$it") }.forEach { channel.trySend(it) } - channel.close() + fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = + runBlocking { + val channel = newEventChannel(capacity = 4) + (1..10).map { event("e$it") }.forEach { channel.trySend(it) } + channel.close() - val received = mutableListOf() - for (e in channel) received += e + val received = mutableListOf() + for (e in channel) received += e - assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.rawId }) - } + assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.rawId }) + } @Test - fun `Given stop is called twice Then it is idempotent and does not crash`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 100, - flushIntervalMillis = 60_000, - ) - controller.start() - repeat(10) { i -> controller.submit(event("e$i")) } + fun `Given stop is called twice Then it is idempotent and does not crash`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) + controller.start() + repeat(10) { i -> controller.submit(event("e$i")) } - controller.stop() - controller.stop() + controller.stop() + controller.stop() - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(10, rows.size) + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(10, rows.size) - controller.close() - } + controller.close() + } @Test - fun `Given start is called twice without stop Then only one flusher runs and every event is persisted`() = runBlocking { - val total = 5 - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = total, - flushIntervalMillis = 60_000, - ) + fun `Given start is called twice without stop Then only one flusher runs and every event is persisted`() = + runBlocking { + val total = 5 + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = total, + flushIntervalMillis = 60_000, + ) + + val flushed = CompletableDeferred() + controller.onBatchFlushed = { flushed.complete(Unit) } - val flushed = CompletableDeferred() - controller.onBatchFlushed = { flushed.complete(Unit) } - - controller.start() - controller.start() + controller.start() + controller.start() - repeat(total) { i -> controller.submit(event("e$i")) } + repeat(total) { i -> controller.submit(event("e$i")) } - withTimeout(10_000) { flushed.await() } + withTimeout(10_000) { flushed.await() } - assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") + assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") - val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() - assertEquals(1, sessions.size, "only one flusher/session must be created") + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(1, sessions.size, "only one flusher/session must be created") - controller.close() - } + controller.close() + } @Test - fun `Given stop has been called When start is called again Then it throws IllegalStateException`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController(toRow = { it }, driver = driver) - controller.start() - controller.submit(event("a")) - controller.stop() - - assertFailsWith { + fun `Given stop has been called When start is called again Then it throws IllegalStateException`() = + runBlocking { + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) controller.start() - } + controller.submit(event("a")) + controller.stop() + + assertFailsWith { + controller.start() + } - controller.close() - } + controller.close() + } @Test - fun `Given buffered events When stopped Then the pending batch is drained before close`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 100, - flushIntervalMillis = 60_000, - ) - val batches = mutableListOf() - controller.onBatchFlushed = { size -> batches += size } - controller.start() + fun `Given buffered events When stopped Then the pending batch is drained before close`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) + val batches = mutableListOf() + controller.onBatchFlushed = { size -> batches += size } + controller.start() - repeat(30) { i -> controller.submit(event("e$i")) } + repeat(30) { i -> controller.submit(event("e$i")) } - controller.stop() + controller.stop() - assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") + assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") - controller.close() - } + controller.close() + } @Test - fun `Given events under the batch size When the deadline passes Then a deadline flush lands`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 50, - flushIntervalMillis = 250, - ) - val flushed = CompletableDeferred() - controller.onBatchFlushed = { flushed.complete(Unit) } - controller.start() + fun `Given events under the batch size When the deadline passes Then a deadline flush lands`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) + val flushed = CompletableDeferred() + controller.onBatchFlushed = { flushed.complete(Unit) } + controller.start() - controller.submit(event("a")) - controller.submit(event("b")) + controller.submit(event("a")) + controller.submit(event("b")) - withTimeout(10_000) { flushed.await() } + withTimeout(10_000) { flushed.await() } - val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() - assertEquals(2, rows.size) + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) - controller.close() - } + controller.close() + } @Test - fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = runBlocking { - val driver = createTestDriver() - val controller = PersistenceController( - toRow = { it }, - driver = driver, - batchSize = 50, - flushIntervalMillis = 250, - ) - - var firstFlushAt = 0L - var firstBatchSize = 0 - val flushed = CompletableDeferred() - controller.onBatchFlushed = { size -> - if (firstFlushAt == 0L) { - firstBatchSize = size - firstFlushAt = nowMillis() - flushed.complete(Unit) + fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) + + var firstFlushAt = 0L + var firstBatchSize = 0 + val flushed = CompletableDeferred() + controller.onBatchFlushed = { size -> + if (firstFlushAt == 0L) { + firstBatchSize = size + firstFlushAt = nowMillis() + flushed.complete(Unit) + } } - } - controller.start() + controller.start() - val startedAt = nowMillis() - repeat(10) { i -> - controller.submit(event("e$i")) - delay(100) - } + val startedAt = nowMillis() + repeat(10) { i -> + controller.submit(event("e$i")) + delay(100) + } - withTimeout(10_000) { flushed.await() } - val elapsed = firstFlushAt - startedAt - assertTrue(firstBatchSize < 10, "deadline flush should fire mid-stream, got a batch of $firstBatchSize") - assertTrue(elapsed < 600, "expected flush within interval, took $elapsed ms") + withTimeout(10_000) { flushed.await() } + val elapsed = firstFlushAt - startedAt + assertTrue(firstBatchSize < 10, "deadline flush should fire mid-stream, got a batch of $firstBatchSize") + assertTrue(elapsed < 600, "expected flush within interval, took $elapsed ms") - controller.close() - } + controller.close() + } } - - diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt index 0f65e9c..258ae1d 100644 --- a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt @@ -70,7 +70,13 @@ internal class SharinganDatabaseRoundTripTest { ) } - assertEquals(3, db.sharinganDatabaseQueries.selectAllEvents().executeAsList().size) + assertEquals( + 3, + db.sharinganDatabaseQueries + .selectAllEvents() + .executeAsList() + .size, + ) db.sharinganDatabaseQueries.deleteSession("session-1") diff --git a/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt index 6654b8c..135ee2e 100644 --- a/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt +++ b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt @@ -3,5 +3,4 @@ package dev.sharingan.db import platform.Foundation.NSDate import platform.Foundation.timeIntervalSince1970 -internal actual fun nowMillis(): Long = - (NSDate().timeIntervalSince1970 * 1000.0).toLong() +internal actual fun nowMillis(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/sharingan/build.gradle.kts b/sharingan/build.gradle.kts index 26535da..50d9b72 100644 --- a/sharingan/build.gradle.kts +++ b/sharingan/build.gradle.kts @@ -49,8 +49,7 @@ kotlin { implementation(libs.ktor.client.core) // Flight-recorder persistence (issue #27 / #49): DTO JSON codec. implementation(libs.kotlinx.serialization.json) - // On-device SQLDelight database; implementation dependency so its - // generated public types are not exported to consumers. + // On-device SQLDelight database; implementation-scoped so its generated types are not exported to consumers. implementation(project(":sharingan-db")) implementation(compose.runtime) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 9163e2d..3f9f317 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -1,10 +1,10 @@ package dev.sharingan -import kotlin.concurrent.Volatile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlin.concurrent.Volatile /** * In-memory ring buffer of captured [SharinganEvent]s. diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt index 3d9b66a..f0b0dec 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt @@ -21,54 +21,64 @@ internal sealed interface EventDto { val error: String? companion object { - fun fromEvent(event: SharinganEvent): EventDto = when (event) { - is HttpEvent -> HttpDto( - id = event.id, - timestampMillis = event.timestampMillis, - method = event.method, - url = event.url, - statusCode = event.statusCode, - durationMillis = event.durationMillis, - requestHeaders = event.requestHeaders.map { (name, value) -> HeaderDto(name, value) }, - responseHeaders = event.responseHeaders.map { (name, value) -> HeaderDto(name, value) }, - requestBody = event.requestBody, - responseBody = event.responseBody, - contentType = event.contentType, - responseSizeBytes = event.responseSizeBytes, - timing = event.timing.map { TimingPhaseDto(it.label, it.millis) }, - error = event.error, - ) - is MqttEvent -> MqttDto( - id = event.id, - timestampMillis = event.timestampMillis, - direction = event.direction.name, - topic = event.topic, - qos = event.qos, - retained = event.retained, - payload = event.payload, - payloadSizeBytes = event.payloadSizeBytes, - error = event.error, - ) - is BleEvent -> BleDto( - id = event.id, - timestampMillis = event.timestampMillis, - operation = event.operation.name, - device = event.device, - characteristic = event.characteristic, - uuid = event.uuid, - payload = event.payload, - sizeBytes = event.sizeBytes, - error = event.error, - ) - } + fun fromEvent(event: SharinganEvent): EventDto = + when (event) { + is HttpEvent -> + HttpDto( + id = event.id, + timestampMillis = event.timestampMillis, + method = event.method, + url = event.url, + statusCode = event.statusCode, + durationMillis = event.durationMillis, + requestHeaders = event.requestHeaders.map { (name, value) -> HeaderDto(name, value) }, + responseHeaders = event.responseHeaders.map { (name, value) -> HeaderDto(name, value) }, + requestBody = event.requestBody, + responseBody = event.responseBody, + contentType = event.contentType, + responseSizeBytes = event.responseSizeBytes, + timing = event.timing.map { TimingPhaseDto(it.label, it.millis) }, + error = event.error, + ) + is MqttEvent -> + MqttDto( + id = event.id, + timestampMillis = event.timestampMillis, + direction = event.direction.name, + topic = event.topic, + qos = event.qos, + retained = event.retained, + payload = event.payload, + payloadSizeBytes = event.payloadSizeBytes, + error = event.error, + ) + is BleEvent -> + BleDto( + id = event.id, + timestampMillis = event.timestampMillis, + operation = event.operation.name, + device = event.device, + characteristic = event.characteristic, + uuid = event.uuid, + payload = event.payload, + sizeBytes = event.sizeBytes, + error = event.error, + ) + } } } @Serializable -internal data class HeaderDto(val name: String, val value: String) +internal data class HeaderDto( + val name: String, + val value: String, +) @Serializable -internal data class TimingPhaseDto(val label: String, val millis: Long) +internal data class TimingPhaseDto( + val label: String, + val millis: Long, +) @Serializable internal data class HttpDto( diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt index 6999474..740bf2a 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -8,9 +8,9 @@ import dev.sharingan.SharinganEvent import dev.sharingan.SharinganStore import dev.sharingan.db.EventRow import dev.sharingan.db.PersistenceController +import kotlinx.serialization.json.Json import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlinx.serialization.json.Json /** * Starts persistence once per process. The only caller is Android's @@ -32,9 +32,7 @@ internal object Persistence { fun start(store: SharinganStore = Sharingan.store) { if (!started.compareAndSet(false, true)) return val controller = PersistenceController(::toRow) - // Wire the seams BEFORE the flusher starts: by the time an event can be - // accepted, the sink is complete. start() is lazy, but the ordering - // dependency should be encoded, not implied. + // Seams before flusher: start() is lazy so the old order worked by luck; this encodes the dependency. store.onRecord = { event -> controller.submit(event) } store.onClear = { controller.clear() } this.store = store @@ -56,32 +54,34 @@ internal object Persistence { internal val json = Json { encodeDefaults = true } -// Bodies stay in memory only — the flight-recorder design defaults to -// persistBodies = false. EventDto keeps the fields so a later slice can add -// the opt-in flag on this path. -internal fun toRow(event: SharinganEvent): EventRow = EventRow( - rawId = event.id, - timestampMillis = event.timestampMillis, - type = event.typeName(), - isFailure = event.isFailure, - hostOrTopic = event.hostOrTopic(), - payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event).withoutBodies()), -) +// Bodies stay off disk (design default persistBodies = false); EventDto keeps the fields for the slice-5 opt-in. +internal fun toRow(event: SharinganEvent): EventRow = + EventRow( + rawId = event.id, + timestampMillis = event.timestampMillis, + type = event.typeName(), + isFailure = event.isFailure, + hostOrTopic = event.hostOrTopic(), + payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event).withoutBodies()), + ) -private fun EventDto.withoutBodies(): EventDto = when (this) { - is HttpDto -> copy(requestBody = null, responseBody = null) - is MqttDto -> copy(payload = null) - is BleDto -> copy(payload = null) -} +private fun EventDto.withoutBodies(): EventDto = + when (this) { + is HttpDto -> copy(requestBody = null, responseBody = null) + is MqttDto -> copy(payload = null) + is BleDto -> copy(payload = null) + } -private fun SharinganEvent.typeName(): String = when (this) { - is HttpEvent -> "HTTP" - is MqttEvent -> "MQTT" - is BleEvent -> "BLE" -} +private fun SharinganEvent.typeName(): String = + when (this) { + is HttpEvent -> "HTTP" + is MqttEvent -> "MQTT" + is BleEvent -> "BLE" + } -private fun SharinganEvent.hostOrTopic(): String? = when (this) { - is HttpEvent -> host - is MqttEvent -> topic - is BleEvent -> device -} +private fun SharinganEvent.hostOrTopic(): String? = + when (this) { + is HttpEvent -> host + is MqttEvent -> topic + is BleEvent -> device + } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index e620dc0..2810fa7 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -182,5 +182,4 @@ internal class SharinganStoreTest { assertEquals(listOf("b", "c"), store.events.value.map { it.id }) assertEquals(listOf("a", "b", "c"), forwarded) } - } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt index 71cd8b8..33cea1f 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt @@ -10,23 +10,23 @@ import kotlin.test.Test import kotlin.test.assertTrue internal class EventDtoTest { - @Test fun `Given an HTTP event When encoded Then method url status and redacted header are present`() { - val event = HttpEvent( - id = "http-1", - timestampMillis = 1_700_000_000_000L, - method = "POST", - url = "https://api.example.com/v1/users", - statusCode = 201, - durationMillis = 42L, - requestHeaders = listOf("Authorization" to HttpLogger.REDACTED_VALUE, "Content-Type" to "application/json"), - responseHeaders = listOf("X-Request-Id" to "abc"), - requestBody = """{"name":"Ada"}""", - responseBody = """{"id":1}""", - contentType = "application/json", - responseSizeBytes = 128L, - ) + val event = + HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + durationMillis = 42L, + requestHeaders = listOf("Authorization" to HttpLogger.REDACTED_VALUE, "Content-Type" to "application/json"), + responseHeaders = listOf("X-Request-Id" to "abc"), + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + contentType = "application/json", + responseSizeBytes = 128L, + ) val dto = EventDto.fromEvent(event) val encoded = json.encodeToString(EventDto.serializer(), dto) @@ -42,16 +42,17 @@ internal class EventDtoTest { @Test fun `Given an MQTT publish event When encoded Then topic direction qos and retained flag are present`() { - val event = MqttEvent( - id = "mqtt-1", - timestampMillis = 1_700_000_000_000L, - direction = MqttDirection.PUBLISH, - topic = "devices/001/status", - qos = 1, - retained = true, - payload = """{"online":true}""", - payloadSizeBytes = 64L, - ) + val event = + MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + qos = 1, + retained = true, + payload = """{"online":true}""", + payloadSizeBytes = 64L, + ) val dto = EventDto.fromEvent(event) val encoded = json.encodeToString(EventDto.serializer(), dto) @@ -65,15 +66,16 @@ internal class EventDtoTest { @Test fun `Given an MQTT receive event When encoded Then direction is RECEIVE`() { - val event = MqttEvent( - id = "mqtt-2", - timestampMillis = 1_700_000_000_001L, - direction = MqttDirection.RECEIVE, - topic = "alerts/critical", - qos = 2, - retained = false, - payload = "alert", - ) + val event = + MqttEvent( + id = "mqtt-2", + timestampMillis = 1_700_000_000_001L, + direction = MqttDirection.RECEIVE, + topic = "alerts/critical", + qos = 2, + retained = false, + payload = "alert", + ) val dto = EventDto.fromEvent(event) val encoded = json.encodeToString(EventDto.serializer(), dto) @@ -85,16 +87,17 @@ internal class EventDtoTest { @Test fun `Given a BLE read event When encoded Then operation device and characteristic are present`() { - val event = BleEvent( - id = "ble-1", - timestampMillis = 1_700_000_000_000L, - operation = BleOperation.READ, - device = "HR-Monitor-A1", - characteristic = "Heart Rate Measurement", - uuid = "00002a37-0000-1000-8000-00805f9b34fb", - payload = "{\"bpm\":72}", - sizeBytes = 32L, - ) + val event = + BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + characteristic = "Heart Rate Measurement", + uuid = "00002a37-0000-1000-8000-00805f9b34fb", + payload = "{\"bpm\":72}", + sizeBytes = 32L, + ) val dto = EventDto.fromEvent(event) val encoded = json.encodeToString(EventDto.serializer(), dto) @@ -107,13 +110,14 @@ internal class EventDtoTest { @Test fun `Given a BLE error event When encoded Then error flag is present`() { - val event = BleEvent( - id = "ble-2", - timestampMillis = 1_700_000_000_001L, - operation = BleOperation.CONNECT, - device = "Scale-B2", - error = "GATT 133", - ) + val event = + BleEvent( + id = "ble-2", + timestampMillis = 1_700_000_000_001L, + operation = BleOperation.CONNECT, + device = "Scale-B2", + error = "GATT 133", + ) val dto = EventDto.fromEvent(event) val encoded = json.encodeToString(EventDto.serializer(), dto) diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt index 751e6cf..4fb7c18 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt @@ -11,18 +11,18 @@ import kotlin.test.assertNull import kotlin.test.assertTrue internal class PersistenceToRowTest { - @Test fun `Given an HTTP event with bodies When it becomes a row Then the bodies are not persisted`() { - val event = HttpEvent( - id = "http-1", - timestampMillis = 1_700_000_000_000L, - method = "POST", - url = "https://api.example.com/v1/users", - statusCode = 201, - requestBody = """{"name":"Ada"}""", - responseBody = """{"id":1}""", - ) + val event = + HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + ) val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) @@ -34,13 +34,14 @@ internal class PersistenceToRowTest { @Test fun `Given an MQTT event with a payload When it becomes a row Then the payload is not persisted`() { - val event = MqttEvent( - id = "mqtt-1", - timestampMillis = 1_700_000_000_000L, - direction = MqttDirection.PUBLISH, - topic = "devices/001/status", - payload = """{"online":true}""", - ) + val event = + MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + payload = """{"online":true}""", + ) val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) @@ -51,13 +52,14 @@ internal class PersistenceToRowTest { @Test fun `Given a BLE event with a payload When it becomes a row Then the payload is not persisted`() { - val event = BleEvent( - id = "ble-1", - timestampMillis = 1_700_000_000_000L, - operation = BleOperation.READ, - device = "HR-Monitor-A1", - payload = "{\"bpm\":72}", - ) + val event = + BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + payload = "{\"bpm\":72}", + ) val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson)