mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
perf: cache the sealed negentropy snapshot across NEG-OPENs
A NIP-77 server session rebuilt its reconciliation structure from scratch on every NEG-OPEN: full id+created_at scan, per-entry hex decode into a fresh StorageVector, O(n log n) seal. That cost grows with the corpus and is paid even when nothing changed — the exact shape of a periodic mirror's heartbeat, where N peers reconcile the same broad filter over and over. relayBench measured 342 ms per identical-set reconcile at 50k events vs strfry's 26 ms off its always-current LMDB tree. Reconciliation only *reads* the sealed storage, so one instance can back any number of concurrent sessions: - NegentropyServerSession now accepts a pre-sealed IStorage (the List<IdAndTime> constructor remains and delegates). - SessionBackend.sealedNegentropyStorage() builds + seals (null when the set exceeds maxSyncEvents); LiveEventStore overrides it with a single-slot cache keyed by (filter set, write generation) plus a 30s TTL. The generation bumps on every accepted ingest; the TTL bounds staleness from delete paths the counter can't see (expiration sweeps, admin purges) — negentropy snapshots are point-in-time sets, so seconds of staleness only means a peer briefly re-offers ids. - NegSessionRegistry.open consumes the shared sealed storage; over-cap NEG-ERR behavior unchanged (strfry parity). relayBench gains a 'heartbeat' measurement — the identical-set reconcile repeated immediately with no writes in between. At 50k events: geode 342 ms -> 27.8 ms vs strfry 21.1 ms (near parity; was 13x). Cold reconciles (first open after a write) are unchanged. Also fixes the GeodeVsStrfryNegentropySyncTest fixture to write 'nofiles = 0' so the opt-in interop test can boot strfry inside containers with a low RLIMIT_NOFILE hard cap; the interop suite passes against strfry v1-b80cda3 with the cache in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
This commit is contained in:
+1
@@ -99,6 +99,7 @@ class GeodeVsStrfryNegentropySyncTest {
|
||||
relay {
|
||||
bind = "127.0.0.1"
|
||||
port = $port
|
||||
nofiles = 0
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+7
-4
@@ -98,9 +98,12 @@ class NegSessionRegistry(
|
||||
// NIP-77: same-subId OPEN replaces any prior session.
|
||||
sessions.remove(cmd.subId)
|
||||
|
||||
val cap = settings.maxSyncEvents
|
||||
val entries = store.snapshotIdsForNegentropy(filters, maxEntries = cap)
|
||||
if (entries.size > cap) {
|
||||
// Sealed storage may come from the backend's snapshot cache — a
|
||||
// repeated NEG-OPEN of the same filter with no writes in between
|
||||
// (the mirror-heartbeat pattern) skips the scan + sort entirely.
|
||||
// `null` = matching set exceeds the cap (strfry-parity error).
|
||||
val sealedStorage = store.sealedNegentropyStorage(filters, maxEntries = settings.maxSyncEvents)
|
||||
if (sealedStorage == null) {
|
||||
send(NegErrMessage(cmd.subId, "blocked: too many query results"))
|
||||
return
|
||||
}
|
||||
@@ -108,7 +111,7 @@ class NegSessionRegistry(
|
||||
val session =
|
||||
NegentropyServerSession(
|
||||
subId = cmd.subId,
|
||||
localEntries = entries,
|
||||
sealedStorage = sealedStorage,
|
||||
frameSizeLimit = settings.frameSizeLimit,
|
||||
)
|
||||
sessions[cmd.subId] = session
|
||||
|
||||
+72
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.backend
|
||||
|
||||
import com.vitorpamplona.negentropy.storage.IStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
|
||||
@@ -27,9 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicLong
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
@@ -90,6 +94,7 @@ class LiveEventStore(
|
||||
) {
|
||||
ingest.submit(event) { outcome ->
|
||||
if (outcome is IEventStore.InsertOutcome.Accepted) {
|
||||
writeGeneration.addAndFetch(1L)
|
||||
fanout(event)
|
||||
}
|
||||
onComplete(outcome)
|
||||
@@ -303,5 +308,72 @@ class LiveEventStore(
|
||||
override suspend fun snapshotIdsForNegentropy(
|
||||
filters: List<Filter>,
|
||||
maxEntries: Int?,
|
||||
<<<<<<< HEAD
|
||||
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters.strippingSearchExtensions(), maxEntries)
|
||||
=======
|
||||
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NIP-77 snapshot cache
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bumped after every accepted write. A cached negentropy snapshot is
|
||||
* only valid while this hasn't moved. Deletion paths that bypass the
|
||||
* ingest queue (expiration sweeps, NIP-86 admin purges) don't bump it,
|
||||
* which is why cache entries also carry a short TTL: a snapshot is a
|
||||
* point-in-time set by NIP-77's nature, and a few seconds of staleness
|
||||
* only means a peer momentarily re-offers ids the relay just dropped.
|
||||
*/
|
||||
private val writeGeneration = AtomicLong(0L)
|
||||
|
||||
private class CachedSnapshot(
|
||||
val filterKey: String,
|
||||
val generation: Long,
|
||||
val builtAt: Long,
|
||||
val storage: IStorage?,
|
||||
)
|
||||
|
||||
private val snapshotCache = AtomicReference<CachedSnapshot?>(null)
|
||||
|
||||
/**
|
||||
* Serves repeated NEG-OPENs of the same filter from one sealed
|
||||
* storage as long as no write landed in between (single slot — the
|
||||
* mirror-heartbeat pattern is many peers reconciling the same broad
|
||||
* filter, not many filters). Rebuilding on every open costs a full
|
||||
* scan + O(n log n) seal that grows with the corpus: relayBench
|
||||
* measured 342 ms per identical-set reconcile at 50k events vs
|
||||
* strfry's 26 ms off its always-current tree.
|
||||
*/
|
||||
override suspend fun sealedNegentropyStorage(
|
||||
filters: List<Filter>,
|
||||
maxEntries: Int,
|
||||
): IStorage? {
|
||||
val generation = writeGeneration.load()
|
||||
val key = filters.joinToString(" | ||||