Skip to content

feat(perf): BlockHound, LRU cache, query-plan CI gate, pg_stat_statements analogue, CPU flamegraphs - #38

Merged
TylerStaplerAtFanatics merged 21 commits into
mainfrom
stelekit-blockhound
Apr 27, 2026
Merged

TylerStaplerAtFanatics merged 21 commits into
mainfrom
stelekit-blockhound

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

  • LRU cache rewrite: Replaced CachedBlockRepository/CachedPageRepository with inline LRU caches inside SqlDelightBlockRepository. Cache invalidation is now wired through DatabaseWriteActor.onWriteSuccess — eliminates the stale-cache-after-actor-write bug where writes went through the actor but bypassed cache eviction
  • BlockHound integration: BlockHoundTestBase detects blocking calls on coroutine dispatchers across all JVM tests
  • CacheInvalidationTest: Covers targeted page-hierarchy eviction, block-level eviction, clearAllCaches, and the full actor→eviction path end-to-end
  • QueryPlanAuditTest (CI gate): Runs EXPLAIN QUERY PLAN over every SELECT in the schema; fails on heap scans without an allowlist entry — catches missing indexes at PR time instead of in production
  • QueryStatsCollector + QueryStatsRepository: SQLite analogue of pg_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) with drainNow() for benchmark flush
  • Benchmark enhancements: CPU flamegraph alongside alloc flamegraph in every PR; top-SQL query stats table in PR comment showing which queries dominate graph load time

Test plan

  • ./gradlew :kmp:jvmTest — all tests pass including CacheInvalidationTest, QueryPlanAuditTest, CacheHitBenchmarkTest, BlockHoundTestBase
  • ./gradlew :kmp:jvmTestProfile — benchmark produces graph-load-alloc.collapsed + graph-load-cpu.collapsed + benchmark-query-stats.json
  • CI benchmark job posts PR comment with both flamegraphs and query stats table
  • No SCAN <table> regressions in QueryPlanAuditTest — any new full-scan query requires an explicit allowlist entry with justification

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings April 26, 2026 20:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LruCache usage 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_statements analogue) 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.

Comment on lines +122 to +126
val pageUuid = resultList.firstOrNull()?.block?.pageUuid
if (pageUuid != null) {
hierarchyIndexMutex.withLock {
hierarchyPageIndex.getOrPut(pageUuid) { mutableSetOf() }.add(rootUuid)
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
onWriteSuccess?.invoke(batch.first())
batch.forEach { req ->
onWriteSuccess?.invoke(req)
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: changed onWriteSuccess?.invoke(batch.first()) to batch.forEach { onWriteSuccess?.invoke(it) } so cache eviction fires for every request in a coalesced batch.

Comment on lines 378 to +385
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) }
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
remember(graphManager) { onGraphManagerReady?.invoke(graphManager) }
LaunchedEffect(graphManager) {
onGraphManagerReady?.invoke(graphManager)
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: replaced remember(graphManager) { ... } with LaunchedEffect(graphManager) { ... } — semantically correct for a Compose side-effect, fires only when graphManager changes.

tstapler and others added 3 commits April 26, 2026 13:30
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>
@github-actions

github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing da5ccd5 (this PR) vs 37cb771 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 10ms 13ms -3ms (-23%) ✅
Phase 2 background ↓ 4ms 3ms +1ms (+33%) ⚠️
Phase 3 index ↓ 12ms 10ms +2ms (+20%) ⚠️
Total ↓ 25ms 25ms 0 (0%)
Write p95 (baseline) ↓ 28ms 35ms -7ms (-20%) ✅
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
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

@github-actions

github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator measuring load performance for the Android app.

Comparing da5ccd5 (this PR) vs 37cb771 (baseline)
Device: API 30 x86_64 emulator — 25 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 68ms 53ms +15ms (+28%) ⚠️
Phase 3 index ↓ 41ms 31ms +10ms (+32%) ⚠️
Write p95 (baseline) ↓ 3ms 3ms 0 (0%)
Write p95 (during phase 3) ↓ 2ms 2ms 0 (0%)
Jank factor ↓ 0.67x 0.67x 0 (0%)
Concurrent writes ↑ 1 1 0 (0%)
↓ lower is better · ↑ higher is better

tstapler and others added 17 commits April 26, 2026 14:01
…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>
@TylerStaplerAtFanatics
TylerStaplerAtFanatics merged commit d782dfb into main Apr 27, 2026
9 checks passed
tstapler added a commit that referenced this pull request Apr 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants