Skip to content

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

Closed

Description

@erikdarlingdata

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:

  1. A held policy is invisible to every metric we collect, so it went unnoticed for 16 days.
  2. 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

  1. added a commit that references this issue on Sep 4, 2026
    6eaacb9
  2. erikdarlingdata commented on Sep 4, 2026

    @erikdarlingdata
    OwnerAuthor

    Resolved by #2878, merged to dev as 6eaacb94. Closing manually: Fixes #N only fires on the dev->main release merge, so completed work otherwise sits open until release.

    Defect 1 (invisible) is fixed. get_store_metrics now carries a retention block read live from the catalog — every held policy with its actual data span and how many times its configured drop_after it is really holding — and a Retention Held self-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.scheduled into 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:

    tier span horizon ratio
    store A raw tiers (x3) 5.09 d 4 d 1.27x
    store B raw tiers 4.09 d 4 d 1.02x
    store B collection_log 31.09 d 30 d 1.04x
    store A collection_log 18.09 d 30 d 0.60x

    Retention 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: EnsureRetentionPoliciesAsync deliberately 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.

  3. added a commit that references this issue on Sep 10, 2026
    b8f17bb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions