mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
perf(quartz/geode): serve full-set NEG-OPENs from the live negentropy index
Milestones 2-3 of quartz/plans/2026-07-03-incremental-negentropy-storage.md. SQLiteEventStore now maintains the LiveNegentropyIndex when the strategy opts in (geode's does by default; [negentropy].live_index = false turns it off; app-side stores are untouched): - Write paths collect a LiveIndexDelta and apply it after COMMIT while still holding the writer mutex — rolled-back savepoint rows never reach the index and updates land in exact commit order (also vs the rebuild, which runs under the same mutex). - Replaceable/addressable overwrites report the row their BEFORE-INSERT trigger displaces via one indexed pre-SELECT that mirrors the trigger predicate (including the NIP-01 lowest-id tie-break and the d_tag-NULL case). - Paths that can't itemize (kind-5, vanish, delete-by-filter/id, expiration sweeps, clearDB) invalidate; the next NEG-OPEN rebuilds from one scan on the writer connection. - Until that first NEG-OPEN populates the index, ingest pays zero bookkeeping — the populated check happens under the writer mutex so it can't race the rebuild. LiveEventStore serves a single unconstrained filter (the relay-relay sync default; relayBench sends exactly this) from the index; everything else keeps the scan+seal path and its single-slot cache. Micro-benchmark at 50k events (LiveNegentropyBenchmark, in-container): scan+seal cold path 80-100 ms; index post-write open 9-16 ms (~5-10x). The relayBench A/B is the acceptance gate and comes next. Correctness: LiveNegentropyIndexStoreTest asserts index content == snapshotIdsForNegentropy scan after every mutation pattern (overwrites, losers, kind-5 rebuilds, filter deletes, mixed-outcome batches, transactions); the full geode suite (NIP-77 + interop sync tests) runs with the index on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
This commit is contained in:
@@ -126,7 +126,12 @@ fun main(args: Array<String>) {
|
||||
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||
?: config.resolveInfo(fullTextSearch)
|
||||
|
||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl, indexStrategy = relayIndexingStrategy(fullTextSearch))
|
||||
val store: IEventStore =
|
||||
EventStore(
|
||||
dbName = dbFile,
|
||||
relay = advertisedUrl,
|
||||
indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index),
|
||||
)
|
||||
|
||||
val policyBuilder: () -> IRelayPolicy = {
|
||||
composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify)
|
||||
|
||||
@@ -40,21 +40,30 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||
* per-event tokenization on ingest (relayBench measured it at roughly a
|
||||
* quarter of write cost) at the price of `search` filters matching
|
||||
* nothing — pair it with a NIP-11 doc that doesn't advertise 50.
|
||||
* @param liveNegentropyIndex keep the always-current `(created_at, id)`
|
||||
* set that serves full-corpus NIP-77 NEG-OPENs without a scan + seal
|
||||
* (strfry answers those off its live tree; the scan+seal path measured
|
||||
* ~340 ms per cold open at 50k events). Costs ~40 B/event of heap and
|
||||
* one indexed pre-SELECT per replaceable insert.
|
||||
* `[negentropy].live_index = false` turns it off.
|
||||
*/
|
||||
fun relayIndexingStrategy(fullTextSearch: Boolean = true) =
|
||||
DefaultIndexingStrategy(
|
||||
indexEventsByCreatedAtAlone = true,
|
||||
// Authors-only filters (no kinds) are relay-common — archives,
|
||||
// migration tools. strfry maintains the same (pubkey, created_at)
|
||||
// index unconditionally; without it the filter walks the whole
|
||||
// time index.
|
||||
indexEventsByPubkeyAlone = true,
|
||||
indexFullTextSearch = fullTextSearch,
|
||||
// Tokenize off the commit path; NostrServer drives the catch-up
|
||||
// worker and search queries drain it first, so NIP-50 stays
|
||||
// exactly as fresh while publishes stop paying for it.
|
||||
deferFullTextSearchIndexing = fullTextSearch,
|
||||
)
|
||||
fun relayIndexingStrategy(
|
||||
fullTextSearch: Boolean = true,
|
||||
liveNegentropyIndex: Boolean = true,
|
||||
) = DefaultIndexingStrategy(
|
||||
indexEventsByCreatedAtAlone = true,
|
||||
// Authors-only filters (no kinds) are relay-common — archives,
|
||||
// migration tools. strfry maintains the same (pubkey, created_at)
|
||||
// index unconditionally; without it the filter walks the whole
|
||||
// time index.
|
||||
indexEventsByPubkeyAlone = true,
|
||||
indexFullTextSearch = fullTextSearch,
|
||||
// Tokenize off the commit path; NostrServer drives the catch-up
|
||||
// worker and search queries drain it first, so NIP-50 stays
|
||||
// exactly as fresh while publishes stop paying for it.
|
||||
deferFullTextSearchIndexing = fullTextSearch,
|
||||
maintainLiveNegentropyIndex = liveNegentropyIndex,
|
||||
)
|
||||
|
||||
/** Stock relay strategy — everything on, matching geode's defaults. */
|
||||
val RelayIndexingStrategy = relayIndexingStrategy()
|
||||
|
||||
@@ -153,6 +153,12 @@ data class StaticConfig(
|
||||
val frame_size_limit: Long = 500_000L,
|
||||
val max_sync_events: Int = 1_000_000,
|
||||
val max_sessions_per_connection: Int = 200,
|
||||
/**
|
||||
* Keep an always-current in-memory `(created_at, id)` set so
|
||||
* full-corpus NEG-OPENs skip the table scan + seal (strfry
|
||||
* parity). ~40 B per stored event of heap; on by default.
|
||||
*/
|
||||
val live_index: Boolean = true,
|
||||
)
|
||||
|
||||
data class AuthorizationSection(
|
||||
|
||||
+9
@@ -381,6 +381,15 @@ class LiveEventStore(
|
||||
filters: List<Filter>,
|
||||
maxEntries: Int,
|
||||
): IStorage? {
|
||||
// Full-set NEG-OPENs — a single unconstrained filter, the shape
|
||||
// relay-relay sync sends by default — are served from the store's
|
||||
// always-current index when it maintains one: no scan, no
|
||||
// O(n log n) seal, cold or not. `null` falls through to the scan
|
||||
// path, which also owns the over-cap NEG-ERR detection.
|
||||
if (filters.size == 1 && filters[0].isEmpty()) {
|
||||
store.liveNegentropySnapshot(maxEntries)?.let { return it }
|
||||
}
|
||||
|
||||
val generation = writeGeneration.load()
|
||||
val key = filters.joinToString(" | ||||