perf(quartz): Tier 3 — parallel Schnorr verify in IngestQueue

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.
This commit is contained in:
Claude
2026-05-07 23:00:30 +00:00
parent 7a92f4ef2f
commit 289bc4bd5c
8 changed files with 281 additions and 60 deletions
@@ -83,6 +83,12 @@ fun main(args: Array<String>) {
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
// in the config).
val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures
// Parallel verify is on whenever signature checking is on; the
// IngestQueue handles it instead of VerifyPolicy. Operators can
// force the legacy in-policy path with `--no-parallel-verify` or
// `[options].parallel_verify = false`.
val parallelVerify =
verifySigs && !a.flag("--no-parallel-verify") && config.options.parallel_verify
// Advertised URL: explicit `info.relay_url` wins, then build from
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
@@ -98,11 +104,22 @@ fun main(args: Array<String>) {
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
val policyBuilder: () -> IRelayPolicy = {
composePolicy(config, advertisedUrl, requireAuth, verifySigs)
// When parallel verify is enabled the IngestQueue runs
// Schnorr verify off the WS pump, so the policy chain skips
// VerifyPolicy to avoid double-verifying every event.
composePolicy(config, advertisedUrl, requireAuth, verifySigs && !parallelVerify)
}
val stateFile = config.admin.state_file?.let { File(it) }
val relay = Relay(advertisedUrl, store, info, policyBuilder, stateFile = stateFile)
val relay =
Relay(
advertisedUrl,
store,
info,
policyBuilder,
stateFile = stateFile,
parallelVerify = parallelVerify,
)
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
// a single per-frame limit; multi-frame messages remain unbounded).
@@ -73,6 +73,17 @@ class Relay(
* everything in memory only fine for tests.
*/
stateFile: File? = null,
/**
* Run Schnorr signature verification in parallel inside the
* [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue]
* instead of serially in the policy chain. Enables the Tier-3
* win in `geode/plans/2026-05-07-event-ingestion-batching.md`.
*
* When set, callers MUST omit `VerifyPolicy` from [policyBuilder]
* having both verifies the same event twice for no benefit.
* `Main.kt` skips `VerifyPolicy` when this flag is on.
*/
parallelVerify: Boolean = false,
) : AutoCloseable {
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
@@ -158,6 +169,7 @@ class Relay(
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
},
parentContext,
parallelVerify = parallelVerify,
)
/**
@@ -141,6 +141,18 @@ data class RelayConfig(
* for trusted-input scenarios (test fixtures, mirror replays).
*/
val verify_signatures: Boolean = true,
/**
* Run signature verification in parallel inside the IngestQueue
* (CPU fan-out across `Dispatchers.Default`) instead of serially
* on each connection's WebSocket pump. Tier-3 of the
* `event-ingestion-batching` plan. Wins scale with how many
* EVENTs a single connection sends back-to-back: ~CPU_COUNT×
* verify-step speed-up on burst publishes. Set false to keep
* the legacy in-policy verify path.
*
* Only takes effect when [verify_signatures] is also true.
*/
val parallel_verify: Boolean = true,
)
data class LimitsSection(
@@ -363,6 +363,52 @@ class LoadBenchmark {
}
}
/**
* Same workload as [publishThroughputSingleClient] (sequential
* publish-and-confirm on one connection) kept as a regression
* floor for the group-commit code path. Synchronous publishes
* never coalesce in the writer (batch size is always 1), so the
* EPS here measures per-event SQLite tx cost. The pipelined win
* shows up in [publishPipelinedSingleClient].
*/
@Test
fun publishGroupCommitSingleClient() =
benchmark("publish group-commit single client") {
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val signer = NostrSignerSync(KeyPair())
val relayUrl = server.url.normalizeRelayUrl()
val n = 10_000
var ok = 0
val elapsed =
measureTime {
runBlocking {
repeat(n) { i ->
val event = signer.sign(TextNoteEvent.build("group-commit $i"))
if (client.publishAndConfirm(event, setOf(relayUrl))) ok++
}
}
}
val eps = (n * 1000.0) / elapsed.inWholeMilliseconds
println(
"events=$n ok=$ok elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
)
check(ok == n) { "expected all $n events accepted, got $ok" }
// Floor: the pre-batching baseline was ~760 EPS
// single-client (see plan). Anything below 500
// means the group-commit / ingest-queue rewrite
// regressed the synchronous path.
check(eps > 500) { "synchronous EPS $eps fell below the 500 floor" }
} finally {
client.disconnect()
scope.cancel()
}
}
}
/**
* One publisher, N subscribers. Publishes one EVENT and measures
* fan-out latency: time from publish to last subscriber receiving.