fix(0136): diagnose the frozen coarse OHLCV rollups + recovery runbook - #159
Open
karczuRF wants to merge 8 commits into
Open
fix(0136): diagnose the frozen coarse OHLCV rollups + recovery runbook#159karczuRF wants to merge 8 commits into
karczuRF wants to merge 8 commits into
Conversation
Found while verifying the 0072 MV rollout on ch-prod-01. change_7d_pct came back 0 for all 3,023 assets, which traced not to the new MV but to price_ohlcv_1h having no rows since 2026-07-21. All six coarse tables are frozen; only price_ohlcv_1m is live. Nothing alarmed because eight of the nine refreshable MVs report success every cycle — rolling up stale input is not an error. Only mv_ohlcv_1m_to_15m carries the failure (TOO_MANY_PARTS, 40,377 occurrences, still firing), and everything downstream starves quietly. Six ALTER DELETE mutations from the 0097 Phoenix rework were created 2026-07-17 11:06:52 on exactly the six frozen tables and have never been attempted in 13 days — no failures, empty fail_reason. Partition 202606 of price_ohlcv_15m stopped being touched 15 seconds later, and every active part is level 1: merged once, never again. Six hypotheses tested and rejected, recorded so they are not re-run: the 15m table was not dropped; disk is 28.2% free; merges run 68k-285k times a day cluster-wide; price_ohlcv_1m merged 5,220 times in 24h so nothing is stopped globally; the mutation pool threshold is legal (20 of an effective 32) and 18 of 20 occupancy samples showed an idle pool; and the 5,000 parts sit in one partition, so this is not the 0132 write- amplification shape. Leading hypothesis is that merges and mutations are administratively stopped on these six tables — it fits every observation, including the total silence in every diagnostic, since that state is exposed in no system table for non-replicated MergeTree. It is recorded as UNTESTED: the discriminating check is a state change on a shared production cluster and was deliberately not run. Impact reaches /ohlcv above 1m granularity, BE's 0199 LP-analytics contract, 0072's change_7d_pct, and 0127's M2 backfill-depth gate. The detection gap is the durable lesson and has its own acceptance criterion.
Runs both arms on local docker ClickHouse 26.3.10.60. Control (merges normal, then ALTER DELETE): the mutation completes in seconds — is_done=1, parts_to_do=0, MutatePart events in part_log, rows 800 -> 532. So a pending mutation on its own does not freeze a table, which eliminates the competing explanation. Test (SYSTEM STOP MERGES, then the same ALTER DELETE, then more inserts): is_done=0, parts_to_do=4, latest_fail_time 1970-01-01, empty fail_reason, parts climbing 4 -> 16 while inserts keep succeeding, and part_log showing NewPart only — no MergeParts, no MutatePart. Every observable matches ch-prod-01. Also corrects a wrong reading in the original write-up. A fresh INSERT into a ReplacingMergeTree produces a level-1 part; a plain MergeTree produces level 0. So prod's uniform level 1 across 5,000 parts does not mean "merged exactly once" — it means not one of them has ever been merged, which is stronger evidence for the freeze than what was first recorded. This confirms the mechanism, not prod's state: that the six tables are actually in the stopped state can only be shown by SYSTEM START MERGES against ch-prod-01, which remains deliberately not run.
Provenance correction. The six price_ohlcv_*_bak tables are OURS — task 0095 created them on 2026-07-17 as the restore path before the APPEND rollup-MV recreate, and 0105 already tracks dropping them. The six Phoenix ALTER DELETE mutations are 0097's preroll-amm-reprice.sql STAGE 0, run the same day. Read as an unexplained third-party maintenance window they are misleading evidence, so the task now says so directly. SYSTEM STOP MERGES appears nowhere in the repository — not in preroll-amm-reprice.sql, not in 0095's steps, not in any runbook — and the operator does not recall running it. The trigger stays unresolved and does not gate recovery, since the fix is identical either way. The transferable lesson is narrower: an ad-hoc STOP MERGES is invisible afterwards, so it must be paired with its START in the same runbook step. The runbook probes on price_ohlcv_1M rather than the blocked _15m: it is the leaf of the chain, smallest by parts, and off the critical path, so a wrong hypothesis costs one small table's merges. It carries a hard 5-minute decision gate that stops the operation if nothing happens, and treats the negative result as worth recording. Two things the earlier write-up missed. Resuming merges changes row counts by design — RMT collapses the duplicate-PK rows 0097's idempotent pre-roll wrote, and the pending delete removes phoenix rows in range — so step 0 captures a per-source baseline first. And the 07-21 gap does NOT self-heal: the rollup MVs read a bounded recent window, so closing it needs a bounded incremental pre-roll, never preroll.sql, which expects TRUNCATE-d tables and would re-run the 0090 history-loss incident. 0105 is blocked: the _bak tables are the only rollback for this recovery, and its watch period was never really satisfied since the rollup stalled four days after 0095. Detection is spawned as 0137. Nine days passed silently because a rollup MV that reads stale input still reports success — health was measured on the MV, not on the data.
…eory Frozen merges mean ReplacingMergeTree has collapsed nothing since 07-17, so the coarse tables carry heavy same-key duplication: sdex is 6.52x in partition 202607 alone, phoenix 3.06x, aquarius 2.70x, soroswap 2.07x table-wide. A working hypothesis that phoenix specifically was double-counted — because 0097's STAGE 0 delete never applied — is rejected by the control. sdex was never touched by 0097 and is the most duplicated of all, and the AMM figures are table-wide (diluted by older merged partitions) while sdex's is the frozen partition alone. So this is generic unmerged backlog, not a missing delete. It is also not a correctness problem. Every consumer reads with FINAL — the OHLCV API query at prices-api/src/assets/queries_ch.rs:578 and the views.sql surfaces BE reads in-cluster — so duplicates collapse at read time and no wrong number has been served. The cost is ~9.9M redundant rows in one sdex partition plus nine days of FINAL running over 5,000 unmerged parts on every /ohlcv request and BE query. Recovery is therefore compaction and read-latency recovery, not data correction. Recorded so the runbook's "row counts will change" note is not mistaken for a correctness claim. Backfill state also captured: sdex_archive completed 07-27, soroban_amm is the known-dead leg (no writes since 07-14), so nothing is contending for I/O.
… scheduled Ran the runbook's step-1 probe against ch-prod-01: SYSTEM START MERGES prices.price_ohlcv_1M, the leaf table, smallest blast radius. Six minutes later parts were still 750, parts_to_do still 60, and part_log completely empty. No state changed — the command is a no-op where merges were never stopped — so nothing needed undoing. The local reproduction still holds: SYSTEM STOP MERGES does produce this signature. It is not what is happening here. A matching signature was never proof of a matching cause, which is why the probe went on the leaf table behind an abort gate. text_log is enabled at trace level, and it names the real finding. Over 30 minutes price_ohlcv_15m emits zero merge-machinery lines — only the MV's insert attempt and the TOO_MANY_PARTS failure, once a minute. The control, price_ohlcv_1m, logs block allocation and reservations continuously; the pool logs background merge memory usage dozens of times a minute for other tables; even price_ohlcv_15m_bak has a live CleanupThread. So the selector is not declining these tables, it is never asked about them, which is a level below merge policy and explains why every knob checked came back innocent. It also explains the failed probe: START MERGES releases a lock on a decision, it does not restart a task that is not running. The assignee is initialized at table startup, so the action that reliably re-establishes it is a server restart. That is BE's call on a shared cluster and is not ours to take. An OPTIMIZE would discriminate further but is a state change and was not run. Runbook steps 1-3 marked superseded; steps 0 and 4-7 stand unchanged for once the tables can merge again.
A cluster restart is ruled out — ch-prod-01 is shared with BE and we may
touch prices.* only. DETACH + ATTACH re-runs startup() for a single table,
rebuilding the background operations assignee that stopped being scheduled.
Four tests on local CH 26.3.10.60, all green:
T4 table pinned at parts_to_throw_insert, inserts failing with the same
Code: 252 as prod -> detach 106ms / attach 105ms -> parts 30 to 1
within 10s, INSERT accepted again
T1 a pending ALTER DELETE survives the detach and completes after the
attach (is_done 0 to 1); MergeParts + MutatePart resume
T3 DETACH of a refreshable MV's TO target is allowed with the MV live;
the MV errors Code: 60 only during the window, then self-recovers
after ATTACH with no recreate
-- count() FINAL unchanged across detach/attach
Records two limits in the header so the evidence is not over-read: the
local wedge is built with SYSTEM STOP MERGES, which prod falsified, so
this proves the command rebuilds a table's scheduler rather than that it
clears prod's specific cause; and T2 (OPTIMIZE as a by-hand stopgap)
returned Code: 236 only because the simulation stops merges, leaving it
unvalidated either way.
Steps 1-3 were built on SYSTEM START MERGES, which production falsified on 07-30. They are rewritten around DETACH + ATTACH, validated locally in the preceding commit, and the falsified attempt moves to an appendix — kept, because it is what forced the trace-logging pass that found the real cause and because it will keep looking like the obvious first move to anyone reading this cold. Gates are rewritten against system.part_log rather than count(). RMT dedup makes count() fall while writes land perfectly — a target table read 800 to 400 during the local test while its MV inserted every 5 seconds. NewPart proves inserts land, MergeParts proves merging resumed. Also: - corrects the mechanism section: the assignee is never scheduled, which is why every merge-policy knob looked innocent - states the cluster restart is out of scope and every command names a single prices table - Step 0 additionally captures count() FINAL baselines and SHOW CREATE for all six tables, saved off-cluster as the ATTACH safety net - adds a 60s hang gate with a stop-and-escalate instruction, a second SSH session as a precondition, DETACH+ATTACH as one invocation, and a warning to never use DETACH ... PERMANENTLY - sharpens the row-count warning: physical rows drop hard, count() FINAL should not move, and the phoenix delete is a partial reduction since ALTER DELETE only touches parts existing when it was created on 07-17 Steps 4-7 are unchanged.
… recovery The task file still recorded a server restart as the fix and BE as the owner of it. The operator ruled that out on 07-31 — the cluster is shared and we are scoped to prices.* — and DETACH/ATTACH was validated on the prod CH pin the same day, so the handover framing was the first thing a future session would have read and the last thing that is still true. - "Handover point" becomes "Recovery — DETACH/ATTACH, validated locally", with the four test results, the limits of the evidence (the local wedge is SYSTEM STOP MERGES, which prod falsified; OPTIMIZE unvalidated; do not quote the 105ms attach for a 5,000-part table), and why it has not been run yet - the falsified stopped-merges section keeps its reproduction but now opens with an explicit do-not-read-this-as-the-cause banner - implementation steps 1-3 rewritten around DETACH/ATTACH: one invocation, a 60s hang gate, plain DETACH never PERMANENTLY, and gates on system.part_log rather than count(), which RMT dedup makes fall while writes land perfectly - the row-count warning is sharpened: physical rows drop hard, count() FINAL should not move, and the phoenix delete is partial since ALTER DELETE only touches parts existing when it was created - root-cause AC restated as mechanism-known/trigger-unknown, plus a second AC for the chosen recovery; both [~], neither runs on prod yet - records the two holds to confirm first — the cleanup rule (still unverified, operator creds expired) and 0105 Task stays in backlog and unactivated; nothing was run against production.
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.
What
Every coarse OHLCV table —
price_ohlcv_15m,_1h,_4h,_1d,_1w,_1M— has been frozensince 2026-07-21 02:44 UTC. Only
price_ohlcv_1mis live. Found while verifying the task-0072MV rollout:
change_7d_pctcame back 0 for all 3,023 assets, which traced not to the new MV but toprice_ohlcv_1hhaving no rows in the trailing 7 days.Nothing alarmed because eight of the nine refreshable MVs report
status = Scheduledwith an emptyexception every cycle — rolling up stale input is not an error. Only
mv_ohlcv_1m_to_15mcarries thefailure (
TOO_MANY_PARTS, 40,377 occurrences, still firing), and everything downstream starvesquietly.
Docs, a test harness and task records only. No schema, no code, nothing deployed.
Root cause
The leading hypothesis (merges administratively stopped via
SYSTEM STOP MERGES) reproducedperfectly on local ClickHouse 26.3.10.60 — and was then falsified in production.
SYSTEM START MERGES prices.price_ohlcv_1Mwas run against the leaf table and had no effect: parts unchanged at750,
parts_to_dounchanged at 60,part_logempty after six minutes. No state changed (the commandis a no-op where merges were never stopped).
What trace logging then showed is the real finding. Over 30 minutes
price_ohlcv_15memits zeromerge-machinery log lines — only the MV's insert attempt and its
TOO_MANY_PARTSfailure, once aminute. The control
price_ohlcv_1mlogs block allocation and reservations continuously; the poollogs background merge memory usage dozens of times a minute for other tables; even
price_ohlcv_15m_bakhas a liveCleanupThread.So the merge selector is not declining these tables — it is never asked about them. The storage's
background operations assignee is not being scheduled. That is a level below merge policy, which
explains why every knob checked came back innocent, and why
START MERGESdid nothing: it releases alock on a decision, it does not restart a task that is not running.
Recovery —
DETACH/ATTACH, validated locallyThe assignee is created in
IStorage::startup(), which is why a server restart would reliablyre-establish it. That is ruled out: ch-prod-01 is shared with BE and we are authorised to touch
prices.*only.DETACH TABLE+ATTACH TABLEre-runsstartup()for one table — the per-table equivalent of arestart, scoped to a single table in a single database. Validated on the prod CH pin
(
scripts/test-0136-detach-attach-recovery.sh, CH 26.3.10.60):parts_to_throw_insert, inserts failing with the identicalCode: 252ALTER DELETEis_done0 → 1);MergeParts+MutatePartresumeDETACHof a refreshable MV'sTOtarget while the MV is liveCode: 60during the window only, then self-recovers afterATTACH, no recreatecount() FINALunchanged (200 → 200) across detach/attachLimits of that evidence, stated in the runbook so it is not over-read: the local wedge is built
with
SYSTEM STOP MERGES, which production falsified — so this proves the command rebuilds a table'sscheduler, not that it clears prod's specific unknown cause. The mechanism argument is what carries:
ATTACHbuilds a new storage object from scratch, so no in-memory state survives it.OPTIMIZEas aby-hand stopgap returned
Code: 236locally, but only because the simulation stops merges — it staysunvalidated and unused. And the 105 ms attach was 30 parts;
_15mhas ~5,000 part descriptors toload, so expect meaningfully slower.
One trap worth flagging:
count()is useless as a health probe here. RMT dedup made a targettable read 800 → 400 while its MV was inserting every 5 seconds. Every runbook gate is written
against
system.part_loginstead.Not yet run against production. Order when it is:
_1M(leaf, off the critical path) with a harddecision gate →
_15m(the blocker) → the other four, one at a time.Nine hypotheses tested and rejected
Recorded in the task so they are not re-run: the 15m table being dropped; disk (496 GiB free, 28%);
merge pool saturation (68k–285k merges/day cluster-wide); a global merge stop (
_1mmerges 5,220×/24h);the mutation pool threshold (legal at 20 of an effective 32, pool sampled idle 18 of 20 times);
write amplification across partitions (all 5,000 parts sit in one); a structural difference between
tables (
SHOW CREATEis byte-identical to the healthy_1m); a pending mutation alone (local controlcompleted in seconds); and finally the stopped-merges flag itself.
Provenance — the 07-17 window was ours
The six
price_ohlcv_*_baktables and the six pending Phoenix deletes look like evidence of anunexplained third-party maintenance window. They are not:
_bakis task 0095 (backup before theAPPEND MV recreate, already tracked by 0105), the deletes are task 0097's
preroll-amm-reprice.sqlSTAGE 0.
SYSTEM STOP MERGESappears nowhere in the repository. The trigger stays unresolved anddoes not gate recovery.
Also measured
Frozen merges mean
ReplacingMergeTreehas collapsed nothing since 07-17:sdexis 6.52× duplicatedin partition 202607 alone, the AMM sources 2–3× table-wide. Not a correctness problem — every
consumer reads with
FINAL(prices-api/src/assets/queries_ch.rs:578and theviews.sqlsurfacesBE uses), so no wrong number has been served. The cost is ~9.9 M redundant rows in one partition and
ten days of
FINALrunning over 5,000 unmerged parts on every/ohlcvrequest and BE query. Aworking theory that phoenix specifically was double-counted by the un-applied delete was tested and
rejected — sdex, untouched by 0097, is the most duplicated of all.
Contents
0136— the incident: impact, full evidence chain, nine rejected hypotheses, provenance.scripts/repro-0136-merge-freeze.sh— local reproduction of the freeze signature (control + test).scripts/test-0136-detach-attach-recovery.sh— new: local validation of the recovery, fourtests on the prod CH pin, with the limits of the simulation recorded in the header.
docs/runbooks/0136-coarse-rollup-merge-recovery.md— recovery plan, rewritten aroundDETACH/ATTACH. Gates run offsystem.part_lograther thancount(); Step 0 additionallycaptures
count() FINALbaselines andSHOW CREATEfor all six tables; a 60 s hang gate with astop-and-escalate instruction; the falsified
START MERGESattempt moved to an appendix. Flagsthat physical row counts will drop while
count() FINALshould not, and that the 07-21 gap doesnot self-heal — closing it needs a bounded incremental pre-roll, never
preroll.sql.0137— new: rollup freshness alarm. Health was measured on the MV, not the data.0105— blocked: the_baktables are the only rollback for the recovery.0072— rollout status (steps 0–3 done, MV live and healthy),change_7d_pctAC marked blocked.0135— second measured failure mode onprice_usd(21 assets publishing 0 whilevwap_24hknowsthe price). Pre-existing, not a 0072 regression.
Staying open
This PR stays open until the recovery has been run and verified in production — the open PR is the
verification-pending signal.