From 4cfd56da3681a33a44bb48e82d49a38972e9f900 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 16:24:15 +0000 Subject: [PATCH] perf(quartz): cheaper hot paths in EventStoreProjection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small wins on paths the projection runs every event arrival or every snapshot publish: 1. Cache `address` on Slot. Previously `removeIndexes(slot)` called `addressOf(slot.flow.value)` on every drop — for replaceables that's a fresh Address allocation every time. Now the address is computed once at slot construction and reused on removal. 2. Single-filter snapshot fast path. The deduped sorted union via TreeSet was unnecessary when there's only one per-filter set — that set is already sorted in the slot comparator's order. The common UI case (one filter per projection) now skips the TreeSet allocation + log-n inserts entirely. 3. Comparator singleton. `slotComparator()` was allocating a fresh Comparator lambda on every call (ctor + every snapshot). Replaced with a single `Comparator>` cast at the use site — the comparator only reads sort keys that don't depend on the type parameter. Also trimmed a verbose comment block in `project()` (3 lines → 1). All 18 projection tests + 240 store + interner tests still pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5 --- .../cache/projection/EventStoreProjection.kt | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt index d734e7e95..ba8fd803b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt @@ -148,13 +148,16 @@ class EventStoreProjection( } /** - * Current snapshot as a [ProjectionState.Loaded]. Cheap — the - * deduped sorted union over [perFilter] sets is computed on - * demand. Returns an empty [ProjectionState.Loaded] when no slots - * are live. + * Current snapshot as a [ProjectionState.Loaded]. The + * single-filter case skips the union pass — that filter's set + * is already sorted. Multi-filter projections dedup + merge + * via a transient TreeSet. */ fun snapshot(): ProjectionState.Loaded { if (byId.isEmpty()) return ProjectionState.Loaded(emptyList()) + if (perFilter.size == 1) { + return ProjectionState.Loaded(perFilter.values.first().map { it.flow }) + } val union = sortedSetOf(slotComparator()) for (set in perFilter.values) union.addAll(set) return ProjectionState.Loaded(union.map { it.flow }) @@ -314,20 +317,19 @@ class EventStoreProjection( /** * Remove a slot from [byId] / [byAddress] without touching the * per-filter sets. Used by the per-filter eviction loop, which - * already owns the bookkeeping for those. + * already owns that bookkeeping. */ private fun removeIndexes(slot: Slot): Boolean { val removed = byId.remove(slot.flow.value.id) != null if (!removed) return false - addressOf(slot.flow.value)?.let(byAddress::remove) + slot.address?.let(byAddress::remove) return true } /** - * Internal slot. Each event added to the projection lives inside - * one of these for as long as it survives. The sort key is frozen - * at construction time — supersession in-place updates rewrite - * `flow.value` but never the sort key, so the position inside + * Wraps an event with the indexes the projection needs. Sort key + * and address are frozen at construction — in-place supersession + * updates rewrite `flow.value` but the slot's position inside * each [perFilter] set is stable across updates. */ private class Slot( @@ -335,6 +337,7 @@ class EventStoreProjection( ) { val sortCreatedAt: Long = initial.createdAt val sortId: HexKey = initial.id + val address: Address? = addressOf(initial) val flow: MutableStateFlow = MutableStateFlow(initial) } @@ -370,18 +373,25 @@ class EventStoreProjection( private fun ownerOf(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey /** - * created_at DESC, id ASC. The keys are snapshots taken at - * insertion time, so a slot's position never changes after it + * created_at DESC, id ASC. Sort keys are frozen at slot + * construction, so a slot's position never changes after it * joins a set. Distinct events have distinct ids, so no third * tiebreak is needed. + * + * Singleton — stored as `Comparator>` and cast at the + * use site, since the comparator only reads `sortCreatedAt` + * and `sortId` which don't depend on `T`. */ - private fun slotComparator(): Comparator> = + private val SLOT_COMPARATOR: Comparator> = Comparator { a, b -> if (a === b) return@Comparator 0 val byTime = b.sortCreatedAt.compareTo(a.sortCreatedAt) if (byTime != 0) return@Comparator byTime a.sortId.compareTo(b.sortId) } + + @Suppress("UNCHECKED_CAST") + private fun slotComparator(): Comparator> = SLOT_COMPARATOR as Comparator> } } @@ -407,17 +417,13 @@ class EventStoreProjection( fun ObservableEventStore.project(filters: List): Flow> = flow { val projection = EventStoreProjection(this@project, filters) - // Capture the outer collector so we can emit ProjectionState - // from inside `changes.onSubscription { }` and `collect { }`, - // where the implicit `this` is FlowCollector. + // `onSubscription` runs after the SharedFlow subscription is + // active but before events are pulled, so emissions during + // seed land in the buffer and we drain them via `apply` when + // collect proceeds. `outer` bridges the StoreChange-typed + // collectors back to our ProjectionState output. val outer = this emit(ProjectionState.Loading) - // `onSubscription` runs after the SharedFlow subscription is - // active but before we pull events — the buffer absorbs - // emissions arriving during the seed query and we drain them - // through `apply` once collect proceeds. Doing the seed - // inside `collect { }` instead would race with concurrent - // inserts. changes .onSubscription { projection.seed()