Recurring problems
Implementation contracts and behavior for recurring problems.
The Doctor explains one stuck download and the agent repairs it, but nothing in the product has ever said “this is the fourth time this month, and repairing it again will not help”: an admin would have to spot that themselves, across issues the agent handled and they mostly never saw. An hourly pass rolls recent auto-detected issues up per (instance, problem label) and, where there is honest advice to give, raises one issue naming the setting that would stop it. The advice catalog is internal/arr/prevention.go, beside the Doctor whose labels key it; the measuring is internal/remediation/prevention.go.
It is advice, and it never writes configuration. Nothing here changes a setting on any service, and that is not a staging decision: most of what a notice names is outside what Cantinarr could write anyway: there is no client support for release profiles, indexer settings, or delay profiles, and the download-client config surface is read-only: while inventing a config change out of one week’s incidents would be worse than saying nothing. Every notice ends by saying so in as many words.
“Keeps happening” is three separate calendar days, and the day count is the condition that carries the meaning. A pattern must clear all three thresholds together over a 90-day window: at least 3 issues, across at least 2 distinct media, on at least 3 distinct days. An auto issue is opened per exact media scope, so ONE five-minute event: a download client dropping out, a disk filling, a wrong path mapping: fans out into dozens of issues across dozens of titles inside the same minute. Counting issues and distinct titles alone, Cantinarr would announce “this keeps happening” about something that happened once, which is precisely the lie this feature exists to avoid. Separate days are recurrence; fan-out is not. Distinct media stays as a secondary guard so one title flapping for a week cannot pass either. The roll-up also requires the instance to still exist: deleting an instance does not delete its issues, and nobody needs advice about the settings of an *arr that was removed last week.
Two details of that query are load-bearing. Days are counted with substr(created_at, 1, 10) rather than date(), because issues.created_at carries two encodings: the SQLite driver stores a bound time.Time in Go’s own String() form, while a row that omits the column gets SQLite’s CURRENT_TIMESTAMP: and date() returns NULL for the first, which would silently collapse every distinct-day count to 1 and make the threshold meaningless. Both encodings share the leading YYYY-MM-DD. And source = 'auto' is written as a literal rather than bound, against this package’s usual habit, because it is the predicate on the partial idx_issues_problem_recurrence: only a literal lets SQLite use that index as a covering index and satisfy the GROUP BY from index order instead of scanning issues into a temp B-tree.
A label with no honest preventative answer gets no catalog entry, and an absent entry means no notice is ever raised for that label. “Waiting to import” and “Already imported” are ordinary states; “Import blocked” and “Download error” are too generic to advise on. Manufacturing advice to fill the table would teach an admin to skip the table. Each entry also carries a scope: instance, client, or disk: because the answer is frequently not the *arr the incident was detected on: two Sonarrs pointed at one qBittorrent each raise their own notice about the same box, and the notice says which system the change belongs to.
The notice itself is one source=system issue at needs_admin (media_type system, tmdb_id 0), keyed system:prevention:<instance>:<hash of instance|label> so it is namespaced away from queue incidents, the pre-air keys, and the health sinks. It states the measurement first: how many times, across how many titles, on how many days: because those counts are the whole argument for the notice existing, then why repeating the repair cannot stop it, then the places to look in the service’s own menu vocabulary. Its own problem_kind is left NULL, so it can never be counted as an occurrence of the very problem it describes (nor match a standing auto-approval rule). Unlike the health sinks, which page immediately, its alert goes through the ordinary 3-minute issue_created hold-down: none of this is urgent, and a notice closed inside that window should never have paged at all. Every field of the advice’s INSTRUCTIONS is a server-side code constant, and the instance is named by its display name: never its URL. The measurement side now also quotes the live values of the settings the notice names, where a section is readable (indexer min seeders, download-client summaries, remote path mappings): bounded, secret-free summaries built inside the arr client. And the advice notices being taken: the hourly pass re-reads the named section on every open notice, and a CHANGE from the captured baseline resolves it as prevention_setting_changed: unchanged values never resolve, a notice with no captured baseline never resolves on first sight, and a pattern that re-forms from newer incidents raises fresh.
The mute already existed. prevention_notices is the durable memory of what has been said and when; the wait before saying it again is chosen by what the admin did about the last one, using the three closures the thread screen already offers on a needs_admin issue:
| The admin chose | Next raise no sooner than |
|---|---|
| Mark resolved | 60 days |
| Dismiss | 180 days |
| Close without fix | 365 days |
“Close without fix” is literally an administrator saying they have decided not to fix this, so it is the longest: and once a notice has been raised three times every cooldown becomes that longest one, whatever the admin chose, because a cause nobody has fixed after three tellings will not be fixed by hearing it more often. Nothing is re-raised while the last notice is still open in front of the admin: but its counts are kept current, because the counts are the whole argument for the notice and an admin who leaves it open for two months should not still be reading the three occurrences it was raised on. That refresh deliberately does not mark it unread again: re-flagging it every hour would be the nag this design exists to avoid. A served cooldown is not sufficient on its own either: the pattern must re-form from incidents newer than the last raise, so a cause that actually stopped never returns whatever the cooldown says, and a sliding 90-day history can never re-page an admin on the strength of incidents they have already seen. There is no new route, no new setting, and no client change anywhere in this: closing the thread is the control.
The pass rides the observation sweeper’s existing goroutine, every 60th one-minute tick, for the same reason the pre-air fallback rides it: a second timer would race reconciliation for the one DB connection. Recurrence is measured in days, so hourly is already far finer than the thing it looks at, and it is a DB-only pass: no arr is contacted. Decisions and owed pushes pace themselves on their own faster clock (rule approvals strictly before the flushes, exactly as before), so a slow arr in the observation sweep can no longer delay a standing rule’s approval or an admin’s page; and a notice’s hold-down still starts the moment it is raised.