From da7fe61b77525cb42391145358cb8f840898c0bb Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 12 Jul 2026 13:12:35 +0400 Subject: [PATCH] perf(talkie): fold idea board once, keep live via subscribeAll (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdeaReader folded eventStore.allEvents() on every activeIdeas()/capturedOf() call — a full deserializing log scan per CHAT context build. Now folds once at construction and stays current incrementally via subscribeAll(); the fold is idempotent so seed/live overlap needs no dedupe. --- .../com/correx/core/talkie/IdeaReader.kt | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/core/talkie/src/main/kotlin/com/correx/core/talkie/IdeaReader.kt b/core/talkie/src/main/kotlin/com/correx/core/talkie/IdeaReader.kt index d24755c2..5ab0411a 100644 --- a/core/talkie/src/main/kotlin/com/correx/core/talkie/IdeaReader.kt +++ b/core/talkie/src/main/kotlin/com/correx/core/talkie/IdeaReader.kt @@ -1,46 +1,61 @@ package com.correx.core.talkie +import com.correx.core.events.events.EventPayload import com.correx.core.events.events.IdeaCapturedEvent import com.correx.core.events.events.IdeaDiscardedEvent import com.correx.core.events.events.IdeaPromotedEvent import com.correx.core.events.stores.EventStore import com.correx.core.events.types.SessionId import com.correx.core.talkie.model.Idea +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** - * Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over - * [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session - * shows on the board (and feeds the router) in every session. A captured idea is dropped once a - * matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture - * stays in the log). + * Holds the operator's idea board in memory. Cross-session by design: it folds over the whole event + * log (not a single session's replay), so an idea captured in one session shows on the board (and + * feeds the router) in every session. A captured idea is dropped once a matching [IdeaDiscardedEvent] + * (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture stays in the log). + * + * The board is folded ONCE at construction from [EventStore.allEvents] (a full, eager table load) and + * then kept current incrementally via [EventStore.subscribeAll]. This replaces the previous + * fold-the-entire-log-per-call design, which ran a full deserializing scan on every CHAT context + * build. The fold is idempotent (map put / set add), so the seed⇄live overlap needs no dedupe. */ -class IdeaReader(private val eventStore: EventStore) { +class IdeaReader( + eventStore: EventStore, + scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), +) { + // id -> its capture event (source for text/sessionId); tombstoned = discarded/promoted ids. + private val captures = ConcurrentHashMap() + private val tombstoned: MutableSet = ConcurrentHashMap.newKeySet() - /** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */ - fun activeIdeas(): List { - val tombstoned = mutableSetOf() - val captured = mutableListOf() - eventStore.allEvents().forEach { stored -> - when (val payload = stored.payload) { - is IdeaDiscardedEvent -> tombstoned += payload.ideaId - is IdeaPromotedEvent -> tombstoned += payload.ideaId - is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs) - else -> Unit - } - } - return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs } + init { + eventStore.allEvents().forEach { apply(it.payload) } + scope.launch { eventStore.subscribeAll().collect { apply(it.payload) } } } + private fun apply(payload: EventPayload) { + when (payload) { + is IdeaDiscardedEvent -> tombstoned += payload.ideaId + is IdeaPromotedEvent -> tombstoned += payload.ideaId + is IdeaCapturedEvent -> captures[payload.ideaId] = payload + else -> Unit + } + } + + /** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */ + fun activeIdeas(): List = + captures.values + .filterNot { it.ideaId in tombstoned } + .map { Idea(it.ideaId, it.text, it.timestampMs) } + .sortedByDescending { it.capturedAtMs } + /** The session that captured [ideaId], so a tombstone lands alongside its capture. */ - fun sessionOf(ideaId: String): SessionId? = - capturedOf(ideaId)?.sessionId + fun sessionOf(ideaId: String): SessionId? = captures[ideaId]?.sessionId /** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */ - fun textOf(ideaId: String): String? = - capturedOf(ideaId)?.text - - private fun capturedOf(ideaId: String): IdeaCapturedEvent? = - eventStore.allEvents() - .mapNotNull { it.payload as? IdeaCapturedEvent } - .firstOrNull { it.ideaId == ideaId } + fun textOf(ideaId: String): String? = captures[ideaId]?.text }