Repository navigation
Retention held by the coverage gate is invisible to store metrics and has no automatic release (use1: 5 query_store_stats policies held 16 days, raw tier at 65 GB / +13 GB per day) #2813
Description
Summary
On the use1 monitoring host, five query_store_stats* retention policies have been held paused by the #1680/#1877 coverage gate since 2026-08-17 — 16 days. collect.query_store_stats is configured drop_after: 4 days and actually holds 18 days 22:56 — 19 chunks, ~65 GB, growing +13 GB/day.
The gate itself is working exactly as designed. This issue is not a request to weaken it. It is about two things the design leaves open:
- A held policy is invisible to every metric we collect, so it went unnoticed for 16 days.
- The release requires a manual CLI action that nothing schedules, prompts for, or alerts on — so "held" is a terminal state in practice, not a transient one.
The mechanism, confirmed
TimescaleSupport.EnsureRetentionPoliciesAsync runs on every service start (DarlingWorker.cs:1064). For each policy it calls MeasureRetentionCoverageAsync; on a measured Short verdict it executes HoldRetentionPolicySql → alter_job(j.job_id, scheduled => false).
Confirmed from the box's own app log — it fires on every restart, including all three tonight (19:08:03, 22:00:57, 22:56:03):
[WARN] Retention policy for query_store_stats HELD PAUSED - query_store_stats_hourly does not yet cover
everything it holds, so arming could drop history that rollup has never materialized.
Backfill past the 4 days horizon and the policy arms itself on the next start.
[WARN] ... query_store_stats_hourly HELD PAUSED - query_store_stats_daily short
[WARN] ... query_store_stats_interval_hourly HELD PAUSED - query_store_stats_corrected_daily short
[WARN] ... query_store_stats_corrected_hourly HELD PAUSED - query_store_stats_corrected_daily short
[WARN] ... query_store_stats_interval_daily HELD PAUSED - query_store_stats_daygrain_daily short
[INFO] TimescaleDB: 17/17 retention policies in place, 12 armed, 5 held paused pending backfill,
0 left as-is (coverage unreadable), 0 moved onto a new horizon
The five held jobs are exactly the coverage-gated Query Store family in RetentionPolicies. The other 12 policies are armed. The named short consumers chain downward and root in the daily CAGGs, which have never been materialized on this store.
Defect 1 — a held policy is invisible outside the app log
StoreSelfMetrics.BackgroundJobInsertSql selects proc_name, hypertable_name, job_id, last_run_duration_ms, schedule_interval_ms, total_runs, total_failures.
It does not select j.scheduled.
So a held policy reports total_failures = 0, last_run_status = Success, and a plausible last_run_duration_ms — byte-for-byte the shape of a healthy job. get_store_metrics cannot distinguish "retention is running" from "retention has been stopped for 16 days", and neither can any alert or hourly health check built on it. The only signal is a [WARN] line emitted once per service start, which nobody reads unless they already suspect the problem.
This is the same failure shape as #2801 (an abandoned cycle recorded as SUCCESS): the honest state exists, and the surface we monitor reports the reassuring one.
Proposed fix: add j.scheduled to BackgroundJobInsertSql and surface it in get_store_metrics. Ideally also emit the hypertable's actual data span against its configured drop_after, since that is the number that makes the consequence legible (4 days configured vs 18 days 22:56 actual) and is what an operator would check by hand.
Defect 2 — "held" has no automatic release and no alert
The design comment says a held policy "arms itself on the next start" after a backfill, and that it "self-heals: a store that is not yet covered stays paused and arms on the first start AFTER a backfill, with no manual step."
The arming is automatic. The backfill is not. It is the --backfill-rollups CLI verb (Program.cs:278, DarlingCliCommands.cs:4918) — a manual, Windows-only, disk-preflighted operator action. Nothing schedules it, nothing prompts for it, and nothing escalates when a policy has been held for an unbounded period.
So on a store where the rollups were never backfilled — which is the state of any store that newly began receiving a workload, as use1 did at the #2296 split — retention for that family is held permanently, and the raw tier grows without bound. Here that is 65 GB and +13 GB/day, on a 250 GB volume.
Proposed fix: a self-alert when a retention policy has been held continuously for longer than some multiple of its own horizon (raw's is 4 days; held for 16 is unambiguous). The alerting seam already exists — the store-disk-pressure self-alert and the compression-job self-heal (#1585) are the precedent, and the latter is the closer analogue: it detects a stalled job, acts once, and escalates rather than looping.
Operational note
Manually running alter_job(..., scheduled => true) does not stick — the next service start re-measures coverage, gets Short, and re-holds. Any restart or deploy reverts it.
It is also worth stating plainly what a manual arm does: raw query_store_stats holds ~15 days of history that no rollup has materialized, so arming drops the only copy. It disappears from every read path, not just the raw tier. That is precisely what the gate exists to prevent, and it is the reason the gate should be released by backfilling rather than by overriding.
Not filed as a defect
The pause itself, and PauseJobSql / HoldRetentionPolicySql / SetRetentionScheduleSql. Searched the full repo for alter_job, scheduled =>, delete_job, remove_retention_policy, add_retention_policy. Every disable path is the coverage gate, is deliberate, is documented, and is correct. Nothing in #2296 or any migration disables jobs as a side effect; the job ids (1072–1079) persisting across the period also rule out a drop-and-recreate.
Activity
Merge pull request #2878 from erikdarlingdata/fix/2813-held-retention…
erikdarlingdata commentedon Sep 4, 2026
Resolved by #2878, merged to
devas6eaacb94. Closing manually:Fixes #Nonly fires on the dev->main release merge, so completed work otherwise sits open until release.Defect 1 (invisible) is fixed.
get_store_metricsnow carries aretentionblock read live from the catalog — every held policy with its actual data span and how many times its configureddrop_afterit is really holding — and aRetention Heldself-alert rides the existing hourly sweep. Present on every response, including when nothing is held, so an absent block and "nothing is held" cannot look alike.Defect 2 (no automatic release) is deliberately NOT fixed, and I would argue it should not be. The gate already self-releases: it arms on the first start after coverage is achieved. What is missing is the backfill, and automating that is the one action here that can destroy data irrecoverably — the history a held tier holds exists nowhere else, so arming early drops the only copy. This change therefore never acts; it makes the need for an operator decision visible instead of silent, which is what would have caught the 16-day incident. If an automatic release is still wanted, it should be its own issue with its own safety argument.
Also deferred: persisting
j.scheduledinto the metrics series, which is the proposal in this issue and would give the hold a history rather than a current-state read. It needs a migration rung and a parallel lane owned the schema bump; taking a second rung number risks the silent-gap failure. Tracked as a follow-up.On the threshold, since the issue asked for "some multiple of its own horizon" without naming one — production data settled it rather than judgement. Every armed, healthy tier across the three stores:
collection_logcollection_logRetention drops whole CHUNKS, so healthy raw tiers sit at 1.27x while purging perfectly — a threshold at or below that fires hourly on three healthy policies on the largest store. Warning is 2.0x (~8 days on the raw tier, against the 16 it actually took), Critical 4.0x, so the motivating incident at 4.5x reads Critical.
It is judged on the CONJUNCTION of held AND past-horizon, never the paused flag alone:
EnsureRetentionPoliciesAsyncdeliberately creates every policy paused, so a flag-only alert would fire on every fresh store at every start.Fleet state at merge: all 18-19 retention policies on each of the three stores read
scheduled = true. Nothing is held, so #2809 is holding.Make retention held by the coverage gate visible (Fixes erikdarlingda…
Deliver the alert's detail text on every channel (erikdarlingdata#3297)