Merge branch 'main' into claude/review-fanout-index-e1Uqq

Conflicts:
- quartz/.../LiveEventStore.kt: main added IngestQueue (group-commit)
  and a fire-and-forget submit() path; this branch replaced the
  SharedFlow live fanout with FilterIndex-driven dispatch. Resolved
  by keeping main's submit/insert pipeline shape (IngestQueue ingest
  ctor param, submit() callback, insert() wrapping submit via
  CompletableDeferred) but routing the on-Accepted fanout through
  FilterIndex.candidatesFor instead of newEventStream.tryEmit.
  Added private fanout(event) helper. Kept main's
  snapshotIdsForNegentropy method.
- geode/.../LoadBenchmark.kt: both sides added a new @Test method.
  Kept fanoutScaling (this branch) and publishPipelinedSingleClient
  (main).
This commit is contained in:
Claude
2026-05-07 23:55:00 +00:00
59 changed files with 5111 additions and 357 deletions
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
/**
* Decorator that canonicalises every [Event] returned by the inner
@@ -111,6 +112,11 @@ class InterningEventStore(
override suspend fun count(filters: List<Filter>): Int = inner.count(filters)
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries)
override suspend fun delete(filter: Filter) = inner.delete(filter)
override suspend fun delete(filters: List<Filter>) = inner.delete(filters)
@@ -0,0 +1,305 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
/**
* Group-commit writer for incoming EVENT publishes.
*
* Submissions from any number of [RelaySession]s land in [incoming],
* a single drain coroutine pulls one item to start a batch then
* greedily drains everything else already queued (up to [maxBatch]),
* and forwards the whole batch through [IEventStore.batchInsert] —
* one writer-mutex acquisition + one BEGIN / COMMIT for the lot.
*
* Why this lives here: the SQLite event store enforces a single-writer
* mutex (matching SQLite's own file-level rule), so per-event
* `useWriter` calls serialise no matter how many publishers we have.
* Group commit collapses N mutex round-trips into one and lets
* BEGIN / WAL-append / COMMIT amortise across the batch.
*
* OK semantics (NIP-01):
* - The OK frame carries the event id, so clients pair replies by
* id, not by arrival order. We dispatch each [Submission.onComplete]
* as its row resolves; reordering across connections (and on the
* same connection) is allowed.
* - `OK true` means "accepted by this relay," not "fsynced." Since
* the underlying SQLite pool runs `synchronous = OFF` and WAL,
* a successful row inside the open transaction is the strongest
* guarantee we provide; replying after the batch's COMMIT (which
* is what happens in the current implementation) just adds the
* in-memory commit cost and keeps the write-failure path simple.
*
* Per-row error isolation lives in the store layer (SAVEPOINT in
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.SQLiteEventStore]).
* A duplicate, expired event, etc. is a Rejected outcome for that
* row only.
*
* Backpressure: [incoming] is bounded at [capacity]. A flood of
* EVENTs that outpaces the writer suspends [submit] callers (i.e.
* the per-connection ingest path) until the writer drains. This
* propagates back through the WebSocket pump so a slow disk
* eventually slows the publisher rather than ballooning JVM memory.
*/
class IngestQueue(
private val store: IEventStore,
parentContext: CoroutineContext,
private val maxBatch: Int = DEFAULT_MAX_BATCH,
capacity: Int = DEFAULT_CAPACITY,
/**
* Optional pre-insert validator. When set, the writer runs each
* batch through this hook in parallel before opening the SQLite
* transaction. Events that return `false` skip the insert and are
* reported as Rejected with [verifyRejectionReason].
*
* The intended use is Schnorr signature verification: an event's
* `verify()` is CPU-bound and parallelisable, so spreading a
* batch's verifies across [Dispatchers.Default] threads is
* straight throughput. Hook fires off the WS pump so a single
* publisher streaming many EVENTs doesn't serialise verify on
* one connection's read coroutine.
*
* Default `null` skips this stage — callers that already verify
* inside their `IRelayPolicy` chain should leave it null to avoid
* double-verify.
*/
private val verify: ((Event) -> Boolean)? = null,
private val verifyRejectionReason: String = "invalid: bad signature or id",
) : AutoCloseable {
/**
* One outstanding ingest request: the event to insert plus the
* callback the writer fires once the row's outcome is known.
*/
class Submission(
val event: Event,
val onComplete: (IEventStore.InsertOutcome) -> Unit,
)
private val incoming = Channel<Submission>(capacity)
private val scope = CoroutineScope(parentContext + SupervisorJob())
/**
* Lazily-launched drain coroutine. We don't start it in `init`
* because eagerly launching from a server's lazy
* `LiveEventStore` allocates a Default-dispatcher slot at server
* construction time — visible to other tests sharing the same
* `Dispatchers.Default` pool, where it can perturb scheduling
* for unrelated REQ/EOSE timing. Starting on first `submit`
* keeps relays that never see an EVENT (read-only sessions,
* negentropy-only) from paying for the writer at all.
*/
@Volatile
private var writerStarted = false
private val startLock = Any()
/**
* Hand off [event] for insertion. [onComplete] is invoked once
* with the per-row outcome from the writer batch — exactly once,
* unless the queue closes mid-flight.
*
* Suspends only when [incoming] is full (writer fell behind by
* [DEFAULT_CAPACITY] events). Otherwise this returns as fast as a
* channel `send`, freeing the WebSocket pump to read the next
* frame — that's where the per-connection pipeline win comes
* from.
*/
suspend fun submit(
event: Event,
onComplete: (IEventStore.InsertOutcome) -> Unit,
) {
ensureWriterStarted()
incoming.send(Submission(event, onComplete))
}
private fun ensureWriterStarted() {
if (writerStarted) return
synchronized(startLock) {
if (writerStarted) return
scope.launch { drainLoop() }
writerStarted = true
}
}
private suspend fun drainLoop() {
val batch = ArrayList<Submission>(maxBatch)
try {
while (true) {
// Block for the first item — anything else would be a
// hot loop. Once we have one, drain greedily without
// blocking so back-to-back publishes coalesce into a
// single transaction.
batch.add(incoming.receive())
while (batch.size < maxBatch) {
val next = incoming.tryReceive().getOrNull() ?: break
batch.add(next)
}
processBatch(batch)
batch.clear()
}
} catch (_: ClosedReceiveChannelException) {
// Normal shutdown via close().
}
}
private suspend fun processBatch(batch: List<Submission>) {
val verifyResults = verifyBatch(batch)
val finalOutcomes = runInsertStage(batch, verifyResults)
dispatchOutcomes(batch, finalOutcomes)
}
/**
* Tier 3: per-row verify. Returns `null` when no [verify] hook is
* configured (skip the stage entirely). For multi-event batches
* each verify runs as its own `async(Default)` so they spread
* across CPU cores; single-event batches short-circuit to a
* direct call to avoid coroutine-scope overhead.
*/
private suspend fun verifyBatch(batch: List<Submission>): BooleanArray? {
val hook = verify ?: return null
if (batch.size == 1) return BooleanArray(1) { hook(batch[0].event) }
return coroutineScope {
batch
.map { sub -> async(Dispatchers.Default) { hook(sub.event) } }
.awaitAll()
.toBooleanArray()
}
}
/**
* Run the SQLite transaction for the verified subset of [batch]
* and stitch outcomes back to a per-batch-index array. Failed
* verifies pre-mark `Rejected` and skip the insert. A whole-batch
* commit failure converts every persisted entry to `Rejected`
* with the throw message.
*/
private suspend fun runInsertStage(
batch: List<Submission>,
verifyResults: BooleanArray?,
): Array<IEventStore.InsertOutcome?> {
val toInsert = ArrayList<Event>(batch.size)
val insertIndices = ArrayList<Int>(batch.size)
for (i in batch.indices) {
if (verifyResults == null || verifyResults[i]) {
toInsert.add(batch[i].event)
insertIndices.add(i)
}
}
val insertOutcomes: List<IEventStore.InsertOutcome> =
if (toInsert.isEmpty()) {
emptyList()
} else {
try {
store.batchInsert(toInsert)
} catch (e: Throwable) {
Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" }
val reason = e.message ?: e::class.simpleName ?: "insert failed"
List(toInsert.size) { IEventStore.InsertOutcome.Rejected(reason) }
}
}
val finalOutcomes = arrayOfNulls<IEventStore.InsertOutcome>(batch.size)
for (j in insertIndices.indices) {
finalOutcomes[insertIndices[j]] =
insertOutcomes.getOrNull(j) ?: missingOutcome
}
if (verifyResults != null) {
for (i in batch.indices) {
if (!verifyResults[i]) {
finalOutcomes[i] = IEventStore.InsertOutcome.Rejected(verifyRejectionReason)
}
}
}
return finalOutcomes
}
private fun dispatchOutcomes(
batch: List<Submission>,
outcomes: Array<IEventStore.InsertOutcome?>,
) {
for (i in batch.indices) {
val sub = batch[i]
val outcome = outcomes[i] ?: missingOutcome
try {
sub.onComplete(outcome)
} catch (e: Throwable) {
// A misbehaving callback must not poison the writer
// loop; the outcome is delivered best-effort.
Log.w("IngestQueue") { "onComplete threw: ${e.message}" }
}
}
}
/**
* Stop accepting new submissions and cancel the writer.
* In-flight submissions whose batch hadn't started yet may never
* receive their callback — the WebSocket on the other side is
* also being torn down in that case, so the OK reply has nowhere
* to go anyway.
*/
override fun close() {
incoming.close()
scope.cancel()
}
companion object {
/**
* Default for a missing per-row outcome — only reachable on a
* contract violation (the store returned fewer outcomes than
* inserts), so the message is informational, not user-facing.
*/
private val missingOutcome =
IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
/**
* Cap per batch. Sized to keep per-batch latency low (each
* transaction holds the SQLite writer mutex; over-large
* batches starve other writers and hurt p99 publish latency).
* 64 events at ~0.2 ms per insert ≈ 13 ms held — well under
* a perceptible UI tick.
*/
const val DEFAULT_MAX_BATCH: Int = 64
/**
* In-flight cap before [submit] suspends. With ~510× group
* commit speed-up over the single-event path, one batch
* cycle is on the order of milliseconds, so a 1024-deep
* queue tolerates short bursts (a publisher dumping a
* thousand notes) without blocking the WS pump.
*/
const val DEFAULT_CAPACITY: Int = 1024
}
}
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -36,16 +38,20 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
* End of Stored Events (EOSE), and then continues to stream matching new events as they
* are inserted.
*
* Live fanout from `insert` to interested subscribers is index-driven via [FilterIndex]:
* each [query] registers its filters; [insert] looks up the candidate set and only delivers
* to those whose filters actually match. This avoids the quadratic
* O(N_subscribers × N_filters_per_sub) per-event walk that a naïve broadcast would do.
* Live fanout from [submit] / [insert] to interested subscribers is index-driven via
* [FilterIndex]: each [query] registers its filters; on accepted ingest the index returns
* the (much smaller) candidate set and we deliver only to those whose filters actually
* match. This avoids the quadratic O(N_subscribers × N_filters_per_sub) per-event walk
* that a SharedFlow-based broadcast would do.
*
* @property store The underlying persistent storage for events.
* @property ingest The group-commit writer pipeline. Accepted events fan out via the
* index; rejected events do not.
*/
@OptIn(ExperimentalAtomicApi::class)
class LiveEventStore(
private val store: IEventStore,
private val ingest: IngestQueue,
) {
private val index = FilterIndex<LiveSubscription>()
@@ -60,12 +66,58 @@ class LiveEventStore(
val deliver: (Event) -> Unit,
)
/**
* Fire-and-forget enqueue: hand [event] to the [IngestQueue] and
* fire [onComplete] once the writer's batch has a per-row
* decision. On `Accepted` the live stream is also fanned out via
* [FilterIndex] so subscribers see the event. Suspends only when
* the ingest queue is full (backpressure).
*/
suspend fun submit(
event: Event,
onComplete: (IEventStore.InsertOutcome) -> Unit,
) {
ingest.submit(event) { outcome ->
if (outcome is IEventStore.InsertOutcome.Accepted) {
fanout(event)
}
onComplete(outcome)
}
}
/**
* Suspending insert kept for callers that don't care about
* pipelining (tests, scripted paths). Routes through the same
* [IngestQueue] as [submit] so the batch write path is exercised
* even by tests that prefer a sequential `insert` API.
* Throws on rejection so callers can `try` around it the way the
* old API did.
*/
suspend fun insert(event: Event) {
store.insert(event)
// Live fanout. The index returns a super-set; `match` enforces
// negative constraints. Synchronous delivery — callers are
// expected to keep `deliver` cheap (typically a `tryEmit` to
// a per-connection outbound queue).
val done = CompletableDeferred<Unit>()
submit(event) { outcome ->
when (outcome) {
IEventStore.InsertOutcome.Accepted -> {
done.complete(Unit)
}
is IEventStore.InsertOutcome.Rejected -> {
done.completeExceptionally(IllegalStateException(outcome.reason))
}
}
}
done.await()
}
/**
* Live fanout for an accepted event. The index returns a
* super-set; `Filter.match` enforces negative constraints. Each
* candidate's `deliver` is expected to be cheap (typically a
* `trySend` to a per-connection outbound queue) — this runs on
* the [IngestQueue] drain coroutine, so a slow sub blocks the
* batch writer.
*/
private fun fanout(event: Event) {
for (sub in index.candidatesFor(event)) {
if (sub.filters.any { it.match(event) }) {
sub.deliver(event)
@@ -85,8 +137,8 @@ class LiveEventStore(
// race the previous SharedFlow-based implementation closed
// with `onSubscription`).
//
// The set is read from the publisher's coroutine (in
// `deliver`, called synchronously from `insert`) and written
// The set is read from the [IngestQueue] drain coroutine (in
// `deliver`, called synchronously from `fanout`) and written
// from this coroutine (the historical-replay closure below).
// It must be a persistent / immutable Set under an
// AtomicReference — wrapping a mutable HashSet would race
@@ -165,4 +217,19 @@ class LiveEventStore(
}
return merged
}
/**
* Lightweight snapshot for NIP-77 negentropy. Returns
* `(created_at, id)` pairs only — no Event materialisation —
* matching strfry's `MemoryView` footprint of ~40 B/entry.
*
* If [maxEntries] is non-null, the underlying store may return
* up to `maxEntries + 1` entries; the +1 sentinel lets the
* caller distinguish "exactly at cap" from "exceeds cap" without
* scanning past the cap.
*/
suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int? = null,
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries)
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
/**
* Per-connection NIP-77 negentropy state and dispatch.
@@ -39,21 +41,37 @@ import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
* Plain [HashMap] is sufficient because the registry is mutated only
* from [RelaySession.receive] — that path is single-threaded per the
* WebSocket handler contract.
*
* Defaults match strfry (`hoytech/strfry`) so a Geode relay reconciles
* with the same round-trip shape and the same operator-visible
* protections — see [NegentropySettings].
*/
class NegSessionRegistry(
private val store: LiveEventStore,
private val send: (Message) -> Unit,
private val settings: NegentropySettings = NegentropySettings.Default,
) {
private val sessions = HashMap<String, NegentropyServerSession>()
/**
* Open a reconciliation session. The relay snapshots its matching
* events at this instant — concurrent inserts during the sync are
* not surfaced; clients re-open if they want fresh state.
* Open a reconciliation session. The relay snapshots the matching
* `(created_at, id)` pairs at this instant — concurrent inserts
* during the sync are not surfaced; clients re-open if they want
* fresh state.
*
* Access control reuses the REQ policy hook: a relay that requires
* AUTH or has kind/pubkey allow-deny lists applies the same rules
* to NEG-OPEN as it does to subscription REQs.
*
* Two strfry-parity protections fire here:
* - **Per-connection session cap.** If an OPEN would push the
* map past [NegentropySettings.maxSessionsPerConnection], we
* send a NOTICE (matching strfry's
* `"too many concurrent NEG requests"`) and drop the OPEN.
* - **Snapshot size cap.** The store is asked for at most
* `maxSyncEvents + 1` entries; if the +1 sentinel comes back,
* the corpus exceeds the cap and we send NEG-ERR
* `"blocked: too many query results"` (matching strfry).
*/
suspend fun open(
cmd: NegOpenCmd,
@@ -66,20 +84,43 @@ class NegSessionRegistry(
}
val filters = (gate as PolicyResult.Accepted).cmd.filters
// Per-connection cap. Only fires when this is a NEW subId —
// a same-subId re-open replaces the prior session 1-for-1.
val isReopen = sessions.containsKey(cmd.subId)
if (!isReopen && sessions.size >= settings.maxSessionsPerConnection) {
send(NoticeMessage("too many concurrent NEG requests"))
return
}
// NIP-77: same-subId OPEN replaces any prior session.
sessions.remove(cmd.subId)
val events = store.snapshotQuery(filters)
val session = NegentropyServerSession(cmd.subId, events)
val cap = settings.maxSyncEvents
val entries = store.snapshotIdsForNegentropy(filters, maxEntries = cap)
if (entries.size > cap) {
send(NegErrMessage(cmd.subId, "blocked: too many query results"))
return
}
val session =
NegentropyServerSession(
subId = cmd.subId,
localEntries = entries,
frameSizeLimit = settings.frameSizeLimit,
)
sessions[cmd.subId] = session
runMessage(cmd.subId, session) { it.processMessage(cmd.initialMessage) }
}
/**
* Process a follow-up NEG-MSG. strfry-parity wording for the
* unknown-subId case: `"closed: unknown subscription handle"`.
*/
fun msg(cmd: NegMsgCmd) {
val session = sessions[cmd.subId]
if (session == null) {
send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}"))
send(NegErrMessage(cmd.subId, "closed: unknown subscription handle"))
return
}
runMessage(cmd.subId, session) { it.processMessage(cmd.message) }
@@ -99,6 +140,9 @@ class NegSessionRegistry(
sessions.clear()
}
/** Test/diagnostic accessor. */
val activeSessionCount: Int get() = sessions.size
private inline fun runMessage(
subId: String,
session: NegentropyServerSession,
@@ -107,9 +151,11 @@ class NegSessionRegistry(
try {
val response = block(session)
if (response != null) send(response)
} catch (e: Exception) {
} catch (_: Exception) {
// strfry sends `PROTOCOL-ERROR` on library reconcile()
// parse failure and tears the session down.
sessions.remove(subId)
send(NegErrMessage(subId, "error: ${e.message ?: e::class.simpleName}"))
send(NegErrMessage(subId, "PROTOCOL-ERROR"))
}
}
}
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -36,17 +38,42 @@ import kotlin.coroutines.CoroutineContext
*
* @param store The [IEventStore] backing this relay.
* @param policyBuilder Controls requirements for relay commands.
* @param parallelVerify When `true`, Schnorr verification runs in
* parallel inside the [IngestQueue] (one async per event, dispatched
* on `Dispatchers.Default`) rather than serially on the WS pump
* coroutine inside [VerifyPolicy]. Callers that flip this on should
* *omit* `VerifyPolicy` from their [policyBuilder] chain to avoid
* double-verifying.
* @param negentropySettings NIP-77 server-side tuning (frame cap,
* snapshot cap, per-connection session cap). Defaults to strfry-
* parity values; see [NegentropySettings].
*/
class NostrServer(
private val store: IEventStore,
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
private val parentContext: CoroutineContext = SupervisorJob(),
parallelVerify: Boolean = false,
private val negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable {
private val subStore = LiveEventStore(store)
/** Scope for all subscriptions. */
private val scope = CoroutineScope(parentContext + SupervisorJob())
/**
* Group-commit writer shared across every connected session.
* Sessions hand off EVENT publishes here instead of awaiting
* [IEventStore.insert] inline; the queue coalesces back-to-back
* publishes into a single SQLite transaction. See [IngestQueue]
* for the OK ordering and durability semantics.
*/
private val ingest =
IngestQueue(
store = store,
parentContext = parentContext,
verify = if (parallelVerify) ({ it.verify() }) else null,
)
private val subStore = LiveEventStore(store, ingest)
/** Active client sessions keyed by an opaque connection id. */
private val connections = LargeCache<Int, RelaySession>()
@@ -65,6 +92,7 @@ class NostrServer(
onClose = { session ->
connections.remove(session.hashCode())
},
negentropySettings = negentropySettings,
).also { session ->
connections.put(session.hashCode(), session)
}
@@ -95,6 +123,7 @@ class NostrServer(
override fun close() {
connections.forEach { _, session -> session.cancelAllSubscriptions() }
connections.clear()
ingest.close()
scope.cancel()
store.close()
}
@@ -34,14 +34,17 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
/**
@@ -54,11 +57,12 @@ class RelaySession(
private val scope: CoroutineScope,
private val onSend: (String) -> Unit,
private val onClose: (RelaySession) -> Unit,
negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable {
private val subscriptions = LargeCache<String, Job>()
/** NIP-77 negentropy state for this connection. */
private val negentropy = NegSessionRegistry(store, ::send)
private val negentropy = NegSessionRegistry(store, ::send, negentropySettings)
private fun addSubscription(
subId: String,
@@ -129,11 +133,29 @@ class RelaySession(
return
}
// Fire-and-forget: hand the event to the group-commit writer
// and continue reading from the WebSocket without waiting on
// SQLite. The OK frame is sent from the writer's callback,
// possibly out of arrival order — NIP-01 pairs OKs to events
// by id, so reordering is fine.
try {
store.insert(cmd.event)
send(OkMessage(cmd.event.id, true, ""))
} catch (e: Exception) {
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
store.submit(cmd.event) { outcome ->
when (outcome) {
IEventStore.InsertOutcome.Accepted -> {
send(OkMessage(cmd.event.id, true, ""))
}
is IEventStore.InsertOutcome.Rejected -> {
send(OkMessage(cmd.event.id, false, outcome.reason))
}
}
}
} catch (_: ClosedSendChannelException) {
// Server is shutting down — the queue is closed. Reply
// with a transient failure so a client re-trying against
// the next instance gets a sane signal; the WS itself is
// about to be torn down by the server-stop path.
send(OkMessage(cmd.event.id, false, "error: relay shutting down"))
}
}
@@ -31,13 +31,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Allows all commands without authentication. This is the default policy.
* Verifies the Schnorr signature + id hash of every incoming
* `EVENT` and `AUTH` event. Other commands pass through.
*
* The default `VerifyPolicy` singleton verifies both. The
* [VerifyAuthOnlyPolicy] singleton skips the EVENT path — use it
* when the [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue]
* is doing parallel verify (Tier 3 of the event-ingestion plan)
* so EVENTs aren't verified twice. AUTH is still verified inline
* because the AUTH path bypasses the queue entirely; without it,
* a forged event could mark a pubkey as authenticated.
*/
object VerifyPolicy : IRelayPolicy {
open class VerifyEventsAndAuthPolicy(
private val verifyEvents: Boolean,
) : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd) =
if (cmd.event.verify()) {
if (!verifyEvents || cmd.event.verify()) {
PolicyResult.Accepted(cmd)
} else {
PolicyResult.Rejected("invalid: bad signature or id")
@@ -56,3 +67,18 @@ object VerifyPolicy : IRelayPolicy {
override fun canSendToSession(event: Event) = true
}
/**
* Default verify policy — checks every EVENT and every AUTH. Use
* this when nothing else in the stack verifies signatures.
*/
object VerifyPolicy : VerifyEventsAndAuthPolicy(verifyEvents = true)
/**
* Verify policy that skips the EVENT path. Use when the
* `IngestQueue` is configured with `parallelVerify`, so EVENTs are
* verified once on the writer's CPU fan-out instead of inline on
* the WebSocket pump. AUTH is still verified inline because that
* command never reaches the queue.
*/
object VerifyAuthOnlyPolicy : VerifyEventsAndAuthPolicy(verifyEvents = false)
@@ -41,6 +41,42 @@ interface IEventStore : AutoCloseable {
suspend fun transaction(body: ITransaction.() -> Unit)
/**
* Per-row outcome from [batchInsert]. The OK frame on the wire is
* built from this — `Accepted` becomes `OK true`, `Rejected.reason`
* becomes the false reason. NIP-01 says OK pairs to its EVENT by
* id, not by order, so callers may dispatch outcomes in any order.
*/
sealed class InsertOutcome {
data object Accepted : InsertOutcome()
data class Rejected(
val reason: String,
) : InsertOutcome()
}
/**
* Bulk insert in a single transaction with per-row error isolation.
* Returns one outcome per input event in the same order.
*
* Implementations must isolate per-row failures so one bad event
* doesn't roll back the others (SQLite uses SAVEPOINTs). If the
* outer commit itself fails, every entry in the returned list is
* `Rejected` with the commit-failure reason.
*
* Default impl runs each insert in its own transaction — correct
* but loses the group-commit win. SQLite overrides this.
*/
suspend fun batchInsert(events: List<Event>): List<InsertOutcome> =
events.map { event ->
try {
insert(event)
InsertOutcome.Accepted
} catch (e: Throwable) {
InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed")
}
}
suspend fun <T : Event> query(filter: Filter): List<T>
suspend fun <T : Event> query(filters: List<Filter>): List<T>
@@ -59,6 +95,38 @@ interface IEventStore : AutoCloseable {
suspend fun count(filters: List<Filter>): Int
/**
* NIP-77 negentropy snapshot. Returns `(created_at, id)` pairs
* for every event matching [filters], with no content/tags/sig
* decode. Used by the server-side reconciliation path to build a
* `StorageVector` without materialising full [Event] objects —
* ~40 B/entry instead of ~1 KB/entry. Order is unspecified;
* negentropy's `seal()` re-sorts.
*
* If [maxEntries] is non-null, the implementation may return up
* to `maxEntries + 1` entries; the caller compares the result
* size to detect overflow (matching strfry's `maxSyncEvents`
* guard). The +1 sentinel lets the caller distinguish "exactly
* capped" from "too many to fit".
*
* Default implementation falls back to the full-decode path so
* non-SQLite stores stay correct; SQLite overrides with a direct
* `SELECT id, created_at` against the `query_by_created_at_id`
* index. Honors the same filter semantics as [query] including
* any `limit`.
*/
suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int? = null,
): List<IdAndTime> {
val all = query<Event>(filters).map { IdAndTime(it.createdAt, it.id) }
return if (maxEntries != null && all.size > maxEntries + 1) {
all.subList(0, maxEntries + 1)
} else {
all
}
}
suspend fun delete(filter: Filter)
suspend fun delete(filters: List<Filter>)
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Lightweight projection of an event used by NIP-77 negentropy: just
* the two fields the reconciliation library indexes — `created_at`
* and the 32-byte event id.
*
* Returned by [IEventStore.snapshotIdsForNegentropy] so the relay can
* build a [com.vitorpamplona.negentropy.storage.StorageVector] without
* materialising full [com.vitorpamplona.quartz.nip01Core.core.Event]
* objects (content, tags, sig). For a 1 M-event snapshot this drops
* peak heap from ~1 GB to ~40 MB — strfry's `MemoryView` parity.
*/
data class IdAndTime(
val createdAt: Long,
val id: HexKey,
)
@@ -96,6 +96,54 @@ class ObservableEventStore(
_changes.emit(StoreChange.Insert(event))
}
override suspend fun batchInsert(events: List<Event>): List<IEventStore.InsertOutcome> {
// Split into ephemeral (no-store) and persistable, delegate the
// persistable subset to the inner store's batched path so we
// keep the group-commit win, then merge outcomes back in input
// order. Already-expired ephemerals are dropped (matching
// [insert]). Accepted events are emitted on [_changes] only
// after the inner batch returns, so a commit failure that
// converts everything to Rejected suppresses the emits.
if (events.isEmpty()) return emptyList()
val outcomes = arrayOfNulls<IEventStore.InsertOutcome>(events.size)
val persistableIndices = ArrayList<Int>(events.size)
val persistable = ArrayList<Event>(events.size)
for (i in events.indices) {
val event = events[i]
if (event.kind.isEphemeral()) {
outcomes[i] =
if (event.isExpired()) {
IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event")
} else {
IEventStore.InsertOutcome.Accepted
}
} else {
persistableIndices.add(i)
persistable.add(event)
}
}
if (persistable.isNotEmpty()) {
val innerOutcomes = inner.batchInsert(persistable)
for (j in persistable.indices) {
outcomes[persistableIndices[j]] = innerOutcomes[j]
}
}
for (i in events.indices) {
if (outcomes[i] is IEventStore.InsertOutcome.Accepted) {
_changes.emit(StoreChange.Insert(events[i]))
}
}
// Every index in `events.indices` was populated above (either
// from the ephemeral pre-pass or the `inner.batchInsert`
// result), so no nulls remain. `requireNoNulls()` enforces
// that with a runtime check instead of an unchecked cast.
return outcomes.requireNoNulls().asList()
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
val accepted = ArrayList<Event>()
inner.transaction {
@@ -141,6 +189,11 @@ class ObservableEventStore(
override suspend fun count(filters: List<Filter>): Int = inner.count(filters)
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries)
override suspend fun delete(filter: Filter) {
inner.delete(filter)
_changes.emit(StoreChange.DeleteByFilter(listOf(filter)))
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
/**
* SQLite-backed [IEventStore] with default DB-file name and relay
@@ -43,6 +44,8 @@ class EventStore(
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
override suspend fun batchInsert(events: List<Event>) = store.batchInsertEvents(events)
override suspend fun <T : Event> query(filter: Filter) = store.query<T>(filter)
override suspend fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
@@ -61,6 +64,11 @@ class EventStore(
override suspend fun count(filters: List<Filter>) = store.count(filters)
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries)
override suspend fun delete(filter: Filter) {
store.delete(filter)
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.utils.EventFactory
@@ -172,6 +173,222 @@ class QueryBuilder(
}
}
// -----------------------------------------------------------------
// NIP-77 negentropy snapshot path
//
// Projects only (id, created_at) — no content/tags/sig decode —
// so the relay can build a StorageVector without materialising
// full Event objects. ~40 B/entry instead of ~1 KB/entry.
// No ORDER BY: negentropy's seal() re-sorts. No limit injection:
// the per-session cap is enforced upstream as a count check.
// -----------------------------------------------------------------
fun snapshotIdsForNegentropy(
filters: List<Filter>,
db: SQLiteConnection,
maxEntries: Int? = null,
): List<IdAndTime> {
val inner =
if (filters.size == 1) {
toSnapshotIdsSql(filters.first(), hasher(db))
} else {
toSnapshotIdsSql(filters, hasher(db))
}
// Safety cap: wrap with `LIMIT maxEntries + 1` so we can
// detect overflow without scanning beyond the cap. The +1
// sentinel lets the caller distinguish "exactly capped" from
// "too many to fit". Matches strfry's `maxSyncEvents` guard.
val query =
if (maxEntries != null) {
QuerySpec(
"SELECT id, created_at FROM (${inner.sql}) LIMIT ${maxEntries + 1}",
inner.args,
)
} else {
inner
}
return db.runIdAndTimeQuery(query)
}
private fun toSnapshotIdsSql(
filter: Filter,
hasher: TagNameValueHasher,
): QuerySpec {
val newFilter = filter.toFilterWithDTags()
// Simple path — no tag joins, no FTS — collapses to a single
// SELECT against event_headers.
if (newFilter.isSimpleQuery()) {
return makeSimpleIdsQuery(
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
// Search path — FTS join. Project id+created_at off
// event_headers via the FTS row_id linkage.
if (newFilter.isSimpleSearch()) {
return makeSimpleIdsSearch(
search = newFilter.search!!,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
// Tag-join path — reuse the existing row_id subquery and
// join back to event_headers for the projection.
val rowIdSubquery = prepareRowIDSubQueries(filter, hasher)
return if (rowIdSubquery == null) {
QuerySpec("SELECT id, created_at FROM event_headers")
} else {
QuerySpec(
"""
SELECT event_headers.id, event_headers.created_at FROM event_headers
INNER JOIN (
${rowIdSubquery.sql}
) AS filtered
ON event_headers.row_id = filtered.row_id
""".trimIndent(),
rowIdSubquery.args,
)
}
}
private fun toSnapshotIdsSql(
filters: List<Filter>,
hasher: TagNameValueHasher,
): QuerySpec {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubqueries == null) {
QuerySpec("SELECT id, created_at FROM event_headers")
} else {
QuerySpec(
"""
SELECT DISTINCT event_headers.id, event_headers.created_at FROM event_headers
INNER JOIN (
${rowIdSubqueries.sql}
) AS filtered
ON event_headers.row_id = filtered.row_id
""".trimIndent(),
rowIdSubqueries.args,
)
}
}
private fun makeSimpleIdsQuery(
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
ids?.let { equalsOrIn("id", it) }
kinds?.let { equalsOrIn("kind", it) }
authors?.let { equalsOrIn("pubkey", it) }
dTags?.let { equalsOrIn("d_tag", it) }
since?.let { greaterThanOrEquals("created_at", it) }
until?.let { lessThanOrEquals("created_at", it) }
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
raw("(kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
append("SELECT id, created_at FROM event_headers")
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ")
append(clause.conditions)
}
// Negentropy honors filter `limit` like REQ does
// (matches strfry's NostrFilterGroup behaviour).
// ORDER BY is required for LIMIT to be meaningful.
if (limit != null) {
append("\nORDER BY created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", id ASC")
}
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
private fun makeSimpleIdsSearch(
search: String,
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
ids?.let { equalsOrIn("event_headers.id", it) }
match(fts.tableName, search)
kinds?.let { equalsOrIn("event_headers.kind", it) }
authors?.let { equalsOrIn("event_headers.pubkey", it) }
dTags?.let { equalsOrIn("event_headers.d_tag", it) }
since?.let { greaterThanOrEquals("event_headers.created_at", it) }
until?.let { lessThanOrEquals("event_headers.created_at", it) }
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
raw("(event_headers.kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
append("SELECT event_headers.id, event_headers.created_at FROM event_headers")
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ${clause.conditions}")
}
if (limit != null) {
append("\nORDER BY event_headers.created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", event_headers.id ASC")
}
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
private fun SQLiteConnection.runIdAndTimeQuery(query: QuerySpec): List<IdAndTime> =
prepare(query.sql).use { stmt ->
query.args.forEachIndexed { index, arg ->
stmt.bindText(index + 1, arg)
}
val results = ArrayList<IdAndTime>()
while (stmt.step()) {
results.add(IdAndTime(stmt.getLong(1), stmt.getText(0)))
}
results
}
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
private fun makeQueryIn(rowIdQuery: String) =
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory
@@ -201,6 +202,63 @@ class SQLiteEventStore(
}
}
/**
* Group-commit batch insert with per-row error isolation via
* SAVEPOINTs. Acquires the writer mutex once and wraps every
* inserts in a single outer transaction so the WAL append + sync
* cost is paid once for the whole batch.
*
* Per-row contract:
* - Validation errors (expired) and per-row INSERT failures
* (UNIQUE constraint, etc.) ROLLBACK only that row's savepoint;
* other rows commit.
* - Ephemeral kinds are accepted without writing the live
* stream still surfaces them; persistence is intentionally a
* no-op per NIP-01.
*
* Outer-commit failure throws; the caller treats every entry as
* `Rejected` (this is what the IEventStore contract documents).
*/
suspend fun batchInsertEvents(events: List<Event>): List<IEventStore.InsertOutcome> {
if (events.isEmpty()) return emptyList()
val outcomes = ArrayList<IEventStore.InsertOutcome>(events.size)
pool.useWriter { db ->
db.transaction {
events.forEachIndexed { i, event ->
outcomes.add(insertWithSavepoint(event, i, this))
}
}
}
return outcomes
}
private fun insertWithSavepoint(
event: Event,
index: Int,
db: SQLiteConnection,
): IEventStore.InsertOutcome {
if (event.isExpired()) {
return IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event")
}
if (event.kind.isEphemeral()) return IEventStore.InsertOutcome.Accepted
val sp = "ev$index"
db.execSQL("SAVEPOINT $sp")
return try {
innerInsertEvent(event, db)
db.execSQL("RELEASE SAVEPOINT $sp")
IEventStore.InsertOutcome.Accepted
} catch (e: Throwable) {
// Roll back just this row, then release the (now empty)
// savepoint frame so the next iteration's BEGIN works.
// Both calls are individually try/catch'd because a failed
// ROLLBACK shouldn't mask the original cause.
runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") }
runCatching { db.execSQL("RELEASE SAVEPOINT $sp") }
IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed")
}
}
inner class Transaction(
val db: SQLiteConnection,
) : IEventStore.ITransaction {
@@ -258,6 +316,11 @@ class SQLiteEventStore(
suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) }
suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int? = null,
): List<IdAndTime> = pool.useReader { queryBuilder.snapshotIdsForNegentropy(filters, it, maxEntries) }
suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) }
suspend fun delete(filters: List<Filter>) = pool.useWriter { queryBuilder.delete(filters, it) }
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip77Negentropy
import com.vitorpamplona.negentropy.Negentropy
import com.vitorpamplona.negentropy.storage.StorageVector
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.utils.Hex
/**
@@ -31,28 +32,65 @@ import com.vitorpamplona.quartz.utils.Hex
* Used when acting as a relay (or relay-relay sync) to respond to
* incoming NEG-OPEN and NEG-MSG from a client.
*
* The constructor takes [IdAndTime] entries (just `created_at` and the
* 32-byte event id) to keep the per-session footprint at ~40 B/entry
* matching strfry's `MemoryView` path. A [List]<Event> overload is
* kept for callers (and tests) that already hold full events.
*
* Usage:
* 1. On NEG-OPEN: create a [NegentropyServerSession] with the matching local events
* 1. On NEG-OPEN: create a [NegentropyServerSession] with the matching local entries
* 2. Call [processMessage] with the initial hex message from NEG-OPEN
* 3. Send back the resulting [NegMsgMessage]
* 4. On subsequent NEG-MSG: call [processMessage] again and send the response
*
* @param frameSizeLimit max bytes per NEG-MSG response (raw payload,
* before hex). Default `500_000` matches strfry's hard-coded
* `Negentropy ne(storage, 500'000)` so a single round-trip carries
* the same payload as strfry's reconciliation.
*/
class NegentropyServerSession(
val subId: String,
localEvents: List<Event>,
frameSizeLimit: Long = 0,
localEntries: List<IdAndTime>,
frameSizeLimit: Long = DEFAULT_FRAME_SIZE_LIMIT,
) {
private val storage = StorageVector()
private val negentropy: Negentropy
init {
for (event in localEvents) {
storage.insert(event.createdAt, event.id)
for (entry in localEntries) {
storage.insert(entry.createdAt, entry.id)
}
storage.seal()
negentropy = Negentropy(storage, frameSizeLimit)
}
companion object {
/**
* strfry parity: `Negentropy ne(storage, 500'000)` in
* `RelayNegentropy.cpp`. Hex-encoded that's ~1 MB on the wire
* per NEG-MSG, the de-facto sync round-trip size.
*/
const val DEFAULT_FRAME_SIZE_LIMIT: Long = 500_000L
/**
* Convenience for callers that hold full [Event] objects
* (mostly tests + relay-relay sync paths). Production server
* code should call the [IdAndTime] constructor directly via
* `IEventStore.snapshotIdsForNegentropy` to avoid the full
* Event materialisation that this projection collapses.
*/
fun fromEvents(
subId: String,
localEvents: List<Event>,
frameSizeLimit: Long = DEFAULT_FRAME_SIZE_LIMIT,
): NegentropyServerSession =
NegentropyServerSession(
subId = subId,
localEntries = localEvents.map { IdAndTime(it.createdAt, it.id) },
frameSizeLimit = frameSizeLimit,
)
}
fun processMessage(hexMessage: String): NegMsgMessage? {
val msgBytes = Hex.decode(hexMessage)
val result = negentropy.reconcile(msgBytes)
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip77Negentropy
/**
* Server-side NIP-77 tuning. Defaults track strfry
* (`hoytech/strfry`) so a Quartz-based relay accepts the same
* workload shape and exchanges the same NEG-MSG round-trip size.
*
* @param frameSizeLimit Max bytes per NEG-MSG response payload
* (raw, before hex). 500_000 matches strfry's hard-coded
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
* The `kmp-negentropy` library enforces `>= 4096` (or `0` for
* unlimited).
* @param maxSyncEvents Hard cap on the snapshot size for a single
* NEG-OPEN. Mirrors strfry's `relay__negentropy__maxSyncEvents`.
* Overflow returns NEG-ERR `"blocked: too many query results"`.
* @param maxSessionsPerConnection Cap on concurrent NEG sessions
* held by one connection. strfry shares 200 with REQ subs; we
* count NEG independently. Overflow sends NOTICE
* `"too many concurrent NEG requests"`.
*/
data class NegentropySettings(
val frameSizeLimit: Long = NegentropyServerSession.DEFAULT_FRAME_SIZE_LIMIT,
val maxSyncEvents: Int = 1_000_000,
val maxSessionsPerConnection: Int = 200,
) {
companion object {
/** strfry-equivalent defaults. */
val Default = NegentropySettings()
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Verifies the NIP-77 negentropy id-and-time projection against the
* full-event query path. Goal: same result set, ~25× lighter
* footprint per row. Run across every indexing strategy via
* [BaseDBTest.forEachDB] so plan changes don't silently break the
* snapshot path.
*/
class SnapshotIdsForNegentropyTest : BaseDBTest() {
private val signer = NostrSignerSync()
private fun makeEvents(count: Int) =
List(count) { i ->
signer.sign(TextNoteEvent.build("event-$i", createdAt = 1_700_000_000L + i))
}
@Test
fun matchesFullQueryForSimpleKindFilter() =
forEachDB { db ->
val events = makeEvents(50)
for (e in events) db.insert(e)
val filter = Filter(kinds = listOf(1))
val full = db.query<com.vitorpamplona.quartz.nip01Core.core.Event>(filter)
val ids = db.snapshotIdsForNegentropy(listOf(filter))
assertEquals(full.size, ids.size, "snapshot must cover the same row set")
assertEquals(
full.map { it.id }.toSet(),
ids.map { it.id }.toSet(),
"snapshot ids must match the full-query ids",
)
// Every (createdAt, id) pair must round-trip.
val byId = full.associate { it.id to it.createdAt }
for (entry in ids) {
assertEquals(byId[entry.id], entry.createdAt, "createdAt mismatch for ${entry.id}")
}
}
@Test
fun honorsSinceUntilLimit() =
forEachDB { db ->
val events = makeEvents(20) // createdAt 1_700_000_000..1_700_000_019
for (e in events) db.insert(e)
// since/until window: [+5, +14] inclusive
val filter =
Filter(
kinds = listOf(1),
since = 1_700_000_005L,
until = 1_700_000_014L,
)
val ids = db.snapshotIdsForNegentropy(listOf(filter))
assertEquals(10, ids.size, "since/until window should yield 10 rows")
}
@Test
fun maxEntriesPlusOneSentinelMarksOverflow() =
forEachDB { db ->
val events = makeEvents(30)
for (e in events) db.insert(e)
val filter = Filter(kinds = listOf(1))
// cap = 10; we have 30 rows, so the result must be 11
// (cap + 1 sentinel) — matches strfry's `maxSyncEvents`
// overflow-detection idiom.
// cap=10 with 30 rows → result must be the +1 sentinel
// (11 rows). Caller compares `size > cap` to detect
// overflow — matches strfry's `maxSyncEvents` idiom.
val capped = db.snapshotIdsForNegentropy(listOf(filter), maxEntries = 10)
assertEquals(11, capped.size)
// cap >= total: returns the whole set unchanged.
val whole = db.snapshotIdsForNegentropy(listOf(filter), maxEntries = 100)
assertEquals(30, whole.size)
}
}
@@ -285,7 +285,7 @@ class NegentropySessionTest {
val openCmd = clientSession.open()
// Server processes via NegentropyServerSession
val serverSession = NegentropyServerSession("sub1", serverEvents)
val serverSession = NegentropyServerSession.fromEvents("sub1", serverEvents)
val response = serverSession.processMessage(openCmd.initialMessage)
assertNotNull(response)