perf(quartz): index-driven fanout for LiveEventStore via FilterIndex<S>
Replace the SharedFlow-based broadcast in LiveEventStore with an inverted index over filter-bearing subscribers. Each REQ registers its filters into a per-store FilterIndex<LiveSubscription>; insert() calls index.candidatesFor(event) and only delivers to candidates whose Filter.match still passes. Cuts the per-event walk from O(N_subs * N_filters) to a few hash lookups plus match() over a small candidate set. FilterIndex itself lives next to Filter.kt (commonMain, KMP-friendly, AtomicReference + COW) so other call sites with the same shape (LocalCache.observables, ObservableEventStore.changes) can reuse it. Each filter contributes entries on its single most-selective dimension (ids > authors > tags > tagsAll > kinds > unindexed) to keep buckets narrow and avoid Set-dedupe work in candidatesFor. The historical-replay race the previous SharedFlow + onSubscription handoff closed is preserved by registering BEFORE replay starts and deduping seen ids until EOSE.
This commit is contained in:
+285
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.filters
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Inverted index over a population of [Filter]-bearing subscribers.
|
||||
* Given an [Event], returns the (much smaller) set of subscribers
|
||||
* whose filters could match — callers still run [Filter.match] on
|
||||
* each candidate to enforce negative constraints (`since` / `until`
|
||||
* / `tagsAll` / etc.) but skip the per-event walk over subscribers
|
||||
* that share no narrowing field with the event.
|
||||
*
|
||||
* Used by the relay server's `LiveEventStore` for fanout from one
|
||||
* inserted event to many `REQ` subscriptions, and by the client-side
|
||||
* `LocalCache.observables` registry for the same shape inside the
|
||||
* app (one accepted event, many feed observers).
|
||||
*
|
||||
* ## Indexing strategy
|
||||
*
|
||||
* Each registered filter contributes entries to **one** dimension —
|
||||
* the most selective indexable field. Picking one dimension instead
|
||||
* of all of them avoids over-counting subscribers in [candidatesFor]
|
||||
* (no `Set` dedup work) and minimises bucket churn on register /
|
||||
* unregister:
|
||||
*
|
||||
* 1. `ids` (most selective; an id matches one event).
|
||||
* 2. `authors`.
|
||||
* 3. The first single-letter tag in `tags` (then `tagsAll`).
|
||||
* 4. `kinds`.
|
||||
* 5. None of the above → registered into [unindexedKey].
|
||||
*
|
||||
* Multi-filter registrations OR the per-filter selections together
|
||||
* (a subscriber matches if *any* of its filters matches the event,
|
||||
* which mirrors `filters.any { it.match(...) }`).
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* State is held in a single [AtomicReference] and mutated via
|
||||
* copy-on-write CAS loops, mirroring the
|
||||
* `nip86RelayManagement.server.BanStore` pattern. Reads in
|
||||
* [candidatesFor] and [forEach] are wait-free single-load atomic.
|
||||
* Writes (subscription register / unregister) copy the inner maps
|
||||
* — fine for this workload because writes are subscription-rate
|
||||
* (rare) while reads are event-rate (frequent).
|
||||
*
|
||||
* ## What the index does NOT cover
|
||||
*
|
||||
* - Negative filter constraints (`since`, `until`, `tagsAll`,
|
||||
* `limit`-already-saturated). The candidate set is a
|
||||
* super-set; callers must still run [Filter.match] on each
|
||||
* candidate.
|
||||
* - Subscribers driven by an arbitrary `(Event) -> Boolean`
|
||||
* predicate without an underlying [Filter]. Use
|
||||
* [registerUnindexed] for those — they're returned for every
|
||||
* event.
|
||||
* - Membership-driven re-evaluation paths (e.g. an addressable
|
||||
* `v2` that no longer matches a filter but the observer
|
||||
* already holds `v1`). Those callers must consult their own
|
||||
* membership state in addition to [candidatesFor].
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class FilterIndex<S : Any> {
|
||||
/**
|
||||
* Bucket key. Five concrete shapes plus a sentinel for filters
|
||||
* with no indexable narrowing field.
|
||||
*/
|
||||
private sealed interface BucketKey
|
||||
|
||||
private data class IdKey(
|
||||
val id: HexKey,
|
||||
) : BucketKey
|
||||
|
||||
private data class AuthorKey(
|
||||
val author: HexKey,
|
||||
) : BucketKey
|
||||
|
||||
private data class TagKey(
|
||||
val letter: String,
|
||||
val value: String,
|
||||
) : BucketKey
|
||||
|
||||
private data class KindKey(
|
||||
val kind: Int,
|
||||
) : BucketKey
|
||||
|
||||
private object Unindexed : BucketKey
|
||||
|
||||
/**
|
||||
* Single immutable snapshot. [buckets] maps a key to the set of
|
||||
* subscribers registered under it; [assignments] is the reverse
|
||||
* map used by [unregister] to find a subscriber's keys without
|
||||
* scanning every bucket.
|
||||
*/
|
||||
private data class State<S>(
|
||||
val buckets: Map<BucketKey, Set<S>> = emptyMap(),
|
||||
val assignments: Map<S, Set<BucketKey>> = emptyMap(),
|
||||
)
|
||||
|
||||
private val state: AtomicReference<State<S>> = AtomicReference(State())
|
||||
|
||||
/** Number of distinct subscribers currently registered. */
|
||||
fun size(): Int = state.load().assignments.size
|
||||
|
||||
fun isEmpty(): Boolean = state.load().assignments.isEmpty()
|
||||
|
||||
/**
|
||||
* Register [subscriber] under the bucket(s) selected for [filter].
|
||||
* If [filter] has no indexable field the subscriber is added to
|
||||
* the unindexed pool and is returned for every event.
|
||||
*/
|
||||
fun register(
|
||||
filter: Filter,
|
||||
subscriber: S,
|
||||
) {
|
||||
val keys = selectKeys(filter).ifEmpty { listOf(Unindexed) }
|
||||
addAssignments(subscriber, keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register [subscriber] for a list of filters (OR semantics).
|
||||
* Each filter's most-selective dimension contributes its keys;
|
||||
* any filter with no indexable field adds the subscriber to the
|
||||
* unindexed pool, which dominates dispatch (the subscriber
|
||||
* matches every event).
|
||||
*/
|
||||
fun register(
|
||||
filters: List<Filter>,
|
||||
subscriber: S,
|
||||
) {
|
||||
if (filters.isEmpty()) {
|
||||
addAssignments(subscriber, listOf(Unindexed))
|
||||
return
|
||||
}
|
||||
val keys = mutableListOf<BucketKey>()
|
||||
for (f in filters) {
|
||||
val perFilter = selectKeys(f)
|
||||
if (perFilter.isEmpty()) {
|
||||
keys.add(Unindexed)
|
||||
} else {
|
||||
keys.addAll(perFilter)
|
||||
}
|
||||
}
|
||||
addAssignments(subscriber, keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register [subscriber] in the unindexed pool. Use this for
|
||||
* subscribers driven by an opaque predicate where the index
|
||||
* can't infer a narrowing field.
|
||||
*/
|
||||
fun registerUnindexed(subscriber: S) = addAssignments(subscriber, listOf(Unindexed))
|
||||
|
||||
/**
|
||||
* Remove [subscriber] from every bucket it was registered in.
|
||||
* No-op if the subscriber isn't currently registered.
|
||||
*/
|
||||
fun unregister(subscriber: S) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val keys = current.assignments[subscriber] ?: return
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
for (key in keys) {
|
||||
val cur = newBuckets[key] ?: continue
|
||||
val next = cur - subscriber
|
||||
if (next.isEmpty()) {
|
||||
newBuckets.remove(key)
|
||||
} else {
|
||||
newBuckets[key] = next
|
||||
}
|
||||
}
|
||||
val newAssignments = current.assignments - subscriber
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribers whose filters might match [event]. The result is a
|
||||
* super-set: callers must still run `filter.match(event)` on each
|
||||
* candidate to handle negative constraints.
|
||||
*
|
||||
* Iteration order is insertion-stable per call but otherwise
|
||||
* unspecified.
|
||||
*/
|
||||
fun candidatesFor(event: Event): Set<S> {
|
||||
val s = state.load()
|
||||
if (s.buckets.isEmpty()) return emptySet()
|
||||
val result = LinkedHashSet<S>()
|
||||
s.buckets[Unindexed]?.let { result.addAll(it) }
|
||||
s.buckets[IdKey(event.id)]?.let { result.addAll(it) }
|
||||
s.buckets[AuthorKey(event.pubKey)]?.let { result.addAll(it) }
|
||||
s.buckets[KindKey(event.kind)]?.let { result.addAll(it) }
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0].length == 1) {
|
||||
s.buckets[TagKey(tag[0], tag[1])]?.let { result.addAll(it) }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit every registered subscriber. Used by callers that need
|
||||
* to broadcast something the index can't help with (e.g.
|
||||
* `LocalCache.refreshDeletedNoteObservers` — the deletion path
|
||||
* has no event-shape to consult, every observer must see it).
|
||||
*/
|
||||
fun forEach(action: (S) -> Unit) {
|
||||
for (sub in state.load().assignments.keys) action(sub)
|
||||
}
|
||||
|
||||
private fun addAssignments(
|
||||
subscriber: S,
|
||||
keys: List<BucketKey>,
|
||||
) {
|
||||
if (keys.isEmpty()) return
|
||||
val keySet = keys.toSet()
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
for (key in keySet) {
|
||||
val cur = newBuckets[key] ?: emptySet()
|
||||
if (subscriber in cur) continue
|
||||
newBuckets[key] = cur + subscriber
|
||||
}
|
||||
val existing = current.assignments[subscriber]
|
||||
val merged = if (existing == null) keySet else existing + keySet
|
||||
val newAssignments = current.assignments + (subscriber to merged)
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the most-selective indexable dimension for [filter] and
|
||||
* expand it into one [BucketKey] per value. Returns an empty
|
||||
* list if no field is indexable — caller maps that to [Unindexed].
|
||||
*/
|
||||
private fun selectKeys(filter: Filter): List<BucketKey> {
|
||||
if (!filter.ids.isNullOrEmpty()) {
|
||||
return filter.ids.map { IdKey(it) }
|
||||
}
|
||||
if (!filter.authors.isNullOrEmpty()) {
|
||||
return filter.authors.map { AuthorKey(it) }
|
||||
}
|
||||
if (!filter.tags.isNullOrEmpty()) {
|
||||
val first =
|
||||
filter.tags.entries.firstOrNull {
|
||||
it.key.length == 1 && it.value.isNotEmpty()
|
||||
}
|
||||
if (first != null) return first.value.map { TagKey(first.key, it) }
|
||||
}
|
||||
if (!filter.tagsAll.isNullOrEmpty()) {
|
||||
val first =
|
||||
filter.tagsAll.entries.firstOrNull {
|
||||
it.key.length == 1 && it.value.isNotEmpty()
|
||||
}
|
||||
if (first != null) return first.value.map { TagKey(first.key, it) }
|
||||
}
|
||||
if (!filter.kinds.isNullOrEmpty()) {
|
||||
return filter.kinds.map { KindKey(it) }
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
+74
-44
@@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* A reactive event store that combines historical data retrieval with live event streaming.
|
||||
@@ -35,21 +36,41 @@ import kotlinx.coroutines.flow.onSubscription
|
||||
* End of Stored Events (EOSE), and then continues to stream matching new events as they
|
||||
* are inserted.
|
||||
*
|
||||
* Live fanout from `insert` to interested subscribers is index-driven via [FilterIndex]:
|
||||
* each [query] registers its filters; [insert] looks up the candidate set and only delivers
|
||||
* to those whose filters actually match. This avoids the quadratic
|
||||
* O(N_subscribers × N_filters_per_sub) per-event walk that a naïve broadcast would do.
|
||||
*
|
||||
* @property store The underlying persistent storage for events.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class LiveEventStore(
|
||||
private val store: IEventStore,
|
||||
) {
|
||||
private val newEventStream =
|
||||
MutableSharedFlow<Event>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100, // Optional: adjust for backpressure
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
|
||||
)
|
||||
private val index = FilterIndex<LiveSubscription>()
|
||||
|
||||
/**
|
||||
* One live REQ subscription. Carries the filters (for the
|
||||
* post-index `match` re-check needed for negative constraints
|
||||
* like `since` / `until` / `tagsAll`) and the delivery callback
|
||||
* the index dispatches into. Identity-keyed inside [FilterIndex].
|
||||
*/
|
||||
private class LiveSubscription(
|
||||
val filters: List<Filter>,
|
||||
val deliver: (Event) -> Unit,
|
||||
)
|
||||
|
||||
suspend fun insert(event: Event) {
|
||||
store.insert(event)
|
||||
newEventStream.tryEmit(event)
|
||||
// Live fanout. The index returns a super-set; `match` enforces
|
||||
// negative constraints. Synchronous delivery — callers are
|
||||
// expected to keep `deliver` cheap (typically a `tryEmit` to
|
||||
// a per-connection outbound queue).
|
||||
for (sub in index.candidatesFor(event)) {
|
||||
if (sub.filters.any { it.match(event) }) {
|
||||
sub.deliver(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun query(
|
||||
@@ -57,42 +78,51 @@ class LiveEventStore(
|
||||
onEach: (Event) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
// Order matters: register the live collector BEFORE replaying
|
||||
// stored events and signalling EOSE. Otherwise an event emitted
|
||||
// between EOSE and `collect` is lost because [newEventStream] has
|
||||
// replay=0. The race is only occasionally visible for kinds the
|
||||
// store persists (insert latency masks it) but fires reliably for
|
||||
// ephemeral kinds (20000-29999) where insert is a no-op — and
|
||||
// ephemeral events MUST still reach matching live subscribers per
|
||||
// NIP-01.
|
||||
// During the historical replay, mark ids the store has
|
||||
// emitted so the live path can dedupe. The index registers
|
||||
// *before* the replay starts (otherwise an event accepted
|
||||
// mid-replay would slip past the live path entirely — same
|
||||
// race the previous SharedFlow-based implementation closed
|
||||
// with `onSubscription`). Anything the store and the live
|
||||
// path both observe gets dropped here on the live side.
|
||||
//
|
||||
// Side effect of registering the collector first: an event
|
||||
// inserted *during* `store.query` will be both replayed by the
|
||||
// store AND emitted to the live stream. We dedupe by tracking
|
||||
// ids seen during the historical replay and skipping them on
|
||||
// the live path. The set is dropped after EOSE so live-only
|
||||
// events don't accumulate memory.
|
||||
var inHistoricalPhase = true
|
||||
var seenIds: HashSet<String>? = HashSet()
|
||||
val historicalOnEach: (Event) -> Unit = { event ->
|
||||
seenIds?.add(event.id)
|
||||
onEach(event)
|
||||
}
|
||||
newEventStream
|
||||
.onSubscription {
|
||||
store.query(filters, historicalOnEach)
|
||||
onEose()
|
||||
// Free the dedupe set once we've crossed EOSE: from
|
||||
// here on the live stream is the only source of
|
||||
// events, so duplicates aren't possible.
|
||||
inHistoricalPhase = false
|
||||
seenIds = null
|
||||
}.collect { newEvent ->
|
||||
if (inHistoricalPhase && seenIds?.contains(newEvent.id) == true) return@collect
|
||||
if (filters.any { it.match(newEvent) }) {
|
||||
onEach(newEvent)
|
||||
}
|
||||
// Held in an AtomicReference because the live-dispatch
|
||||
// coroutine (which calls `deliver` from `insert`) needs to
|
||||
// see the post-EOSE handoff promptly. Once cleared to null,
|
||||
// the dedupe check short-circuits and every live event is
|
||||
// forwarded. The set itself is mutated only from the
|
||||
// historical-replay closure below, which runs on the same
|
||||
// coroutine that owns `query` — no cross-thread mutation.
|
||||
val seenIds = AtomicReference<HashSet<String>?>(HashSet())
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val seen = seenIds.load()
|
||||
if (seen != null && seen.contains(event.id)) return@LiveSubscription
|
||||
onEach(event)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.query<Event>(filters) { event ->
|
||||
seenIds.load()?.add(event.id)
|
||||
onEach(event)
|
||||
}
|
||||
onEose()
|
||||
// Drop the dedupe set so the live path stops paying for
|
||||
// it. From this point the index drives delivery and
|
||||
// duplicates are no longer possible.
|
||||
seenIds.store(null)
|
||||
// Suspend until the caller's coroutine is cancelled
|
||||
// (e.g. NIP-01 CLOSE or connection drop). The `finally`
|
||||
// unregisters from the index.
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
index.unregister(sub)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.filters
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FilterIndexTest {
|
||||
private val authorA = "a".repeat(64)
|
||||
private val authorB = "b".repeat(64)
|
||||
private val authorC = "c".repeat(64)
|
||||
|
||||
private val pTag1 = "1".repeat(64)
|
||||
private val pTag2 = "2".repeat(64)
|
||||
|
||||
private fun event(
|
||||
id: String = "e".repeat(64),
|
||||
pubkey: String = authorA,
|
||||
kind: Int = 1,
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
createdAt: Long = 1_700_000_000,
|
||||
) = Event(
|
||||
id = id,
|
||||
pubKey = pubkey,
|
||||
createdAt = createdAt,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = "",
|
||||
sig = "",
|
||||
)
|
||||
|
||||
/** Distinct identity wrapper so tests can hold a stable handle. */
|
||||
private data class Sub(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun emptyIndexReturnsNoCandidates() {
|
||||
val index = FilterIndex<Sub>()
|
||||
assertTrue(index.isEmpty())
|
||||
assertTrue(index.candidatesFor(event()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorFilterMatchesByAuthor() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(authors = listOf(authorA)), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA)))
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorB)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleAuthorsAllRoutedToSameSubscriber() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(authors = listOf(authorA, authorB)), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA)))
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorB)))
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorC)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindFilterMatchesByKind() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(kinds = listOf(1, 7)), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(kind = 1)))
|
||||
assertTrue(s in index.candidatesFor(event(kind = 7)))
|
||||
assertFalse(s in index.candidatesFor(event(kind = 30023)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorWinsOverKindWhenBothPresent() {
|
||||
// Filter has authors AND kinds — the more-selective dimension
|
||||
// (authors) is used. An event with the right kind but a
|
||||
// different author must NOT appear in candidates, otherwise
|
||||
// the index didn't actually narrow.
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(authors = listOf(authorA), kinds = listOf(1)), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA, kind = 1)))
|
||||
// Wrong author, right kind — index excludes correctly.
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorB, kind = 1)))
|
||||
// Right author, wrong kind — index includes; Filter.match
|
||||
// would post-reject. Exposed candidate is acceptable.
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA, kind = 7)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idFilterMostSelective() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
val targetId = "9".repeat(64)
|
||||
index.register(Filter(ids = listOf(targetId), kinds = listOf(1)), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(id = targetId)))
|
||||
assertFalse(s in index.candidatesFor(event(id = "8".repeat(64))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tagFilterMatchesEventsCarryingTheTag() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(tags = mapOf("p" to listOf(pTag1))), s)
|
||||
|
||||
val matching = event(tags = arrayOf(arrayOf("p", pTag1)))
|
||||
val nonMatching = event(tags = arrayOf(arrayOf("p", pTag2)))
|
||||
|
||||
assertTrue(s in index.candidatesFor(matching))
|
||||
assertFalse(s in index.candidatesFor(nonMatching))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unindexedFilterMatchesEverything() {
|
||||
// A filter with no narrowing field (e.g. just `since`) lives
|
||||
// in the unindexed pool. Every event must include it.
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(since = 1L), s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA, kind = 1)))
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorB, kind = 30023)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registerUnindexedExplicit() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.registerUnindexed(s)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA)))
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorB)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unregisterRemovesFromAllBuckets() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(Filter(authors = listOf(authorA, authorB)), s)
|
||||
assertEquals(1, index.size())
|
||||
|
||||
index.unregister(s)
|
||||
assertEquals(0, index.size())
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorA)))
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorB)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unregisterOfUnknownIsNoOp() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.unregister(s) // should not throw
|
||||
assertEquals(0, index.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiFilterRegistrationOrsAllSelections() {
|
||||
// Subscriber wants events from authorA OR kind 30023.
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
index.register(
|
||||
filters =
|
||||
listOf(
|
||||
Filter(authors = listOf(authorA)),
|
||||
Filter(kinds = listOf(30023)),
|
||||
),
|
||||
subscriber = s,
|
||||
)
|
||||
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorA, kind = 1)))
|
||||
assertTrue(s in index.candidatesFor(event(pubkey = authorB, kind = 30023)))
|
||||
assertFalse(s in index.candidatesFor(event(pubkey = authorB, kind = 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleSubscribersUnionInCandidates() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s1 = Sub("s1")
|
||||
val s2 = Sub("s2")
|
||||
val s3 = Sub("s3")
|
||||
|
||||
index.register(Filter(authors = listOf(authorA)), s1)
|
||||
index.register(Filter(authors = listOf(authorB)), s2)
|
||||
index.register(Filter(kinds = listOf(1)), s3)
|
||||
|
||||
val cands = index.candidatesFor(event(pubkey = authorA, kind = 1))
|
||||
// s1 hits via author, s3 via kind, s2 must be excluded.
|
||||
assertTrue(s1 in cands)
|
||||
assertTrue(s3 in cands)
|
||||
assertFalse(s2 in cands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forEachVisitsEverySubscriberOnce() {
|
||||
val index = FilterIndex<Sub>()
|
||||
val s1 = Sub("s1")
|
||||
val s2 = Sub("s2")
|
||||
val s3 = Sub("s3")
|
||||
index.register(Filter(authors = listOf(authorA, authorB)), s1)
|
||||
index.register(Filter(kinds = listOf(1)), s2)
|
||||
index.registerUnindexed(s3)
|
||||
|
||||
val visited = mutableListOf<Sub>()
|
||||
index.forEach { visited.add(it) }
|
||||
|
||||
assertEquals(3, visited.size)
|
||||
assertEquals(setOf(s1, s2, s3), visited.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registerThenUnregisterLeavesNoStaleBuckets() {
|
||||
// Churn test — repeatedly add and remove a subscriber and
|
||||
// verify the index ends up empty (no leaked bucket entries).
|
||||
val index = FilterIndex<Sub>()
|
||||
val s = Sub("s")
|
||||
repeat(100) {
|
||||
index.register(
|
||||
Filter(authors = listOf(authorA), kinds = listOf(1, 7), tags = mapOf("p" to listOf(pTag1))),
|
||||
s,
|
||||
)
|
||||
index.unregister(s)
|
||||
}
|
||||
assertTrue(index.isEmpty())
|
||||
assertTrue(index.candidatesFor(event(pubkey = authorA)).isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user