Context
Rolling test automations currently maintain five global ClickHouse aggregate tables containing the latest 100, 250, 500, 750, and 1,000 physical run rows for every project and test case. Each table has separate flaky and successful materialized views.
Every test_case_runs insert therefore executes ten materialized views and writes up to ten aggregate states, even when no automation reads most of them.
A background merge of test_case_runs_recent_750_per_case recently exhausted the production ClickHouse memory limit for roughly 13 hours. The runner autoscaling query reported in Sentry was a victim of global memory pressure, not the source.
Impact
During the incident:
- A merge of only 100 to 130 mebibytes of compressed input expanded to 8 to 9 gibibytes in memory.
- The merge failed 1,971 times across three compute replicas.
- 1,370 foreground ClickHouse queries failed while the merge kept the service near its 18-gibibyte memory limit.
- Sentry recorded 220 errors in 14 days whose event text explicitly names the 750-run table, including ingestion buffer failures and unrelated reads. Sentry query
The large merge eventually completed on one compute replica, but the table shape has not changed, so this can recur as new parts accumulate.
Root cause
The rolling tables use unpartitioned AggregatingMergeTree storage with large groupArraySorted or groupArrayLast states.
During a merge, ClickHouse has to deserialize and combine those arrays. Memory therefore scales with the number of logical keys in the merge, the retained array capacity, and the number of aggregate columns. Compressed source-part size is a poor predictor of peak memory for this data shape.
All five tables are structurally exposed because no partition boundary limits how many project and test-case keys a merge may combine. The 1,000-run table is currently the largest at 15.86 gibibytes uncompressed.
The replacement proposed here creates a fixed number of independently growing merge domains. It reduces the blast radius substantially, but it is not a mathematical upper bound on merge memory because the number of keys inside each partition can continue growing. The 10-times and 100-times load replays are therefore release gates, not optional validation.
The current implementation is in:
ClickHouse documents that parts never merge across partitions and that every incremental materialized view executes on every source insert and creates target parts: ClickHouse guidance.
Current usage
Production currently has three enabled rolling automations:
- All three belong to one project.
- All three use a trigger window of 75.
- Only the 100-run table is read.
- Their recovery window of 500 reads raw runs and does not use the 750-run table.
The 250, 500, 750, and 1,000 tables are therefore maintained globally without serving any currently enabled rolling trigger.
There is also a separate baseline scaling problem. Established automations evaluate changed test cases in chunks of at most 2,000, but a new automation passes no identifiers and scans aggregate state for the whole project. That whole-project query already exceeds its one-gibibyte query limit against the 100-run table.
The largest relevant project currently has 140,593 test cases, which is about 71 bounded chunks.
Required rolling-window semantics
For a configured window N, the result must mean the latest N distinct logical run identifiers ordered by (ran_at, run_identifier), with the corrected flaky value and the original successful value for each run.
The run identifier is part of the ordering and deduplication key. Two distinct runs with the same timestamp must both remain in the window. A flaky correction for an existing run must update that run and must not consume an additional logical slot.
The current writer can reinsert the same historical failure more than once because test_case_runs_by_commit does not expose the latest is_flaky value. This makes physical multiplicity unbounded and means deduplicating only after groupArraySorted(capacity) cannot provide exact logical windows.
Before the new rolling tables are enabled:
- Extend the commit-scoped lookup with the latest
is_flaky value and exclude failures that are already flaky.
- Make the correction operation durable and idempotent by logical run identifier, including concurrent processing and buffer retries.
- Give a run at most one original tuple and one correction tuple in rolling state.
- Add a metric for physical tuple multiplicity per logical run and alert if the maximum exceeds two.
With that invariant, a capacity of 2 * N is sufficient for exact post-capacity deduplication even when every logical run has a correction.
Proposed storage and routing
Replace the five tables and ten materialized views with two tables and two materialized views:
- Capacity 250 serves windows from 1 through 125.
- Capacity 1,000 serves windows from 126 through 500.
- New or modified rolling trigger windows above 500 are rejected until exact pre-capacity run resolution is available.
- Each tuple contains the ordering timestamp, stable run identifier, correction rank, flaky flag, and successful flag.
- Read-side deduplication groups by run identifier, selects the corrected version, then slices to the configured logical window.
This temporarily lowers the supported maximum from 1,000 to 500. No enabled production automation currently uses a value above 75. Supporting 501 through 1,000 with post-capacity deduplication would require capacity 2,000, which works against the scaling goal. That range should return only after either pre-capacity per-run resolution or a demand-gated large-window tier passes the same merge and read gates.
Use a fixed hybrid partition key:
PARTITION BY (
cityHash64(project_id) % 128,
cityHash64(test_case_id) % 4
)
ORDER BY (project_id, test_case_id)
This creates at most 512 partitions per table. Every project touches only four partitions, so project queries remain local while large projects are split four ways.
Using the complete current 1,000-run key distribution:
- The current largest proposed partition contains 35,328 logical keys.
- At 100 times the current customer distribution, the simulated average is 268,950 keys per partition.
- The simulated 95th percentile is 361,662.
- The simulated maximum is 390,569.
- Project-only hashing into 512 partitions produced a maximum of 656,964.
Production five-second insert windows currently contain a median of one project, a 95th percentile of two, and a maximum of four. The four-way test-case salt adds predictable part fan-out while protecting against one large tenant dominating a partition.
The 80 percent reduction applies to materialized-view executions and aggregate-state writes, from ten to two. It does not imply an 80 percent reduction in target parts:
- One project can touch four partitions in each of two target tables, for up to eight target parts per source block.
- The production 95th percentile of two projects can touch up to sixteen target parts.
- The observed maximum of four projects can touch up to thirty-two target parts.
Actual part creation may be lower when projects share hash partitions. Production-shaped replay must measure it directly.
Read measurements
The requested routes were measured in production on a fixed contiguous range of 1,000 test cases from the largest project. The query used two threads and the application’s one-gibibyte memory limit. These numbers use the current tuple shape, so they are a lower bound for the proposed tuple that also carries a stable run identifier.
| Requested window |
Capacity read |
Database duration |
Peak memory |
Bytes read |
| 75 |
250 |
364 milliseconds |
159 mebibytes |
3.8 mebibytes |
| 249 |
250 |
393 milliseconds |
172 mebibytes |
3.8 mebibytes |
| 250 |
1,000 |
91 milliseconds |
49 mebibytes |
27.9 mebibytes |
| 500 |
1,000 |
55 milliseconds |
60 mebibytes |
27.9 mebibytes |
| 750 |
1,000 |
55 milliseconds |
51 mebibytes |
27.9 mebibytes |
| 1,000 |
1,000 |
58 milliseconds |
49 mebibytes |
27.9 mebibytes |
The read trade-off is acceptable on the current state shape, but it is not yet an approval for the replacement schema. Repeat this matrix against fully populated candidate tables with the wider tuple, cold storage caches, 2,000 identifiers, and concurrent ingestion before cutover.
Exact baseline cursor and retry semantics
Baseline evaluation must use a durable attempt record in PostgreSQL rather than advancing the cursor to completion time.
- Create or resume a baseline attempt and persist a cursor captured before identifier enumeration.
- Enumerate project test-case identifiers and evaluate them in chunks of at most 2,000 without emitting events or actions.
- Persist the completed triggered-identifier set on the attempt before publishing any ClickHouse events.
- Publish baseline events in bounded batches with deterministic event identifiers derived from the attempt and test-case identifier.
- Make event reads deduplicate by event identifier so a successful ClickHouse insert followed by a PostgreSQL failure is safe to retry.
- Only after every event batch succeeds, atomically mark the attempt committed, mark the alert baseline established, and set the scoped cursor to the cursor captured in step 1.
- The first incremental evaluation uses the normal flush lookback around that captured cursor, so changes that arrive while the baseline runs are evaluated.
Retry behavior depends on the durable attempt state:
- An evaluating attempt reruns chunks using the original cursor.
- A publishing attempt reuses the persisted triggered-identifier set and deterministic event identifiers.
- A committed attempt is a no-op.
This avoids skipped changes, duplicate baseline events, and a partially published baseline becoming active.
Application changes
- Group rolling alerts by window size rather than by aggregate column.
- Read flaky and successful counts in one ClickHouse query per chunk.
- Evaluate individual thresholds in Elixir.
- Deduplicate by stable run identifier, not timestamp.
- Enforce the one-original-plus-one-correction invariant before routing reads to the new tables.
- Reject unsupported windows above 500 at validation and at execution.
- Implement the durable baseline attempt workflow described above.
Immediate containment
Before the replacement tables are ready:
- Gate creation or modification of unsupported rolling windows.
- Recheck all saved production configurations, including disabled alerts.
- Disable the unused 250, 500, 750, and 1,000 materialized views.
- Disable merges on the unused old 750 table so existing parts cannot trigger another large merge.
- Keep the current 100-run table operational for the existing window-75 automations.
- Add monitoring for background merge memory, failed merges, aggregate-table part counts, physical run multiplicity, and ingestion buffer failures.
max_bytes_to_merge_at_max_space_in_pool may be used as containment, including setting it to zero on an unused table to disable merges, but it is not the durable fix. The setting limits compressed source bytes and cannot reliably bound deserialized aggregate-state memory. ClickHouse setting documentation
Migration and rollback
- Deploy idempotent flaky correction and tuple-multiplicity monitoring first.
- Deploy the durable chunked baseline workflow.
- Create the two new partitioned tables and consolidated materialized views.
- Keep the old 100-run materialized views active for rollback.
- Backfill by project and bounded test-case ranges, starting with the largest project.
- Compare the new results against a sampled raw query that deduplicates by run identifier before applying the logical window.
- Use the old tables only as a regression comparison, not as the correctness oracle.
- Switch reads behind a runtime flag.
- Soak for at least seven days, longer than the incident duration.
- Stop old materialized views only after the soak succeeds.
- Retain old tables for an additional rollback period before removing them.
Old materialized views must remain active during the rollback window. Keeping only the old tables would leave rollback data stale.
Alternatives considered
One capacity-1,000 table
A scoped 1,000-identifier query was 3.6 times slower and read 3.2 times more data than the capacity-100 table in an earlier production comparison. One tier simplifies writes but makes common small-window reads deserialize more state.
Capacity 2,000 for window 1,000
This makes post-capacity deduplication exact under the two-physical-tuples invariant, but doubles the largest retained state and directly conflicts with the merge-memory goal. It should not be maintained globally without demonstrated demand and a successful 100-times load replay.
Direct project partitioning
There are currently 498 projects in the complete rolling state, but customer growth would create tens of thousands of partitions. A fixed hash keeps the partition count fixed.
Pure test-case hashing
This protects large tenants but makes every insert and project read touch many partitions. The proposed four-way salt limits that trade-off.
Plain raw history with query-time latest runs
An exact current-state query for 1,000 identifiers exceeded one gibibyte. A query using FINAL completed but took 5.9 seconds for 1,000 identifiers, compared with 146 milliseconds for the current capacity-100 aggregate. It would approach or exceed the five-minute cadence for the largest project.
Demand-gated aggregation
Maintaining rolling state only for projects with enabled automations could reduce work substantially and may be the right way to restore windows above 500. It requires a reliable activation registry, race-free backfill, and repair logic across PostgreSQL and ClickHouse.
Acceptance criteria
- No ClickHouse memory-limit errors or test-case ingestion buffer failures for seven days.
- Peak memory for one background merge remains below 4.5 gibibytes during a 100-times load replay.
- Concurrent background merge memory remains below 9 gibibytes, leaving half the service limit for reads and ingestion.
- Actual target-part creation is measured for source blocks containing one, two, and four projects, and active parts per partition reach a steady state below ClickHouse’s insert-delay threshold.
- A 2,000-identifier rolling query remains below 512 mebibytes for every supported route using fully populated candidate states.
- The largest baseline completes within two minutes and below its five-minute evaluation cadence.
- A change written during baseline evaluation is observed by the first incremental evaluation.
- Retrying after any baseline chunk or event batch produces one committed baseline and no duplicate visible event.
- Exact boundary tests cover windows 125, 126, 249, 250, and 500 with every run corrected once.
- Validation and execution reject windows 501, 999, and 1,000 until an exact larger-window design is enabled.
- Two distinct run identifiers sharing the same timestamp remain distinct and are ordered deterministically.
- Sampled comparisons against a raw query that resolves the latest row per run identifier return identical triggered identifiers.
- Load tests cover 10 times and 100 times the current customer distribution, 10 times the largest tenant, corrections, retries, out-of-order arrivals, and fully populated aggregate states.
Related work
- #11978 fixes test-report ingestion reverting muted test-case state.
- #11979 removes legacy test-case state-column writes.
Neither pull request changes these ClickHouse tables, materialized views, partitioning, merge behavior, rolling correction multiplicity, or baseline query shape.