perf(relay): isolate WS frame dispatch onto a dedicated pool, off Dispatchers.IO

Measured on a GrapeRank crawl, frame decode/dispatch (per-connection consumer
coroutines) ran on the shared Dispatchers.IO — the same pool that runs the store's
blocking SQLite inserts. During event floods, frame coroutines queued behind those
inserts: mean 200ms and up to 3.5s of dispatch lag, with 43k frames waiting >1s in
our pipeline. That lag also skews the relay-idle/EOSE timing the crawler reads.

Give frame processing its own daemon thread pool (sized to a small multiple of
cores; decode is light + CPU-bound), shared across all connections. Frame delivery
stays prompt regardless of what the IO pool is doing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
This commit is contained in:
Claude
2026-07-09 17:56:22 +00:00
parent 4c20522d78
commit ea1093adaf
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
@@ -36,6 +36,7 @@ import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.util.concurrent.Executors
import kotlin.time.TimeSource import kotlin.time.TimeSource
import kotlin.time.TimeSource.Monotonic.ValueTimeMark import kotlin.time.TimeSource.Monotonic.ValueTimeMark
import okhttp3.WebSocket as OkHttpWebSocket import okhttp3.WebSocket as OkHttpWebSocket
@@ -52,6 +53,21 @@ class BasicOkHttpWebSocket(
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
Log.e("BasicOkHttpWebSocket", "WebsocketListener Caught exception: ${throwable.message}", throwable) Log.e("BasicOkHttpWebSocket", "WebsocketListener Caught exception: ${throwable.message}", throwable)
} }
// Frame decode + dispatch runs on its OWN pool, isolated from Dispatchers.IO.
// The shared IO pool also runs the store write path (blocking SQLite inserts);
// measured on a GrapeRank crawl, frame-processing coroutines were queueing
// behind those inserts, adding a 200ms MEAN and up to 3.5s TAIL of dispatch lag
// during event floods — which in turn skews the relay-idle/EOSE timing the
// crawler reads. A dedicated daemon pool keeps frame delivery prompt regardless
// of what the IO pool is doing. Shared across all connections; sized to the
// machine (decode is light + CPU-bound, so a small multiple of cores suffices).
private val frameDispatcher =
Executors
.newFixedThreadPool(
(Runtime.getRuntime().availableProcessors() * 2).coerceIn(4, 32),
) { r -> Thread(r, "ws-frame").apply { isDaemon = true } }
.asCoroutineDispatcher()
} }
private var socket: OkHttpWebSocket? = null private var socket: OkHttpWebSocket? = null
@@ -63,7 +79,7 @@ class BasicOkHttpWebSocket(
val listener = val listener =
object : OkHttpWebSocketListener() { object : OkHttpWebSocketListener() {
val scope = CoroutineScope(Dispatchers.IO + exceptionHandler) val scope = CoroutineScope(frameDispatcher + exceptionHandler)
// UNLIMITED on purpose — do NOT bound this channel. The app // UNLIMITED on purpose — do NOT bound this channel. The app
// holds 2000+ relay connections; a bounded buffer under a // holds 2000+ relay connections; a bounded buffer under a