perf(relay): load benchmark + bump per-session outbound buffer
Adds an opt-in load benchmark suite (`-DrunLoadBenchmark=true`)
covering: held-open WebSocket count, single + concurrent EVENT
publish throughput, and live-event fan-out latency to N
subscribers on one connection.
Numbers from a small VM (4 GiB RAM, 4 vCPU, ulimit -n 4096):
Connections (raw WS, one REQ each)
100 : 100 OK, 0.7 s
500 : 500 OK, 1.2 s
1000 : 1000 OK, 2.0 s
2000 : 1993 OK (hits the 4096-fd ceiling: 2 fds per WS)
Single-publisher serial publish + OK
10000 events, 13.2 s, 760 EPS (round-trip latency bound)
Concurrent publishers (each on its own WS)
parallel=2 : 807 EPS
parallel=4 : 1452 EPS
parallel=8 : 1989 EPS ← knee
parallel=16 : 1704 EPS ← SQLite single-writer ceiling
parallel=32 : 1778 EPS
Fanout (one publish, N active subs on a single WS)
100 subs : 67 ms last
500 subs : 52 ms last
1000 subs : 51 ms last
2000 subs : 111 ms last
Bumped SESSION_OUTGOING_BUFFER from 1024 → 8192. The 1024 cap was
hit by the 2000-sub fanout test (2000 outbound frames into one
session's queue), causing the relay to drop the connection as a
slow-consumer protection. 8192 fits the realistic upper bound for
a high-fan-out client (a few thousand subs on one WS) and caps
per-session memory at ~2 MiB before we drop.
Numbers also confirm:
- The SQLite single-writer plateau is around 2000 EPS on this
box. Production behind WAL + a faster disk should beat that.
- Each WebSocket consumes 2 file descriptors. Operators must
raise `ulimit -n` to ~3× their target connection count.
- Fan-out scales sub-linearly (2000 subs ≈ 2× the latency of
100 subs) — the bottleneck is shared (broadcast + SQLite
write), not per-sub.
Total :quartz-relay tests: 99 (4 new benchmarks, opt-in), 0 failures.
This commit is contained in:
@@ -27,6 +27,20 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
|
||||||
|
// perf.LoadBenchmark tests opt in. Off by default — load tests
|
||||||
|
// are noisy and slow.
|
||||||
|
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
|
||||||
|
// Show println output from test JVM so the benchmark numbers are
|
||||||
|
// actually visible without grepping the report XML.
|
||||||
|
testLogging {
|
||||||
|
showStandardStreams =
|
||||||
|
(System.getProperty("runLoadBenchmark") == "true")
|
||||||
|
events("standard_out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":quartz"))
|
api(project(":quartz"))
|
||||||
|
|
||||||
|
|||||||
@@ -473,7 +473,13 @@ class LocalRelayServer(
|
|||||||
* this many frames behind, we close their connection rather
|
* this many frames behind, we close their connection rather
|
||||||
* than silently dropping further frames (which would corrupt
|
* than silently dropping further frames (which would corrupt
|
||||||
* NIP-01 by missing EVENT/EOSE messages).
|
* NIP-01 by missing EVENT/EOSE messages).
|
||||||
|
*
|
||||||
|
* Sized to hold fan-out for a connection holding several
|
||||||
|
* thousand subscriptions when one event matches all of them
|
||||||
|
* — the realistic upper bound for a relay client. At ~250B
|
||||||
|
* per frame this caps per-session memory at ~2 MiB before
|
||||||
|
* we drop the connection.
|
||||||
*/
|
*/
|
||||||
const val SESSION_OUTGOING_BUFFER: Int = 1024
|
const val SESSION_OUTGOING_BUFFER: Int = 8192
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.relay.perf
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.relay.LocalRelayServer
|
||||||
|
import com.vitorpamplona.quartz.relay.Relay
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-process load tests that report real numbers for:
|
||||||
|
* - WebSocket connection establishment rate
|
||||||
|
* - Concurrent subscription steady-state count
|
||||||
|
* - Live-event fanout latency to N subscribers
|
||||||
|
* - End-to-end EVENT publish throughput (EVENT → store → OK)
|
||||||
|
*
|
||||||
|
* Disabled by default — runs only when `runLoadBenchmark` system
|
||||||
|
* property is set. Add `-DrunLoadBenchmark=true` to a Gradle test
|
||||||
|
* invocation. Skipped under the normal test run because (a) numbers
|
||||||
|
* vary on busy CI runners, and (b) some scenarios spin up thousands
|
||||||
|
* of sockets which is rude on shared infra.
|
||||||
|
*/
|
||||||
|
class LoadBenchmark {
|
||||||
|
private val enabled = System.getProperty("runLoadBenchmark") == "true"
|
||||||
|
|
||||||
|
private inline fun benchmark(
|
||||||
|
name: String,
|
||||||
|
block: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!enabled) {
|
||||||
|
println("[skip] $name — set -DrunLoadBenchmark=true to enable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
println("--- $name ---")
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many concurrent WebSocket *connections* can we hold open?
|
||||||
|
* Each test client opens a raw WS, sends one REQ, expects EOSE.
|
||||||
|
* Stays connected after that.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun connectionsHeldOpen() =
|
||||||
|
benchmark("connections held open") {
|
||||||
|
for (target in listOf(100, 500, 1_000, 2_000, 5_000, 10_000)) {
|
||||||
|
runBenchmarkServer { server, http ->
|
||||||
|
val httpUrl =
|
||||||
|
okhttp3.Request
|
||||||
|
.Builder()
|
||||||
|
.url(server.url.replace("ws://", "http://"))
|
||||||
|
.build()
|
||||||
|
val sockets = java.util.concurrent.CopyOnWriteArrayList<okhttp3.WebSocket>()
|
||||||
|
val opened = AtomicLong()
|
||||||
|
val gotEose = AtomicLong()
|
||||||
|
val opens =
|
||||||
|
measureTime {
|
||||||
|
repeat(target) {
|
||||||
|
val ws =
|
||||||
|
http.newWebSocket(
|
||||||
|
httpUrl,
|
||||||
|
object : okhttp3.WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: okhttp3.WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
opened.incrementAndGet()
|
||||||
|
webSocket.send(
|
||||||
|
"""["REQ","s",{"kinds":[1],"limit":1}]""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: okhttp3.WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (text.startsWith("[\"EOSE\"")) {
|
||||||
|
gotEose.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sockets += ws
|
||||||
|
}
|
||||||
|
// Wait for either EOSE on every connection or 60s deadline.
|
||||||
|
val deadline = System.currentTimeMillis() + 60_000
|
||||||
|
while (gotEose.get() < target && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Let activeSessionCount settle.
|
||||||
|
Thread.sleep(200)
|
||||||
|
println(
|
||||||
|
"target=$target opened=${opened.get()} eosed=${gotEose.get()} " +
|
||||||
|
"active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds}",
|
||||||
|
)
|
||||||
|
sockets.forEach { runCatching { it.cancel() } }
|
||||||
|
if (gotEose.get() < target) {
|
||||||
|
println(" --> degradation at $target; stopping ramp-up")
|
||||||
|
return@runBenchmarkServer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One publisher sends 10k events serially. Measures the round-trip
|
||||||
|
* `EVENT` → `OK true` time, which is dominated by SQLite write
|
||||||
|
* throughput + the write side of the policy stack.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun publishThroughputSingleClient() =
|
||||||
|
benchmark("publish throughput 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("hello $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)}")
|
||||||
|
} finally {
|
||||||
|
client.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One publisher, N subscribers. Publishes one EVENT and measures
|
||||||
|
* fan-out latency: time from publish to last subscriber receiving.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun fanoutLatency() =
|
||||||
|
benchmark("fanout latency") {
|
||||||
|
for (subs in listOf(100, 500, 1_000, 2_000)) {
|
||||||
|
runBenchmarkServer { server, http ->
|
||||||
|
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||||
|
val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||||
|
try {
|
||||||
|
val relayUrl = server.url.normalizeRelayUrl()
|
||||||
|
val received = AtomicLong()
|
||||||
|
val firstReceiveNs = AtomicLong(-1)
|
||||||
|
val lastReceiveNs = AtomicLong(-1)
|
||||||
|
|
||||||
|
// Set up `subs` subscribers.
|
||||||
|
val eosed = AtomicLong()
|
||||||
|
repeat(subs) { i ->
|
||||||
|
subClient.subscribe(
|
||||||
|
"fanout-$i",
|
||||||
|
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||||
|
object : SubscriptionListener {
|
||||||
|
override fun onEvent(
|
||||||
|
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||||
|
isLive: Boolean,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
val now = System.nanoTime()
|
||||||
|
firstReceiveNs.compareAndSet(-1, now)
|
||||||
|
lastReceiveNs.set(now)
|
||||||
|
received.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEose(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
eosed.incrementAndGet()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(60_000) {
|
||||||
|
while (eosed.get() < subs) kotlinx.coroutines.delay(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("$subs subs ready; publishing one event...")
|
||||||
|
|
||||||
|
val signer = NostrSignerSync(KeyPair())
|
||||||
|
val event = signer.sign(TextNoteEvent.build("fanout"))
|
||||||
|
val publishStart = System.nanoTime()
|
||||||
|
runBlocking {
|
||||||
|
pubClient.publishAndConfirm(event, setOf(relayUrl))
|
||||||
|
}
|
||||||
|
val publishEnd = System.nanoTime()
|
||||||
|
|
||||||
|
// Wait for fanout to complete.
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(60_000) {
|
||||||
|
while (received.get() < subs) kotlinx.coroutines.delay(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstFanoutMs = (firstReceiveNs.get() - publishStart) / 1_000_000.0
|
||||||
|
val lastFanoutMs = (lastReceiveNs.get() - publishStart) / 1_000_000.0
|
||||||
|
val publishMs = (publishEnd - publishStart) / 1_000_000.0
|
||||||
|
println(
|
||||||
|
"subs=$subs publishMs=${"%.1f".format(publishMs)} " +
|
||||||
|
"fanoutFirstMs=${"%.1f".format(firstFanoutMs)} " +
|
||||||
|
"fanoutLastMs=${"%.1f".format(lastFanoutMs)} " +
|
||||||
|
"received=${received.get()}/$subs",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
subClient.disconnect()
|
||||||
|
pubClient.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Many concurrent publishers, each on their own WebSocket. Tells
|
||||||
|
* us whether the SQLite single-writer bottleneck is the floor or
|
||||||
|
* if there's contention upstream.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun publishThroughputConcurrent() =
|
||||||
|
benchmark("publish throughput concurrent") {
|
||||||
|
for (parallel in listOf(2, 4, 8, 16, 32)) {
|
||||||
|
runBenchmarkServer { server, http ->
|
||||||
|
val total = 5_000
|
||||||
|
val perThread = total / parallel
|
||||||
|
val ok = AtomicLong()
|
||||||
|
val elapsed =
|
||||||
|
measureTime {
|
||||||
|
val threads =
|
||||||
|
(0 until parallel).map { tid ->
|
||||||
|
Thread {
|
||||||
|
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||||
|
try {
|
||||||
|
val signer = NostrSignerSync(KeyPair())
|
||||||
|
val relayUrl = server.url.normalizeRelayUrl()
|
||||||
|
runBlocking {
|
||||||
|
repeat(perThread) { i ->
|
||||||
|
val ev = signer.sign(TextNoteEvent.build("hi-$tid-$i"))
|
||||||
|
if (client.publishAndConfirm(ev, setOf(relayUrl))) ok.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}.also { it.start() }
|
||||||
|
}
|
||||||
|
threads.forEach { it.join() }
|
||||||
|
}
|
||||||
|
val eps = (ok.get() * 1000.0) / elapsed.inWholeMilliseconds
|
||||||
|
println(
|
||||||
|
"parallel=$parallel total=${ok.get()}/$total elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spin up an isolated relay + http client per scenario. */
|
||||||
|
private inline fun runBenchmarkServer(block: (LocalRelayServer, OkHttpClient) -> Unit) {
|
||||||
|
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
val relay = Relay(url = placeholder)
|
||||||
|
val server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||||
|
val http =
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
// Don't bottleneck on the OkHttp dispatcher when we
|
||||||
|
// open thousands of WS connections from one client.
|
||||||
|
.dispatcher(
|
||||||
|
okhttp3.Dispatcher().apply {
|
||||||
|
maxRequests = 100_000
|
||||||
|
maxRequestsPerHost = 100_000
|
||||||
|
},
|
||||||
|
).build()
|
||||||
|
try {
|
||||||
|
block(server, http)
|
||||||
|
} finally {
|
||||||
|
http.dispatcher.executorService.shutdownNow()
|
||||||
|
server.stop(gracePeriodMillis = 200, timeoutMillis = 1_000)
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user