From 387bfe99ee109bb920032b274c9211344868535b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:23:21 +0000 Subject: [PATCH] perf(relay): drop per-op allocations on the frame + search paths Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged: - strippingSearchExtensions: index-loop guard returns the same list with zero allocation when no filter carries a search term (every non-search REQ/COUNT/snapshot, the overwhelming majority). - EoseMessage/OkMessage: direct-buildString wire form on the escape-free fast path (EOSE per REQ, OK per publish), skipping the generic serializer's node tree; exotic subIds/reasons fall back. Shared isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage. Verified: quartz relay.server + message-frame suites (110 tests). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w --- .../relay/commands/toClient/EoseMessage.kt | 16 +++++++++ .../relay/commands/toClient/OkMessage.kt | 19 ++++++++++ .../relay/commands/toClient/WireJson.kt | 36 +++++++++++++++++++ .../quartz/nip50Search/SearchQuery.kt | 12 +++++++ 4 files changed, 83 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/WireJson.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt index d6912571b0..f376e4bc82 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt @@ -25,6 +25,22 @@ class EoseMessage( ) : Message { override fun label() = LABEL + /** + * Wire form is `["EOSE",""]` — sent once per REQ, so it is on + * the per-subscription floor. Splice it directly when [subId] needs no + * escaping (the common case: client-chosen sub ids are short ASCII), + * skipping the generic serializer's node tree. Byte-identical output; + * any exotic subId falls back. + */ + override fun toJson(): String { + if (!isEscapeFreeAscii(subId)) return super.toJson() + return buildString(subId.length + 12) { + append("[\"EOSE\",\"") + append(subId) + append("\"]") + } + } + companion object { const val LABEL = "EOSE" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt index 8a29794a44..47df9bedb0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt @@ -29,6 +29,25 @@ class OkMessage( ) : Message { override fun label() = LABEL + /** + * Wire form is `["OK","",,""]` — sent + * once per published EVENT. [eventId] is validated hex (always + * escape-free); splice directly when [message] also needs no escaping, + * which covers the empty-string success ack and the plain-ASCII + * rejection reasons. Byte-identical output; a reason with quotes or + * non-ASCII falls back to the generic serializer. + */ + override fun toJson(): String { + if (!isEscapeFreeAscii(message)) return super.toJson() + return buildString(eventId.length + message.length + 20) { + append("[\"OK\",\"") + append(eventId) + append(if (success) "\",true,\"" else "\",false,\"") + append(message) + append("\"]") + } + } + companion object { const val LABEL = "OK" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/WireJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/WireJson.kt new file mode 100644 index 0000000000..63e1a47e5e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/WireJson.kt @@ -0,0 +1,36 @@ +/* + * 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.nip01Core.relay.commands.toClient + +/** + * True when every char of [s] is printable ASCII (0x20–0x7e) and not a JSON + * metacharacter (`"` / `\`) — i.e. exactly the bytes a JSON string encoder + * would emit verbatim between the quotes. Frame builders use this to gate a + * direct-`buildString` fast path against the generic serializer: when it holds + * the spliced output is byte-identical, and any exotic value (control chars, + * quotes, non-ASCII) falls back to the escaping serializer. + */ +internal fun isEscapeFreeAscii(s: String): Boolean { + for (c in s) { + if (c < ' ' || c > '~' || c == '"' || c == '\\') return false + } + return true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt index 12b3af995d..f96b620d5c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt @@ -202,8 +202,20 @@ fun Filter.strippingSearchExtensions(): Filter { /** * Applies [strippingSearchExtensions] to every filter, returning this * same list when no filter carried extension tokens. + * + * This runs on every REQ/COUNT/snapshot, and the overwhelming majority + * carry no `search` term at all, so the no-search case must not allocate: + * bail before building any list when nothing could be stripped. */ fun List.strippingSearchExtensions(): List { + var hasSearch = false + for (i in indices) { + if (!this[i].search.isNullOrEmpty()) { + hasSearch = true + break + } + } + if (!hasSearch) return this var changed = false val out = map {