Repository navigation
Held retention policies only arm on service restart - re-evaluate them on a periodic cadence too #3812
Description
Summary
A retention policy held by the coverage gate can only be armed by TimescaleSupport.EnsureRetentionPoliciesAsync (Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs:4773), and that method has exactly one call site: the service startup path, Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs:1397.
So once coverage catches up, nothing arms the policy until someone restarts the service. That is a bad dependency for a service that is designed to run for weeks or months untouched. The only operators who get a timely release are the ones who happen to update often.
Add a periodic re-evaluation on the running service. Keep the startup check — this is an additional cadence, not a replacement.
Why this is safe to run repeatedly (it already is)
The arming path was written to be re-runnable and is already exercised that way:
- Every step is idempotent:
add_retention_policy(if_not_exists => true)returns-1for an existing policy, the horizon converge is named-config-only and a no-op once converged, and arm/hold are directalter_jobstate sets. - The verdict is a measurement, not an inference —
MeasureRetentionCoverageAsync(TimescaleSupport.cs:4694) returns three-valuedCovered/Short/Unknownand is fail-closed.Unknowntouches nothing in either direction (TimescaleSupport.cs:4868-4875), so a probe timeout on a periodic run cannot stop purging fleet-wide. - It is failure-isolated per policy.
TimescaleSupportTests.cs:1487/:1496already call it twice in a row and assert convergence, and:1627/:1666/:1696walk a hold through to an arm across repeated calls. Repeat invocation is a tested property, not a hope.
The cost is small: one coverage query per policy (RetentionArmSafetySql — a chunk-pruned min(time) over the source plus each consumer), 20 policies on a current store.
Suggested shape
Mirror the fleet-level periodic-maintenance pattern that already sits a few lines away in the same loop — the compression-job self-heal (#1581):
s_compressionCheckInterval = TimeSpan.FromHours(1)(DarlingWorker.cs:118)_nextCompressionCheckUtcfield,MinValue-seeded (DarlingWorker.cs:480)- the tick at
DarlingWorker.cs:2159-2162, gated on_timescaleAvailable
A s_retentionPolicyCheckInterval / _nextRetentionPolicyCheckUtc pair in the same block, calling the same EnsureRetentionPoliciesAsync, is the whole change. Hourly matches the sibling cadences and is far tighter than "next restart"; anything from 1-6h is defensible since a hold costs disk slowly.
One thing to get right: the periodic call needs its own store connection from the worker's pool rather than reusing the startup-scoped timescaleConnection, which is disposed after the setup block.
Log noise
EnsureRetentionPoliciesAsync always logs a summary line, and logs one HELD PAUSED warning per held policy. Hourly, a store with a long-lived hold would emit the same warnings 24x/day. Either downgrade the periodic pass's per-policy log to Debug and keep the summary, or log only on state change (held->armed, armed->held, converged). State-change-only is the better answer: an arm is exactly the event an operator wants in the log, and it is currently the one thing that never gets a distinct line.
Docs and strings that become wrong
Several places assert the restart requirement in prose, and all of them are load-bearing (one is the remediation text an operator reads inside the alert itself):
docs/retention-hold-runbook.md— step 3 of the sequence and the whole "Why step 3 is not optional" section.DarlingSelfAlertEvaluator.cs:3789— theRetention Heldalert's remediation text, verbatim: "arms at STARTUP rather than the moment coverage catches up - EnsureRetentionPoliciesAsync runs on the service start path and nowhere else."TimescaleSupport.cs:4762— theEnsureRetentionPoliciesAsyncdoc comment: "this gate arms the held policy by itself on the next start."TimescaleSupport.cs:4864— theHELD PAUSEDwarning text: "the policy arms itself on the next start."DarlingSelfAlertTests.cs:3741andTimescaleSupportTests.cs:1377pin some of this wording.
The --backfill-rollups runbook should end up saying: run the backfill, and the hold clears on its own within the check interval; restart if you want it immediately.
Guard to watch
PayloadDimensionTests.cs:1453 string-scans for MethodBody(source, "public static async Task<int> EnsureRetentionPoliciesAsync") and fails loudly on a parse miss. Adding a call site is fine; changing the signature is not free.
Field evidence (DARLING01, 2026-09-20)
Startup logs from both 2026-09-19 and 2026-09-20 report the identical line:
20/20 retention policies in place, 16 armed, 4 held paused pending backfill
Two restarts, no change — because coverage is genuinely Short, which is the gate working correctly. The four holds and their measured shortfalls:
| Held policy | Waiting on | Source oldest | Consumer oldest |
|---|---|---|---|
query_store_stats_hourly |
query_store_stats_daily |
2026-09-02 23:00 | 2026-09-04 00:00 |
query_store_stats_corrected_hourly |
query_store_stats_corrected_daily |
2026-09-02 23:00 | 2026-09-04 00:00 |
query_store_stats_interval_hourly |
query_store_stats_corrected_daily |
— | 2026-09-04 00:00 |
query_store_stats_interval_daily |
query_store_stats_daygrain_daily |
2026-09-04 00:00 | empty |
That store is restarted often (nightly installs). A field store on a stable build would sit held indefinitely after a backfill that actually worked, with the Retention Held alert continuing to fire — which is precisely the "reads like the backfill failed" failure the runbook warns about, except with no restart coming to end it.
Activity
erikdarlingdata commentedon Sep 20, 2026
Audited the rest of the startup path for the same defect class. Three siblings, all filed:
_timescaleAvailableis probed once at startup and latched. A transient store blip during start silently disables the hourly store-job health check for the life of the process, which takes the Retention Held alert down with it - so a store in that state cannot even report the condition this issue is about.policy_refresh_continuous_aggregatejob stops a rollup materializing, which is the upstream cause of a retention hold, and nothing detects it.If a shared periodic convergence pass lands for #3817, this issue folds into it -
EnsureRetentionPoliciesAsyncis one member of that same sequence and wants the same cadence.Held retention policies waited for a restart nobody was going to do: …
erikdarlingdata commentedon Sep 20, 2026
Closed by the merge of #3829 (its body names this issue with a closing keyword; keywords do not fire against dev, so the watcher closes it).
Store-object convergence runs every hour, not only at start (#3817)
Store-object convergence runs every hour, not only at start: one fail…
Wire the raw-job converge into the retention sweep (#4299)