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
@@ -24,9 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
@@ -73,6 +77,25 @@ class IngestQueue(
parentContext: CoroutineContext,
private val maxBatch: Int = DEFAULT_MAX_BATCH,
capacity: Int = DEFAULT_CAPACITY,
/**
* Optional pre-insert validator. When set, the writer runs each
* batch through this hook in parallel before opening the SQLite
* transaction. Events that return `false` skip the insert and are
* reported as Rejected with [verifyRejectionReason].
*
* The intended use is Schnorr signature verification: an event's
* `verify()` is CPU-bound and parallelisable, so spreading a
* batch's verifies across [Dispatchers.Default] threads is
* straight throughput. Hook fires off the WS pump so a single
* publisher streaming many EVENTs doesn't serialise verify on
* one connection's read coroutine.
*
* Default `null` skips this stage — callers that already verify
* inside their `IRelayPolicy` chain should leave it null to avoid
* double-verify.
*/
private val verify: ((Event) -> Boolean)? = null,
private val verifyRejectionReason: String = "invalid: bad signature or id",
) : AutoCloseable {
/**
* One outstanding ingest request: the event to insert plus the
@@ -130,7 +153,6 @@ class IngestQueue(
private suspend fun drainLoop() {
val batch = ArrayList<Submission>(maxBatch)
val events = ArrayList<Event>(maxBatch)
try {
while (true) {
// Block for the first item — anything else would be a
@@ -142,32 +164,8 @@ class IngestQueue(
val next = incoming.tryReceive().getOrNull() ?: break
batch.add(next)
}
events.clear()
for (sub in batch) events.add(sub.event)
val outcomes =
try {
store.batchInsert(events)
} catch (e: Throwable) {
Log.w("IngestQueue") { "batchInsert failed for ${batch.size} events: ${e.message}" }
val reason = e.message ?: e::class.simpleName ?: "insert failed"
List(batch.size) { IEventStore.InsertOutcome.Rejected(reason) }
}
for (i in batch.indices) {
val sub = batch[i]
val outcome =
outcomes.getOrNull(i)
?: IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
try {
sub.onComplete(outcome)
} catch (e: Throwable) {
// A misbehaving callback must not poison the
// writer loop; the outcome is delivered
// best-effort.
Log.w("IngestQueue") { "onComplete threw: ${e.message}" }
}
}
processBatch(batch)
batch.clear()
}
} catch (_: kotlinx.coroutines.channels.ClosedReceiveChannelException) {
@@ -175,6 +173,82 @@ class IngestQueue(
}
}
private suspend fun processBatch(batch: List<Submission>) {
// Tier 3: parallel pre-insert verify. Each batch entry that
// fails verification is pre-marked Rejected and excluded from
// the SQLite transaction. The remaining (verified) events go
// through batchInsert in original order; we re-stitch outcomes
// back to the full batch by index.
val verifyResults: BooleanArray? =
verify?.let { hook ->
coroutineScope {
batch
.map { sub -> async(Dispatchers.Default) { hook(sub.event) } }
.awaitAll()
.toBooleanArray()
}
}
val toInsert: List<Event>
val insertIndices: IntArray
if (verifyResults == null) {
toInsert = batch.map { it.event }
insertIndices = IntArray(batch.size) { it }
} else {
val accepted = ArrayList<Event>(batch.size)
val mapping = ArrayList<Int>(batch.size)
for (i in batch.indices) {
if (verifyResults[i]) {
accepted.add(batch[i].event)
mapping.add(i)
}
}
toInsert = accepted
insertIndices = mapping.toIntArray()
}
val insertOutcomes: List<IEventStore.InsertOutcome> =
if (toInsert.isEmpty()) {
emptyList()
} else {
try {
store.batchInsert(toInsert)
} catch (e: Throwable) {
Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" }
val reason = e.message ?: e::class.simpleName ?: "insert failed"
List(toInsert.size) { IEventStore.InsertOutcome.Rejected(reason) }
}
}
// Build a per-batch-index outcome array.
val finalOutcomes = arrayOfNulls<IEventStore.InsertOutcome>(batch.size)
for (j in insertIndices.indices) {
finalOutcomes[insertIndices[j]] = insertOutcomes.getOrNull(j)
?: IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
}
if (verifyResults != null) {
for (i in batch.indices) {
if (!verifyResults[i]) {
finalOutcomes[i] = IEventStore.InsertOutcome.Rejected(verifyRejectionReason)
}
}
}
for (i in batch.indices) {
val sub = batch[i]
val outcome =
finalOutcomes[i]
?: IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
try {
sub.onComplete(outcome)
} catch (e: Throwable) {
// A misbehaving callback must not poison the writer
// loop; the outcome is delivered best-effort.
Log.w("IngestQueue") { "onComplete threw: ${e.message}" }
}
}
}
/**
* Stop accepting new submissions and cancel the writer.
* In-flight submissions whose batch hadn't started yet may never
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -36,11 +38,18 @@ import kotlin.coroutines.CoroutineContext
*
* @param store The [IEventStore] backing this relay.
* @param policyBuilder Controls requirements for relay commands.
* @param parallelVerify When `true`, Schnorr verification runs in
* parallel inside the [IngestQueue] (one async per event, dispatched
* on `Dispatchers.Default`) rather than serially on the WS pump
* coroutine inside [VerifyPolicy]. Callers that flip this on should
* *omit* `VerifyPolicy` from their [policyBuilder] chain to avoid
* double-verifying.
*/
class NostrServer(
private val store: IEventStore,
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
private val parentContext: CoroutineContext = SupervisorJob(),
parallelVerify: Boolean = false,
) : AutoCloseable {
/** Scope for all subscriptions. */
private val scope = CoroutineScope(parentContext + SupervisorJob())
@@ -52,7 +61,12 @@ class NostrServer(
* publishes into a single SQLite transaction. See [IngestQueue]
* for the OK ordering and durability semantics.
*/
private val ingest = IngestQueue(store, parentContext)
private val ingest =
IngestQueue(
store = store,
parentContext = parentContext,
verify = if (parallelVerify) ::verifyEvent else null,
)
private val subStore = LiveEventStore(store, ingest)
@@ -114,4 +128,11 @@ class NostrServer(
*/
@Deprecated("Use close() instead", replaceWith = ReplaceWith("close()"))
fun shutdown() = close()
private companion object {
// Function reference (`Event::verify`) wrapper so the
// ingest hook keeps a single instance per server rather
// than allocating a fresh lambda on every call site.
private fun verifyEvent(event: Event): Boolean = event.verify()
}
}