Add durable_sessions(last_seen_at_ms DESC, session_key) index to fix hydration temp B-tree spill (#168) - #179
Merged
Conversation
stream_sessions orders the snapshot walk by d.last_seen_at_ms DESC, d.session_key with no supporting index, so SQLite builds a temp B-tree to satisfy the ORDER BY. This connection never sets PRAGMA temp_store (scan_cache.rs's connection does), so the sorter spills to disk-backed temp pages, and peak private memory scales with spilled PMA count rather than session_json payload -- measured at 2,952 MB against a real 3.5 GB / 4,826-session corpus, versus 36 MB with the ORDER BY removed. Adds a v8->v9 ledger schema migration (SQL-only, per AGENTS.md) that creates durable_sessions_last_seen_idx so the query planner can serve the ORDER BY directly from the index instead of a temp B-tree. Verification: - stream_sessions_query_plan_has_no_temp_btree_for_order_by asserts on EXPLAIN QUERY PLAN's actual output (not just the index's existence) and includes a self-check that the same query DOES regress to a temp B-tree with the index removed, proving the assertion isn't vacuous. - stream_sessions_row_order_is_unchanged_by_the_last_seen_index proves the real StoredSession key sequence is identical with and without the index, including under last_seen_at_ms ties broken by session_key. - probe_index_migration_one_time_cost_on_realistic_corpus (ignored perf probe) times CREATE INDEX IF NOT EXISTS against a 4,826-row / ~3 GB corpus: 3.74 ms. The index only touches the narrow durable_sessions table, never the multi-GB session_snapshots blob table, so the one-time migration cost is negligible. Measured before/after with src-tauri/examples/hydration_memory.rs at a 2,500-session / 1.13 GB synthetic corpus (same PRNG seed both runs): peak RSS during the affected stream_sessions pass drops from 403.3 MB (+286.7 MB over baseline) to 117.1 MB (no measurable increase), and that pass's wall time drops from 8.568s to 3.792s. Scope check: grepped the whole crate for the same last_seen_at_ms ORDER BY -- stream_sessions (history_store.rs:1459-ish) is the only production usage; the only other occurrence is an in-file "old_style_load_sessions" test helper used for an unrelated before/after allocation comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the mechanism described in #168:
stream_sessions's snapshot walk (ORDER BY d.last_seen_at_ms DESC, d.session_key) had no supporting index, so SQLite built a temp B-tree to satisfy the sort. This connection never setsPRAGMA temp_store(unlikescan_cache.rs's connection), so the sorter spilled to disk-backed temp pages and peak private memory scaled with spilled PMA count rather thansession_jsonpayload — the issue measured 2,952 MB against the real 3.5 GB / 4,826-session corpus, versus 36 MB with theORDER BYremoved.The change
Ledger schema migration v8 → v9 (SQL-only, per AGENTS.md):
CREATE INDEX IF NOT EXISTS durable_sessions_last_seen_idx ON durable_sessions(last_seen_at_ms DESC, session_key). No backfill needed — an index has no historical data to reconcile, unlike every prior migration step in this file.Verification
stream_sessions_query_plan_has_no_temp_btree_for_order_byasserts onEXPLAIN QUERY PLAN's actual output (not just the index's existence — an index that exists but isn't chosen is the real failure mode and looks identical to success from the outside). It also includes a self-check: with the index dropped, the identical query DOES regress to a temp B-tree, proving the assertion isn't vacuous.stream_sessions_row_order_is_unchanged_by_the_last_seen_indexproves the realStoredSessionkey sequence fromstream_sessionsis identical with and without the index, including underlast_seen_at_msties broken bysession_key— the ordering the doc comment calls load-bearing for surfacing a missing-snapshot invariant violation per-key.probe_index_migration_one_time_cost_on_realistic_corpus(ignored perf probe) times the migration'sCREATE INDEX IF NOT EXISTSagainst a corpus shaped like the real one (4,826 sessions, ~3 GB db file): 3.74 ms. The index only touches the narrowdurable_sessionstable, never the multi-GBsession_snapshotsblob table, so the one-time cost on existing installs is negligible — not the "real time" concern the task raised.schema_fingerprint_matches_committed_expected_value,fresh_and_fully_migrated_databases_produce_identical_schema_shapes,migration_from_every_prior_version_reaches_schema_version_with_no_gap_or_duplicate,migration_step_count_matches_steps_migrate_actually_runs,interrupting_migration_between_steps_resumes_cleanly_at_the_right_version) all updated and passing.EXPLAIN QUERY PLAN, before and after (literal SQLite output against the real schema, empty tables so the planner's choice is schema-driven, not row-count-driven)Before:
After:
The temp B-tree step disappears entirely;
dis scanned directly in final order via the new index.The win, measured
src-tauri/examples/hydration_memory.rs, 2,500-session / 1.13 GB synthetic corpus (same PRNG seed both runs, so byte-identical corpora), release build,resident_onlypass (the first pass, so its peak is attributable tostream_sessionsalone with no carryover from a later pass):stream_sessionswall time (this pass)Rust heap deltas (
heap_bytes) are byte-identical between the two runs (e.g. +12,395,531 / +12,394,475 forresident_only), confirming the corpora and workload are identical and only the SQLite-side memory changed.private_bytes, sampled only immediately before and after each pass, doesn't show this delta — SQLite releases the temp B-tree's pages once the statement is fully drained, before the "after" sample is taken, matching the issue's own "released incrementally" observation.peak_rss(Windows' tracked high-water mark, monotonic since process start) is what actually captures the transient spike, and it does.At the default 1,200-session / 603 MB corpus the same pattern holds at smaller scale: peak RSS 111.0 → 267.4 MB before, essentially flat after.
The mechanism the issue described reproduces cleanly in this harness and the fix eliminates it — this is not a stretched result.
Scope check
Grepped the whole crate for the same
last_seen_at_msordering.stream_sessionsis the only production usage ofORDER BY d.last_seen_at_ms DESC, d.session_key(or anylast_seen_at_msordering at all — the column touches no other query). The only other occurrence in the file is anold_style_load_sessionstest helper (a pre-existing before/after allocation-shape comparison, unrelated to this fix) that queries the same order for oracle purposes. No other query benefits from or regresses against the new index; ordinary session writes now maintain one additional narrow index alongside the existing two (durable_sessions_identity_idx,durable_sessions_project_idx), which is expected, unavoidable overhead for this fix and not something this PR tries to avoid.Anything the spec didn't anticipate
CREATE INDEX IF NOT EXISTSon an existing multi-GB table "takes real time") turned out to be a non-issue:durable_sessionsitself is a narrow table (no blob columns) even when the database file is multiple GB, because all the size lives in the separatesession_snapshots.session_jsonblob column the index never touches. 3.74 ms, not something worth budgeting for.private_bytes's instantaneous before/after sampling in the harness doesn't show the SQLite-side spike at all (see above) — onlypeak_rssdoes. Worth knowing if this harness is reused for a future SQLite-memory investigation.Validation
All six AGENTS.md commands pass:
npm run check,npm test(174 passed),npm run build,cargo fmt --check,cargo clippy -D warnings,cargo test --locked(372 passed, all crates).Not merging — opening for review per the task.
🤖 Generated with Claude Code