docs(geode): performance plans for future work

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.
This commit is contained in:
Claude
2026-05-07 14:05:11 +00:00
parent c19bd4e92e
commit 2c0ad4fbf5
5 changed files with 403 additions and 0 deletions
@@ -0,0 +1,93 @@
# 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 1030k. 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 110 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:
```kotlin
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 -n` requirement
for operators targeting >1k connections.