Audit follow-ups on the live NIP-77 index, all with the store/scan equivalence test extended to cover them: - Same-batch replaceable displacement left a dead id in the index. applyAfterCommit applied all removes before all adds, so when a later row in one transaction displaced an earlier row of the same batch (two versions of one replaceable — the mirror-backfill hot path), the displaced row's remove no-op'd against an index that hadn't taken its add yet, then the add re-inserted it: the index advertised an id the trigger had already deleted. recordAccepted now cancels the pending add instead of queueing a remove (added is a LinkedHashSet for O(1) cancel). - A kind-5 that deleted nothing (a delete broadcast for events this relay never stored — the common case) still invalidated the whole index, forcing a full-scan rebuild under the writer mutex on the next NEG-OPEN. DeletionRequestModule.insert now returns the rows it deleted; recordAccepted only invalidates when that count is > 0, else records the kind-5 as a plain row. - The first NEG-OPEN over a corpus larger than the serve cap scanned the whole table uncapped, built a full index that could never produce a snapshot, and then maintained it forever for zero benefit. liveNegentropySnapshot now caps the rebuild scan at maxEntries + 1 and leaves the index unpopulated when the corpus is over-cap (the scan path answers NEG-ERR, as before). - delete/deleteExpired/clearDB invalidate() moved inside the writer mutex so no NEG-OPEN can seal a snapshot of just-deleted rows, and no concurrent rebuild can be discarded by a late invalidate. Also corrects the ~40 B/event heap figure to ~140 B (IdAndTime keeps the id as a 64-char hex string, not 32 bytes) in the strategy/plan docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
6.6 KiB
Incremental live negentropy storage
Status: shipped — backlog item 3 of the relay performance campaign (PR #3466 follow-up). All four milestones landed on this branch.
Measured results. Micro (LiveNegentropyBenchmark, 50k events,
in-container): scan+seal cold path 80–100 ms per open; index post-write
open 9–16 ms (~5–10×); first open ~70 ms (pays the one-time rebuild).
relayBench A/B (both variants in ONE run — geode with index vs geode
with [negentropy].live_index = false — 50k corpus, then repeated with
the relay order reversed to control for run-order bias):
- identical-set reconcile: 56/57 ms with the index vs 110/130 ms without, in both orders — the real ~2.2× win. This phase runs after the delta transfer wrote events, so the old single-slot cache always misses — the exact gap the index closes.
- initial (first-ever) reconcile and ingest throughput: whichever relay ran first won/lost respectively in BOTH runs — pure run-order effect, i.e. no ingest regression and (as designed) no win on the very first open, which pays the lazy rebuild.
Measurement note for future A/Bs: putting both variants in one relayBench run controls container noise better than alternating runs, but per-phase run-order bias is real (~100 ms on initial reconcile, ~10% on ingest) — always repeat with the order reversed.
strfry head-to-head (same container, 50k corpus, --geode-no-search,
strfry built from source, single run): identical-set reconcile geode
41.4 ms vs strfry 30.1 ms — the always-current-tree gap is down to
~1.4× from the campaign-opening "full scan + seal per open". Initial
(cold) reconcile 177 vs 112 ms: strfry keeps its tree across the whole
run while geode's first open pays the lazy rebuild. Ingest measured at
parity in this container (7,972 vs 7,860 ev/s — treat as ±noise, the
earlier 1.59× gap was measured on different hardware); storage 72.8 vs
106.0 MiB.
Possible follow-up if the residual matters: the per-open cost is now
the O(n) copy + re-seal into a StorageVector (~16 ms at 50k). A custom
immutable IStorage view over the live entries (copy-on-write chunks,
no re-seal) would make an open O(1), matching strfry's zero-copy read of
its tree — that is the "interesting part" the original backlog item
anticipated, deferred until a benchmark says the remaining ~11 ms is
worth it.
Problem
A cold NEG-OPEN pays a full scan + O(n log n) seal: snapshotIdsForNegentropy
(SQL scan of every matching row) → NegentropyServerSession.sealVector
(sort + seal). relayBench measured ~340 ms per reconcile at 50k events before
the single-slot snapshot cache; strfry answers ~21 ms off its always-current
in-memory view.
The cache added in #3466 (keyed on filter JSON + write generation + 30 s TTL) only helps the identical-filter, zero-writes-in-between repeat. On a relay ingesting continuously the generation moves constantly, so mirror heartbeats are effectively always cold; any new filter is cold by definition.
Design
1. LiveNegentropyIndex (quartz, server-only, opt-in)
An always-current sorted set of (createdAt, id₃₂) maintained from the
store's write path — strfry's MemoryView equivalent, ~140 B/entry on
the JVM (IdAndTime keeps the id as a 64-char hex string; 1M events ≈
140 MB; the index is only built when the corpus fits
negentropy.max_sync_events, so that also caps the heap).
- Structure: single sorted array with binary-search insert. Nostr inserts are near-tail (created_at ≈ now), so the memmove is tiny in the common case; measure the out-of-order (backfill) worst case and only move to a chunked layout if it shows.
- Snapshot: NEG-OPEN copies the array into a sealed
IStorage— O(n) arraycopy of already-sorted data (~2 MB at 50k, sub-ms), reusing the existing single-slot cache so back-to-back opens share one snapshot. Reconcile only reads (size/getItem/iterate/indexAtOrBeforeBound), so one sealed snapshot serves any number of concurrent sessions. - Serves index-total filters only: no
ids/authors/kinds/tags/searchconstraints.since/untilARE served — the structure is time-sorted, so a time window is an index sub-range. Constrained filters keep the scan+seal path (+ cache). The mirror-heartbeat pattern this optimizes is a broad time-window filter, so this covers the case that matters.
2. Removal correctness (the interesting part)
The index must never advertise ids the store no longer has, or peers fetch dead ids. Removal paths differ in frequency and get different treatment:
- Replaceable/addressable overwrite (frequent — every kind 0/3/1xxxx/
3xxxx update):
ReplaceableModule/AddressableModulerunDELETE FROM event_headers WHERE …inside the insert transaction. AddRETURNING created_at, id(bundled SQLite ≥ 3.35) and report displaced rows to the index alongside the insert. - Wholesale/rare paths (kind-5 NIP-09, expiration sweep, right-to-vanish,
NIP-86
delete(filter), FTS reindex/clear): invalidate the whole index; the next NEG-OPEN rebuilds it lazily from one scan and incremental maintenance resumes. Deletes are rare enough that occasional rebuilds beat threading deltas through every module.
3. Gating
IndexingStrategy.maintainLiveNegentropyIndex, default false — library
defaults unchanged for app-side stores (campaign ground rule). geode's
RelayIndexingStrategy turns it on; config kill-switch under
[negentropy].
4. Concurrency
Mutations happen only on the writer path (single-writer mutex — same discipline as the FTS worker). Snapshots swap in via copy-on-write so a reconcile never observes a mid-insert array. Shutdown: the index is memory only, rebuilt on boot from the first NEG-OPEN's scan; no lifecycle beyond the store's own close (ground rule 4: no worker, no uncaught exceptions).
Measurement plan
- Micro: quartz jvmTest benchmark, cold NEG-OPEN time at 50k events, before/after (expect ~340 ms → single-digit ms).
- Headline: relayBench pairwise sync, alternating A/B runs on the 50k corpus (container noise ±10–30%; never trust a single run). Keep only if it wins end-to-end; document either way.
Milestones
LiveNegentropyIndex+ unit tests (ordering, tail/backfill inserts, removal, snapshot immutability under concurrent insert, cap behavior).- Displaced-row
RETURNINGplumbing in Replaceable/Addressable modules + wholesale-invalidation hooks on the rare paths. LiveEventStore.sealedNegentropyStoragewiring: index-total filters from the index; everything else keeps scan+seal+cache.- Benchmarks (micro then relayBench A/B); revert if not a real win.