Bound the force-plan bot's decision journal with a retention horizon - #2986
Conversation
collect.plan_force_actions is append-only, is not in CollectorCatalog.All and is not a hypertable, so neither the catalog purge loop nor chunk dropping ever reached it and nothing else bounded its size. Enrolled at PlanForceLedgerRetentionDays (365) through the same batched DELETE the config_alert_log and config.config_command purges use, on its own action_time column, failure-isolated like every sibling. A year is deliberately the longest horizon in the store: this is the audit trail of a bot writing to production servers, so it has to outlive the metrics that motivated each decision. A DELETE rather than a hypertable conversion because action_id is a PRIMARY KEY that related_action_id points back to, and TimescaleSupport already excludes PK-bearing tables for exactly that reason. The V107 doc comment claimed the table was deliberately not enrolled in retention on the grounds that the bot's cooldowns bound its volume. Those cooldowns bound the arrival rate, which is not a size bound, so the comment now states the horizon that does bound it.
The retention section enumerates the non-hypertable bounded deletes, so it understated the set while the journal's purge was absent from it.
|
Reviewed. This is a clean, well-scoped addition — I couldn't find a correctness, parity, security, or performance issue to flag. Specifically verified:
One pre-existing (not introduced by this PR) pattern worth noting for awareness only: |
overstating what its index does for the purge The Darling README's retention table is what an operator reads to learn how long anything is kept, so the journal gets a row there, labelled as a fixed horizon rather than a per-collector one because that table's other rows come from CollectorScheduleDefaults and this one does not. The purge comment claimed idx_plan_force_actions_time serves the min(action_time) probes and the slice range scan. It cannot: the index leads with server_id and the DELETE has no server_id predicate, so it is not seekable here. The work bound is the one-day slice, which is what makes the purge cheap, and the comment now says that instead.
The class summary enumerates which tables never convert to hypertables and so stay on the batched DELETE, and which are owned elsewhere. The journal belongs in the first list, for its own reason: it keeps an identity PRIMARY KEY its own rows reference, where the other two are config-side registry tables.
|
Reviewed. This is a clean, narrowly-scoped fix that follows the existing purge patterns exactly (same shape as Checked and confirmed:
No correctness, security, or performance issues found. Nothing to flag. |
Part of #2948.
collect.plan_force_actions— the auto force-plan bot's decision journal — had no purge path. It is not inCollectorCatalog.All, so the catalog-driven loop inDarlingRetentionskipped it, and it was never converted to a hypertable, so there was no chunk dropping either. Append-only by design, with nothing bounding it in either sense.The gap is real rather than a grep artifact, positive-controlled both ways:
plan_force_actionsappears 0 times inDarlingRetention.csand 0 times inCollectorCatalog.cs, while the same grep form findscollection_log15 times andfile_io_statsonce in the former, and both in the latter.The premise the issue got wrong, and why the fix still stands
The V107 doc comment did not merely omit a horizon — it asserted one should not exist: "Deliberately NOT enrolled in retention ... its volume is bounded by the bot's own cooldowns." The issue quotes that same comment but stops one clause short of it.
That justification does not hold. The cooldowns bound the arrival rate; a bounded rate over unbounded time is unbounded size. So the documented decision rested on a false premise, and the property it was actually protecting — an audit of writes to production outliving the metrics that motivated it — is preserved by a long horizon rather than by no horizon. The comment now states the horizon that does the bounding. Nothing pinned the old claim; it existed only as prose.
Horizon
PlanForceLedgerRetentionDays = 365, inDarlingRetention.csbeside its siblings — a named constant, so overriding it is a one-line change.A fixed horizon independent of
DataRetentionBaseDays, followingAlertHistoryRetentionDays(90), which is the closest analogue in kind. Deliberately the longest horizon in the store, because this is the only series recording a bot writing to production servers: 4x alert history, 12x the metric base. Nearly free — volume is capped by the bot's per-query cooldown and per-server daily force budget, so the ceiling is a few decisions per server per day, not a sample per collection cycle. It also clears the longest window the bot itself reads back for eligibility (a week, for the two-taken-back-forces cooldown) by a wide margin, so retention cannot make the bot forget a decision it is still bound by.Batched DELETE, not a hypertable
TimescaleSupportalready excludes the registry tables from conversion because "registries keep their PRIMARY KEYs, which TimescaleDB would reject or force onto the partition column". This table hasaction_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, andrelated_action_idpoints back to it — that self-reference is how a review row references the force row it re-judges. Forcing the key onto(action_id, action_time)would break it. So conversion is not merely poor value for a table holding one row per force/unforce decision per server; it is the thing the repo already declines to do to PK-bearing tables, and it would need a data-moving migration rung against the census register on top.On the index:
idx_plan_force_actions_timeleads withserver_idand this DELETE has noserver_idpredicate, so it is not seekable here — #2948 says the existing index makes the delete cheap, and that part does not hold. What makes it cheap is the one-day slice, exactly as forconfig_alert_log's purge againstidx_config_alert_log_time (server_id, metric_name, alert_time), and the bot's cooldowns cap arrivals at a few rows per server per day so there is never much to scan. The code comment says this rather than the original claim.Where it lives
Beside the
config_alert_logandconfig.config_commandpurges inPurgeAsync, matching that precedent exactly:PurgeOneAsyncoverTimeSlicedDeleteSql, counted intotablesPurged/totalRowsDeleted, and failure-isolated — a null return warns, incrementstablesFailedand lets the sweep continue, so this table failing cannot abort the others. Schema-qualified to match the V107 DDL andPgPlanForceActionStore; unlikeconfig.config_commanda bare name would also resolve, but naming the schema keeps purge and writer readable against each other.Tests
The failure mode worth pinning is a retention path that silently purges nothing, so the constant-and-SQL-string pin is deliberately not the evidence — it passes against a no-op. The behavioural assertion lives in the existing live-Postgres end-to-end purge test, whose two ages discriminate the horizon rather than just exercise it: a 400-day row goes, and a 100-day row survives even though it is past every other horizon in the store (90-day alert history, 60-day
collection_log, 30-day base).Verified red-first against a real PostgreSQL 17 + TimescaleDB 2.28.1 store, three mutations, each asserted applied by anchor count and content hash and each confirmed to change the output assembly:
365→90AlertHistoryRetentionDays, constant left at 365Two of the three leave the test assembly byte-identical and move only
PerformanceMonitor.Darling.Service.dll, and two are invisible to the pin — which is the case for the behavioural test carrying the contract.Docs
docs/how-collection-works.md's retention section enumerates the non-hypertable bounded deletes, so it understated the set while this purge was missing from it; it now names the journal's horizon.Darling/README.md's horizon table is what an operator reads to learn how long anything is kept, so the journal gets a row there too, labelled a fixed horizon rather than a per-collector one — every other row in that table comes fromCollectorScheduleDefaultsand this one does not.That README table has two problems this PR deliberately does not fix, because another lane owns them: its preamble claims the whole table is "driven by the same shared per-collector horizons Lite uses" (it already mixes in non-collector tables), and it lists
collection_logat 30 days whenCollectionLogRetentionDaysis 60. My row is written to be relocatable into whichever shape that lane lands.Lite
No equivalent table, measured rather than assumed: the force-plan bot exists only under
Darling/(Service, Viewer, Tests — zero files underLite/), and none of the 12 tables in Lite'sSchema.GetAllTableStatements()is a plan-force journal. TheForcePlanFailure*hits underLite/are a different concept — reading a monitored server's own Query Store forcing failures, not a bot's audit trail of its own writes. So this is not the one-SKU half of a shared seam; the subsystem does not exist in Lite, and there is nothing to keep in parity.Not verified
No schema change and no new migration rung, so no rung-census or ladder impact. The write path (#2138 / PR #2731) is treated as absent. The "one row per force/unforce decision per server" rate is from reading the writer, not observed traffic — the table is empty everywhere the bot is off, which is everywhere, so it cannot be measured. Landing it now is the point: the retention path arrives with no data at risk and before the writer ships.