feat(negentropy): strfry-parity NIP-77 reconciliation path

Implements the A+B+C+D plan in geode/plans/2026-05-07-negentropy-large-corpus.md:

- A: id-and-time-only snapshot. Adds IEventStore.snapshotIdsForNegentropy
  returning IdAndTime(createdAt, id) with no Event materialization. SQLite
  override projects directly off event_headers (~40 B/entry instead of
  ~1 KB/entry; matches strfry's MemoryView footprint).

- B: snapshot-size cap. New [negentropy] config section with
  max_sync_events=1_000_000 (mirrors strfry's relay__negentropy__maxSyncEvents).
  Overflow returns NEG-ERR "blocked: too many query results" (strfry-exact
  wording). No default since-window (strfry honors filters as-is; bounding
  silently would break interop).

- C: 500_000-byte frame cap. NegentropyServerSession.DEFAULT_FRAME_SIZE_LIMIT
  matches strfry's hard-coded Negentropy ne(storage, 500'000). Configurable
  via [negentropy].frame_size_limit.

- D: per-connection session cap = 200 (matches strfry). Overflow sends NOTICE
  "too many concurrent NEG requests" (strfry parity).

Error-string interop:
  - NEG-MSG with unknown subId → "closed: unknown subscription handle"
  - reconcile() parse failure → "PROTOCOL-ERROR"
  - snapshot overflow → "blocked: too many query results"
  - per-conn cap → NOTICE "too many concurrent NEG requests"

NegentropySettings flows: RelayConfig.NegentropySection → Relay → NostrServer
→ RelaySession → NegSessionRegistry. RelayHub takes optional settings for
tests that need to exercise the caps.

Tests:
  - SnapshotIdsForNegentropyTest covers projection correctness across all
    indexing strategies + the maxEntries+1 sentinel contract.
  - Nip77NegentropyTest gains negOpenSnapshotOverflowReturnsStrFryNegErr
    and negOpenPerConnectionCapEmitsNotice.
  - Existing negMsgWithoutOpenReturnsNegErr updated to assert strfry wording.
This commit is contained in:
Claude
2026-05-07 22:37:59 +00:00
parent ab71e45e77
commit 4b7e0e880c
20 changed files with 712 additions and 18 deletions
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPo
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import java.io.File
/**
@@ -109,6 +110,12 @@ fun main(args: Array<String>) {
}
val stateFile = config.admin.state_file?.let { File(it) }
val negentropySettings =
NegentropySettings(
frameSizeLimit = config.negentropy.frame_size_limit,
maxSyncEvents = config.negentropy.max_sync_events,
maxSessionsPerConnection = config.negentropy.max_sessions_per_connection,
)
val relay =
Relay(
advertisedUrl,
@@ -117,6 +124,7 @@ fun main(args: Array<String>) {
policyBuilder,
stateFile = stateFile,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.coroutines.SupervisorJob
@@ -84,6 +85,11 @@ class Relay(
* `Main.kt` skips `VerifyPolicy` when this flag is on.
*/
parallelVerify: Boolean = false,
/**
* NIP-77 server-side tuning (frame cap, snapshot cap,
* per-connection session cap). Defaults to strfry-parity values.
*/
negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable {
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
@@ -168,8 +174,9 @@ class Relay(
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
},
parentContext,
parentContext = parentContext,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
/**
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import java.util.concurrent.ConcurrentHashMap
/**
@@ -50,6 +51,7 @@ import java.util.concurrent.ConcurrentHashMap
*/
class RelayHub(
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
private val negentropySettings: NegentropySettings = NegentropySettings.Default,
) : WebsocketBuilder,
AutoCloseable {
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
@@ -60,7 +62,11 @@ class RelayHub(
fun getOrCreate(url: NormalizedRelayUrl): Relay {
check(!closed) { "RelayHub has been closed" }
return relays.getOrPut(url) {
Relay(url = url, policyBuilder = defaultPolicy)
Relay(
url = url,
policyBuilder = defaultPolicy,
negentropySettings = negentropySettings,
)
}
}
@@ -43,6 +43,7 @@ data class RelayConfig(
val limits: LimitsSection = LimitsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
val negentropy: NegentropySection = NegentropySection(),
) {
/**
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
@@ -160,6 +161,34 @@ data class RelayConfig(
val max_ws_frame_bytes: Int? = null,
)
/**
* NIP-77 negentropy tuning. Defaults track strfry
* (`hoytech/strfry`) so a Geode relay accepts the same workload
* shape and exchanges the same NEG-MSG round-trip size as
* strfry — the de-facto reference implementation.
*
* - [frame_size_limit] mirrors strfry's hard-coded
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
* Hex-encoded that's ~1 MB on the wire per NEG-MSG; ensure
* `[limits].max_ws_frame_bytes` (when set) is at least double
* this or NEG-MSGs get truncated by the WS layer.
* - [max_sync_events] mirrors strfry's
* `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot
* exceeds this returns
* `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
* - [max_sessions_per_connection] caps concurrent NEG-OPEN
* sessions held by a single connection. strfry shares its
* 200-cap with REQ subs via `relay__maxSubsPerConnection`;
* Geode counts NEG independently for now (REQ has no cap yet).
* Overflow returns NOTICE
* `"too many concurrent NEG requests"` (matches strfry).
*/
data class NegentropySection(
val frame_size_limit: Long = 500_000L,
val max_sync_events: Int = 1_000_000,
val max_sessions_per_connection: Int = 200,
)
data class AuthorizationSection(
val pubkey_whitelist: List<String> = emptyList(),
val pubkey_blacklist: List<String> = emptyList(),
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
@@ -237,12 +239,82 @@ class Nip77NegentropyTest {
val response = client.nextMessage()
assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}")
assertEquals("ghost-sub", response.subId)
assertTrue(response.reason.contains("no negentropy session"))
// strfry-parity wording — clients in the wild string-match this.
assertEquals("closed: unknown subscription handle", response.reason)
} finally {
client.close()
}
}
@Test
fun negOpenSnapshotOverflowReturnsStrFryNegErr() =
runBlocking {
// Tiny cap so the test is fast. Preload more events than the
// cap so NEG-OPEN must reject — strfry's parity behaviour for
// `relay__negentropy__maxSyncEvents`.
val capped = RelayHub(negentropySettings = NegentropySettings(maxSyncEvents = 5))
try {
val capUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/")
val events = makeEvents(20)
capped.getOrCreate(capUrl).preload(events)
val client = WireClient(capped, capUrl)
try {
val session =
NegentropySession(
subId = "neg-overflow",
filter = Filter(kinds = listOf(1)),
localEvents = emptyList(),
)
client.send(OptimizedJsonMapper.toJson(session.open()))
val response = client.nextMessage()
assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}")
assertEquals("neg-overflow", response.subId)
// strfry-parity wording.
assertEquals("blocked: too many query results", response.reason)
} finally {
client.close()
}
} finally {
capped.close()
}
}
@Test
fun negOpenPerConnectionCapEmitsNotice() =
runBlocking {
// Cap = 2, so the third NEG-OPEN on one connection should
// be rejected with a NOTICE (matching strfry's wording).
val capped = RelayHub(negentropySettings = NegentropySettings(maxSessionsPerConnection = 2))
try {
val capUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/")
capped.getOrCreate(capUrl).preload(makeEvents(3))
val client = WireClient(capped, capUrl)
try {
repeat(2) { i ->
val s = NegentropySession("ok-$i", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(s.open()))
// Drain the NEG-MSG response so the next OPEN goes
// through cleanly.
client.nextMessage() as NegMsgMessage
}
// Third OPEN — should be rejected with a NOTICE.
val third = NegentropySession("third", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(third.open()))
val response = client.nextMessage()
assertTrue(response is NoticeMessage, "expected NOTICE, got ${response::class.simpleName}")
assertEquals("too many concurrent NEG requests", response.message)
} finally {
client.close()
}
} finally {
capped.close()
}
}
@Test
fun negOpenWithSameSubIdReplacesPriorSession() =
runBlocking {