perf(amethyst): use FilterIndex for LocalCache.observables fanout

Replaces the ConcurrentHashMap<Observable, Observable> registry with
a FilterIndex<Observable>. observeNotes / observeEvents /
observeNewEvents(Filter) call register(filter, observer); the
predicate-only observeNewEvents(predicate) overload uses
registerUnindexed because the index can't introspect an opaque
predicate.

refreshNewNoteObservers now iterates index.candidatesFor(event)
instead of every observer. refreshDeletedNoteObservers still uses
forEach — the index doesn't help on deletes (every observer might
hold the deleted note in its result set, no event-shape to consult).

Same FilterIndex<S> the relay's LiveEventStore uses; one structure,
two call sites, identical fanout shape.
This commit is contained in:
Claude
2026-05-07 22:37:44 +00:00
parent 3946117084
commit a43783879b
2 changed files with 43 additions and 22 deletions
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
Log.d( Log.d(
STATE_DUMP_TAG, STATE_DUMP_TAG,
"Observables: " + "Observables: " +
LocalCache.observables.size, LocalCache.observables.size(),
) )
Log.d( Log.d(
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
@@ -261,7 +262,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.SortedSet import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
interface ILocalCache { interface ILocalCache {
fun markAsSeen( fun markAsSeen(
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex() val deletionIndex = DeletionIndex()
val observables = ConcurrentHashMap<Observable, Observable>(10) /**
* Inverted index over the active [Observable]s. New events fan
* out only to observers whose filter actually narrows on a field
* the event carries (author, kind, single-letter tag), instead
* of waking every observer per event. Predicate-only observers
* (no underlying [Filter]) live in the unindexed pool and still
* see every event.
*/
val observables = FilterIndex<Observable>()
fun Filter.match(note: Note): Boolean { fun Filter.match(note: Note): Boolean {
val event = note.event val event = note.event
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
newFilter.init() newFilter.init()
observables[newFilter] = newFilter observables.register(filter, newFilter)
awaitClose { awaitClose {
observables.remove(newFilter) observables.unregister(newFilter)
} }
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
cachedFilter.init() cachedFilter.init()
observables.put(cachedFilter, cachedFilter) observables.register(filter, cachedFilter)
awaitClose { awaitClose {
observables.remove(cachedFilter) observables.unregister(cachedFilter)
} }
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
trySend(it) trySend(it)
} }
observables.put(newFilter, newFilter) // Unindexed: predicate is opaque, the index can't narrow.
// Caller is delivered every event and runs the predicate.
observables.registerUnindexed(newFilter)
awaitClose { awaitClose {
observables.remove(newFilter) observables.unregister(newFilter)
} }
} }
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match) fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
callbackFlow {
val newFilter =
NewEventMatchingFilter<T>(filter::match) {
trySend(it)
}
observables.register(filter, newFilter)
awaitClose {
observables.unregister(newFilter)
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() } fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
@@ -2514,22 +2536,21 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) { private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event val event = newNote.event as Event
val observableBiConsumer = // Index-driven fanout: only observers whose filter narrows
java.util.function.BiConsumer<Observable, Observable> { _, u -> // on a field this event carries (or that registered as
u.new(event, newNote) // unindexed) get woken up. The match check inside each
} // observer's `new()` still enforces negative constraints.
for (observer in observables.candidatesFor(event)) {
observables.forEach(observableBiConsumer) observer.new(event, newNote)
}
live.newNote(newNote) live.newNote(newNote)
} }
private fun refreshDeletedNoteObservers(newNote: Note) { private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer = // Deletes don't have a filterable shape — every observer
java.util.function.BiConsumer<Observable, Observable> { _, u -> // might hold this note in its result set, so iterate them
u.remove(newNote) // all. The index doesn't help here.
} observables.forEach { it.remove(newNote) }
observables.forEach(observableBiConsumer)
live.removedNote(newNote) live.removedNote(newNote)
} }