perf(datastore): replace single-column event indexes with composite (bucket_id, timestamp) index - #147
Merged
Merged
Conversation
…bucket_id, timestamp) index Every event query in the peewee storage filters on bucket_id and orders by timestamp (or narrows to a timestamp range). With only single-column indexes, SQLite reads all of a bucket's rows via the bucket_id index and sorts them in a temp B-tree - even for the LIMIT 1 last-event query that replace_last() issues on every merged heartbeat. On multi-million-event buckets this meant seconds of CPU and hundreds of MB read per heartbeat, sustained ~60% CPU on aw-server, terabytes read per day, and heartbeat-lock timeouts (503s) fragmenting or dropping events. The composite index serves the seek, the range and the ORDER BY in one pass. Measured on a real 1.45 GB database (6.7M events, largest bucket 1.65M rows): - last-event query: 5.06 s -> 0.028 s - 1-day range query: 4.16 s -> 0.037 s - one-time migration: ~54 s, no file growth (drops run before create in the same transaction so freed pages are reused) Mirrors aw-server-rust's v5 migration (events_bucketrow_starttime_endtime_index). Adds a query-plan regression test asserting the hot queries use the composite index and never fall back to a temp B-tree sort.
Greptile SummaryThis PR replaces Peewee’s single-column event indexes with a composite bucket-and-timestamp index.
Confidence Score: 5/5The PR appears safe to merge, with the migration matching the repository’s bucket-scoped event-query patterns. The initialization sequence creates the event table before migration, the index replacement is atomic and idempotent for supported schema states, and all repository event queries retain bucket_id as the composite index’s leading predicate. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[PeeweeStorage initialization] --> B[Create tables if absent]
B --> C[Inspect eventmodel indexes]
C --> D{Composite index exists?}
D -->|Yes| E[Continue startup]
D -->|No| F[Drop legacy bucket and timestamp indexes]
F --> G[Create bucket_id, timestamp composite index]
G --> E
Reviews (1): Last reviewed commit: "perf(datastore): replace single-column e..." | Re-trigger Greptile |
yxzpqt
approved these changes
Jul 31, 2026
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.
Problem
aw-server(python) was found pinned at ~60% CPU with terabytes of disk reads per day (38 TB over ~30 days on the machine where this was diagnosed), and the log full ofHeartbeat lock could not be acquired within timeout503s, causing dropped heartbeats and fragmented events.Root cause: the peewee storage only has single-column indexes on
eventmodel(bucket_id,timestamp). Every event query filters onbucket_idand orders bytimestamp, for which SQLite picks:i.e. it reads all of a bucket's rows and sorts them in a temp B-tree — even for the
LIMIT 1last-event query thatreplace_last()issues on every merged heartbeat. On a 1.65M-row bucket that's a multi-second full-bucket scan per heartbeat, back to back, forever. (The WAL change in v0.14.0b1 fixed write amplification; this read-side pathology is independent of it, and grows linearly with bucket size.)Fix
Replace the single-column indexes with a composite
(bucket_id, timestamp)index, which serves the seek, the range and the ORDER BY in one pass. This mirrors aw-server-rust's v5 migration (events_bucketrow_starttime_endtime_index), including dropping the old indexes before creating the new one in the same transaction so freed pages are reused (no file growth) and inserts get cheaper (2 indexes → 1).auto_migrate()creates the composite.auto_migrate()detects the missing composite index, drops the old ones, and builds it (logged, since it takes a while on large DBs).Measured (real 1.45 GB DB, 6.7M events, largest bucket 1.65M rows)
replace_last, per merged heartbeat)get_events)This also eliminates the heartbeat-lock 503s, since the lock was being held across those multi-second scans.
Tests
test_index_migration: fresh DBs get the composite index; simulated pre-migration DBs (old single-column indexes) are migrated.test_query_plan_uses_composite_index: query-plan regression guard — asserts the hot queries are served by the composite index and never fall back toUSE TEMP B-TREE FOR ORDER BY. Deterministic (no timing flakiness), catches this class of regression in CI.Note: cross-bucket queries filtering on
timestampalone would no longer have an index, but nothing in aw-core issues such queries (all event queries are bucket-scoped) — same trade-off aw-server-rust made.Intended for inclusion in v0.14.0b3 (needs an aw-core release + bump in aw-server).