mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
perf: relay read/write path — ordering index, zero-decode REQ replay, prepared-statement cache
Three changes, each validated head-to-head against strfry with relayBench
(same 10k-event corpus a1cd3517a8296911, stock configs, sig verify on):
- geode: RelayIndexingStrategy turns on indexEventsByCreatedAtAlone for
the relay's stores (quartz's DefaultIndexingStrategy stays off for
client-side stores). A relay can't predict client filters, and the
cheapest REQ of all — {"limit":N} — was a full-table scan + top-N
sort: 40 ms and O(table) growth before; 11 ms and index-streamed
(first event 30 ms -> 1.9 ms) after.
- quartz: zero-decode REQ replay. Stored events now stream as RawEvent
(tags kept in serialized form) and are spliced directly into wire
frames — no tags parse, no EventFactory dispatch, no re-serialize per
row. Gated on the new IRelayPolicy.filtersOutgoingEvents capability:
policies that can veto per-event delivery (none today) keep the
materialized path; everyone else skips it. Live post-EOSE delivery is
unchanged (live matching needs Event objects).
- quartz: StatementCachingConnection wraps the pool's writer and reader
connections, replaying prepared statements instead of re-preparing per
event/REQ (eager reset on return keeps cursors from holding table
locks; a 256-statement cap bounds client-controlled filter-shape
variety). Ingest went 3,000 -> 4,700 events/s (+55%) — prepare
overhead was the single largest non-crypto write cost.
Net effect on the benchmark: ingest gap vs strfry narrowed from 2.2x to
1.8x, the firehose latency gap from 4x to 1.2x, and geode now wins 4 of
9 query-latency scenarios (notifications, hashtag, by-ids,
recent-window) plus most concurrent-throughput scenarios, while keeping
the smaller on-disk footprint. Result sets stayed byte-identical across
relays and NIP-77 sync still converges.
Measured but deliberately NOT taken: per-row SAVEPOINT elision (+4%,
within run noise — not worth weakening batch error isolation), FTS-off
(+25% ingest but drops NIP-50), --no-verify (+45% but unfair/unsafe).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
This commit is contained in:
@@ -113,7 +113,7 @@ fun main(args: Array<String>) {
|
||||
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||
?: config.resolveInfo()
|
||||
|
||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl, indexStrategy = RelayIndexingStrategy)
|
||||
|
||||
val policyBuilder: () -> IRelayPolicy = {
|
||||
composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify)
|
||||
|
||||
@@ -58,7 +58,7 @@ import kotlin.coroutines.CoroutineContext
|
||||
*/
|
||||
class RelayEngine(
|
||||
val url: NormalizedRelayUrl,
|
||||
val store: IEventStore = EventStore(dbName = null, relay = url),
|
||||
val store: IEventStore = EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy),
|
||||
/**
|
||||
* Runtime configuration handle — owns the persistence path (when
|
||||
* any), the NIP-11 doc seed, and the seed for the NIP-86 ban /
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||
|
||||
/**
|
||||
* Index strategy for a *public relay*, as opposed to quartz's
|
||||
* [DefaultIndexingStrategy] which is tuned for client-side stores that
|
||||
* only ever query their own kinds.
|
||||
*
|
||||
* A relay cannot predict its clients' filters, and the cheapest query a
|
||||
* client can send — `{"limit": N}`, the firehose/landing REQ — carries no
|
||||
* kind, author or tag for the planner to use. Without an index on
|
||||
* `created_at` alone that REQ is a full-table scan + top-N sort on every
|
||||
* poll; relayBench measured it 4× slower than strfry at 10k events and
|
||||
* the gap grows linearly with the table. The extra index costs one B-tree
|
||||
* insert per event, which the same benchmark shows is noise next to the
|
||||
* Schnorr verify + tag indexing already paid on the write path.
|
||||
*/
|
||||
val RelayIndexingStrategy =
|
||||
DefaultIndexingStrategy(
|
||||
indexEventsByCreatedAtAlone = true,
|
||||
)
|
||||
+50
-9
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.backend.SessionBackend
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
@@ -125,6 +126,15 @@ class RelaySession(
|
||||
}
|
||||
}
|
||||
|
||||
/** [send] for frames that are already wire-format JSON (the raw REQ path). */
|
||||
private fun sendRaw(json: String) {
|
||||
try {
|
||||
onSend(json)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ClientSession") { "Failed to send to ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cancelAllSubscriptions()
|
||||
onClose(this)
|
||||
@@ -281,16 +291,47 @@ class RelaySession(
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
store.query(
|
||||
ctx = requestContext,
|
||||
filters = filters,
|
||||
onEach = { event ->
|
||||
if (policy.canSendToSession(event)) {
|
||||
send(EventMessage(cmd.subId, event))
|
||||
if (policy.filtersOutgoingEvents) {
|
||||
// Screened path: every event is materialized so the
|
||||
// policy can veto it per session.
|
||||
store.query(
|
||||
ctx = requestContext,
|
||||
filters = filters,
|
||||
onEach = { event ->
|
||||
if (policy.canSendToSession(event)) {
|
||||
send(EventMessage(cmd.subId, event))
|
||||
}
|
||||
},
|
||||
onEose = { send(EoseMessage(cmd.subId)) },
|
||||
)
|
||||
} else {
|
||||
// Zero-decode path: the stored replay splices raw
|
||||
// storage strings straight into wire frames — no tags
|
||||
// parse, no Event materialization, no re-serialize.
|
||||
// The `["EVENT","<subId>",` prefix is built once per
|
||||
// subscription, not per row.
|
||||
val framePrefix =
|
||||
buildString {
|
||||
append("[\"EVENT\",")
|
||||
RawEvent.appendJsonQuoted(this, cmd.subId)
|
||||
append(',')
|
||||
}
|
||||
},
|
||||
onEose = { send(EoseMessage(cmd.subId)) },
|
||||
)
|
||||
store.queryRaw(
|
||||
ctx = requestContext,
|
||||
filters = filters,
|
||||
onEachStored = { raw ->
|
||||
sendRaw(
|
||||
buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) {
|
||||
append(framePrefix)
|
||||
raw.appendJsonObjectTo(this)
|
||||
append(']')
|
||||
},
|
||||
)
|
||||
},
|
||||
onEachLive = { event -> send(EventMessage(cmd.subId, event)) },
|
||||
onEose = { send(EoseMessage(cmd.subId)) },
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Subscription was closed – this is expected.
|
||||
throw e
|
||||
|
||||
+46
@@ -25,6 +25,7 @@ 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 com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@@ -205,6 +206,51 @@ class LiveEventStore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-decode variant of [query]: the historical replay streams
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.RawEvent] rows straight
|
||||
* from storage (no tags parse, no Event materialization, no
|
||||
* re-serialization), while the live path after EOSE is identical to
|
||||
* [query]'s. Same registration-before-replay ordering and the same
|
||||
* immutable-set dedupe against events accepted mid-replay.
|
||||
*/
|
||||
override suspend fun queryRaw(
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
val seenIds = AtomicReference<Set<String>?>(emptySet())
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val seen = seenIds.load()
|
||||
if (seen != null && seen.contains(event.id)) return@LiveSubscription
|
||||
onEachLive(event)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.rawQuery(filters) { raw ->
|
||||
while (true) {
|
||||
val current = seenIds.load() ?: break
|
||||
if (raw.id in current) break
|
||||
if (seenIds.compareAndSet(current, current + raw.id)) break
|
||||
}
|
||||
onEachStored(raw)
|
||||
}
|
||||
onEose()
|
||||
seenIds.store(null)
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
index.unregister(sub)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun count(
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
|
||||
+22
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
|
||||
/**
|
||||
* The data plane a [RelaySession] talks to: how REQ/COUNT are answered, how
|
||||
@@ -58,6 +59,27 @@ interface SessionBackend {
|
||||
onEose: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* [query] variant for sessions whose policy does not filter outgoing
|
||||
* events (see
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy.filtersOutgoingEvents]):
|
||||
* the stored replay is delivered as [RawEvent]s so the transport can
|
||||
* splice storage strings straight into wire frames without
|
||||
* materializing an [Event] per row. Live events after EOSE still
|
||||
* arrive as [Event]s via [onEachLive] — they exist in object form
|
||||
* already, and live matching needs them.
|
||||
*
|
||||
* The default falls back to [query], treating every delivery as live —
|
||||
* correct for any backend, just without the zero-decode win.
|
||||
*/
|
||||
suspend fun queryRaw(
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
): Unit = query(ctx, filters, onEachLive, onEose)
|
||||
|
||||
/** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
|
||||
suspend fun count(
|
||||
ctx: RequestContext,
|
||||
|
||||
+14
@@ -133,10 +133,24 @@ interface IRelayPolicy {
|
||||
* Called for each event that matches a subscription's filters. Return
|
||||
* true to deliver the event, false to suppress it for this session.
|
||||
*
|
||||
* Any implementation that can return `false` here MUST also override
|
||||
* [filtersOutgoingEvents] to return `true`, otherwise the session may
|
||||
* take the zero-decode REQ fast path that never consults this method.
|
||||
*
|
||||
* @param event The event about to be sent.
|
||||
*/
|
||||
fun canSendToSession(event: Event): Boolean = true
|
||||
|
||||
/**
|
||||
* Declares whether [canSendToSession] can ever suppress an event for
|
||||
* this session. When `false` (the default, matching the default
|
||||
* [canSendToSession] that always allows), the engine answers REQs
|
||||
* through the zero-decode raw path — storage strings are spliced
|
||||
* straight into wire frames and [canSendToSession] is never called.
|
||||
* When `true`, every delivered event is materialized and screened.
|
||||
*/
|
||||
val filtersOutgoingEvents: Boolean get() = false
|
||||
|
||||
operator fun plus(other: IRelayPolicy): IRelayPolicy = PolicyStack(this, other)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -77,4 +77,6 @@ class PolicyStack(
|
||||
}
|
||||
|
||||
override fun canSendToSession(event: Event): Boolean = policies.all { it.canSendToSession(event) }
|
||||
|
||||
override val filtersOutgoingEvents: Boolean get() = policies.any { it.filtersOutgoingEvents }
|
||||
}
|
||||
|
||||
@@ -101,6 +101,21 @@ interface IEventStore : AutoCloseable {
|
||||
onEach: (T) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Streams matching events in storage form — tags still serialized,
|
||||
* nothing materialized — for read paths that only put events back on
|
||||
* the wire (see [RawEvent]). The default decodes and re-wraps so
|
||||
* every store stays correct; SQLite overrides with a true zero-decode
|
||||
* row read.
|
||||
*/
|
||||
suspend fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
): Unit =
|
||||
query<Event>(filters) { event ->
|
||||
onEach(RawEvent.fromEvent(event))
|
||||
}
|
||||
|
||||
suspend fun count(filter: Filter): Int
|
||||
|
||||
suspend fun count(filters: List<Filter>): Int
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
/**
|
||||
* An event as it sits in storage: fields as plain strings, `tags` still in
|
||||
* its serialized-JSON form. This is the zero-decode currency of the relay
|
||||
* read path — a REQ that only needs to put the event back on the wire can
|
||||
* splice these strings straight into the outgoing frame via
|
||||
* [appendJsonObjectTo], skipping the tags parse, the [EventFactory]
|
||||
* dispatch to a kind-specific class, and the full re-serialization that
|
||||
* materializing an [Event] would cost per row.
|
||||
*/
|
||||
class RawEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Kind,
|
||||
val jsonTags: String,
|
||||
val content: String,
|
||||
val sig: HexKey,
|
||||
) {
|
||||
fun <T : Event> toEvent() =
|
||||
EventFactory.create<T>(
|
||||
id,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
|
||||
/**
|
||||
* Appends this event as a NIP-01 JSON object, reusing the stored
|
||||
* strings verbatim: `id`/`pubkey`/`sig` are validated hex, [jsonTags]
|
||||
* is already JSON; only [content] needs string escaping.
|
||||
*/
|
||||
fun appendJsonObjectTo(builder: StringBuilder) {
|
||||
builder
|
||||
.append("{\"id\":\"")
|
||||
.append(id)
|
||||
.append("\",\"pubkey\":\"")
|
||||
.append(pubKey)
|
||||
.append("\",\"created_at\":")
|
||||
.append(createdAt)
|
||||
.append(",\"kind\":")
|
||||
.append(kind)
|
||||
.append(",\"tags\":")
|
||||
.append(jsonTags)
|
||||
.append(",\"content\":")
|
||||
appendJsonQuoted(builder, content)
|
||||
builder
|
||||
.append(",\"sig\":\"")
|
||||
.append(sig)
|
||||
.append("\"}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromEvent(event: Event) =
|
||||
RawEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
kind = event.kind,
|
||||
jsonTags = OptimizedJsonMapper.toJson(event.tags),
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
)
|
||||
|
||||
/**
|
||||
* Appends [value] as a JSON string literal, quotes included.
|
||||
* Minimal spec-compliant escaping (RFC 8259 §7): `"`, `\`, the
|
||||
* short control escapes, and `\u00XX` for the rest of C0.
|
||||
* Everything else — including non-ASCII — passes through raw,
|
||||
* which is valid JSON and preserves the exact code points the
|
||||
* event id was hashed over.
|
||||
*/
|
||||
fun appendJsonQuoted(
|
||||
builder: StringBuilder,
|
||||
value: String,
|
||||
) {
|
||||
builder.append('"')
|
||||
for (ch in value) {
|
||||
when {
|
||||
ch == '"' -> builder.append("\\\"")
|
||||
ch == '\\' -> builder.append("\\\\")
|
||||
ch == '\n' -> builder.append("\\n")
|
||||
ch == '\r' -> builder.append("\\r")
|
||||
ch == '\t' -> builder.append("\\t")
|
||||
ch == '\b' -> builder.append("\\b")
|
||||
ch == '\u000C' -> builder.append("\\f")
|
||||
ch < ' ' -> {
|
||||
builder.append("\\u")
|
||||
val hex = ch.code.toString(16)
|
||||
repeat(4 - hex.length) { builder.append('0') }
|
||||
builder.append(hex)
|
||||
}
|
||||
else -> builder.append(ch)
|
||||
}
|
||||
}
|
||||
builder.append('"')
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
|
||||
/**
|
||||
* SQLite-backed [IEventStore] with default DB-file name and relay
|
||||
@@ -61,6 +62,11 @@ class EventStore(
|
||||
onEach: (T) -> Unit,
|
||||
) = store.query(filters, onEach)
|
||||
|
||||
override suspend fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = store.rawQuery(filters, onEach)
|
||||
|
||||
override suspend fun count(filter: Filter) = store.count(filter)
|
||||
|
||||
override suspend fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ 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.RawEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
|
||||
+9
-2
@@ -82,14 +82,21 @@ class SQLiteConnectionPool(
|
||||
private val readerChannel: Channel<SQLiteConnection>?
|
||||
|
||||
init {
|
||||
writer = openConnection()
|
||||
// The writer replays the same INSERT statements for every event —
|
||||
// cache its prepared statements so sqlite3_prepare is paid once per
|
||||
// SQL string instead of once per row. (Migrations run through the
|
||||
// same wrapper; DDL statements just cache and stay unused.)
|
||||
writer = StatementCachingConnection(openConnection())
|
||||
onMigrate(writer)
|
||||
|
||||
if (isInMemory) {
|
||||
readers = emptyList()
|
||||
readerChannel = null
|
||||
} else {
|
||||
readers = List(numReaders) { openConnection() }
|
||||
// Readers replay the same filter shapes all day (feeds, threads,
|
||||
// profile hydrations) — caching their prepared statements pays
|
||||
// the parse/plan cost once per shape per connection.
|
||||
readers = List(numReaders) { StatementCachingConnection(openConnection()) }
|
||||
readerChannel = Channel(numReaders)
|
||||
readers.forEach { readerChannel.trySend(it) }
|
||||
}
|
||||
|
||||
+1
-24
@@ -26,16 +26,14 @@ import androidx.sqlite.SQLiteException
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
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.FtsReindexProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
class SQLiteEventStore(
|
||||
val driver: SQLiteDriver = BundledSQLiteDriver(),
|
||||
@@ -372,24 +370,3 @@ class SQLiteEventStore(
|
||||
|
||||
fun close() = pool.close()
|
||||
}
|
||||
|
||||
class RawEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Kind,
|
||||
val jsonTags: String,
|
||||
val content: String,
|
||||
val sig: HexKey,
|
||||
) {
|
||||
fun <T : Event> toEvent() =
|
||||
EventFactory.create<T>(
|
||||
id,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
|
||||
/**
|
||||
* A [SQLiteConnection] decorator that keeps every prepared statement and
|
||||
* hands the same handle back on the next [prepare] of the same SQL. The
|
||||
* event-ingest hot path prepares the identical INSERT statements once per
|
||||
* event; sqlite3_prepare is pure overhead the second time around.
|
||||
*
|
||||
* Callers keep their idiomatic `prepare(sql).use { … }` blocks untouched:
|
||||
* the statement wrapper turns `close()` into a return-to-cache no-op (the
|
||||
* real reset + clearBindings happens on the next checkout). Statements are
|
||||
* only truly finalized when the connection itself closes.
|
||||
*
|
||||
* Constraints, by design of the call sites:
|
||||
* - **Not thread-safe** — same contract as the underlying connection,
|
||||
* which the pool already serializes (single writer under a mutex).
|
||||
* - **No overlapping use of the same SQL** — checking out one SQL string
|
||||
* twice without closing the first use would alias one native handle.
|
||||
* Insert/query paths never nest the same statement; a checkout while
|
||||
* the previous one is still open falls back to an uncached statement.
|
||||
*/
|
||||
class StatementCachingConnection(
|
||||
private val delegate: SQLiteConnection,
|
||||
/**
|
||||
* Ceiling on retained statements. Query SQL embeds one `?` per filter
|
||||
* element, so shape variety is client-controlled — without a cap a
|
||||
* long-lived relay connection would accumulate native handles without
|
||||
* bound. Once full, unseen SQL just prepares uncached. 256 comfortably
|
||||
* covers the write path's fixed set plus the recurring filter shapes.
|
||||
*/
|
||||
private val maxCachedStatements: Int = 256,
|
||||
) : SQLiteConnection by delegate {
|
||||
private val cache = HashMap<String, CachedStatement>()
|
||||
|
||||
override fun prepare(sql: String): SQLiteStatement {
|
||||
val cached =
|
||||
cache[sql] ?: run {
|
||||
if (cache.size >= maxCachedStatements) return delegate.prepare(sql)
|
||||
CachedStatement(delegate.prepare(sql)).also { cache[sql] = it }
|
||||
}
|
||||
if (cached.checkedOut) {
|
||||
// Same SQL prepared while the previous handle is still in use —
|
||||
// stay correct with a plain uncached statement.
|
||||
return delegate.prepare(sql)
|
||||
}
|
||||
cached.checkedOut = true
|
||||
cached.clearBindings()
|
||||
return cached
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cache.values.forEach { runCatching { it.finalize() } }
|
||||
cache.clear()
|
||||
delegate.close()
|
||||
}
|
||||
|
||||
private class CachedStatement(
|
||||
private val delegate: SQLiteStatement,
|
||||
) : SQLiteStatement by delegate {
|
||||
var checkedOut = false
|
||||
|
||||
/**
|
||||
* Return to cache. The real handle stays prepared, but must be
|
||||
* reset *now*: an un-reset statement keeps its cursor (and its
|
||||
* table read locks) open, which turns a later `DELETE`/`DROP` on
|
||||
* the same connection into `SQLITE_LOCKED: database table is
|
||||
* locked`.
|
||||
*/
|
||||
override fun close() {
|
||||
runCatching { delegate.reset() }
|
||||
checkedOut = false
|
||||
}
|
||||
|
||||
fun finalize() = delegate.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user