Files
amethyst/relayBench/plans/2026-07-04-sync-throughput-1m.md
T
Claude 5db2543cfc feat(geode): add import / export NDJSON verbs; drop the benchmark-only server
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
2026-07-05 13:56:11 +00:00

16 KiB
Raw Blame History

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 sync is 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 until windows 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:

  1. strfry → strfry (strfry's native strfry sync, NIP-77 negentropy)
  2. geode → geode (geode's production MirrorWorker, REQ backfill)
  3. 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 down against 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: MirrorSyncThroughputTest default mode. An in-process KtorRelay preloaded with N events is mirrored by the production MirrorWorker (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 drives NostrServer.ingest directly from a backpressured socket drain instead of MirrorWorker.

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:

  1. 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).
  2. 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:

  1. strfry's maxFilterLimit (default 500). MirrorWorker's down path is a plain REQ with no limit. strfry caps an unbounded REQ at maxFilterLimit stored 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.

  2. MirrorWorker's unbounded intake channel. Its SubscriptionListener callback can't suspend, so events funnel through a Channel.UNLIMITED via trySend into a consumer that calls the (bounded, backpressured) IngestQueue. This is a deliberate trade — blocking the shared OkHttp reader would park other upstreams' backlogs (see MirrorWorker kdoc). 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). IngestQueue itself is bounded (capacity 1024) and submit suspends, so a direct feeder gets proper backpressure — the fragility is only the unbounded hop in front of it.

  3. …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=20000 windows. 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 exactly Pending: 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 its maxPendingOutboundBytes = 32 MB cap 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 — the Pending: 1.05G / Broken pipe seen 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-made INostrClient.negentropySyncOrFetch — client-paced, so strfry can't overrun/kill us, and it completes the bulk pull. A small localEntries param was added to the public negentropySync/ negentropySyncOrFetch so the reconcile diffs against what we already hold (incremental, like strfry sync) instead of re-downloading.
  • Either mode, transparently: negentropySyncOrFetch auto-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 now when catch-up is on (history is the sync's job); the two windows overlap at now and 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 each onHaveIds batch straight to client.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 whole have/need diff (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 the have diff 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.