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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user