Four sketches, queued by impact, each grounded in current code paths and observed benchmark numbers: - event-ingestion-batching: SQLite group commit + EVENT pipelining + off-thread Schnorr verify. Targets 5–10× EPS on a fast SSD. - live-broadcast-fanout-index: indexed filter matching to replace the O(N_subs × N_filters) per-event walk in LiveEventStore. Targets flat fanout p99 up to high subscriber counts. - connection-scaling: shrink the per-session outQueue footprint (currently the dominant per-conn cost), tune Ktor CIO group sizes, reduce JSON parse allocations. Targets 10 000+ concurrent conns. - negentropy-large-corpus: id-and-time-only snapshot path so NEG-OPEN on a 5M-event store doesn't materialise full Event objects, plus bounded-window defaults and concurrent-session caps. Each plan names the verification benchmark to add. Plans are queued, not committed work — README orders them by expected impact.
4.2 KiB
Connection scaling: pushing past 2 000
Problem
Current measurement (LoadBenchmark.connectionsHeldOpen): ~2 000
concurrent connections before file-descriptor pressure / Ktor CIO
event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol)
sustain 10–30k. Geode shouldn't be the bottleneck for an Amethyst-
adjacent operator who scales beyond a thousand-user community.
What's spending memory per connection today
| Cost | Per connection | At 5 000 conns |
|---|---|---|
outQueue Channel |
8 192 string slots × ~8 b ref | ~320 MB pinned |
RelaySession |
LargeCache<String, Job> for subs (likely 1–10 entries) |
~negligible |
NegSessionRegistry |
HashMap<String, NegentropyServerSession> — usually 0 |
~negligible |
| Ktor CIO buffers | TCP read + write buffers | ~10 MB |
| Per-session writer Job | one coroutine | ~few KB |
The outQueue reservation is the dominant cost. The 8 192 was sized
for a worst case "thousands of subscriptions, one event matches all" —
but at 5 000 connections we've over-provisioned by ~300 MB just on
the channel array, even though most connections never fan out.
Sketch
A — adaptive outQueue capacity
Start every connection with INITIAL_OUTGOING_BUFFER = 64. When the
producer side trySends and we observe queue depth crossing a high-water
mark (e.g. 75% full), grow the channel up to MAX_OUTGOING_BUFFER = 8192. This is not how kotlinx.coroutines.channels.Channel is
structured (capacity is fixed at construction), so the implementation
is "swap in a wider channel under a per-session lock when watermark
trips" — drains the old, then routes new sends through the new.
Expected: 90% of connections never fan out, so they stay at 64 slots × ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB. Hot-fanout connections still get the 2 MB cap.
B — per-relay event-loop pool sizing
Ktor CIO defaults to one event-loop thread per available CPU. Beyond a few thousand connections, this becomes the bottleneck — and none of geode's per-connection work is CPU-bound (it's mostly waiting on incoming frames). Tune CIO via:
embeddedServer(CIO, ...) {
connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2)
workerGroupSize = max(4, Runtime.getRuntime().availableProcessors())
callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4)
}
Expose these through RelayConfig.NetworkSection so an operator on a
big VM can lift them.
C — reduce per-message JSON allocations
OptimizedJsonMapper.fromJsonToCommand allocates a JsonNode tree per
incoming frame. At 10k connections with 1 msg/s each that's 10k tree
allocations/sec. Investigate streaming Jackson + reusing ObjectMapper
per session, or using kotlinx-serialization's lower-overhead path.
This is more of a quartz-level change than geode-specific, but geode's load benchmark is the right place to measure it.
How to verify
Add to geode.perf.LoadBenchmark:
connectionsHeldOpen10k— opens 10 000 idle WebSocket connections; asserts no FD exhaustion + RSS stays under 1 GB.connectionsHeldOpenWithFanout— 5 000 idle subscribers, 10 EPS published; measures p99 fanout latency at scale.
The current connectionsHeldOpen benchmark stays as the baseline
floor (~2 000 conns).
Risks
- Adaptive channel swap is fiddly: drains under the producer's nose
must preserve OK ordering. A simpler alternative: keep capacity fixed,
but lazily allocate a small
ArrayDeque<String>only when the first message is sent. Channels in kotlinx.coroutines do allocate up-front. - Bumping CIO group sizes can hurt: more threads can mean worse L1/L2 locality. Always benchmark before/after, don't trust intuitive sizing.
- OS-level FD limit: per-process FD limit on Linux defaults to
1024 in many environments. Document the
ulimit -nrequirement for operators targeting >1k connections.