Files
amethyst/geode/plans/2026-05-07-event-ingestion-batching.md
T
Claude 2c0ad4fbf5 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.
2026-05-07 14:05:11 +00:00

92 lines
3.8 KiB
Markdown
Raw 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.
# Event ingestion: write batching + pipelined OK
## Problem
EVENT acceptance is the hot path on a busy relay — every published note,
every reaction, every DM lands here. Today the per-event flow is fully
serial:
1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`)
awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in
the stack — ~0.1 ms on JVM).
2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by
the connection-pool writer mutex (`SQLiteConnectionPool`).
3. Sends `OkMessage` back through the writer coroutine.
`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**;
the concurrent variant **~2000 EPS** (limited by SQLite writer mutex
contention, not WS throughput).
## Constraints we must keep
- **OK ordering**: NIP-01 requires the OK reply to follow its EVENT.
We cannot reply OK before the insert decision (the OK carries
accepted/rejected + reason).
- **Durability semantics**: clients reasonably assume `OK true` means
"stored." Batching must not make us reply OK before fsync.
- **Per-connection FIFO**: a publisher that sends three EVENTs in a
row expects three OKs in that order. Reordering across connections
is fine.
## Sketch
### Tier 1 — SQLite WAL + group commit (cheap win)
Confirm `PRAGMA journal_mode=WAL` + `PRAGMA synchronous=NORMAL` on the
event-store DB; group commits across the writer mutex's hold window.
Today each insert is its own transaction. Wrap N inserts (or a 5 ms
budget, whichever first) in a single transaction managed by the writer
coroutine. On commit, fan back N OK replies.
Implementation lives in quartz's `EventStore` / `SQLiteConnectionPool`,
not geode — but geode owns the benchmark and validates the gain.
Expected: **~510× write throughput** on a fast SSD. SQLite group
commit is well-trodden territory (nostr-rs-relay, strfry both do it).
### Tier 2 — pipelined OK over multiple in-flight EVENTs
`RelaySession.receive` is currently single-flight: one EVENT in,
process, OK out, next EVENT. Allow a connection to push N EVENTs
concurrently, dispatch them to a per-connection ingest pipeline, and
serialise OKs back in arrival order via a small commit log.
A `Channel<EventCmd> with capacity = INGEST_PIPELINE_DEPTH` per
connection, drained by a coroutine that batches into the group-commit
above. OK responses are written to an `outQueue.send()` already — so
the pipeline just needs to record arrival order and emit OKs in that
order after each batch commits.
Expected: hides the verify+insert latency behind another EVENT's
parse, gets us closer to network-bound throughput.
### Tier 3 — eager Schnorr verify off the writer thread
`VerifyPolicy` is in the policy stack and runs synchronously on
`receive`. Move it into the ingest pipeline so verification of EVENT N+1
runs concurrently with the SQLite commit of EVENT N. secp256k1 verify
is parallelisable; the writer should never block on it.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `publishGroupCommitSingleClient` — same workload as the current
single-client benchmark, asserts >5000 EPS.
- `publishPipelinedSingleClient` — sends 100 EVENTs without awaiting
intermediate OKs; measures end-to-end and OK-ordering correctness.
Existing benchmarks stay as the regression floor.
## Risks
- **Group commit windows**: if a single bad event in the batch fails
validation, we must not roll back the good ones. The batch needs
per-row commit semantics (row-level errors → row-level OK false).
- **Backpressure on slow disks**: deeper pipelines on slow storage
amplify out-of-memory pressure. Cap the in-flight queue depth and
apply existing slow-client backpressure if it fills.
- **Replay protection**: the existing dedupe table needs to see the
event before commit, not after — keep that check inside the writer
coroutine.