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
@@ -500,6 +500,7 @@ class LoadBenchmark {
// FilterIndex's byAuthor bucket.
val keys = List(subs) { KeyPair() }
val pubkeys = keys.map { it.pubKey.toHexKey() }
val signers = keys.map { NostrSignerSync(it) }
// Per-subscriber arrival timestamp. We
// overwrite per round; the publish path waits
@@ -579,8 +580,7 @@ class LoadBenchmark {
val start = System.nanoTime()
for (i in 0 until totalEvents) {
val target = i % subs
val signer = NostrSignerSync(keys[target])
val event = signer.sign(TextNoteEvent.build("scale-$i"))
val event = signers[target].sign(TextNoteEvent.build("scale-$i"))
arrivalNs.set(target, 0)
val pubStart = System.nanoTime()
pubClient.publishAndConfirm(event, setOf(relayUrl))