Bulk NDJSON import/export as first-class geode subcommands, mirroring `strfry import` / `strfry export` (one JSON event per line — the interchange format for seeding a relay, migrating between relays, or taking a backup): geode import [--db …] [--no-verify] [FILE…] # files, or stdin when none geode export [--db …] # NDJSON to stdout Both stream — memory is bounded to one batch (import) / one event (export), so a multi-million-event corpus round-trips in roughly constant memory. `import` verifies signatures by default (same `Event.verify()` the relay's VerifyPolicy uses), upholding the relay's verify-by-default stance rather than trusting the file; `--no-verify` is the trusted-input escape hatch. Verb dispatch is backward-compatible: a bare `geode --port …` (no verb) still serves. This makes the benchmark-only `CorpusServerMain` redundant — a corpus source is now just `geode import` into a DB, then a normal `geode` serve — so it's deleted, removing benchmark-only code from the production geode artifact (the question that started this). The 1M sync-throughput plan is updated to describe sources via `geode import` + serve. Also fixes a native-target CI break: MergeQueryCorrectnessTest used the deprecated `String(CharArray)` (error-level on Kotlin/Native) — switched to `CharArray.concatToString()`. Verified end-to-end through the packaged `geode` binary: import (file + stdin, --no-verify), export round-trip, and verify-on rejecting bad signatures. ImportExportTest covers the counts, duplicate handling, malformed-line skipping, and verify accepting a freshly-signed event while rejecting bad sigs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
16 KiB
1M-event sync throughput: strfry↔strfry vs geode↔geode vs strfry→geode
Date: 2026-07-04
Four-pair NEGENTROPY comparison (2026-07-05, apples-to-apples)
All four source→sink pairings, all NIP-77 negentropy, empty sink pulls the
same ~1M damus.io corpus, run sequentially (no contention). geode sink
configured like strfry: verify ON, FTS OFF (-DsyncVerify=true -DsyncFts=false);
strfry sync verifies too and has no FTS. Sinks: geode = its negentropy client;
strfry = strfry sync --dir down. Sources: a geode relay seeded with
geode import <corpus.ndjson> --db … then served normally, and a strfry relay
(strfry import + serve) — each holding the corpus.
| # | source → sink | sink engine | synced | time | throughput |
|---|---|---|---|---|---|
| A | geode → geode | geode neg client | 994,162 / 994,832 | 142.6 s | 6,971 ev/s |
| B | strfry → geode | geode neg client | 994,828 / 994,917 | 148.7 s | 6,690 ev/s |
| C | strfry → strfry | strfry sync |
893,000 | 333.0 s | 2,682 ev/s |
| D | geode → strfry | strfry sync |
994,151 | 467.3 s | 2,127 ev/s |
Reads:
- The SINK sets the rate, not the source. geode sink ≈ 6.7–7.0k ev/s from either source; strfry sink ≈ 2.1–2.7k ev/s from either source. Sync throughput is an ingest property.
- geode ingests ≈ 2.6–3.3× faster than strfry via negentropy, verifying.
- Full geode↔strfry NIP-77 interop, both directions: B (geode client ↔ strfry server) and D (strfry client ↔ geode server) both complete. geode's negentropy server and client are wire-compatible with strfry's.
- Count artifacts (throughput is unaffected): C synced 893k, not ~994k,
because strfry's negentropy snapshot excludes NIP-40-expired events, so a
strfry source only offers ~893k. geode has no expiration cutoff ("no limits"),
so a geode source offers its full ~994k (D transfers all of it). Same
NIP-62/09/40 fairness family as
2026-07-04-sync-fairness-nip62-09-40.md.
REQ-mode counterpart (2026-07-05)
Same corpus + same geode sink config (verify ON, FTS OFF), but a plain paged
REQ pull instead of negentropy (-DsyncMode=req, 20k-event until windows).
| # | source → sink | mechanism | synced | time | throughput |
|---|---|---|---|---|---|
| A | geode → geode | geode paged REQ | 994,144 / 994,832 | 200.3 s | 4,963 ev/s ✅ |
| B | strfry → geode | geode paged REQ | 994,813 / 994,898 | 171.5 s | 5,801 ev/s ✅ |
| C | strfry → strfry | — | — | — | n/a |
| D | geode → strfry | — | — | — | n/a |
- strfry has no REQ bulk sync. Its REQ path is
strfry stream, which sends["REQ","sub",{"limit":0}]— live-only, no historical replay;strfry syncis negentropy. So the strfry-sink REQ pairs (C, D) have no strfry-native mechanism — that gap is the finding: geode can bulk-pull over REQ, strfry can't. - strfry→geode over REQ now completes (B) — earlier it stalled at ~310k because FTS-on ingest fell behind strfry's page serving and strfry killed the slow client at its 32 MB pending cap. With FTS off the sink keeps pace, so strfry never trips the cap. (Completion over REQ is contingent on the sink out-running the source; negentropy is immune because it's pull-paced.)
- REQ is ~15–30% slower than negentropy for the geode sink (4,963–5,801 vs
6,690–6,971): the paged
untilwindows re-fetch boundary duplicates and range- scan the source, vs negentropy's direct id fetch. And geode-as-a-REQ-source is slower than strfry-as-a-source (A 4,963 < B 5,801) — SQLite range scans vs strfry's LMDB.
Question
When syncing a full 1,000,000-event corpus between two local relays, what is the end-to-end throughput (events/second) for each pairing:
- strfry → strfry (strfry's native
strfry sync, NIP-77 negentropy) - geode → geode (geode's production
MirrorWorker, REQ backfill) - strfry → geode (geode ingesting a strfry-served corpus)
Corpus: the latest ~1M events from relay.damus.io
(relayBench/.corpus-cache/corpus-download-relay.damus.io-n1000000.ndjson).
After strfry import + de-dup the source holds 997,980 events (a few
thousand of the raw 1M are duplicates / superseded replaceables).
How each number is measured
Every run starts with an empty sink and pulls the whole corpus from a full source over a real loopback WebSocket, timed from first byte to the sink reaching the target (or plateauing). Sink = geode uses its real relay config: deferred-FTS indexing + the live negentropy index on (i.e. the same work a production geode does on every insert).
- strfry→strfry:
strfry sync ws://src --dir downagainst a running source strfry. Native negentropy reconcile + strfry ingest. Script:relayBench/sync-throughput-strfry.sh(and the reuse variant that syncs a fresh sink from an already-running source). - geode→geode:
MirrorSyncThroughputTestdefault mode. An in-processKtorRelaypreloaded with N events is mirrored by the productionMirrorWorker(REQ backfill, trusted/skip-verify) into an empty geode. - strfry→geode:
MirrorSyncThroughputTest -DsyncSourceUrl=ws://…. See the "MirrorWorker can't do a bulk backfill from strfry" finding below — this mode drivesNostrServer.ingestdirectly from a backpressured socket drain instead ofMirrorWorker.
Results (1M corpus, single 16 GB host)
| Pair | Mechanism | Synced | Time | Throughput | Completes? |
|---|---|---|---|---|---|
| strfry→strfry | strfry sync (negentropy) |
997,977 / 997,980 | ~391 s | ~2,550 ev/s | ✅ |
| geode→geode | MirrorWorker REQ (synthetic) | 1,000,000 / 1M | 76.0 s | 13,161 ev/s | ✅ |
| strfry→geode | negentropy (geode client) | 994,936 / 997,980 | 171.0 s | 5,818 ev/s | ✅ |
| strfry→geode | paged REQ drain (real corpus) | ~310k / 997,980 | — | ~7,000 ev/s * | ❌ (strfry kills conn) |
* the paged-REQ drain sustains ~7,000 ev/s (peaks ~11k warm) but does not
finish — see "strfry kills a slow REQ client" below. Switching geode to a
NIP-77 negentropy client (the same mechanism strfry sync uses) does
finish: reconcile against strfry's full set took 11.4 s / 64 rounds
(empty local → 995,024 need-ids), then a client-paced fetch (2,000-id REQ
batches) + ingest completed at ~7k ev/s steady-state, 5,818 ev/s end-to-end.
(The 994,936 vs 997,980 gap is the NEG-scope/expiry artifact — strfry's
negentropy set excludes a few thousand expired/superseded events it still
exports — same family as the strfry→strfry 3-event shortfall, not data loss.)
The headline, apples-to-apples comparison (both negentropy, same corpus, empty→full): strfry→geode 5,818 ev/s vs strfry→strfry ~2,550 ev/s — geode ingests real content ≈2.3× faster than strfry, and finishes the pull.
Two things make the raw table misleading — read the caveats:
- geode→geode's 13,161 is inflated by synthetic content. That source
preloads events with 4-byte content (
"e$i"), empty tags, and a fake sig. Real damus.io events are ~2.4 KB on the wire with real content + tags, so FTS tokenization, tag indexing, and store growth all cost more. The real-content geode ingest rate is the ~7,000 ev/s measured on strfry→geode, not 13k. So the honest real-content ingest comparison is geode ~7,000 ev/s vs strfry ~2,550 ev/s (≈2.7× — consistent with the earlier ingest benchmarks; strfry's figure also carries negentropy reconcile). - strfry→strfry's 3-event shortfall is the known NIP-62/09/40 harness
artifact (
2026-07-04-sync-fairness-nip62-09-40.md), not data loss.
Neither geode number is a like-for-like protocol comparison with strfry→strfry (REQ streaming vs negentropy reconcile); they're each relay's real "pull the whole corpus" path.
Finding: MirrorWorker can't bulk-backfill from a fast foreign relay
The first strfry→geode attempts via the production MirrorWorker stalled at
~500, then ~16k of 1M and never converged. Root cause, in order of discovery:
-
strfry's
maxFilterLimit(default 500).MirrorWorker's down path is a plain REQ with nolimit. strfry caps an unbounded REQ atmaxFilterLimitstored events, sends EOSE, then streams only live events. geode-as-source has no such cap, which is why geode→geode fully converges. Fix for the benchmark: boot the source strfry with--set relay.maxFilterLimit=5000000 --set relay.maxPendingOutboundBytes=0. A raw REQ then returns all 997,986 events in 12.8 s (~78k ev/s) — strfry is not the bottleneck. -
MirrorWorker's unbounded intake channel. Its
SubscriptionListenercallback can't suspend, so events funnel through aChannel.UNLIMITEDviatrySendinto a consumer that calls the (bounded, backpressured)IngestQueue. This is a deliberate trade — blocking the shared OkHttp reader would park other upstreams' backlogs (seeMirrorWorkerkdoc). For live tailing it's correct. For a 1M bulk backfill from a 78k-ev/s source into a ~7k-ev/s sink, the unbounded channel balloons past the heap and the JVM dies GC-thrashing (observed: consumer stops at ~16k/1M).IngestQueueitself is bounded (capacity 1024) andsubmitsuspends, so a direct feeder gets proper backpressure — the fragility is only the unbounded hop in front of it. -
…and even a correctly-backpressured, client-paced REQ can't finish. The benchmark's external drain replaces MirrorWorker with a dedicated socket whose reader we are free to block (bounded hand-off channel), walking the corpus newest→oldest in bounded
limit=20000windows. That pulls cleanly at ~7,000 ev/s… until ~310k events, where it stalls every time (index on/off, 2 GB heap or 10 GB — same wall). Root cause, from the strfry source log: every drain connection disconnects at exactlyPending: 32.01M(1006). geode's real-content ingest gradually slows as its in-memory store grows; once it drops below strfry's send rate, strfry's outbound buffer for the in-flight page crosses itsmaxPendingOutboundBytes = 32 MBcap and strfry kills the connection. The pager then waits forever for an EOSE that never arrives. Raising strfry's cap just moves the wall (it OOM-buffers gigabytes instead — thePending: 1.05G / Broken pipeseen earlier).
Conclusion: cross-relay bulk sync needs negentropy, not REQ
strfry's REQ serving is structurally hostile to any client slower than its
scan: it buffers outbound and hard-kills (or OOM-buffers) the laggard. No
amount of client-side backpressure or paging fixes this, because the failure
is on strfry's send side. NIP-77 negentropy is the only mechanism that
completes: it is pull/reconcile-based and client-paced (strfry's own sync
fetches the diff 50 events at a time), so a slow client is never killed. This
is exactly why strfry sync — the strfry→strfry path — is negentropy, and it
is the direct answer to "why aren't we using negentropy for geode too": we
should, and over plain REQ geode structurally cannot finish a 1M pull from
strfry.
Implemented: MirrorWorker now mirrors strfry's two-phase model
MirrorWorker gained a NIP-77 "sync" catch-up phase (geode's equivalent of
strfry sync --dir down) that runs once per down/both upstream before the live
REQ tail:
- Catch-up reconciles the local set against the upstream over the historical
[now - backfill_seconds, now]window and downloads only the diff, via the ready-madeINostrClient.negentropySyncOrFetch— client-paced, so strfry can't overrun/kill us, and it completes the bulk pull. A smalllocalEntriesparam was added to the publicnegentropySync/negentropySyncOrFetchso the reconcile diffs against what we already hold (incremental, likestrfry sync) instead of re-downloading. - Either mode, transparently:
negentropySyncOrFetchauto-falls back to paged REQ for an upstream that doesn't speak NIP-77 — no config toggle. - Live tail unchanged: the REQ subscription now starts at
nowwhen catch-up is on (history is the sync's job); the two windows overlap atnowand the store's unique-id constraint dedups the seam.
Both directions, matching strfry sync --dir both (which strfry's source
confirms is bidirectional negentropy — doUp = both||up, doDown = both||down):
- down catch-up pulls what the upstream has and we lack
(
negentropySyncOrFetch, with paged fallback), then the live REQ sub tails. - up catch-up reconciles and pushes what we have and the upstream lacks
(streaming
negentropyReconcile, publishing eachonHaveIdsbatch straight toclient.publish), then the live up-session tails. Negentropy-only (no paged fallback needed — the live up-session covers a non-NIP-77 upstream). The streaming reconcile keeps peak memory at one id batch rather than materializing the wholehave/needdiff (an important distinction at 1M — the full diff is ~100 MB of id strings). The push runs as a reconcile→push convergence loop:client.publish's outbox is best-effort under a bulk burst (each publish also churns a reconnect; measured ~1–2% dropped per pass), so each round re-reconciles — the reconcile is the delivery check — and re-pushes only the stragglers until thehavediff is empty. Observed on the 3000-event up test: 3000 → 69 → 2 → 0 across 3 rounds, lossless.
Same vocabulary as strfry throughout — one [[mirror]] entry, one dir
(down/up/both) driving both phases; negentropy-vs-REQ is an internal transport
detail. The geode binary opts in (Main passes negentropyBackfill = true +
the store); the MirrorWorker default stays off so existing live-REQ tests are
unchanged. See MirrorNegentropyCatchUpTest: the down test isolates catch-up
from the live tail by preloading historical events a live-only sub can't
deliver (3000 + 1 live); the up test pushes 3000 local events to an empty sink.
Structural note: strfry keeps these as separate commands (sync = negentropy
both-ways; router/stream = REQ live both-ways). geode folds both into one
MirrorWorker under a single dir — more integrated, same semantics.
Remaining follow-ups: liveNegentropySnapshot-based local enumeration for very
large mirrors (the up path's snapshotIdsForNegentropy currently scans); a
single-pass reconcile for dir=both (today it runs one reconcile per
direction); and optionally bounding the live-tail intake per-upstream.
Separately worth a look: geode's real-content ingest decays from ~11k→~7k ev/s as the in-memory store grows to a few hundred k — expected B-tree/FTS cost, but worth confirming it's not superlinear at 1M on a disk-backed store.
Why the geode number is a paged REQ, not negentropy — and why it's the same
strfry→strfry already is negentropy (strfry sync). The natural question:
shouldn't strfry→geode use negentropy too, for parity?
NIP-77 sync is two phases: (1) reconcile — exchange range fingerprints to
compute the set difference, no events moved; (2) transfer — fetch the
missing events. Into an empty sink phase 1 trivially says "you're missing
everything," so the whole cost collapses onto phase 2: fetch + ingest all
~998k events in bounded batches (strfry's own sync pulls 50 at a time —
DOWN: 50 events in its log). Therefore:
- strfry→strfry's ~2,600 ev/s is ingest/transfer-bound, not reconcile-bound.
- Into an empty geode, negentropy and the paged-REQ drain measure the same thing — geode's paced ingest rate. The paged drain is negentropy's transfer phase minus a near-free reconcile round.
- Negentropy's reconcile only wins when the sink is already mostly full
(small diff). Geode's reconcile performance there is measured separately —
at 1M, geode's negentropy (kmp-negentropy v1.2.0, PrefixSumStorageVector) is
~1.5–4× faster than strfry's C implementation (see the negentropy-speedup
tasks /
2026-07-04-sync-fairness-nip62-09-40.md).
So the paged-REQ figure is the correct empty-sink strfry→geode number; a full negentropy-client run lands at the same place plus a cheap reconcile.