Tier 3 used to say operators "must omit VerifyPolicy from their policy chain" when parallelVerify is on — that turned out to be the AUTH-verify regression caught in the audit. Updated the plan to describe the real wiring: VerifyPolicy was split into a parameterised base with two singletons, and composePolicy swaps in VerifyAuthOnlyPolicy so AUTH commands keep signature verification even when the IngestQueue takes EVENT verify.
6.6 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. Internal direct callers of
NostrServer (tests, library users) are opt-in: the flag defaults
to false to keep existing VerifyPolicy-in-chain semantics
unchanged.
VerifyPolicy was split into a parameterised
VerifyEventsAndAuthPolicy(verifyEvents) with two singletons:
VerifyPolicy(default): verifies bothEVENTandAUTH.VerifyAuthOnlyPolicy: verifiesAUTHonly, used when theIngestQueueis doing the EVENT verify.
When parallelVerify is on, composePolicy swaps VerifyPolicy
for VerifyAuthOnlyPolicy so EVENTs aren't verified twice while
AUTH commands — which bypass the queue entirely — keep their
signature check. Without this split, removing VerifyPolicy from
the chain would let a forged AUTH event mark a pubkey as
authenticated.
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.