Skip to content

Move heartbeats to ClickHouse - #1745

Draft
skyfallwastaken wants to merge 28 commits into
mainfrom
clickhouse-heartbeats
Draft

skyfallwastaken wants to merge 28 commits into
mainfrom
clickhouse-heartbeats

Conversation

@skyfallwastaken

@skyfallwastaken skyfallwastaken commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary of the problem

Heartbeats are the bulk of our Postgres: 755 GB for 426M rows, 592 GB of which is 32 indexes. Keeping that hot needs a lot of RAM, the dashboard rollups that make it fast take about 17 worker-hours a day to refresh and global analytics (admin scans, leaderboards, Sailors' Log) take seconds to minutes.

Describe your changes

Heartbeats now live in ClickHouse. Everything else stays in Postgres.

  • Storage: a plain MergeTree ordered by (user_id, time, id). All of production fits in 5.5 GiB. time keeps fractional seconds; created_at, updated_at and deleted_at are stored to the second. fields_hash is gone: exact duplicates are rejected at ingest (an identity check under a per-user advisory lock) and the 23.4M existing duplicates are removed during the copy. Ids still come from Postgres's heartbeats_id_seq, so they stay unique and below 2^53.
  • Duration maths: Heartbeatable and the dashboard queries are rewritten for ClickHouse with the same gap rules as before. Halves round to even, as Postgres did.
  • Rollups: a per-user heartbeat_rollups table in ClickHouse (one row per local hour and dimension), rebuilt by DashboardRollupRefreshJob with the same debounce. Each rebuild writes a new generation and publishes it in Postgres (heartbeat_rollup_states), so readers never see a half-built rollup.
  • Removed: the Postgres dashboard_rollups table, the rollup staleness logic and the Cache::* jobs (now a 1 minute Rails.cache on the owning models).
  • Ported: admin raw SQL, leaderboards, weekly summaries, exports (now keyset paged), Sailors' Log and seeds.
  • Postgres heartbeats: becomes read-only via a trigger and keeps its data for verification until we drop it. Its foreign keys are dropped so deleting users or JA4 fingerprints still works.
  • Dev and CI: a clickhouse service in docker-compose alongside Postgres, a ClickHouse container in CI and the production Coolify compose file with nightly S3 backups. Brakeman is bumped to 8.1.0, which scan_ruby needed; its new warnings are on ClickHouse SQL built from casts, quoted values and validated timezones, and each is ignored with a note.
  • Cutover: script/clickhouse/cutover.sh copies everything ahead of time, pauses writes with the read-only trigger, copies what changed and verifies every row afterwards. Editor clients queue heartbeats rejected during the pause and resend them. It has been rehearsed end to end against a copy of production data.

Screenshots / Media

No visual changes.

Postgres ClickHouse
Heartbeats storage 755 GB 5.5 GiB
Rollup rebuild, largest user 21 s 2 s
Rollup refresh worker time per day about 17 hours about 3 hours
Admin machine and IP scans 5 to 20 s well under a second

Heartbeats move from Postgres to ClickHouse. Dashboard rollups become a per-user ClickHouse table rebuilt from Rails, the Postgres rollup table and cache jobs are removed, and Postgres heartbeats becomes read-only.
Adds an opt-in ClickHouse service to docker-compose, a ClickHouse container in CI, the production Coolify compose file and server config, and the nightly backup script.
@socket-security

socket-security Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedgem/​brakeman@​8.0.6 ⏵ 8.1.075 +110010010070
Addedgem/​clickhouse-activerecord@​1.7.098100100100100

View full report

Empty rollups report zero totals, the rollup activity graph stops at today, today's range includes the day's last second and the Sailors' Log leaderboard no longer embeds a Postgres subquery in a ClickHouse query. Also corrects the rounding description: halves round to even, as in Postgres.
bin/brakeman requires the latest release, so scan_ruby failed on 8.0.6 before analysing anything. 8.1.0 flags the ClickHouse SQL built from Integer() casts, quoted values, validated timezones and Active Record relations; each is reviewed and ignored with a note.

@github-advanced-security github-advanced-security 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.

Brakeman found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Drops the opt-in compose profile: ClickHouse now always starts with the stack and web waits for it, as it does for Postgres.
created_at, updated_at and deleted_at drop from microseconds to whole seconds. On a full copy of production the two populated columns shrink from 1.0 GiB to 236 MiB each, taking the table from 7.0 GiB to 5.5 GiB. time keeps its fractional seconds.
Comment thread app/controllers/api/admin/v1/admin_controller.rb Fixed
Comment thread app/controllers/api/admin/v1/admin_controller.rb Fixed
Comment thread app/controllers/api/admin/v1/heartbeats_controller.rb Fixed
Comment thread app/controllers/api/admin/v1/heartbeats_controller.rb Fixed
Comment thread app/models/concerns/heartbeatable.rb Fixed
Comment thread app/models/heartbeat_rollup.rb Fixed
Comment thread app/services/dashboard_data/snapshots.rb Fixed
Comment thread app/services/dashboard_data/snapshots.rb Fixed
Comment thread app/services/dashboard_data/snapshots.rb Fixed
Comment thread app/services/dashboard_data/snapshots.rb Fixed
skyfallwastaken and others added 15 commits October 4, 2026 22:22
Replaces the hand-rolled SQL loader with the adapter's multi-database support: migrations in db/clickhouse_migrate, a SQL structure dump in db/clickhouse_structure.sql and Rails' own db:create, db:prepare, db:schema:load and per-worker test databases.
On rollups built for every user from a full production copy, the project column shrinks from 28 MiB to 16 MiB and dashboard reads get about 1 ms faster. Rebuild time is unchanged.
A request that read HeartbeatRollupState just before a rebuild published
queried a generation the rebuild had already deleted and rendered an
empty dashboard. Keep the generation being replaced; account deletion
still removes everything at once.

Co-authored-by: Amp <amp@ampcode.com>
Site-wide reads filter on time alone, but ORDER BY starts with user_id,
so they read the newest granules of every user. The index cuts rows read
by 3 to 12 times for the currently hacking, footer and weekly summary
queries.

Co-authored-by: Amp <amp@ampcode.com>
ClickHouse costs a few milliseconds per query however small, so light users
paid for every round trip. Live dashboards now take one aggregate query
instead of nine and one filter-options query instead of five, last_7_days
one instead of seven, and the stats API one instead of three.
For the heaviest users' all-time stats, one combined query needed over three
times the memory of the separate ones and was slower.
Summing every user's heartbeats in one window query exceeds ClickHouse's
8 GiB per-query limit.
Admin lookups by IP address or machine and the repo sync job's created_at
filter scanned all 400M rows. The indexes take under 6 MiB and cut these
from 110-490 ms to 28-45 ms.
The rebuild that publishes a generation writes its dashboard snapshot to the
cache, so the dashboard, profile and projects pages read it instead of
querying ClickHouse. Whether a user has heartbeats also comes from the
snapshot.
Total time, file count and the language, editor, OS, category, file and
branch breakdowns came from eight queries.
Requests no longer pay 80-300 ms to recompute currently hacking, the footer
counts and the homepage totals when their cache expires.

This branch has not been deployed

No deployments
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.

2 participants