perf(quartz): group-commit + per-connection ingest pipeline
Implements the event-ingestion-batching plan: SQLite group commit with per-row SAVEPOINT isolation, and a per-server IngestQueue that turns RelaySession.handleEvent into fire-and-forget. The OK frame is emitted from the writer's callback once the row's outcome is known, relying on NIP-01 pairing OKs by event id (not by order). - IEventStore.batchInsert + InsertOutcome contract; SQLite override uses SAVEPOINTs so one bad event doesn't roll back the others. ObservableEventStore forwards persistable rows to the inner batch and emits StoreChange.Insert for accepted ones. - IngestQueue drains submissions in batches up to 64 per transaction. Writer coroutine starts lazily on the first submit so subscription-only sessions don't pay for it (and don't perturb Default-dispatcher scheduling — the eager launch was visible as intermittent NostrClientRepeatSubTest flakes under full-suite load). - RelaySession.handleEvent posts to the queue and returns immediately; the WS pump moves to the next frame instead of awaiting SQLite. ClosedSendChannelException during shutdown surfaces as OK false rather than crashing the pump. - LiveEventStore.submit fans an event onto the live stream only after the writer reports Accepted; the suspending insert is retained for tests, routed through the same queue. - New publishPipelinedSingleClient benchmark in geode.perf: 10 000 EVENTs back-to-back without awaiting OKs, asserts every event id receives exactly one OK (in any order).
This commit is contained in:
@@ -450,6 +450,101 @@ class LoadBenchmark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One publisher fires N EVENTs back-to-back without awaiting
|
||||||
|
* intermediate OKs, then collects all OKs by event id. This is
|
||||||
|
* the workload that exercises Tier 2 (per-connection ingest
|
||||||
|
* pipeline) + Tier 1 (group commit) together — multiple events
|
||||||
|
* are in flight on the same connection, so the writer can batch.
|
||||||
|
*
|
||||||
|
* Verifies the relaxed OK contract: every event id receives
|
||||||
|
* exactly one OK frame, in any order.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun publishPipelinedSingleClient() =
|
||||||
|
benchmark("publish pipelined single client") {
|
||||||
|
runBenchmarkServer { server, http ->
|
||||||
|
val n = 10_000
|
||||||
|
val signer = NostrSignerSync(KeyPair())
|
||||||
|
val events =
|
||||||
|
runBlocking {
|
||||||
|
(0 until n).map { i ->
|
||||||
|
signer.sign(TextNoteEvent.build("pipe $i"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ids = events.mapTo(HashSet()) { it.id }
|
||||||
|
|
||||||
|
val httpUrl =
|
||||||
|
okhttp3.Request
|
||||||
|
.Builder()
|
||||||
|
.url(server.url.replace("ws://", "http://"))
|
||||||
|
.build()
|
||||||
|
val okSeen = AtomicLong()
|
||||||
|
val okFailures = AtomicLong()
|
||||||
|
val unknownIds = AtomicLong()
|
||||||
|
val seenIds =
|
||||||
|
java.util.concurrent.ConcurrentHashMap
|
||||||
|
.newKeySet<String>()
|
||||||
|
val done = java.util.concurrent.CountDownLatch(1)
|
||||||
|
|
||||||
|
val ws =
|
||||||
|
http.newWebSocket(
|
||||||
|
httpUrl,
|
||||||
|
object : okhttp3.WebSocketListener() {
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: okhttp3.WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (!text.startsWith("[\"OK\"")) return
|
||||||
|
// ["OK","<id>",true|false,"<reason>"] —
|
||||||
|
// a tiny string scan is enough for a
|
||||||
|
// bench. Index 6 is past `["OK","`.
|
||||||
|
val idStart = 7
|
||||||
|
val idEnd = text.indexOf('"', idStart)
|
||||||
|
if (idEnd <= idStart) return
|
||||||
|
val id = text.substring(idStart, idEnd)
|
||||||
|
if (!ids.contains(id)) {
|
||||||
|
unknownIds.incrementAndGet()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!seenIds.add(id)) return
|
||||||
|
if (text.contains(",true,")) {
|
||||||
|
okSeen.incrementAndGet()
|
||||||
|
} else {
|
||||||
|
okFailures.incrementAndGet()
|
||||||
|
}
|
||||||
|
if (okSeen.get() + okFailures.get() == n.toLong()) done.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val elapsed =
|
||||||
|
measureTime {
|
||||||
|
// Burst-send: queue every EVENT to OkHttp's
|
||||||
|
// outbound buffer without any await, then
|
||||||
|
// wait for the corresponding OK frames.
|
||||||
|
for (event in events) {
|
||||||
|
ws.send("""["EVENT",${event.toJson()}]""")
|
||||||
|
}
|
||||||
|
check(done.await(60, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||||
|
"timed out waiting for OKs: ok=${okSeen.get()} rej=${okFailures.get()} unknown=${unknownIds.get()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val eps = (n * 1000.0) / elapsed.inWholeMilliseconds
|
||||||
|
println(
|
||||||
|
"events=$n ok=${okSeen.get()} rejected=${okFailures.get()} " +
|
||||||
|
"unknownIds=${unknownIds.get()} elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
|
||||||
|
)
|
||||||
|
check(okSeen.get() == n.toLong()) {
|
||||||
|
"expected $n accepted OKs, got ${okSeen.get()} (rejected ${okFailures.get()})"
|
||||||
|
}
|
||||||
|
check(seenIds.size == n) {
|
||||||
|
"expected $n unique OK ids, got ${seenIds.size} — duplicate or missing OKs"
|
||||||
|
}
|
||||||
|
ws.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Many concurrent publishers, each on their own WebSocket. Tells
|
* Many concurrent publishers, each on their own WebSocket. Tells
|
||||||
* us whether the SQLite single-writer bottleneck is the floor or
|
* us whether the SQLite single-writer bottleneck is the floor or
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* 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.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
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,
|
||||||
|
) : 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)
|
||||||
|
val events = ArrayList<Event>(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)
|
||||||
|
}
|
||||||
|
events.clear()
|
||||||
|
for (sub in batch) events.add(sub.event)
|
||||||
|
|
||||||
|
val outcomes =
|
||||||
|
try {
|
||||||
|
store.batchInsert(events)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w("IngestQueue") { "batchInsert failed for ${batch.size} events: ${e.message}" }
|
||||||
|
val reason = e.message ?: e::class.simpleName ?: "insert failed"
|
||||||
|
List(batch.size) { IEventStore.InsertOutcome.Rejected(reason) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in batch.indices) {
|
||||||
|
val sub = batch[i]
|
||||||
|
val outcome =
|
||||||
|
outcomes.getOrNull(i)
|
||||||
|
?: IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
|
||||||
|
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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.clear()
|
||||||
|
}
|
||||||
|
} catch (_: kotlinx.coroutines.channels.ClosedReceiveChannelException) {
|
||||||
|
// Normal shutdown via close().
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-2
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.onSubscription
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
@@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.onSubscription
|
|||||||
*/
|
*/
|
||||||
class LiveEventStore(
|
class LiveEventStore(
|
||||||
private val store: IEventStore,
|
private val store: IEventStore,
|
||||||
|
private val ingest: IngestQueue,
|
||||||
) {
|
) {
|
||||||
private val newEventStream =
|
private val newEventStream =
|
||||||
MutableSharedFlow<Event>(
|
MutableSharedFlow<Event>(
|
||||||
@@ -47,9 +49,47 @@ class LiveEventStore(
|
|||||||
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
|
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 emitted to 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) {
|
||||||
|
newEventStream.tryEmit(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) {
|
suspend fun insert(event: Event) {
|
||||||
store.insert(event)
|
val done = CompletableDeferred<Unit>()
|
||||||
newEventStream.tryEmit(event)
|
submit(event) { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
IEventStore.InsertOutcome.Accepted -> {
|
||||||
|
done.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
is IEventStore.InsertOutcome.Rejected -> {
|
||||||
|
done.completeExceptionally(IllegalStateException(outcome.reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun query(
|
suspend fun query(
|
||||||
|
|||||||
+12
-2
@@ -42,11 +42,20 @@ class NostrServer(
|
|||||||
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
|
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
|
||||||
private val parentContext: CoroutineContext = SupervisorJob(),
|
private val parentContext: CoroutineContext = SupervisorJob(),
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
private val subStore = LiveEventStore(store)
|
|
||||||
|
|
||||||
/** Scope for all subscriptions. */
|
/** Scope for all subscriptions. */
|
||||||
private val scope = CoroutineScope(parentContext + SupervisorJob())
|
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, parentContext)
|
||||||
|
|
||||||
|
private val subStore = LiveEventStore(store, ingest)
|
||||||
|
|
||||||
/** Active client sessions keyed by an opaque connection id. */
|
/** Active client sessions keyed by an opaque connection id. */
|
||||||
private val connections = LargeCache<Int, RelaySession>()
|
private val connections = LargeCache<Int, RelaySession>()
|
||||||
|
|
||||||
@@ -95,6 +104,7 @@ class NostrServer(
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
connections.forEach { _, session -> session.cancelAllSubscriptions() }
|
connections.forEach { _, session -> session.cancelAllSubscriptions() }
|
||||||
connections.clear()
|
connections.clear()
|
||||||
|
ingest.close()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
store.close()
|
store.close()
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -34,6 +34,7 @@ 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.CountCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
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.NegCloseCmd
|
||||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||||
@@ -129,11 +130,29 @@ class RelaySession(
|
|||||||
return
|
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 {
|
try {
|
||||||
store.insert(cmd.event)
|
store.submit(cmd.event) { outcome ->
|
||||||
send(OkMessage(cmd.event.id, true, ""))
|
when (outcome) {
|
||||||
} catch (e: Exception) {
|
IEventStore.InsertOutcome.Accepted -> {
|
||||||
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
|
send(OkMessage(cmd.event.id, true, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
is IEventStore.InsertOutcome.Rejected -> {
|
||||||
|
send(OkMessage(cmd.event.id, false, outcome.reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: kotlinx.coroutines.channels.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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,42 @@ interface IEventStore : AutoCloseable {
|
|||||||
|
|
||||||
suspend fun transaction(body: ITransaction.() -> Unit)
|
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(filter: Filter): List<T>
|
||||||
|
|
||||||
suspend fun <T : Event> query(filters: List<Filter>): List<T>
|
suspend fun <T : Event> query(filters: List<Filter>): List<T>
|
||||||
|
|||||||
+45
@@ -96,6 +96,51 @@ class ObservableEventStore(
|
|||||||
_changes.emit(StoreChange.Insert(event))
|
_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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return outcomes.toList() as List<IEventStore.InsertOutcome>
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
|
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
|
||||||
val accepted = ArrayList<Event>()
|
val accepted = ArrayList<Event>()
|
||||||
inner.transaction {
|
inner.transaction {
|
||||||
|
|||||||
+2
@@ -43,6 +43,8 @@ class EventStore(
|
|||||||
|
|
||||||
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
|
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(filter: Filter) = store.query<T>(filter)
|
||||||
|
|
||||||
override suspend fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
|
override suspend fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
|
||||||
|
|||||||
+57
@@ -201,6 +201,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(
|
inner class Transaction(
|
||||||
val db: SQLiteConnection,
|
val db: SQLiteConnection,
|
||||||
) : IEventStore.ITransaction {
|
) : IEventStore.ITransaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user