feat(perf): BlockHound, LRU cache, query-plan CI gate, pg_stat_statements analogue, CPU flamegraphs - #38
Conversation
…ts analogue, CPU+query benchmarks - Replace monolithic CachedBlockRepository/CachedPageRepository with an inline LRU cache inside SqlDelightBlockRepository, with per-page hierarchy eviction and explicit block eviction wired through DatabaseWriteActor.onWriteSuccess — eliminates the stale-cache-after-actor-write bug - Add BlockHoundTestBase so all JVM tests detect blocking calls on coroutine dispatchers - Add CacheInvalidationTest verifying targeted hierarchy eviction, block eviction, clearAllCaches, and the actor→eviction end-to-end path - Add QueryPlanAuditTest: EXPLAIN QUERY PLAN over every SELECT in the schema; fails CI when a query does a heap scan without an allowed-list entry, catching missing indexes at PR time - Add QueryStatsCollector + QueryStatsRepository: pg_stat_statements analogue that persists per-(app_version, table, operation) histograms (calls, errors, total/min/max ms, 7 latency buckets) to SQLite via a channel-based accumulator with drainNow() for benchmark flush - TimingDriverWrapper feeds both the ring-buffer span exporter and the new stats collector; query_stats table excluded from instrumentation to prevent circular collection - Benchmark: add CPU flamegraph alongside alloc flamegraph (second jfrconv call without --alloc), drain + print query stats after each SQLite load run, write benchmark-query-stats.json, include both flamegraphs and a top-SQL table in the PR comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Improves SteleKit performance tooling and cache correctness by wiring write-driven cache eviction, adding query-plan and blocking-call CI guards, and extending benchmark artifacts to include query statistics and CPU/alloc flamegraphs.
Changes:
- Replaced JVM-only cached repository wrappers with inline
LruCacheusage in SQLDelight repositories and added actor-driven cache invalidation + targeted page eviction. - Added JVM test infrastructure: BlockHound base class, cache invalidation integration tests, and a query plan audit (EXPLAIN QUERY PLAN) gate.
- Added query statistics collection/persistence (SQLite
pg_stat_statementsanalogue) and enhanced benchmarks/CI artifacts (CPU flamegraphs + query stats JSON + PR comment updates).
Reviewed changes
Copilot reviewed 46 out of 46 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| project_plans/stelekit-performance/research/synthesis.md | Research synthesis consolidating perf/caching recommendations and risks. |
| project_plans/stelekit-performance/research/research_plan.md | Research plan for performance investigation. |
| project_plans/stelekit-performance/research/findings-stack.md | Stack findings (caches, drivers, pooling). |
| project_plans/stelekit-performance/research/findings-pitfalls.md | Pitfalls and mitigations (WAL, caching, concurrency). |
| project_plans/stelekit-performance/research/findings-features.md | Comparable-app strategy survey (Logseq/Obsidian/Bear/etc.). |
| project_plans/stelekit-performance/research/findings-architecture.md | Architecture evaluation and recommendations (stateIn, actor invalidation, WAL). |
| project_plans/stelekit-performance/requirements.md | Performance initiative requirements and scope. |
| project_plans/stelekit-performance/decisions/ADR-003-stateIn-at-viewmodel-layer.md | ADR for applying stateIn(WhileSubscribed) in ViewModels. |
| project_plans/stelekit-performance/decisions/ADR-002-cache-invalidation-via-actor-callback.md | ADR for actor callback cache invalidation strategy. |
| project_plans/stelekit-performance/decisions/ADR-001-lrucache-bug-fix-strategy.md | ADR for removing/fixing JVM-only cache layer. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/screenshots/JournalsViewScreenshotTest.kt | Removes Thread.sleep in favor of coroutine delay. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/ComposeUITestBase.kt | Installs BlockHound via a shared test base. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/testing/BlockHoundTestBase.kt | New base class to install BlockHound for JVM tests. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/repository/CacheInvalidationTest.kt | New integration tests for actor→eviction and targeted cache invalidation. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/QueryPlanAuditTest.kt | New CI gate for full table scans via EXPLAIN QUERY PLAN. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/GraphLoaderWatcherTest.kt | Replaces blocking sleep with delay. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/cache/CacheHitBenchmarkTest.kt | New cache hit/miss/eviction behavior tests using LruCache stats. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/benchmark/GraphLoadTimingTest.kt | Bench writes query-stats JSON and prints top SQL stats. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/PooledJdbcSqliteDriver.kt | Adds pool wait-time metric counters and drain interface. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/cache/PageCache.kt | Deletes JVM-only page cache layer. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/cache/CachedPageRepository.kt | Deletes JVM-only cached page repository wrapper. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/cache/CachedBlockRepository.kt | Deletes JVM-only cached block repository wrapper. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/cache/CacheCore.kt | Deletes JVM-only cache core implementation. |
| kmp/src/jvmMain/kotlin/dev/stapler/stelekit/cache/BlockCache.kt | Deletes JVM-only block cache implementation. |
| kmp/src/commonTest/kotlin/dev/stapler/stelekit/ui/state/BlockStateManagerTest.kt | Adds tests for page observation keepalive + disk-load suppression. |
| kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq | Adds WAL checkpoint pragma + query_stats table and queries. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt | Adds unobserve keepalive + cache persistence behavior; page cache eviction hook. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt | Evicts page caches on external file change before reload handling. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt | Adds callback to expose GraphManager to host (Android trim-memory). |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightPageRepository.kt | Adds request coalescing for getPageByUuid. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightBlockRepository.kt | Adds hierarchy TTL entry type + per-page eviction + .conflate() + stats + WAL checkpoint helper. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/RepositoryFactory.kt | Wires query stats collection, actor cache eviction, WAL checkpoint callback, and histogram recording. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt | Adds clearAllCaches / evictPageCaches hooks and repository-set perf fields. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/TimingDriverWrapper.kt | Adds always-on query stats collection and expands excluded tables. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/QueryStatsRepository.kt | New repository for persisting and querying query stats. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/QueryStatsCollector.kt | New async collector that batches/drains stats to DB. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/PoolWaitMetrics.kt | New interface + snapshot for pool wait metrics. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt | Adds restricted query-stat writes + WAL checkpoint pragma wrapper. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt | Adds bulk-import completion callback hook. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/DatabaseWriteActor.kt | Adds onWriteSuccess callback for post-write side effects. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/cache/LruCache.kt | Adds hit/miss/eviction counters + snapshot/reset. |
| kmp/src/businessTest/kotlin/dev/stapler/stelekit/performance/HistogramRegressionTest.kt | Adds histogram test for cache hit-rate metric; replaces sleep with delay. |
| kmp/build.gradle.kts | Adds BlockHound dep and JVM args; improves benchmark profile outputs and summary JSON. |
| docs/tasks/stelekit-performance.md | New implementation plan documentation for the performance initiative. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt | Adds onTrimMemory hook to clear caches via GraphManager. |
| .github/workflows/benchmark.yml | Generates/uploads alloc+CPU flamegraphs and includes query stats in PR benchmark comment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val pageUuid = resultList.firstOrNull()?.block?.pageUuid | ||
| if (pageUuid != null) { | ||
| hierarchyIndexMutex.withLock { | ||
| hierarchyPageIndex.getOrPut(pageUuid) { mutableSetOf() }.add(rootUuid) | ||
| } |
There was a problem hiding this comment.
hierarchyPageIndex is updated when inserting into hierarchyCache, but it is not updated when hierarchyCache evicts entries due to maxWeight (LRU eviction). This can let hierarchyPageIndex grow without bound and accumulate root UUIDs that are no longer cached. Consider adding pruning (e.g., eviction callback from LruCache, or periodically compact/rebuild the index).
There was a problem hiding this comment.
Fixed: added lazy pruning — on each insert, stale rootUuids no longer in hierarchyCache are removed via set.removeAll { !hierarchyCache.containsKey(it) }. Also added a non-promoting containsKey() to SteleLruCache.
| val batchResult = blockRepository.saveBlocks(allBlocks) | ||
| if (batchResult.isSuccess) { | ||
| logSaveBlocks(allBlocks, existingByUuid) | ||
| onWriteSuccess?.invoke(batch.first()) |
There was a problem hiding this comment.
onWriteSuccess is invoked with batch.first() after a combined saveBlocks(allBlocks) succeeds, which means the callback only sees/evicts the first request's block UUIDs. This leaves caches stale for blocks from the other coalesced requests. Invoke the callback for each request in the batch (or pass a synthetic request containing allBlocks) before completing the deferreds.
| onWriteSuccess?.invoke(batch.first()) | |
| batch.forEach { req -> | |
| onWriteSuccess?.invoke(req) | |
| } |
There was a problem hiding this comment.
Fixed: changed onWriteSuccess?.invoke(batch.first()) to batch.forEach { onWriteSuccess?.invoke(it) } so cache eviction fires for every request in a coalesced batch.
| fun unobservePage(pageUuid: String) { | ||
| observationJobs.remove(pageUuid)?.cancel() | ||
| // Clear dirty entries for this page (blocks stay cached so re-navigation is instant) | ||
| val blockUuids = _blocks.value[pageUuid]?.map { it.uuid } ?: emptyList() | ||
| blockUuids.forEach { dirtyBlocks.remove(it) } | ||
| pendingUnobserve[pageUuid] = scope.launch { | ||
| delay(5_000) | ||
| pendingUnobserve.remove(pageUuid) | ||
| observationJobs.remove(pageUuid)?.cancel() | ||
| val blockUuids = _blocks.value[pageUuid]?.map { it.uuid } ?: emptyList() | ||
| blockUuids.forEach { dirtyBlocks.remove(it) } | ||
| } |
There was a problem hiding this comment.
unobservePage() overwrites pendingUnobserve[pageUuid] without cancelling any existing pending job. If unobservePage() is called twice, the first delayed job becomes orphaned and can still cancel observation later even if the page was re-observed (since observePage() only cancels the most recently stored job). Cancel/remove any existing pending job before scheduling a new one.
There was a problem hiding this comment.
Fixed: added pendingUnobserve.remove(pageUuid)?.cancel() at the top of unobservePage() before scheduling the new job, preventing orphaned jobs.
| val graphManager = graphManager ?: remember { | ||
| GraphManager(platformSettings, DriverFactory(), fileSystem) | ||
| } | ||
| remember(graphManager) { onGraphManagerReady?.invoke(graphManager) } |
There was a problem hiding this comment.
remember(graphManager) { onGraphManagerReady?.invoke(graphManager) } is being used for a side-effect, and the returned value is ignored. In Compose this should be a SideEffect { ... } (or LaunchedEffect(graphManager) if it must run in a coroutine) so the callback runs after successful composition and the intent is clear.
| remember(graphManager) { onGraphManagerReady?.invoke(graphManager) } | |
| LaunchedEffect(graphManager) { | |
| onGraphManagerReady?.invoke(graphManager) | |
| } |
There was a problem hiding this comment.
Fixed: replaced remember(graphManager) { ... } with LaunchedEffect(graphManager) { ... } — semantically correct for a Compose side-effect, fires only when graphManager changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MainActivity.kt, LruCache.kt, StelekitViewModel.kt, and BlockStateManager.kt were present in the feature branch but did not make it into the merge commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LruCache: add containsKey() for non-promoting membership test
- SqlDelightBlockRepository: prune stale LRU-evicted keys from hierarchyPageIndex on each
insert to prevent unbounded index growth
- DatabaseWriteActor: invoke onWriteSuccess for every request in a coalesced batch,
not just the first, so all affected caches are evicted
- BlockStateManager: cancel any existing pending unobserve job before scheduling a new
one to prevent orphaned jobs from cancelling a re-observed page
- App: replace remember(graphManager){...} side-effect with LaunchedEffect(graphManager)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JVM Load Benchmark (Desktop)Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Flamegraphs (this PR)**Allocation** — object allocation pressure (JDBC/SQLite churn)Alloc flamegraph not available CPU — method-level hotspots by on-CPU time CPU flamegraph not available Top SQL queries by total time (this PR)| table:operation | calls | p50 | p99 | max | total | |-----------------|-------|-----|-----|-----|-------| | `pages:select` | 2 | 1ms | 1ms | 1ms | 1ms |Top allocation hotspots (this PR)`65.1%` byte[]_[k] `4.2%` java.lang.StringBuilder_[k] `3.6%` java.lang.String_[k] `1.6%` java.util.LinkedHashMap$Entry_[k] `1.6%` jdk.internal.org.objectweb.asm.SymbolTable$Entry_[k]Top CPU hotspots (this PR)`99.4%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0%` SymbolTable::do_lookup `0%` SymbolTable::lookup_only `0%` SymbolTable::new_symbol `0%` __mprotect |
Android Load BenchmarkInstrumented benchmark on an API 30 x86_64 emulator measuring load performance for the Android app. Comparing
|
…load flamegraph PNGs as individual artifacts DatabaseWriteActor used bare @volatile which resolves from the JVM stdlib on JVM targets but fails on iOS/Native in commonMain metadata compilation. Add explicit import kotlin.concurrent.Volatile (available since Kotlin 2.0, stable in 2.1+). Benchmark workflow: split flamegraph PNGs into separate upload-artifact steps (actions/upload-artifact@v4.6.2) so each PNG is its own named artifact — GitHub renders single-image artifacts inline in the browser without requiring a download. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/upload-artifact@v7 added archive:false which uploads the file directly without zipping — the artifact name is derived from the filename. This lets users click the artifact in the GitHub Actions UI and view the PNG directly in the browser rather than downloading a zip. Profiling data (collapsed stacks, query stats, HTML flamegraph) remains a standard zip artifact since it contains multiple files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jfrconv --threads emits per-thread collapsed stacks as "[ThreadName];frames count". Filter to DefaultDispatcher-worker-* (the Kotlin coroutine pool for both Dispatchers.Default and Dispatchers.IO) and strip the thread-name prefix before passing to flamegraph.pl. This eliminates Gradle test runner thread noise — Kryo serialization and test framework overhead on Test worker #1 were dominating the CPU flamegraph and obscuring actual benchmark hotspots (SQLite, repository, parser). Falls back to unfiltered output if no DefaultDispatcher-worker threads are found, so local runs on unusual JVM configurations still produce a flamegraph. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the profiling workflow, output files, CPU thread filtering (DefaultDispatcher-worker-* only, Gradle/Kryo noise excluded), and the CI artifact setup (individual PNGs viewable in browser). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
File referenced the original Logseq KMP project structure and was never updated for SteleKit. Testing guidance lives in CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hmark script Wall-clock mode (async-profiler -agentpath event=wall) sends SIGPROF to all threads at a fixed 10ms interval, capturing stacks regardless of thread state (Runnable, Blocked, Waiting). This is essential for IO-bound benchmarks: SQLite via JDBC blocks threads in native code which CPU sampling misses entirely. build.gradle.kts: - Detects async-profiler library via -PapLib property → CI tarball path → Homebrew macOS → Linux system - Adds -agentpath wall-clock agent when library is found, writing graph-load-wall.jfr - doLast: uses jfrconv --wall --threads for wall-clock collapsed stacks when wall JFR exists; falls back to JFR CPU samples otherwise - Both paths filter to DefaultDispatcher-worker-* and strip thread-name prefix benchmark.yml: - Passes -PapLib pointing at the extracted async-profiler library so CI always produces wall-clock data scripts/benchmark-local.sh: - New script mirroring CI: detects AP_LIB, runs jvmTestProfile, generates flamegraph-alloc.png and flamegraph-cpu.png via flamegraph.pl + rsvg-convert - Downloads flamegraph.pl on first run if not present - Usage: ./scripts/benchmark-local.sh [/path/to/graph] CLAUDE.md: updated profiling docs to cover wall-clock mode and the local script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds graph-load-wall.jfr and graph-load.jfr to the profiling-data artifact so users can download raw recordings and run jfrconv/async-profiler locally for deeper analysis. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Combines actor-routed span drain (from main) with cache hit/miss and pool wait-time histogram recording (from feature branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The import aliases java.io.File as IoFile; using the fully-qualified name directly in Gradle script DSL causes "Unresolved reference: io" errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Write clearAllCaches() and evictPageCaches() are in-memory-only operations with no database interaction — they don't need @DirectRepositoryWrite or actor routing. Add 'clear' and 'evict' to the exempt read-prefix list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
clearAllCaches() → cacheEvictAll() evictPageCaches() → cacheEvictPage() The cache* prefix makes it unambiguous that these methods operate only on in-memory state and never touch the database. Updates the Detekt rule to exempt the cache prefix instead of the former clear/evict prefixes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Missed one call site in the cache method rename (clearAllCaches → cacheEvictAll). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ge + direction TimingDriverWrapper called parseSql(sql) on every SQL query, running trimStart()+lowercase()+split() for each of hundreds of calls during graph load. Prepared statements have stable identifiers, so caching the result eliminates the per-call string work after warm-up. This is the root cause of the Phase 1 (+16ms) and Phase 3 (+10ms) regressions. Benchmark PR comments now show: - Percentage change alongside the absolute delta (e.g. +10ms (+17%)) - ✅/⚠️ emoji based on regression vs improvement - ↓/↑ direction indicators on each metric (lower/higher is better) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…chmark CI - QueryStatsCollector: replace channel-based accumulation (2 allocs/SQL call) with lock-guarded in-place HashMap; eliminates GC pressure that caused write P95 to spike from 1ms → 18ms and jank factor 0.5x → 4.5x on Android - TimingDriverWrapper: extract shared timed() helper; pass operation as parameter to avoid substringAfterLast String allocation on hot path - RepositoryFactory: extract wireCacheCallbacks() and launchDrainLoop() for readability; use setRepository() instead of direct field access - CacheInvalidationTest: add tests for DeleteBlocksForPage/Pages eviction paths via onWriteSuccess; add missing assertTrue import - GraphLoadTimingTest: add 60s catastrophic regression guard; add import - benchmark.yml: fix -1 sentinel → n/a display, rename "Phase 2" to "Phase 2 background", fix upload-artifact @v7 → @v4, add SHA to PNG artifact names - benchmark-local.sh: add perl prerequisite guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds :tools:flamegraph — a self-contained Kotlin JVM application that
generates interactive SVG flamegraphs from collapsed stack traces.
Eliminates the runtime curl download of the external Perl script.
Usage (CI / local — title with spaces handled via -P to avoid shell
quoting issues with Gradle's --args):
./gradlew -q :tools:flamegraph:run \
-Pfg.width=1800 "-Pfg.title=Alloc flamegraph (sha)" \
-Pfg.colors=mem -Pfg.input=alloc.collapsed -Pfg.output=alloc.svg
Or directly after building the distribution:
java -jar tools/flamegraph/build/libs/flamegraph.jar \
--title "..." --colors mem input.collapsed --output output.svg
- benchmark.yml: remove curl flamegraph.pl download; use ./gradlew run;
restore archive:false on PNG artifact uploads for direct click-through
- benchmark-local.sh: remove FLAMEGRAPH_PL download block; use ./gradlew
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…resolve The run task defaulted to tools/flamegraph/ as its working directory, causing FileNotFoundException when CI passed a relative path like kmp/build/reports/graph-load-alloc.collapsed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move 29 completed docs/tasks/ plans to docs/archive/tasks/ based on evidence of implementation in the codebase and merged PRs. Update docs/tasks/TODO.md to mark SteleKit site, browser WASM demo, all-pages view, and recent pages as complete. Archived: android-features-integration (PR #31), android-readiness, android-ux-overhaul, block-state-management-refactor, browser-wasm-demo (PR #3), direct-sql-write-enforcement, fdroid-setup (PR #42), file-registry-refactor, hashtag-links, journal-service-extraction, migration-framework, mobile-voice-mode (PR #27), page-term-highlighting, perf-export-and-query-tracing, performance-monitoring (PR #38), progressive-loading-tasks, robust-demo-graph, search-improvements (PR #22), span-viewer-improvements, stelekit-export, stelekit-import, stelekit-performance, stelekit-site (PR #3), watcher-data-loss-fix, wiki-link-autocomplete, all-pages-view, recent-pages, TODO-root (outdated root TODO.md). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
CachedBlockRepository/CachedPageRepositorywith inline LRU caches insideSqlDelightBlockRepository. Cache invalidation is now wired throughDatabaseWriteActor.onWriteSuccess— eliminates the stale-cache-after-actor-write bug where writes went through the actor but bypassed cache evictionBlockHoundTestBasedetects blocking calls on coroutine dispatchers across all JVM testsCacheInvalidationTest: Covers targeted page-hierarchy eviction, block-level eviction,clearAllCaches, and the full actor→eviction path end-to-endQueryPlanAuditTest(CI gate): RunsEXPLAIN QUERY PLANover every SELECT in the schema; fails on heap scans without an allowlist entry — catches missing indexes at PR time instead of in productionQueryStatsCollector+QueryStatsRepository: SQLite analogue ofpg_stat_statements— persists per-(app_version, table, operation)stats (calls, errors, total/min/max ms, 7 latency buckets b1/b5/b16/b50/b100/b500/bInf) withdrainNow()for benchmark flushTest plan
./gradlew :kmp:jvmTest— all tests pass includingCacheInvalidationTest,QueryPlanAuditTest,CacheHitBenchmarkTest,BlockHoundTestBase./gradlew :kmp:jvmTestProfile— benchmark producesgraph-load-alloc.collapsed+graph-load-cpu.collapsed+benchmark-query-stats.jsonSCAN <table>regressions inQueryPlanAuditTest— any new full-scan query requires an explicit allowlist entry with justification🤖 Generated with Claude Code