mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
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
682 lines
37 KiB
Markdown
682 lines
37 KiB
Markdown
# 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.
|