From 58c580cd34994d7955dcca4d2770953f32dbbd74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 15:23:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(geode):=20--no-search=20/=20[options].full?= =?UTF-8?q?=5Ftext=5Fsearch=20=E2=80=94=20run=20without=20NIP-50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strfry implements no NIP-50 at all, so geode's default setup pays FTS tokenization on every searchable event for a feature the other side of a benchmark isn't providing. The new switch removes it cleanly: - geode: --no-search CLI flag and [options].full_text_search TOML key (default true). When off, the store skips the FTS index entirely, NIP-11 stops advertising 50 (an explicit [info] nips list stays operator-authoritative), and search filters match nothing via the existing QueryBuilder guard. RelayIndexingStrategy becomes relayIndexingStrategy(fullTextSearch) with the stock val kept. - relayBench: --geode-no-search runs the geode entry with the flag (relay named geode-nosearch in reports); README documents the apples-to-apples rationale and a recipe for benchmarking both geode flavors side by side. Alternating A/B on the 10k corpus: 4,767/4,792 ev/s without search vs 4,111/3,977 with — NIP-50 costs ~18% of ingest throughput at current write-path speed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46 --- geode/config.example.toml | 6 ++++ .../kotlin/com/vitorpamplona/geode/Main.kt | 13 ++++++-- .../geode/RelayIndexingStrategy.kt | 11 ++++++- .../geode/config/StaticConfig.kt | 15 +++++++-- relayBench/README.md | 21 +++++++++++++ .../com/vitorpamplona/relaybench/Main.kt | 8 ++++- .../relaybench/relays/RelayUnderTest.kt | 31 ++++++++++++------- 7 files changed, 88 insertions(+), 17 deletions(-) diff --git a/geode/config.example.toml b/geode/config.example.toml index 7c83311b7b..73f7adca1c 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -59,6 +59,12 @@ verify_signatures = true # legacy in-policy verify path. # parallel_verify = true +# NIP-50 full-text search. Default: true. When false no FTS index is +# built or maintained (inserts skip tokenization — a measurable share +# of ingest cost), NIP-11 stops advertising NIP-50, and REQ filters +# with a `search` term match nothing. CLI: --no-search. +# full_text_search = true + # Require clients to NIP-42 AUTH before REQ/EVENT/COUNT. require_auth = false diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 16312e4d95..963269af0d 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -71,6 +71,11 @@ import java.io.File * --no-verify DO NOT verify event signatures (off by default * verify is on; use only for trusted-input * scenarios like fixture replay). + * --no-search disable NIP-50 full-text search (sets + * options.full_text_search = false): no FTS index is + * built or maintained, NIP-11 stops advertising 50, + * and `search` filters match nothing. Skips the + * per-event tokenization cost on ingest. */ fun main(args: Array) { val a = parseArgs(args) @@ -101,6 +106,10 @@ fun main(args: Array) { // `[options].parallel_verify = false`. val parallelVerify = verifySigs && !a.flag("--no-parallel-verify") && config.options.parallel_verify + // NIP-50 search is on by default; `--no-search` (or + // `[options].full_text_search = false`) trades it for cheaper ingest — + // e.g. to match relays that don't implement NIP-50 at all. + val fullTextSearch = !a.flag("--no-search") && config.options.full_text_search // Advertised URL: explicit `info.relay_url` wins, then build from // host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42 @@ -111,9 +120,9 @@ fun main(args: Array) { val info = cliInfoFile?.let { RelayInfo.fromFile(it) } - ?: config.resolveInfo() + ?: config.resolveInfo(fullTextSearch) - val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl, indexStrategy = RelayIndexingStrategy) + val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl, indexStrategy = relayIndexingStrategy(fullTextSearch)) val policyBuilder: () -> IRelayPolicy = { composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayIndexingStrategy.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayIndexingStrategy.kt index 259bef25bc..dadef48a06 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayIndexingStrategy.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayIndexingStrategy.kt @@ -35,8 +35,17 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy * the gap grows linearly with the table. The extra index costs one B-tree * insert per event, which the same benchmark shows is noise next to the * Schnorr verify + tag indexing already paid on the write path. + * + * @param fullTextSearch maintain the NIP-50 FTS index. Off skips the + * per-event tokenization on ingest (relayBench measured it at roughly a + * quarter of write cost) at the price of `search` filters matching + * nothing — pair it with a NIP-11 doc that doesn't advertise 50. */ -val RelayIndexingStrategy = +fun relayIndexingStrategy(fullTextSearch: Boolean = true) = DefaultIndexingStrategy( indexEventsByCreatedAtAlone = true, + indexFullTextSearch = fullTextSearch, ) + +/** Stock relay strategy — everything on, matching geode's defaults. */ +val RelayIndexingStrategy = relayIndexingStrategy() diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 9b59d9f45e..b9c9f6aacd 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -46,7 +46,7 @@ data class StaticConfig( val admin: AdminSection = AdminSection(), val negentropy: NegentropySection = NegentropySection(), ) { - fun resolveInfo(): RelayInfo = + fun resolveInfo(fullTextSearch: Boolean = true): RelayInfo = RelayInfo( Nip11RelayInformation( name = info.name ?: RelayInfo.NAME, @@ -58,7 +58,9 @@ data class StaticConfig( version = info.version ?: RelayInfo.VERSION, supported_nips = info.supported_nips?.map(Int::toString) - ?: RelayInfo.SUPPORTED_NIPS, + // An explicit [info] nips list is operator-authoritative; + // the default list stays honest about search. + ?: if (fullTextSearch) RelayInfo.SUPPORTED_NIPS else RelayInfo.SUPPORTED_NIPS - "50", privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, @@ -123,6 +125,15 @@ data class StaticConfig( val verify_signatures: Boolean = true, /** CPU fan-out verification in the IngestQueue. No-op when [verify_signatures] is false. */ val parallel_verify: Boolean = true, + /** + * NIP-50 full-text search. On by default. When off, no FTS index + * is created or maintained (inserts skip tokenization — a + * measurable share of ingest cost), NIP-11 stops advertising + * NIP-50, and REQ filters carrying a `search` term match nothing. + * Turn off when search isn't needed, or to compare against relays + * that don't offer NIP-50 at all (strfry, for example). + */ + val full_text_search: Boolean = true, ) /** diff --git a/relayBench/README.md b/relayBench/README.md index e0a2a822e4..e7f6afa986 100644 --- a/relayBench/README.md +++ b/relayBench/README.md @@ -80,6 +80,27 @@ benchmark artifact. > and manifest/fingerprint convention are designed so other relay authors can > run the exact same workload and publish comparable numbers. +## Feature parity: NIP-50 search + +geode maintains a NIP-50 full-text index by default; strfry has no search +at all. That skews the *ingest* comparison — geode tokenizes every +searchable event into the FTS index (~a quarter of its write cost) for a +feature strfry isn't providing. `--geode-no-search` runs geode with +`--no-search` (no FTS index, NIP-11 stops advertising 50) for the +apples-to-apples write path: + +```bash +./relayBench/run.sh --geode-no-search # geode(no NIP-50) vs strfry +``` + +To see the price of search inline, run both geode flavors side by side: + +```bash +GEODE=geode/build/install/geode/bin/geode +./relayBench/run.sh --geode-no-search \ + --relay "geode-fts=$GEODE --host 127.0.0.1 --port {port} --db {dir}/geode.sqlite" +``` + ## Adding another relay No code needed if the relay can be launched from a command line: diff --git a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt index 26b3fedb6e..f4f16cfb6b 100644 --- a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt +++ b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt @@ -57,6 +57,7 @@ import kotlin.system.exitProcess */ class Options( val geodeBin: String?, + val geodeNoSearch: Boolean, val strfryBin: String?, val customRelays: List>, val events: Int, @@ -87,6 +88,10 @@ private val USAGE = Relays (at least one): --geode-bin PATH geode launcher (from :geode:installDist) + --geode-no-search run geode with --no-search (NIP-50 off) so the + write path matches relays that have no FTS at + all, e.g. strfry — the apples-to-apples ingest + comparison --strfry-bin PATH strfry binary --relay NAME=CMD any other relay; CMD may use {port} and {dir} (repeatable) @@ -199,7 +204,7 @@ fun main(args: Array) { private fun buildRelays(options: Options): List = buildList { - options.geodeBin?.let { add(GeodeRelay(it)) } + options.geodeBin?.let { add(GeodeRelay(it, noSearch = options.geodeNoSearch)) } options.strfryBin?.let { add(StrfryRelay(it, options.maxEventBytes, options.maxTags)) } options.customRelays.forEach { (name, cmd) -> add(CustomRelay(name, cmd)) } } @@ -307,6 +312,7 @@ private fun parseArgs(args: Array): Options? { val moduleDir = File("relayBench").takeIf { it.isDirectory } ?: File(".") return Options( geodeBin = one("--geode-bin"), + geodeNoSearch = "--geode-no-search" in flags, strfryBin = one("--strfry-bin"), customRelays = (map["--relay"] ?: emptyList()).map { diff --git a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt index 631efe5c54..a4ee0fa9c8 100644 --- a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt +++ b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt @@ -133,23 +133,32 @@ data class Nip11Info( val version: String?, ) -/** Geode — the standalone JVM relay from this repo (`:geode:installDist`). */ +/** + * Geode — the standalone JVM relay from this repo (`:geode:installDist`). + * + * [noSearch] passes `--no-search`, dropping NIP-50 (no FTS index, no + * per-event tokenization on ingest). strfry has no NIP-50 at all, so this + * is the apples-to-apples ingest configuration against it; the default + * keeps geode's stock feature set and eats the indexing cost. + */ class GeodeRelay( private val bin: String, -) : RelayUnderTest("geode") { + private val noSearch: Boolean = false, +) : RelayUnderTest(if (noSearch) "geode-nosearch" else "geode") { override fun command( port: Int, dataDir: File, ): List = - listOf( - bin, - "--host", - "127.0.0.1", - "--port", - "$port", - "--db", - File(dataDir, "geode.sqlite").absolutePath, - ) + buildList { + add(bin) + add("--host") + add("127.0.0.1") + add("--port") + add("$port") + add("--db") + add(File(dataDir, "geode.sqlite").absolutePath) + if (noSearch) add("--no-search") + } } /**