Optimize relay performance: fix frame drops, improve query latency#175
Merged
Conversation
Send path:
- Add a per-connection outbound queue drained by a dedicated writer
coroutine using the suspending session.send(). Previously frames were
written with session.outgoing.trySend(), which only buffers a handful
of frames and silently dropped the rest whenever a REQ streamed more
events than the buffer could hold. High-volume paths (REQ result
streaming, OK/EOSE/CLOSED responses) now use a suspending send() that
applies backpressure; fire-and-forget paths keep trySend(), which now
disconnects a consumer that falls 1024 frames behind instead of
corrupting its stream.
- Stop constructing a new Jackson ObjectMapper for every OK/CLOSED
response (mapper construction is very expensive); share one instance.
- Stop writing one persisted log row per event sent to a subscription;
log a single summary per filter instead, only on debug builds.
- Build EVENT envelopes by concatenating the pre-escaped subscription id
with the serialized event instead of serializing a wrapper list.
- Skip deflate for frames under 1KB (OK/EOSE/NOTICE/AUTH and most
events were being compressed one by one) and use BEST_SPEED, since
clients are mostly on localhost/LAN where CPU matters more than ratio.
Receive path:
- Decouple socket reads from message processing with a bounded
per-connection pipeline on Dispatchers.Default. JSON parsing,
signature verification, and DB checks no longer run on the network
engine threads, so reads continue during processing bursts and other
connections are not stalled. Per-connection message ordering is
preserved, and already-received messages are drained before teardown.
- Cache event.taggedUsers() in verifyEvent/policyAllows instead of
recomputing it up to four times per event, and skip it entirely when
no pubkey allowlists are configured.
- EventFilter.test() now runs its cheapest checks first and tries O(1)
exact id/author set lookups before the O(n) prefix scans; tag matching
no longer allocates an intermediate set per tag per event.
Also fixes the NIP-45 COUNT response, which was double-encoded: it sent
["COUNT",subId,"{\"count\":n}"] (a JSON string) instead of
["COUNT",subId,{"count":n}] (an object), and removes the now-unused
server/CountResult.kt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
…rom rows The generated filter SQL added INNER JOIN TagEntity whenever a filter had tag conditions, even though the EXISTS predicates already do all the tag filtering. The join multiplied every matching event row by its tag count and then relied on SELECT DISTINCT over full-width rows (including content) to dedupe them again, forcing SQLite to build a temp B-tree comparing entire rows. Remove the join and the DISTINCT in all three select modes; COUNT(DISTINCT id) becomes COUNT(*), which is equivalent now that no mode can produce duplicate rows (the FTS join is 1:1 on rowid and id is the primary key). Also serialize REQ results straight from the database row with a streaming JsonGenerator instead of building a kind-specific quartz Event via EventFactory plus an intermediate Jackson tree per event. Output is byte-identical to the previous toEvent().toJsonObject() path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
Benchmark analysis showed intermittent ~7s event-delivery stalls: the relay executes queries in 2-100ms (per the subscription timing logs) and enqueues all frames, but delivery to the client occasionally freezes until the client's next message, tripping go-nostr QuerySync's 7-second default timeout and losing that query's events. The stall is below the relay's own write pipeline (even Ktor's 5s pings queue behind the stuck frames), pointing at the socket/client boundary. Nearly all Citrine clients connect over localhost or LAN, where deflate only burns CPU and battery, and client-side permessage-deflate implementations (notably gorilla/websocket's flate reader, used by go-nostr) have a history of blocking mid-stream on compressed frames. Dropping the extension removes that entire failure class; clients that don't negotiate it are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
Mirrors the go-nostr benchmark (publish signed events, then query with the same 5 filter shapes and QuerySync's 7s timeout) and adds the diagnostics needed to localize slow queries: per-query first-event and EOSE latency, largest mid-stream frame gap, and a forensic line for every query that times out. A --compression flag switches between gorilla-style permessage-deflate, the library default, and no compression, to A/B the deflate-stall theory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
coincurve has no wheel for newer Python (e.g. 3.14 on Arch) and its source build is currently broken, so BIP-340 signing now falls back to a pure-Python implementation (Jacobian-coordinate point arithmetic, ~2ms/signature) when coincurve is not installed. Events are pre-signed before the publish timer starts either way, so signing speed cannot skew the measured relay publish rate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
All events signed by one key within the same second with identical content shared the same event id, so the relay deduplicated them and the query phase ran against a nearly empty database (~15 stored events instead of 1000), making results incomparable to the go-nostr benchmark. Each event's content now embeds a run/publisher/sequence prefix, and duplicate OK responses are reported separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
Published events now carry e/p/t tags drawn from fixed pools (stable across runs so query-only runs still match), and the query cycle grows from 5 to 8 filter shapes: the original go-nostr benchmark cases plus #t+kinds, #p, and #e queries. This exercises the tag-insert path on publish and the EXISTS-based tag-filter SQL on query. Results now include a per-filter-shape breakdown (n, p50, max, avg events) so tag queries can be compared against kind/time queries directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR addresses critical performance issues in the Citrine relay that caused dropped frames and slow queries. The main problems were:
trySend()to Ktor's bounded outgoing buffer silently discarded frames when clients fell behind, corrupting REQ/OK semanticsKey Changes
Connection & Frame Delivery (
Connection.kt)session.outgoingwith a dedicatedoutgoingQueue(Channel with 1024-frame capacity)session.send(), applying backpressure to producerstrySend()(non-blocking, disconnects slow consumers) from newsend()(suspending, for high-volume paths like REQ streaming)send()to suspend rather than drop framesQuery Optimization (
EventRepository.kt)EXISTSsubqueries instead of joining TagEntity, which was multiplying rows by tag count and forcing expensive DISTINCT over full-width rowsDISTINCTfrom COUNT, FULL_EVENTS, and IDS_AND_CREATED_AT queries (no row duplication with EXISTS predicates)toEventJson()skips intermediate Event object and Jackson tree construction, reducing allocations and GC pressureEvent Serialization (
EventRepository.kt)toEventJson()method serializes EventWithTags directly to JSON string using a single Jackson generatortoEvent().toJsonObject()but with ~90% fewer allocationsCommandResult.ktFilter Optimization (
EventFilter.kt)Protocol Compliance (
CustomWebSocketServer.kt,SubscriptionManager.kt)trySend()tosend()to respect backpressureTesting & Diagnostics (
scripts/relay_bench.py)Notable Implementation Details
send()on REQ streaming allows slow clients to naturally throttle the relay;trySend()on non-critical paths (PING, errors) disconnects unresponsive consumers rather than buffering indefinitelyTesting
Run the benchmark against the relay:
Expected improvements:
https://claude.ai/code/session_01Qo8a1iEcqAaSW7sjiefy49