mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
2 Commits
b6bf3b8a70
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8370a2385b | ||
|
|
7126c3c53e |
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user