Skip to content

Commit 24ac966

Browse files
committed
Degrade failing instances at startup instead of exiting the whole app
Previously any single instance failing its startup check terminated decluttarr via wait_and_exit(), taking healthy instances down with it and causing container crash-loops on slow servers (ManiMatter#317 follow-up). Now each unit (arr, qBittorrent, SABnzbd) records a readiness state: - Transient failures (timeout, connection, 5xx, unknown) degrade the instance; setup is re-attempted every timer cycle and the instance rejoins automatically (including its detect_deletions watchers). - Definitive config errors (401/403, wrong username/password, bad SABnzbd api key, non-English UI, client version too old) degrade the instance with a per-cycle ERROR + tip; they are not retried since they cannot heal without user action (and retrying a bad password would get the IP banned by qBittorrent). - The app exits when nothing is configured, or when every configured unit has failed definitively - re-checked each cycle, not only at launch. Degraded download clients are skipped everywhere a job would call them (bandwidth checks, obsolete-tagging, bad-file handling) via a ready_only lookup. Removal jobs additionally fail closed: a download whose configured client is degraded is left untouched rather than deleted, since its protection status (protected tag, private/public tracker) cannot be verified while the client is down. Wrong-arr-type and arr-version-too-old keep their existing log-and-continue behavior. Repeated identical setup failures log a single-line skip instead of the full error block each cycle. Also fixes main.py handing the deletion watchers to a throwaway WatcherManager, which left terminate() stopping an instance that owned no observers.
1 parent 58510b1 commit 24ac966

22 files changed

Lines changed: 879 additions & 81 deletions

main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from src.job_manager import JobManager
99
from src.settings.settings import Settings
1010
from src.utils.log_setup import logger
11-
from src.utils.startup import launch_steps
11+
from src.utils.startup import launch_steps, retry_degraded_instances
1212

1313
settings = Settings()
1414
job_manager = JobManager(settings)
@@ -53,13 +53,18 @@ async def main():
5353
await launch_steps(settings)
5454

5555
if settings.jobs.detect_deletions.enabled:
56-
await WatcherManager(settings).setup()
56+
await watch_manager.setup()
5757
# Start Cleaning
5858
while True:
5959
logger.info("-" * 50)
6060

61+
# Give degraded instances a chance to rejoin before this cycle's jobs
62+
await retry_degraded_instances(settings, watch_manager)
63+
6164
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
6265
for qbit in settings.download_clients.qbittorrent:
66+
if not qbit.ready:
67+
continue
6368
try:
6469
await qbit.refresh_cookie()
6570
except Exception as err: # noqa: BLE001
@@ -70,6 +75,8 @@ async def main():
7075

7176
# Run script for each instance
7277
for arr in settings.instances:
78+
if not arr.ready: # skip was already logged by retry_degraded_instances
79+
continue
7380
await job_manager.run_jobs(arr)
7481
logger.verbose("")
7582

src/deletion_handler/deletion_handler.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,35 +99,52 @@ async def setup(self):
9999
for arr, folder_path in folders_to_watch:
100100
self.set_watcher(arr, folder_path)
101101

102+
async def setup_for_arr(self, arr):
103+
"""Set up deletion watchers for a single arr (e.g. one that rejoined after a degraded startup)."""
104+
if self.loop is None:
105+
self.loop = asyncio.get_running_loop()
106+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
107+
self.set_watcher(arr, folder_path)
108+
102109
async def get_folders_to_watch(self):
103110
"""Gets from all arrs the root folders and lists those that are accessible for the arr, and have present for decluttarr."""
104111
folders_to_watch = []
105112
logger.verbose("")
106113
logger.verbose("*** Setting up monitoring for deletions ***")
107114
for arr in self.settings.instances:
108-
if arr.arr_type not in (
109-
"sonarr",
110-
"radarr",
111-
): # only working for sonarr / radarr for now
112-
continue
113-
root_folders = await arr.get_root_folders()
114-
115-
for folder in root_folders:
116-
if folder.get("accessible") and "path" in folder:
117-
path = Path(folder["path"])
118-
if path.exists():
119-
folders_to_watch.append((arr, folder["path"]))
120-
else:
121-
logger.warning(
122-
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
123-
)
115+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
116+
folders_to_watch.append((arr, folder_path))
117+
118+
return folders_to_watch
119+
120+
async def get_folders_to_watch_for_arr(self, arr):
121+
"""Root folder paths of one arr that are accessible for both the arr and decluttarr."""
122+
folders_to_watch = []
123+
if arr.arr_type not in (
124+
"sonarr",
125+
"radarr",
126+
): # only working for sonarr / radarr for now
127+
return folders_to_watch
128+
if not arr.ready: # degraded instance; watchers are added when it rejoins
129+
return folders_to_watch
130+
root_folders = await arr.get_root_folders()
131+
132+
for folder in root_folders:
133+
if folder.get("accessible") and "path" in folder:
134+
path = Path(folder["path"])
135+
if path.exists():
136+
folders_to_watch.append(folder["path"])
137+
else:
138+
logger.warning(
139+
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
140+
)
141+
logger.info(
142+
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
143+
)
144+
if self.settings.envs.in_docker:
124145
logger.info(
125-
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
146+
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
126147
)
127-
if self.settings.envs.in_docker:
128-
logger.info(
129-
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
130-
)
131148

132149
return folders_to_watch
133150

src/job_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ async def run_download_client_jobs(self):
5656
)
5757

5858
for client in download_clients:
59+
if not client.ready:
60+
continue
61+
5962
# Get jobs for this client
6063
download_client_jobs = self._get_download_client_jobs_for_client(
6164
client,
@@ -150,6 +153,8 @@ async def _download_clients_connected(self):
150153

151154
async def _check_client_connection_status(self, clients):
152155
for client in clients:
156+
if not client.ready: # never-set-up client must not veto or be polled
157+
continue
153158
logger.debug(
154159
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected",
155160
)

src/jobs/removal_handler.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ async def _tag_as_obsolete(self, affected_download, download_id):
4444
f"Job '{self.job_name}' triggered obsolete-tagging: {affected_download['title']}"
4545
)
4646
for qbit in self.settings.download_clients.qbittorrent:
47+
if not qbit.ready:
48+
continue
4749
await qbit.set_tag(
4850
tags=[self.settings.general.obsolete_tag], hashes=[download_id]
4951
)
@@ -55,12 +57,12 @@ async def _get_handling_method(self, download_id, affected_download):
5557
download_client_name = affected_download["downloadClient"]
5658
_, download_client_type = (
5759
self.settings.download_clients.get_download_client_by_name(
58-
download_client_name
60+
download_client_name, ready_only=True
5961
)
6062
)
6163

6264
if download_client_type != "qbittorrent":
63-
return "remove" # handling is only implemented for qbit
65+
return "remove" # handling is only implemented for qbit (and only if ready)
6466

6567
if len(self.settings.download_clients.qbittorrent) == 0:
6668
return "remove" # qbit not configured, thus can't tag

src/jobs/removal_job.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def run(self) -> int:
5050

5151
# -- Checks --
5252
self._ignore_protected()
53+
self._ignore_degraded_client_downloads()
5354
if self.max_strikes:
5455
self.affected_downloads = self.strikes_handler.filter_strike_exceeds(
5556
self.affected_downloads, self.queue
@@ -76,6 +77,35 @@ def _ignore_protected(self):
7677
if download_id not in self.arr.tracker.protected
7778
}
7879

80+
def _ignore_degraded_client_downloads(self):
81+
"""Fail-closed: never remove a download whose configured client is degraded.
82+
83+
A degraded (not-ready) download client can't be queried for protection
84+
status (protected tag, private/public tracker), so its downloads must be
85+
left untouched rather than deleted blindly. Downloads on healthy clients,
86+
or on clients not configured in decluttarr, are unaffected.
87+
"""
88+
safe = {}
89+
skipped = 0
90+
for download_id, queue_items in self.affected_downloads.items():
91+
client_name = queue_items[0].get("downloadClient") if queue_items else None
92+
client = None
93+
if client_name:
94+
client, _ = self.settings.download_clients.get_download_client_by_name(
95+
client_name
96+
)
97+
if client is not None and not client.ready:
98+
skipped += 1
99+
continue
100+
safe[download_id] = queue_items
101+
102+
if skipped:
103+
logger.warning(
104+
f">>> {self.job_name}: skipped {skipped} download(s) on {self.arr.name} "
105+
"whose download client is degraded (protection can't be verified; left untouched).",
106+
)
107+
self.affected_downloads = safe
108+
79109
@abstractmethod # Implemented on level of each removal job
80110
async def _find_affected_items(self) -> None:
81111
pass

src/jobs/remove_bad_files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def _group_download_ids_by_client(self):
6262

6363
download_client, download_client_type = (
6464
self.settings.download_clients.get_download_client_by_name(
65-
download_client_name
65+
download_client_name, ready_only=True
6666
)
6767
)
6868
if not download_client or not download_client_type:

src/jobs/remove_slow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ async def add_download_client_to_queue_items(self):
168168
download_client_name = item["downloadClient"]
169169
download_client, download_client_type = (
170170
self.settings.download_clients.get_download_client_by_name(
171-
download_client_name
171+
download_client_name, ready_only=True
172172
)
173173
)
174174
item["download_client"] = download_client

src/settings/_download_clients.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,16 @@ def check_unique_download_client_types(self):
8181
seen.add(name.lower())
8282

8383
def get_download_client_by_name(
84-
self, name: str, download_client_type: str | None = None
84+
self,
85+
name: str,
86+
download_client_type: str | None = None,
87+
ready_only: bool = False,
8588
):
8689
"""
8790
Retrieve the download client and download client type by its name.
8891
If download_client_type is provided, search only in that type.
92+
If ready_only is True, a client that failed its startup check (not ready)
93+
is treated as unavailable, so degraded clients never get called from jobs.
8994
"""
9095
name_lower = name.lower()
9196
types_to_search = (
@@ -97,6 +102,8 @@ def get_download_client_by_name(
97102

98103
for download_client in download_clients:
99104
if download_client.name.lower() == name_lower:
105+
if ready_only and not getattr(download_client, "ready", False):
106+
return None, None
100107
return download_client, client_type
101108

102109
return None, None

0 commit comments

Comments
 (0)