fix(quartz): close HashSet race in LiveEventStore.query dedupe

The previous design wrapped a mutable HashSet in an AtomicReference,
which makes the *reference* thread-safe but not the set's internals.
The historical-replay closure ran `seenIds.load()?.add(event.id)`
from the subscriber's coroutine while `deliver` ran
`seen.contains(event.id)` from the publisher's coroutine —
concurrent add/contains can corrupt buckets or throw CME.

The pre-index implementation didn't have this race because both
operations ran on the same Flow collector coroutine. Index-driven
dispatch put them on different coroutines.

Switch to AtomicReference<Set<String>?> over an immutable Set with a
CAS-loop on add. Reads in `deliver` always see a fully-constructed
snapshot. Single-writer in steady state so the CAS typically
succeeds first try; the loop only matters across the
`seenIds.store(null)` post-EOSE handoff.

Also:
- hoist `NostrSignerSync(keys[target])` out of the per-iteration
  loop in LoadBenchmark.fanoutScaling.
- add FilterIndex tests for `tagsAll` selection, multi-char tag
  fall-through, and re-register key-union semantics.
This commit is contained in:
Claude
2026-05-07 23:16:18 +00:00
parent 0d0fbf36c9
commit 70afcd10ca
3 changed files with 84 additions and 14 deletions
@@ -233,6 +233,62 @@ class FilterIndexTest {
assertEquals(setOf(s1, s2, s3), visited.toSet())
}
@Test
fun tagsAllFilterMatchesEventsCarryingAllTagValues() {
// tagsAll keys the index on the first single-letter entry
// exactly like `tags`. Events that match the index lookup
// still have to pass `Filter.match` for the AND-of-values
// semantics — the index is a super-set.
val index = FilterIndex<Sub>()
val s = Sub("s")
index.register(Filter(tagsAll = mapOf("p" to listOf(pTag1))), s)
assertTrue(s in index.candidatesFor(event(tags = arrayOf(arrayOf("p", pTag1)))))
assertFalse(s in index.candidatesFor(event(tags = arrayOf(arrayOf("p", pTag2)))))
}
@Test
fun multiCharTagKeyFallsThroughToKindThenUnindexed() {
// The index only buckets single-letter tag keys (NIP-12).
// A filter with only multi-char tag keys should fall through
// to the next dimension.
val index = FilterIndex<Sub>()
val withKind = Sub("withKind")
val withoutKind = Sub("withoutKind")
index.register(Filter(tags = mapOf("alt" to listOf("foo")), kinds = listOf(7)), withKind)
index.register(Filter(tags = mapOf("alt" to listOf("foo"))), withoutKind)
// withKind goes to KindKey(7); withoutKind to Unindexed.
assertTrue(withKind in index.candidatesFor(event(kind = 7)))
assertFalse(withKind in index.candidatesFor(event(kind = 1)))
assertTrue(withoutKind in index.candidatesFor(event(kind = 1)))
assertTrue(withoutKind in index.candidatesFor(event(kind = 7)))
}
@Test
fun reRegisterAddsAdditionalKeysWithoutDuplicating() {
// Calling register twice on the same subscriber unions the
// bucket assignments — useful when an observer wants to
// listen on multiple narrowing dimensions accumulated over
// time. The bucket sets must dedupe so the subscriber
// appears once in `candidatesFor`.
val index = FilterIndex<Sub>()
val s = Sub("s")
index.register(Filter(authors = listOf(authorA)), s)
index.register(Filter(kinds = listOf(7)), s)
// Author hit + kind hit = same subscriber, returned once.
val cands = index.candidatesFor(event(pubkey = authorA, kind = 7))
assertEquals(1, cands.size)
assertTrue(s in cands)
// Unregister cleans both dimensions.
index.unregister(s)
assertTrue(index.candidatesFor(event(pubkey = authorA)).isEmpty())
assertTrue(index.candidatesFor(event(kind = 7)).isEmpty())
}
@Test
fun registerThenUnregisterLeavesNoStaleBuckets() {
// Churn test — repeatedly add and remove a subscriber and