Repository navigation
Speed up slow heartbeat queries in Postgres - #1791
Merged
Merged
Conversation
…two unused indexes The statistics let the admin machine/IP aggregates run in parallel workers, and the BRIN index serves the Sailor's Log created_at poll. Production last scanned the dropped indexes when the geocoding job was removed (2026-09-02) and when leaderboards started counting ai coding (2026-10-25). Co-authored-by: Amp <amp@ampcode.com>
The correlated exclusion EXISTS kept the lookback aggregate out of parallel workers, and ip_machine_pairs and alt candidates aggregated it twice for the self-join. A LEFT JOIN on the exclusion rules keeps the same MIN, MAX and BOOL_AND results, a materialized CTE aggregates once for both sides, and shared_machines counts users per machine with a window. Co-authored-by: Amp <amp@ampcode.com>
A recursive query steps through the (user_id, dimension, ...) index one distinct value per probe instead of reading every heartbeat of the user. The probes are built from the same relation, so visibility, deletion and archived-project filters are unchanged. Co-authored-by: Amp <amp@ampcode.com>
…once The graph rounds heartbeat times to hours in integer arithmetic (equal to the numeric EXTRACT form) and counts distinct (hour, user) pairs, which can be gathered from parallel workers. The footer read Cache::HeartbeatCountsJob twice, once per count. Co-authored-by: Amp <amp@ampcode.com>
User#display_name and #avatar_url fall back to the first email address, which the API loaded one user at a time: about 11,000 queries for a weekly board. They are now preloaded in id order, the order email_addresses.first reads. Co-authored-by: Amp <amp@ampcode.com>
The language and project durations of a stats summary do not depend on the total, so in production they run on Active Record's async query executor while the total is computed. Each web process uses up to four extra connections, within the pool of RAILS_MAX_THREADS + 8. Other environments run them in the foreground: in tests, async queries on the pinned transactional connection can deadlock (rails/rails#58478). Co-authored-by: Amp <amp@ampcode.com>
Contributor
[Medium impact] The PR appears safe to merge; the latest test change still matches the notification lookup URL. SummarySpeeds up heartbeat queries for admin tools, dashboards, leaderboards, footer counts and Sailor's Log notifications.
Reviews (4) · Last reviewed commit: "Anchor and escape the Cachet stub URL in..." · Reviewed by Greptile |
New heartbeats land in free space across old pages, so a BRIN index on created_at made the Sailor's Log poll a lossy scan over a third of the table and slower than the sequential scan it replaced. The migration now only creates the ndistinct statistics, and no longer runs ANALYZE at boot. Co-authored-by: Amp <amp@ampcode.com>
…side a transaction select_all(async: true) raises in a console and inside a transaction when the async executor is enabled; fall back to a completed promise there. Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Amp <amp@ampcode.com>
…ted_at Heartbeats received since the cutoff have ids above the newest one received a minute before it, which the primary key finds with a short backward scan. With the floor as a value the poll becomes a bitmap OR of the time index and the primary key instead of a full table scan. Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Amp <amp@ampcode.com>
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.
Summary of the problem
Several heartbeat queries read far more rows than they need to:
time >= cutoff OR (direct_entry AND created_at >= cutoff)) has no usable index for thecreated_atbranch, so it scans the whole heartbeats table. It is the slowest query in production by total time (about 47 s mean inpg_stat_statements).EXISTSblocks parallel aggregation and the planner badly misestimates the machine/IP group count.SELECT DISTINCTover every heartbeat a user has (about 1 s for very active users).HeartbeatCountsJobtwice.Describe your changes
ndistinctstatistics on(user_id, machine, ip_address)and(machine, user_id)so the admin machine/IP queries get parallel aggregation. They take effect at the nextANALYZE heartbeats(autovacuum's or a manual one); the migration does not run it so deploys stay fast.schema.rbdoes not dumpCREATE STATISTICS, so databases built withdb:schema:loadwill not have them (this only affects plans).index_heartbeats_on_user_id_with_ip(15 GB, last scanned 2026-09-02 after the geocoding job was removed) andidx_heartbeats_lb_eligible_user_time(10 GB, last scanned 2026-10-25 when leaderboards started counting AI coding, so its predicate no longer matches).HeartbeatExclusion::HIDDEN_JOIN_SQL(aLEFT JOINon the same match conditions), a materialised CTE for the self join and a window count for shared machines.MATCHED_SQL,VISIBLE_SQLandHIDDEN_SQLare unchanged.Heartbeat.distinct_values(column): a recursive loose index scan built from the relation's own SQL, so visibility, deletion and archived project filters still apply. It is used for dashboard filter options, the rollup refresh payload and goal targets.EXTRACTform, and the footer readsHeartbeatCountsJobonce.display_nameandavatar_urlresolve without N+1 queries. The JSON is unchanged.created_atorder, so the minute of slack is generous.load_asyncwhile the total runs. The async executor is enabled inproduction.rbonly, because in tests it deadlocks with transactional fixtures (Deadlock inConnectionPool#checkoutwhen the pinned connection is replaced concurrently (transactional system tests + Action Cable) rails/rails#58478). Outside an executor session (such asrails console) or inside a transaction the query runs in the foreground instead. It uses up to 4 extra connections per process, which fits within the pool'sRAILS_MAX_THREADS + 8.Responses are unchanged. Both versions were compared on every endpoint across user sizes, time ranges and project/language/editor/OS/category filters, and after fresh writes. The only differences are the admin alt candidates and IP pairs lists. They use
LIMITwithoutORDER BY, so which of the tied rows come back already depends on the plan. Every returned row is in the same full set.index_heartbeats_on_project(covered byindex_heartbeats_on_project_and_time) andindex_heartbeats_on_category_and_time(not scanned in production since 2026-10-13) look droppable too but are left for a separate decision.Screenshots / Media
Production-shaped synthetic data (9.6M heartbeats; users sized at the production p50, p90, p99 and p99.9) on PostgreSQL 18 with 16 vCPU and 32 GB. Rails in production mode, p50 ms with the Rails cache disabled unless noted.
mainEXPLAIN ANALYZE)Across 1,887 cases (every endpoint by user size, time range and filter), the geometric mean of the p50 speedup is 1.57x with caches off and 1.13x with them on.
AI Usage
anthropic/claude-opus-5-5) in Amp, default reasoning effort.time, which led to the id floor.