Files
amethyst/quartz/plans/2026-07-02-nostrclient-receiver-perf.md
Claude b5fa6b20c1 feat(cli): migrate amy sync onto the windowed negentropyReconcile pipeline
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
2026-07-03 14:37:25 +00:00

682 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
1. **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.
2. **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.
3. **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).
4. **Offloading verification cuts queue latency 2–5× and raises the per-relay
ceiling ~3×** with no protocol or API change — the exact pattern
`IngestQueue.parallelVerify` already 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:
1. **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.
2. **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 `PoolRequests` busy-wait spin lock serializes every EVENT frame from
every relay and burns the other cores spinning. Isolated `PoolRequests`
shows 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.
3. **Handoff granularity matters:** a per-event `Channel.send` costs ~180ns
extra per event (halves single-feeder throughput); a 64-event batch makes
the handoff essentially free — same conclusion the server's `IngestQueue`
already embodies.
4. **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_at` range 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:
1. **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.
2. **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.
3. **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); `negentropySync` measured
1.6k/s with `maxConcurrentReqs = 8` because its download workers starve —
the reconcile rounds pace id production (bounded `idBatches` buffer of
`workerCount` batches) and overflow windows recurse **sequentially**
(`syncWindow` lo-half then hi-half).
4. 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.
5. 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:
1. **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.
2. **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.
3. 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).
- **`reconcileConcurrency` parameter** — 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.
- **`idBufferBatches` parameter** — depth of the bounded buffer between
reconciliation and download workers (was hardcoded to `workerCount`).
- KDoc contract: peak subscriptions = `maxConcurrentReqs +
reconcileConcurrency + 1`; sizing it under the relay's
`limitation.max_subscriptions` is 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 in `batchSize` chunks
as rounds arrive: `onNeedIds` (relay has, local set lacks → download) and
`onHaveIds` (local has, relay lacks → publish). Back-pressured: the
callbacks suspend the reconcile round that produced them. Local state goes
in as `List<IdAndTime>` (the same 40 B/entry projection
`IEventStore.snapshotIdsForNegentropy` returns), and window splits slice
it by `createdAt` via binary search, so both sides always reconcile the
same timeline slice. Overflow windows split exactly like `negentropySync`,
with the same `reconcileConcurrency` knob.
- **`negentropyReconcileIds(…): NegentropyIdDiff`** — convenience that
materializes `needIds` + `haveIds` lists (KDoc warns about heap on
multi-million diffs; the streaming form is the scalable one).
- `NegentropySession`'s primary constructor now takes `List<IdAndTime>`
(JVM erasure forbids overloading on `List<Event>`); the old event-list
form moved to `NegentropySession.fromEvents(…)`, mirroring
`NegentropyServerSession` — call sites in `cli`, `geode` tests and
`NegentropyManager` migrated. **API note for external quartz users:**
`NegentropySession(subId, filter, events)` must become
`NegentropySession.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 `drain`s 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:
1. **Page with until-cursors — never one giant REQ** (silent drops, server
slow paths, relay clamps).
2. **Shard the `created_at` range 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.
3. **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.
4. **Use negentropy for the second sync onward**, not the first.
5. **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:
1. **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.
2. **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:
1. **O(n²) replay dedup** (`LiveEventStore.query`): the historical-replay
dedup set was an immutable Kotlin `Set` under an `AtomicReference` with
copy-on-add — `set + id` copies the whole set per streamed event (100k
events ≈ 5 billion hash inserts). Fixed with a spin-lock-guarded mutable
`HashSet` (same read/write threads as before; lock sections are a single
contains/add).
2. **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)
1. **Move Schnorr verification off the receiver coroutine** in the app's
consume path (`CacheClientConnector` → `LocalCache.justConsume`): a bounded
verify stage on `Dispatchers.Default` (batch + `async` per batch, or a small
worker pool), mirroring `IngestQueue`. Ordering constraint: dedup must stay
before verify (it already is), and consumers of `justConsume`'s return value
need an async-tolerant path.
2. **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).
3. **Fix the `PoolRequests` spin lock's negative scaling** — the dispatch
microbenchmark shows 4 concurrent relay consumers deliver *less* than one
thread through it. Either replace the busy-wait with `synchronized`/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.
4. 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.