Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
8370a2385b feat: add async SQLite event persistence layer
Memory remains source of truth at runtime (10k events/sec throughput).
AsyncEventWriter batches events into SQLite transactions in the background.
EventStoreBootstrap enables fast cold starts by loading from SQLite before
relay connections. SQLite triggers guarantee convergence regardless of
insertion order (replaceable dedup, deletions, expirations).

https://claude.ai/code/session_01MsP6nwrJEa8tEWAkKGFLbp
2026-03-28 18:03:04 +00:00
Vitor Pamplona
7126c3c53e feat: add async SQLite event persistence layer
Memory remains source of truth at runtime (10k events/sec throughput).
AsyncEventWriter batches events into SQLite transactions in the background.
EventStoreBootstrap enables fast cold starts by loading from SQLite before
relay connections. SQLite triggers guarantee convergence regardless of
insertion order (replaceable dedup, deletions, expirations).

https://claude.ai/code/session_01MsP6nwrJEa8tEWAkKGFLbp
2026-03-28 14:02:02 -04:00
3 changed files with 299 additions and 0 deletions

View File

@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.data.AsyncEventWriter
import com.vitorpamplona.amethyst.commons.data.EventStoreBootstrap
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
@@ -80,6 +82,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
@@ -217,6 +220,7 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
@@ -263,6 +267,52 @@ object LocalCache : ILocalCache, ICacheProvider {
val observables = ConcurrentHashMap<Observable, Observable>(10)
// Async persistence: memory is source of truth, SQLite catches up in background.
private var asyncWriter: AsyncEventWriter? = null
private var bootstrap: EventStoreBootstrap? = null
/**
* Initializes the async SQLite persistence layer.
* Call once at app startup, before connecting to relays.
*
* @param store The SQLite event store to persist into.
* @param scope CoroutineScope that controls the writer's lifetime.
*/
fun initPersistence(
store: IEventStore,
scope: CoroutineScope,
) {
bootstrap = EventStoreBootstrap(store)
asyncWriter = AsyncEventWriter(store, scope)
}
/**
* Loads events from SQLite into memory caches for a fast cold start.
* Call after [initPersistence] and before connecting to relays.
*
* Events are fed through the normal consume pipeline so all in-memory
* indexes and observable flows are populated correctly. They are NOT
* re-enqueued to the [AsyncEventWriter] since they already exist in SQLite.
*
* @param filters Filters selecting which events to bootstrap (e.g., user's
* own metadata, recent feed, deletion events).
* @return The number of events loaded.
*/
fun loadFromStore(filters: List<Filter>): Int =
bootstrap?.loadInto(filters) { event ->
justConsumeAndUpdateIndexes(event, null, true)
} ?: 0
/**
* Flushes pending writes and closes the persistence layer.
* Call during graceful app shutdown.
*/
fun closePersistence() {
asyncWriter?.close()
asyncWriter = null
bootstrap = null
}
fun Filter.match(note: Note): Boolean {
val event = note.event
return if (event != null) {
@@ -359,6 +409,13 @@ object LocalCache : ILocalCache, ICacheProvider {
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
/**
* Queries the SQLite store directly with a [Filter] and returns matching events.
* Useful for historical lookups when the in-memory cache doesn't have the data.
* Returns an empty list if persistence is not initialized.
*/
fun <T : Event> queryStore(filter: Filter): List<T> = bootstrap?.query(filter) ?: emptyList()
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
@@ -403,6 +460,26 @@ object LocalCache : ILocalCache, ICacheProvider {
override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) }
/**
* Tries to find a note in memory first. If the note has been garbage-collected
* from [LargeSoftCache], attempts to recover it from SQLite, re-consuming it
* into memory. Returns null only if the event doesn't exist anywhere.
*/
fun getOrRecoverNote(hexKey: String): Note? {
getNoteIfExists(hexKey)?.let { return it }
if (hexKey.length != 64) return null
// Attempt SQLite recovery
val event = bootstrap?.queryById(hexKey) ?: return null
val note = getOrCreateNote(event.id)
if (note.event == null) {
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
}
return note
}
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
fun getPublicChatChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key)
@@ -2887,6 +2964,9 @@ object LocalCache : ILocalCache, ICacheProvider {
observables.forEach(observableBiConsumer)
live.newNote(newNote)
// Persist asynchronously — fire-and-forget, never blocks
asyncWriter?.enqueue(event)
}
private fun refreshDeletedNoteObservers(newNote: Note) {

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.data
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Asynchronously persists events to a SQLite [IEventStore] without blocking
* the caller. Events are buffered in a [Channel] and written in batched
* transactions on a background coroutine.
*
* Memory (LocalCache) remains the source of truth while the app is running.
* The database is a best-effort persistence layer that converges to the same
* state via SQLite triggers (replaceable dedup, deletion rejection, expiration,
* etc.), regardless of insertion order.
*
* @param store The SQLite event store to persist into.
* @param scope CoroutineScope that controls the writer's lifetime.
* @param maxBatchSize Maximum number of events to write in a single transaction.
*/
class AsyncEventWriter(
private val store: IEventStore,
private val scope: CoroutineScope,
private val maxBatchSize: Int = 1000,
) {
private val queue = Channel<Event>(capacity = Channel.UNLIMITED)
init {
scope.launch(Dispatchers.IO) {
processQueue()
}
}
/**
* Enqueues an event for background persistence. Never blocks the caller.
*/
fun enqueue(event: Event) {
queue.trySend(event)
}
private suspend fun processQueue() {
val batch = mutableListOf<Event>()
while (scope.isActive) {
batch.clear()
// Suspend until at least one event is available
batch.add(queue.receive())
// Drain more events without suspending, up to maxBatchSize
while (batch.size < maxBatchSize) {
val next = queue.tryReceive().getOrNull() ?: break
batch.add(next)
}
writeBatch(batch)
}
}
private fun writeBatch(batch: List<Event>) {
try {
store.transaction {
for (event in batch) {
try {
insert(event)
} catch (e: Exception) {
// Expected for duplicates, expired events, etc.
// SQLite triggers reject them — this is normal.
}
}
}
} catch (e: Exception) {
Log.e("AsyncEventWriter", "Transaction failed for batch of ${batch.size}: ${e.message}", e)
}
}
/**
* Flushes all pending events synchronously. Useful during graceful shutdown
* to minimize data loss.
*/
fun flush() {
val remaining = mutableListOf<Event>()
while (true) {
val event = queue.tryReceive().getOrNull() ?: break
remaining.add(event)
}
if (remaining.isNotEmpty()) {
// Write in batches
remaining.chunked(maxBatchSize).forEach { chunk ->
writeBatch(chunk)
}
}
}
fun close() {
flush()
queue.close()
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.data
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.Log
/**
* Loads events from the SQLite [IEventStore] into memory on cold start.
*
* This runs once at app launch, before relay connections are established,
* so the UI can display cached data immediately. Each event is fed through
* a consumer callback (typically [LocalCache.justConsumeMyOwnEvent]) so
* the in-memory caches, indexes, and observable flows are populated exactly
* as if the events had arrived from a relay.
*/
class EventStoreBootstrap(
private val store: IEventStore,
) {
/**
* Loads events matching the given [filters] from SQLite and feeds each
* one through [consumer]. Returns the total number of events loaded.
*
* @param filters Filters selecting which events to load (e.g., user's
* own replaceable events, recent feed, deletion events).
* @param consumer Callback that ingests each event into memory caches.
* Should NOT re-enqueue events to the [AsyncEventWriter]
* since they are already persisted.
*/
fun loadInto(
filters: List<Filter>,
consumer: (Event) -> Unit,
): Int {
var count = 0
for (filter in filters) {
try {
store.query<Event>(filter) { event ->
consumer(event)
count++
}
} catch (e: Exception) {
Log.e("EventStoreBootstrap", "Failed to load filter $filter: ${e.message}", e)
}
}
Log.d("EventStoreBootstrap", "Loaded $count events from local store")
return count
}
/**
* Queries the store for a single event by ID. Useful for on-demand
* cache-miss recovery when a Note has been garbage-collected from
* the in-memory [LargeSoftCache].
*/
fun queryById(id: HexKey): Event? =
try {
val results: List<Event> = store.query(Filter(ids = listOf(id), limit = 1))
results.firstOrNull()
} catch (e: Exception) {
Log.e("EventStoreBootstrap", "Failed to query event $id: ${e.message}", e)
null
}
/**
* Queries the store with an arbitrary filter. Useful for on-demand
* lookups when the memory cache doesn't have the data.
*/
fun <T : Event> query(filter: Filter): List<T> =
try {
store.query(filter)
} catch (e: Exception) {
Log.e("EventStoreBootstrap", "Failed to query: ${e.message}", e)
emptyList()
}
}