Closes the last item on the event-ingestion-batching plan: signature
verification no longer serialises on each connection's WebSocket
pump. Instead, IngestQueue takes a `verify: ((Event) -> Boolean)?`
hook and fan-outs the per-batch verify across Dispatchers.Default
(`coroutineScope { events.map { async { verify(it) } }.awaitAll() }`)
before opening the SQLite transaction. Failed verifies pre-mark
Rejected and skip the insert.
Wiring:
- NostrServer takes `parallelVerify: Boolean = false` (opt-in to
preserve existing behaviour for direct library users).
- geode.Relay forwards a matching flag.
- Main.kt enables it whenever signature checking is on (config
`[options].parallel_verify = true`, default true), and when so,
composePolicy is told to skip VerifyPolicy from the chain to
avoid double-verifying every event.
- New CLI escape hatch `--no-parallel-verify` for the legacy path.
Bench: adds publishGroupCommitSingleClient (sequential publish-and-
confirm; 500 EPS regression floor for the synchronous path) — the
companion to the existing pipelined bench that exercises the
group-commit + parallel-verify wins.
Plan doc updated to describe what shipped (batchInsert + SAVEPOINTs
in Tier 1, IngestQueue mechanics in Tier 2, the verify hook in
Tier 3) and to drop the obsolete `synchronous=NORMAL` confirmation
note — the project ships `synchronous=OFF` and intentionally keeps
that.
6.2 KiB
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:
RelaySession.handleEvent(quartz/nip01Core/relay/server/RelaySession.kt:131) awaitspolicy.accept(cmd)(Schnorr verify ifVerifyPolicyis in the stack — ~0.1 ms on JVM).- Awaits
store.insert(cmd.event)— a single SQLite write, guarded by the connection-pool writer mutex (SQLiteConnectionPool). - Sends
OkMessageback 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 pairs by event id, not by order: the OK frame carries the event id, so clients pair replies to publishes by id. OKs can be emitted in any order — including reordered against the EVENT stream, and against each other on the same connection. This frees us to fan OKs out as soon as the writer has a per-row decision.
- OK semantics = accepted, not fsynced: NIP-01 treats
OK trueas "accepted by the relay," not "durably on disk." We can reply as soon as SQLite returns success for the row (inside the open transaction, before commit/fsync). Group commit can batch the fsync without holding OKs back. - Per-row decision still required: the OK reason field is per-event (duplicate, blocked, invalid sig, pow, etc.), so we cannot fan out a single batch-level OK. Each row's OK must reflect that row's outcome.
Sketch
Tier 1 — SQLite WAL + group commit (cheap win)
WAL is already on (PRAGMA journal_mode=WAL). The pool runs with
PRAGMA synchronous=OFF, which is one notch more permissive than
the originally-sketched synchronous=NORMAL — we keep it as-is
because the project already accepted the OS-crash trade-off there.
Group commit is implemented via a new IEventStore.batchInsert:
the SQLite override holds the writer mutex once and wraps N events
in one BEGIN IMMEDIATE … COMMIT. Per-row error isolation uses
SAVEPOINTs so one bad event (expired, duplicate id) doesn't roll
back the good ones — just that row reports Rejected.
OKs fire as soon as each row's outcome is known inside the writer batch, not waiting for fsync (per the OK-semantics constraint above).
Implementation lives in quartz/nip01Core/store/sqlite/SQLiteEventStore.batchInsertEvents,
exposed through IEventStore.batchInsert and consumed by the new
IngestQueue (Tier 2 below).
Expected: ~5–10× 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 was single-flight: one EVENT in, process, OK
out, next EVENT. With Tier 2 the connection's pump posts to the
shared IngestQueue and returns immediately — the WS pump moves
straight to the next frame.
IngestQueue (one per NostrServer) holds a bounded
Channel<Submission> (capacity = 1024 per the DEFAULT_CAPACITY
constant) drained by a single writer coroutine. The writer pulls
the first item to start a batch then tryReceive-drains everything
else queued (up to 64 — DEFAULT_MAX_BATCH), feeds the whole batch
to IEventStore.batchInsert, and dispatches each row's
onComplete callback as soon as the batch returns. The callback
turns into the OK frame at the WS layer.
OKs are not order-preserving (per the constraints above). The
writer coroutine starts lazily on first submit so subscription-
only sessions don't pay for it and don't perturb Dispatchers.Default
scheduling.
Expected: hides verify+insert latency behind the next EVENT's parse, gets us closer to network-bound throughput.
Tier 3 — eager Schnorr verify off the writer thread
VerifyPolicy ran synchronously on receive, serialising verify
on each connection's pump coroutine. With Tier 3, IngestQueue
takes a verify: ((Event) -> Boolean)? hook; when set, the writer
fan-outs a coroutineScope { events.map { async(Default) { verify(it) } }.awaitAll() }
on each batch before opening the SQLite transaction. Failed
verifies pre-mark Rejected and skip the insert.
Wired through NostrServer(parallelVerify = ...) and
geode.Relay(parallelVerify = ...), controlled by
[options].parallel_verify in the relay config (default true)
and --no-parallel-verify on the CLI. Operators that flip it on
must omit VerifyPolicy from their policy chain — Main.kt does
this automatically; composePolicy is told to skip the
VerifyPolicy piece when parallelVerify is true. Internal
direct callers of NostrServer (tests, library users) are
opt-in: the flag defaults to false to keep existing
VerifyPolicy-in-chain semantics unchanged.
Expected: ≈CPU_COUNT× verify-step speed-up on burst publishes from a single connection, where verify was previously serial on that pump.
How to verify
geode.perf.LoadBenchmark carries the perf tests:
publishGroupCommitSingleClient— sequential publish-and-confirm on one connection (the same shape as the originalpublishThroughputSingleClient). Synchronous publishing means batch size is always 1, so this case shows per-event SQLite tx cost rather than the group-commit win — kept as a 500-EPS floor to catch regressions from the rewrite.publishPipelinedSingleClient— bursts 10 000 EVENTs back-to- back without awaiting intermediate OKs; verifies end-to-end throughput and that every event id receives exactly one OK (in any order). This is where Tier 1 + Tier 2 both light up.
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.