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:
+6
@@ -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)
|
||||
|
||||
+305
@@ -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 ~5–10× 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
|
||||
}
|
||||
}
|
||||
+78
-11
@@ -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)
|
||||
}
|
||||
|
||||
+54
-8
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -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()
|
||||
}
|
||||
|
||||
+27
-5
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -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,
|
||||
)
|
||||
+53
@@ -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)))
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
|
||||
+217
@@ -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) =
|
||||
|
||||
+63
@@ -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) }
|
||||
|
||||
+43
-5
@@ -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)
|
||||
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user