Replaces the hand-rolled raw-WebSocket NIP-77 negotiate loop (single un-windowed session — a strfry max_sync_events overflow was a hard error) with quartz's negentropyReconcile: created_at window splitting on overflow, keep-alive connection pinning, and streaming id batches. Downloads and uploads now pipeline with the remaining reconcile rounds: need-id batches feed 4 concurrent by-id drains, have-ids feed an uploader (peak 7 subscriptions, under the common relay cap of 20). Every downloaded event still funnels through the verify-and-store path. Output field 'rounds' (protocol round-trips) is now 'windows' (created_at splits). Verified end-to-end against embedded geode relays: down-only 25/25, up-only 5/5, and bidirectional re-runs converge to a zero diff. Also records both adoptions in the perf plan doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
37 KiB
NostrClient receiver performance — review + production measurements
Date: 2026-07-02
Status: findings + measurement harness; no production code changed yet
Harness: quartz/src/jvmTest/.../relay/prodbench/ProductionReceiverBenchmark.kt
(run with ./gradlew :quartz:jvmTest --tests "*.ProductionReceiverBenchmark" -PprodRelayBench=1)
Question
Is the NostrClient's single-threaded receiver making us significantly slower? And how do our filters actually behave against production relays?
What the receiver actually is (review)
The receive path is single-threaded per relay connection, not globally:
OkHttp reader thread (per socket)
└─ trySendBlocking → Channel(UNLIMITED) [BasicOkHttpWebSocket / app OkHttpWebSocket]
└─ ONE consumer coroutine per connection (Dispatchers.IO)
├─ OptimizedJsonMapper.fromJsonToMessage [JSON parse] ~32µs/msg (JVM)
├─ PoolRequests.onIncomingMessage [spin-lock + state]
│ └─ SubscriptionListener.onEvent [assemblers, inline]
└─ NostrClient.listeners.forEach [EventCollector, loggers…]
└─ LocalCache.justConsume(…, wasVerified = false)
└─ dedup by id → justVerify() [Schnorr verify] ~75µs/event (JVM)
└─ note creation, index updates, flow emissions
Everything downstream of the channel — parse, subscription state machine, every
listener, signature verification, and LocalCache consumption — runs serially on
that one consumer coroutine. Different relays run on different coroutines
(cross-relay parallelism exists), but they contend on shared state: the
PoolRequests spin lock, LocalCache's maps, and (on Android) the same
Dispatchers.IO pool as everything else.
Notably, the relay-server side already solved this on the write path:
IngestQueue batches incoming EVENTs and runs Schnorr verification for a batch
in parallel on Dispatchers.Default (parallelVerify), precisely because "an
event's verify() is CPU-bound and parallelisable". The client receiver has no
equivalent.
Production measurements (2026-07-02)
Environment: JVM 21, 4-core x86 cloud container (server CPU — phones are
5–10× slower per core), relays relay.damus.io, nos.lol, relay.primal.net,
nostr.wine. All scenarios ran after a discarded warmup pass (JIT warm).
INLINE = verify on the receiver coroutine (what the app does today via
CacheClientConnector); PARALLEL = verify offloaded to a
Dispatchers.Default pool.
Filter behavior
| Scenario | received | unique | duplicates | notes |
|---|---|---|---|---|
| kind-1 firehose, limit 500 ×4 relays | 2013 | 1738 | 14% | all relays honored limit; EOSE 0.3–1.0s after REQ |
| notifications (p=busy pubkey, kinds 1,6,7,9735) | 1552 | 986 | 36% | primal returned only 52 (thin index for this query) |
| metadata burst (kinds 0,10002, 300 authors) | 1146 | 491 | 57% | each profile fetched ~2.3× across 4 relays |
- Time from
subscribe()to REQ-on-the-wire is dominated by connect/TLS (~0.7–1.5s cold); local dispatch is negligible. - Event sizes vary wildly by relay: damus kind-1 averaged 8.7 KB/event (4.4 MB for 500 events) vs ~0.8 KB on nos.lol — parse cost tracks size (p90 proc 1.5ms on damus vs 0.18ms on nos.lol).
- Duplicate factor grows with fan-out: the metadata pattern wastes >half the received bytes on duplicates. Dedup happens before verify (LocalCache checks the id first), so duplicates cost parse + dedup lookup, not a verify.
Receiver queue behavior (the actual question)
Queue delay = how long a frame sat in the per-connection channel before the consumer picked it up. This is the direct symptom of a saturated receiver.
kind-1 firehose (large events), per relay:
| INLINE p50 / p90 / max | PARALLEL p50 / p90 / max | |
|---|---|---|
| relay.damus.io | 6.8ms / 26.6ms / 77.9ms | 5.4ms / 38.1ms / 41.5ms |
| nos.lol | 4.3ms / 11.2ms / 15.9ms | 1.3ms / 3.8ms / 4.5ms |
| relay.primal.net | 5.4ms / 24.5ms / 28.1ms | 1.2ms / 1.8ms / 7.7ms |
| nostr.wine | 9.4ms / 30.3ms / 34.8ms | 2.4ms / 8.4ms / 20.7ms |
metadata burst (small events, fastest arrival — damus delivered at 3300–8400 msg/s):
| INLINE p50 / max | PARALLEL p50 / max | |
|---|---|---|
| relay.damus.io | 3.0ms / 10.7ms | 0.14ms / 0.48ms |
Consumer busy fraction peaked at 53% (nostr.wine, INLINE, 3000 msg/s) on this fast CPU — i.e. a single relay bursting at production speed already consumes half of one core with inline verification, on hardware much faster than a phone.
Single-thread ceilings (offline, captured production frames)
- JSON parse: 31,000 msg/s (32µs/msg, mixed sizes incl. damus 8.7KB events)
- Schnorr verify: 13,200 events/s sequential (75µs/event); 34,400 events/s across 4 cores (2.6–3.0× speedup)
- Combined parse+verify ceiling for one receiver coroutine: ~9,000 events/s on this CPU. On a phone core (5–10× slower): ~1,000–2,000 events/s, and that is before LocalCache's note creation, index updates and flow emissions, which the harness does not model and which run on the same coroutine.
Answer
-
The receiver is not globally single-threaded — it is one consumer per relay. Cross-relay parallelism already exists. The per-relay serialization is what caps throughput.
-
For steady-state browsing the receiver is not the bottleneck. Queue delays with today's inline design are single-digit-to-tens of ms; connect latency and relay EOSE times (300ms–1.5s) dominate what the user perceives.
-
For burst ingest (app start, feed switch, big profile sync) it is a real cost, and it scales linearly with burst size. A 5,000-event burst from one fast relay serializes ~375ms of parse+verify on this benchmark machine — plausibly 2–4s per relay on a mid-range phone, during which that relay's EOSE, OKs and live events all sit behind the backlog. Verification is 60–70% of that inline cost and is embarrassingly parallel (measured 2.6–3× on 4 cores; phones have 8).
-
Offloading verification cuts queue latency 2–5× and raises the per-relay ceiling ~3× with no protocol or API change — the exact pattern
IngestQueue.parallelVerifyalready uses on the server side.
Dispatch stage (post-parse, pre-verify) microbenchmark
Harness: quartz/src/jvmTest/.../relay/prodbench/DispatchStageBenchmark.kt
(offline, ungated: ./gradlew :quartz:jvmTest --tests "*.DispatchStageBenchmark")
Measures everything between the JSON parse and where verification would start:
NostrClient.onIncomingMessage → PoolRequests (spin lock + sub state) →
listeners → id-dedup → handoff to a verify stage. 30k unique synthetic events,
2 overlapping subs per relay, delivered by 1 feeder thread (one busy relay) and
by 4 (four relays bursting into the shared client). JVM 21, 4 cores.
| variant | 1 feeder (deliveries/s) | 4 feeders (aggregate deliveries/s) |
|---|---|---|
| PoolRequests-only | 11.1M | 3.6M |
| full dispatch, no-op sink | 10.2M | 4.3M |
| + dedup (CHM keySet) | 6.8M | 3.5M |
| + dedup + per-event channel handoff | 3.6M | 3.1M |
| + dedup + batched handoff (64) | 6.8M | 2.8M |
| early dedup + batched handoff | 7.2M | 11.4M |
Findings:
- Uncontended, the dispatch stage is ~100ns/message — 300× cheaper than parse (32µs) and 750× cheaper than verify (75µs). One relay can never saturate it; nothing to fix for the single-relay case.
- It scales negatively under concurrency. Four feeder threads deliver
less aggregate throughput (3.6–4.3M/s) than one thread alone (10–11M/s):
the
PoolRequestsbusy-wait spin lock serializes every EVENT frame from every relay and burns the other cores spinning. IsolatedPoolRequestsshows the same collapse, so the lock (not listeners or dedup) is the cause. At production message rates (~3k msg/s/relay) this is not yet the bottleneck, but it wastes cores the verify pool would want, and it is the structural ceiling once verify moves off the receiver. - Handoff granularity matters: a per-event
Channel.sendcosts ~180ns extra per event (halves single-feeder throughput); a 64-event batch makes the handoff essentially free — same conclusion the server'sIngestQueuealready embodies. - Early dedup is the biggest lever under multi-relay load: checking the
seen-ids set right after parse — before entering the locked dispatch path —
let duplicate frames (75% of deliveries in the 4-relay setup; production
showed 14–57% dups) skip the contended section entirely: 2.7–4× the
aggregate throughput of every other 4-feeder variant. Semantic caveat: a
short-circuited duplicate no longer bumps that sub's per-relay
onNewEvent/stats counters, so paging/EOSE bookkeeping would need the cheap counters kept ahead of the skip.
Bulk download: N-million events from one relay (download + parse only)
Harness: quartz/src/jvmTest/.../relay/prodbench/BulkDownloadBenchmark.kt
(gated: ./gradlew :quartz:jvmTest --tests "*.BulkDownloadBenchmark" -PprodRelayBench=1)
Local ceilings (geode on localhost TCP, 100k seeded events, 4 cores)
| strategy | events/s | wall for 100k |
|---|---|---|
| quartz, 1 conn, one giant REQ | 476 | 210s (and 2 events silently missing) |
| quartz, 4 conns time-sharded, giant REQs | 2,001 | 50s |
| quartz, 1 conn, paged 1000/page | 12,480 | 8.0s |
| quartz, 4 conns time-sharded + paged | 24,210 | 4.1s |
| raw socket (no parse), one giant REQ | ~676 | timed out at 120s (81k/100k) |
- Giant single REQs are a trap. The raw (no-parse) variant proves it's
server-side: geode streams a 100k-event response at ~700 events/s
(per-frame cost in the session pump / query streaming path — needs its own
investigation,
WebSocketSessionPump), and the quartz run came back 2 events short. Public relays are worse: they clamp limits and may drop frames or the connection under output backpressure. Paged cursors through the very same server ran 26× faster. - Sharding the
created_atrange across connections stacks with paging: 4 sharded + paged connections ≈ 2× one paged connection locally (24k/s), and each connection gets its own receiver coroutine, so parse parallelizes for free.
Per-frame strategies, offline (50k frames, ~580B each)
| strategy | events/s | µs/frame |
|---|---|---|
full fromJsonToMessage, 1 thread |
276k | 3.6 |
| full parse, 4 threads | 657k | 1.5 (aggregate) |
| id-scan only (archive raw, parse lazily) | 2.96M | 0.34 |
Parse of small events is ~3.6µs; the earlier production capture (mixed sizes, damus 8.7KB events) measured 32µs. Either way parse is not the bottleneck for bulk download: one core parses 10M small events in ~36s, and a 4-core fan-out does it in ~15s. The wire and the relay's page cadence dominate.
Production test case: kinds=[30382] on nip85.nosfabrica.com (cap 20k)
| strategy | wall | events/s | notes |
|---|---|---|---|
| paged download (until-cursor) | 5.4s | 3,711 | 41 pages ≈ 500/page (relay clamp) |
| NIP-77 negentropy sync | 12.7s | 1,575 | full set = 31,241 ids, 9 reconcile windows |
Negentropy correctly enumerated the whole 31,241-id set (splitting 9 windows
around the relay's max_sync_events cap) and streamed id-batch REQs — but
for a cold download it was 2.4× slower than plain paging: the reconcile
rounds and by-id lookups cost more than sequential pages when you need
everything anyway. Negentropy's win is incremental re-sync: once the
10M events are local, the next sync transfers only fingerprints + the diff
instead of re-paging the world. (kind 30382 events carry empty content —
all data in tags — so the MB/s column reads 0.)
Per-connection wall: relay pacing, not client parse (2026-07-03)
A user report proposed "parallel parse + strictly-ordered dispatch" on the
theory that the per-connection serial parse caps a single connection at
~4k events/s (fetch-by-id, 12 idle cores, no gain past ~4 concurrent REQs,
throughput scaling only with connections). The discriminating test
(BulkDownloadBenchmark.perConnectionWall) pages the same production query
on one connection twice — once through a raw socket that does NO JSON parse
(only an indexOf scan for created_at), once through the full quartz
stack:
| events/s (nip85.nosfabrica.com, kinds 30382, 20k cap) | |
|---|---|
| raw socket, no parse | 3,382 |
| full quartz stack, parse + dispatch | 3,754 |
Identical within noise. The ~3.5–4k/s single-connection ceiling is the relay's per-connection page/response cadence, not client CPU — at 465B events the parser is ~1% busy at this rate (single-thread parse ceiling: 276k/s small events, 31k/s for the large-frame production mix). The same relay served our negentropy fetch-by-id at only 1.6k/s with 8 concurrent REQs and an idle client — also server-side. This matches every symptom in the report: per-connection pacing explains "more REQs on one connection don't help" and "throughput scales with connections" just as well as a client-side serial stage would, and "CPU idle" is what a network wall looks like.
Implications for the proposed parallel-parse pipeline:
- It will NOT lift the reported 4k/s ceiling on relays with this pacing; the proven lever is more connections (created_at- or id-range sharding).
- It IS still worth having for the cases where the consumer genuinely saturates: large-frame relays (32µs/frame mix) on phones (5–10× slower cores → ~3–6k/s parse ceiling) being streamed by fast relays, and any setup where verify/consume stays inline on the consumer.
- The proposal's ordering constraints are correct and non-negotiable: NEG-MSG/NEG-ERR handshake order, EOSE-only-after-its-EVENTs, per-subscription EVENT order, OK/CLOSED/NOTICE/AUTH stream order. A sequence-numbered reorder buffer after a parallel parse pool satisfies all four (dispatch order = receive order).
- The bounded-pipeline half of the proposal is worth doing regardless — the UNLIMITED per-connection channel is the unbounded-memory risk under any slow consumer (see below), and bounding it converts overload into TCP backpressure. One caveat: blocking OkHttp's reader thread also delays its PING/PONG handling, so sustained backpressure can trip relay-side ping timeouts — the bound should be sized generously.
Parallel fetch-by-id matrix — full corpus, no cap (2026-07-03)
Harness: quartz/src/jvmTest/.../relay/prodbench/ByIdFetchBenchmark.kt.
Enumerates every kind-30382 id on nip85.nosfabrica.com (33,000 that day), then
each cell re-downloads the FULL corpus by 250-id REQ batches from a shared
queue. Connections are pre-established before timing; every cell completed
with zero missing ids and zero timed-out batches.
| cell | events/s |
|---|---|
| 1 conn × 1 REQ | 1,970 |
| 1 conn × 2 REQs | 3,423 |
| 1 conn × 4 REQs | 8,026 |
| 1 conn × 8 REQs | 17,214 |
| 1 conn × 16 REQs | 30,735 |
| 2 conns × 4 REQs | 15,557 |
| 4 conns × 4 REQs | 27,251 |
| 8 conns × 4 REQs | 40,402 |
This corrects both the user report AND this doc's own earlier "relay per-connection pacing" phrasing:
- No wall at ~4 concurrent REQs — one connection scales near-linearly to 16 in-flight REQs (1,970 → 30,735 events/s). The relay is happy to serve 30k+/s down a single socket when the client keeps requests in flight.
- Connections and per-connection REQs are interchangeable: 1×16 ≈ 4×4. The real variable is total in-flight REQs (Little's law: throughput ≈ in-flight ÷ per-REQ latency; a 250-id batch serves in ~130ms here). Diminishing returns start around 32 in-flight (~40k/s) — likely relay query capacity.
- Every slow configuration measured so far is a serialization problem,
not a bandwidth/CPU one: until-cursor paging is 1-in-flight by
construction (~2–3.7k/s ≈ the 1×1 cell);
negentropySyncmeasured 1.6k/s withmaxConcurrentReqs = 8because its download workers starve — the reconcile rounds pace id production (boundedidBatchesbuffer ofworkerCountbatches) and overflow windows recurse sequentially (syncWindowlo-half then hi-half). - The reported "stops helping past ~4 REQs" is consistent with
negentropySync's internal throttling (or a relay-side subscription cap, or a slow per-event sink) — not with independent by-id REQs on this relay. - Client parse at 30.7k/s through ONE connection's consumer coroutine was ~11% of a core (3.6µs × 30.7k) on this machine — still not the wall. On a phone (5–10× slower) 15–30k/s IS where the single consumer saturates, so the parallel-parse proposal becomes relevant on mobile at exactly the rates this fan-out unlocks.
Concrete quartz improvements this implies (in negentropySync):
raise maxConcurrentReqs (16 measured safe here), deepen the idBatches
buffer so downloads don't starve on reconcile cadence, and reconcile
overflow-split windows concurrently instead of recursing sequentially.
Together these should move it from 1.6k/s toward the ~30k/s the same
connection demonstrably sustains. At 40k/s, 10M events by id is ~4 minutes
(plus id enumeration).
Extended matrix on a 2.6M corpus — saturation found (2026-07-03, later)
Between the two runs the relay was backfilled from 33k to 2,628,328 kind-30382 events (it's an actively-loading NIP-85 dataset — cross-run comparisons must account for corpus size). Rerun with 125-id batches, cells capped at a 120s budget (all cells partial by design at this corpus size):
| cell | events/s | effective per-REQ latency |
|---|---|---|
| 1 conn × 1 REQ | 1,088 | ~115ms |
| 1 conn × 4 REQs | 3,953 | ~126ms |
| 1 conn × 8 REQs | 5,420 | ~184ms |
| 1 conn × 16 REQs | 5,480 | ~365ms |
| 1 conn × 20 REQs (relay cap) | 5,464 | ~458ms |
| 2 conns × 10 REQs | 10,508 | ~285ms |
| 4 conns × 10 REQs | 14,757 | ~542ms |
| 8 conns × 10 REQs | 14,617 | ~1.1s |
| 16 conns × 10 REQs | 14,921 | ~2.2s |
Also learned the hard way (first extended run wedged): the relay's NIP-11
advertises limitation.max_subscriptions = 20 — REQs 21+ on one connection
aren't just rejected, they wedged the connection, and with the client's
5-minute reconnect backoff every subsequent batch burned its full timeout.
A bulk downloader must read NIP-11 and stay under the caps.
What changed vs the 33k-corpus run:
- On the big corpus there IS a per-connection wall — at ~8 in-flight REQs / ~5.5k events/s, where the small corpus scaled linearly to 16 / 30.7k/s. Past 8, added in-flight only inflates per-REQ latency (queueing, textbook Little's law). The user report's "stops helping past ~4" is therefore scale-dependent: wrong on a hot 33k dataset, roughly right (off by 2×) on a cold 2.6M one — by-id lookups on the larger index cost more and strfry's per-connection service saturates.
- The relay-wide ceiling is ~15k events/s at ~40 total in-flight (vs ~40k/s on the small corpus). 4 conns × 10 REQs already reaches it; 16 conns adds nothing but latency. More connections cannot beat the server's aggregate query capacity.
- Client-side cost remains negligible at these rates — every wall in this table is server-side.
Practical shape for a bulk-by-id downloader, from the data: read NIP-11 → open ~4 connections × ~10 REQs — and make concurrency adaptive (grow in-flight until events/s stops improving / per-REQ latency inflates, then back off), since the same relay's sweet spot moved 8× in one day as its dataset grew. At today's ~15k/s ceiling: 2.6M events ≈ 3 min; 10M ≈ 11 min.
negentropySync pipelining (implemented, 2026-07-03)
NostrClientNegentropySyncExt was restructured around the findings — no
NIP-11 auto-detection, the budget knobs are the caller's:
- Global download-worker pool spanning the whole sync (was per-window with a join between windows, so the connection idled while the next window's NEG rounds ran).
reconcileConcurrencyparameter — overflow-split windows are reconciled by N concurrent NEG sessions from a shared work queue (was a strictly sequential lo-half/hi-half recursion). Default 1.idBufferBatchesparameter — depth of the bounded buffer between reconciliation and download workers (was hardcoded toworkerCount).- KDoc contract: peak subscriptions =
maxConcurrentReqs + reconcileConcurrency + 1; sizing it under the relay'slimitation.max_subscriptionsis the caller's responsibility.
All 44 negentropy tests (unit + end-to-end against the in-process relay +
geode interop) pass unchanged. Production shootout on the 2.6M corpus,
100k-event cap, same day, single connection
(BulkDownloadBenchmark.negentropyPipelineShootout):
| variant | events/s | note |
|---|---|---|
| old implementation (previous day, 31k corpus) | 1,575 | not same-day comparable |
| new pipeline, old-equivalent params (8 reqs, seq windows) | 3,601 | global pool already overlaps windows |
| new pipeline, tuned (12 reqs, 4 reconcilers, 96-batch buffer) | 4,497 | 17 subs ≤ strfry's 20 cap |
Tuned reaches ~82% of the measured ~5.5k/s single-connection by-id
ceiling; the 4 reconcilers also produced ids 2.3× faster (need=171,812
enumerated vs 109,336 in less wall time). The remaining gap is reconcile
burstiness plus by-id query cost. Going past ~5.5k/s on this relay requires
multiple connections (caller-level: disjoint created_at windows per
client), which the matrix caps at ~15k/s relay-wide.
negentropyReconcile: the composable half (implemented, 2026-07-03)
To let callers own the loading strategy (fan the download across several
connections/clients, prioritize, or push local events up), the reconcile is
now available standalone in NostrClientNegentropySyncExt:
negentropyReconcile(relay, filter, localEntries, …)— pure NIP-77 diff, no downloads/uploads. Streams both directions inbatchSizechunks as rounds arrive:onNeedIds(relay has, local set lacks → download) andonHaveIds(local has, relay lacks → publish). Back-pressured: the callbacks suspend the reconcile round that produced them. Local state goes in asList<IdAndTime>(the same 40 B/entry projectionIEventStore.snapshotIdsForNegentropyreturns), and window splits slice it bycreatedAtvia binary search, so both sides always reconcile the same timeline slice. Overflow windows split exactly likenegentropySync, with the samereconcileConcurrencyknob.negentropyReconcileIds(…): NegentropyIdDiff— convenience that materializesneedIds+haveIdslists (KDoc warns about heap on multi-million diffs; the streaming form is the scalable one).NegentropySession's primary constructor now takesList<IdAndTime>(JVM erasure forbids overloading onList<Event>); the old event-list form moved toNegentropySession.fromEvents(…), mirroringNegentropyServerSession— call sites incli,geodetests andNegentropyManagermigrated. API note for external quartz users:NegentropySession(subId, filter, events)must becomeNegentropySession.fromEvents(subId, filter, events).
negentropySync itself now delegates to the same window engine. Covered by
NostrClientNegentropyReconcileTest (empty-local, partial overlap both
ways, identical sets, batch streaming, since/until window slicing) — 49
negentropy tests green.
amy sync migrated onto it (2026-07-03): the CLI's hand-rolled raw
WebSocket negotiate loop (single un-windowed session; a strfry
max_sync_events overflow was a hard error) is gone. SyncCommand now
calls negentropyReconcile on amy's own NostrClient and pipelines the
loop-closing: need-id batches feed 4 concurrent by-id drains and have-ids
feed an uploader, both overlapping the remaining reconcile rounds (peak 7
subs on the relay, under the common cap of 20). Downloads still funnel
through amy's verify-and-store path. Output field rounds (protocol
round-trips) became windows (created_at splits). Verified end-to-end
against two embedded geode relays: down-only 25/25, up-only 5/5, and
bidirectional re-runs converge to a zero diff.
Answer for "10M events from one relay, fastest"
At nosfabrica's measured page cadence, one connection ≈ 3.7k events/s → 10M in ~45 min. To improve, in order:
- Page with until-cursors — never one giant REQ (silent drops, server slow paths, relay clamps).
- Shard the
created_atrange across K connections to the same relay (each shard pages independently; no cursor dependency between shards). Local: 2× at K=4; WAN, where RTT dominates page turnaround, closer to linear until the relay rate-limits per-IP. - Don't optimize parse first — it's 2–5% of the budget. If the goal is archival, id-scan + store raw frames (0.34µs/frame) and parse lazily in parallel later.
- Use negentropy for the second sync onward, not the first.
- Memory: the per-connection channel is UNLIMITED — at 10M events a sink slower than the socket accumulates heap without bound. A bulk downloader should bound the channel (blocking the OkHttp reader thread is fine — that's TCP backpressure doing its job) and stream events to disk, never hold the set.
Implemented optimizations (2026-07-03)
PoolRequests lock sharding (done)
The global spin lock moved into RequestSubscriptionState — one lock per
subscription — since every compound mutation is scoped to one subId and
different subs share no wire state. decideCommandLocked now takes the state
instance so it never re-enters the (non-reentrant) lock, and the all-subs
iterations (connect/disconnect/cannot-connect) lock one sub at a time.
withLock is inline to keep the per-EVENT hot path allocation-free.
Validation (DispatchStageBenchmark, PoolRequests-only, 4 feeder threads vs 1): scaling flipped from negative (11.1M → 3.6M deliveries/s, 0.33×) to positive (3.4–7.3× across runs; absolute numbers on the shared CI box are noisy, the scaling direction is consistent across every run). All PoolRequests concurrency + NostrClient + negentropy suites pass.
CachingEventDecoder: duplicates skip the re-parse (done)
BasicRelayClient's per-frame decode step is now a pluggable
MessageDecoder (default: full parse, unchanged). The opt-in
CachingEventDecoder scans an EVENT frame for its id (~0.3µs; the scan is
JSON-escape-safe, so reposts embedding event JSON in content can't confuse
it, and ANY irregularity falls back to a full parse) and, on a hit in its
generational id→Event cache, synthesizes the EventMessage from the
already-parsed Event with the frame's own subId. Dispatch semantics are
IDENTICAL — every subscription still gets its delivery, per-relay
bookkeeping still happens — only the redundant parse is skipped. Wire it via
NostrClient(builder, decoder = CachingEventDecoder()).
Validation (DedupDecodeBenchmark, 60k frames, 67% duplicates — the
production-measured dup share is 14–57%): full parse 10.0µs/frame vs caching
decoder 1.5µs/frame — 6.5×, with duplicates costing ~0.3µs instead of
10µs. 7 scan-safety unit tests (CachingEventDecoderTest) cover repost
embedding, escaped subIds, malformed ids, cache rotation, non-EVENT frames.
Follow-up (2026-07-03): the decoder's spin lock was replaced with lock-free
concurrent maps — a new ConcurrentHashCache expect/actual
(ConcurrentHashMap on JVM/Android, CacheMap on Apple, copy-on-write on
Linux, mirroring LargeCache's per-platform choices) plus atomic counters.
Rotation races are deliberately tolerated (documented: every failure mode is
a redundant re-parse, never a wrong message).
CachingEventDecoderConcurrencyTest hammers one shared decoder from 8
threads (80k duplicate-heavy frames, capacity 256 so rotations fire
constantly) and asserts zero wrong messages with exact counter accounting;
the benchmark assertion still holds post-refactor.
Same-JVM A/B against the resurrected spin-lock implementation (3 rounds, interleaved): 1 thread — parity (0.79–1.02×, noise around 1.0; the uncontended lock was already ~free), 8 threads sharing one decoder — lock-free wins 3.0–4.5× (e.g. 127ms → 28ms for 128k frames). The lock was serializing the concurrent hit path exactly the way the old global PoolRequests lock did; ConcurrentHashMap removes it.
Adopted in all four front-end clients (2026-07-03): the Android app's
pool (AppModules.kt), the Android crawl client
(AccountViewModel.buildCrawlClient — Event Sync / Cashu discovery, the
duplicate-heaviest path), the desktop RelayConnectionManager, and amy's
Context. Each passes CachingEventDecoder() at NostrClient
construction; no other behavior change.
Bounded per-connection receive buffer (REVERTED — deliberate design choice)
A 4096-frame bound on the reader→consumer channel was tried (backpressure via TCP flow control instead of unbounded heap; proven lossless by a slow-consumer test) and then reverted by explicit maintainer decision, for two reasons that outweigh the heap tail-risk:
- The remote infrastructure isn't ours. Backpressure parks the backlog in the RELAY's outbound buffers/TCP window — a client should release the relay from its duties as fast as the relay can send, and own the buffering itself.
- The app holds 2000+ simultaneous relay connections. A bounded buffer under a slow consumer blocks OkHttp reader THREADS; at that connection count, blocked readers are a thread-starvation hazard far worse than the heap growth they prevent.
The Channel.UNLIMITED receive queues now carry an explicit "do not bound"
comment with this rationale. The slow-consumer risk is addressed from the
other side instead: make the consumer fast enough that backlogs don't form
(CachingEventDecoder, ParallelEventVerifier, PoolRequests sharding).
ParallelEventVerifier: batched verify off the receiver coroutine (done)
The client-side mirror of IngestQueue.parallelVerify:
accessories/ParallelEventVerifier. submit(event, context) is a cheap
bounded-channel send from the receiver coroutine; a drain loop batches
greedily (up to 256) and fans each batch across Dispatchers.Default in
core-sized CHUNKS — a per-event async measured ~40µs/event of scheduling
overhead that swallowed the entire parallel gain, and the per-batch
fork/join barrier at batch 64 still cost half of it (64 → 1.2×, 256 → 2.2×,
1024 → 3.2× on 4 cores). Callbacks dispatch in submission order, a
preVerified hook short-circuits already-trusted ids, and the bounded
submit channel backpressures the socket.
Also learned while measuring: Event caches derived state after its first
verification, so any verify benchmark must use disjoint event sets per pass
(the first version accidentally measured warm objects and reported 0.94–1.2×).
Validation: ParallelVerifyBenchmark (4k fresh signed events, 8 signers) —
sequential 50.6µs/event vs pipeline 28.1µs/event, 1.8× on the noisy
4-core CI box with an in-benchmark ≥1.5× assertion (crypto-only parallel
ceiling measured 2.89× there). 4 correctness tests: valid/tampered routing,
submission-order preservation, preVerified short-circuit, callback-crash
resilience.
App-side scope correction (2026-07-03): do NOT wire this into
CacheClientConnector. Code review confirmed the mobile app's
verification is ALREADY parallel across relays: each connection's
OkHttpWebSocket owns its own consumer coroutine on Dispatchers.IO, and
nothing downstream funnels — NostrClient.onIncomingMessage →
EventCollector → LocalCache.justConsume → justVerify all run inline
on the delivering connection's coroutine, LargeCache is a
ConcurrentSkipListMap, and the one global serializer (PoolRequests'
lock) is now per-subscription. With 2000+ connections a multi-relay burst
already saturates the cores; the verifier would only reshuffle the work.
Its real scope is where a SINGLE connection's stream serializes verifies:
bulk flows (fetchAllPages-style downloads, each client's leg inside
negentropySyncFanOut, amy/CLI syncs). One second-order note for a future
on-device profile: today's verifies run on Dispatchers.IO (64 threads on
~8 phone cores), so a large burst can oversubscribe cores with CPU-bound
crypto and starve the IO pool — if profiling ever shows that, moving verify
to a core-bounded dispatcher (which this accessory does) is the fix.
negentropySyncFanOut: multi-connection sync (done)
accessories/NostrClientNegentropyFanOutExt: one reconcile feeding by-id
download batches to N clients (one socket each) × reqsPerClient workers,
with reconcile windows ALSO round-robined across the connections. Events
funnel through a bounded channel to a single consumer (exact maxEvents,
single-threaded onEvent); everything backpressures; localEntries diffing
and have-reporting work as in negentropyReconcile. 4 in-process
multi-client tests (full download, cap, local-diff, single-client
degradation) — 18 negentropy tests green.
Production shootout (100k cap, kind 30382, same-run pairs): fan-out 4×8 vs tuned single client measured +18% to +64% (7.3k vs 6.1k/s; 7.5k vs 4.5k/s) with heavy relay-side variance — the relay is being actively backfilled and its NEG snapshot caching favors whichever variant runs second. The honest bound: this relay produces reconcile ids at only ~9–10k/s regardless of connection count (server-side snapshot build), so end-to-end fan-out gains cap there even though the download stage alone scales 2.7× with connections (the by-id matrix). On download-bound syncs (bigger events, relays with faster NEG production, phone-CPU clients) the gain approaches the matrix ratio.
Descoped with evidence — adaptive in-flight control (#6): an AIMD window on download REQs would idle-down against today's reconcile-bound relay and the benchmarks could not demonstrate a win (the criterion for shipping); revisit if a relay shows download-bound behavior with volatile capacity.
permessage-deflate: verified active (no change needed)
The per-connection-wall test now prints the negotiated
Sec-WebSocket-Extensions; against nip85.nosfabrica.com (strfry) OkHttp
negotiates permessage-deflate; client_no_context_takeover out of the box.
Compression was on the suspect list as a 3–5× bandwidth lever for mobile —
it's already engaged; nothing to code.
Geode giant-REQ crawl + wedge: fixed (two masked bugs)
The "giant single REQ streams at ~700 events/s and came back 2 events short" finding decomposed into two server bugs hiding each other:
- O(n²) replay dedup (
LiveEventStore.query): the historical-replay dedup set was an immutable KotlinSetunder anAtomicReferencewith copy-on-add —set + idcopies the whole set per streamed event (100k events ≈ 5 billion hash inserts). Fixed with a spin-lock-guarded mutableHashSet(same read/write threads as before; lock sections are a single contains/add). - Session-pump slow-client policy misfire (
WebSocketSessionPump): with the throttle gone, a fast replay instantly overflowed the 8192-frame backlog cap — which conflated "client is slow" with "replay outruns the socket writer" (normal for bulk) — and the "drop" only closed the internal queue, leaving the socket half-dead: the client hung with no EOSE/close and silently missed the response tail (the likely story behind the original 99,998/100,000). Now the producer is PACED against a full backlog (bounded blocking wait for the writer, consistent with the documented ingest-fanout behavior) and only a client still behind after 30s is dropped — by actually cancelling the socket.
Validation: GiantReqStreamTest (committed regression guard) — 20k-event
single REQ over a real socket: pre-fix 8.4s (~2.4k events/s; 100k measured
476–700/s), post-fix 0.7s (~27k events/s), all 20k delivered with EOSE.
96 geode tests + quartz relay/server suites green.
Recommendations (in order of value/risk)
- Move Schnorr verification off the receiver coroutine in the app's
consume path (
CacheClientConnector→LocalCache.justConsume): a bounded verify stage onDispatchers.Default(batch +asyncper batch, or a small worker pool), mirroringIngestQueue. Ordering constraint: dedup must stay before verify (it already is), and consumers ofjustConsume's return value need an async-tolerant path. - Keep parse on the receiver coroutine (32µs/msg is cheap and keeps message ordering per subscription), but consider skipping re-serialization work for duplicates (57% of metadata-burst traffic never needs more than an id lookup).
- Fix the
PoolRequestsspin lock's negative scaling — the dispatch microbenchmark shows 4 concurrent relay consumers deliver less than one thread through it. Either replace the busy-wait withsynchronized/a parking lock, or shard the state by subId, and consider the early-dedup short-circuit so duplicate frames never enter the locked path at all. - Re-run this harness on-device (the same class compiles for Android instrumentation with minor changes) before/after any fix; the offline ceilings section gives the numbers to compare.