Skip to content

Commit 0681c86

Browse files
fix(docker): always start the RQ scheduler
The scheduler only started when one of four ENABLE_SCHEDULED_* flags was true, but nothing else respects that condition. startup.py registers the netplay, upload-tmp and zip-cache cleanups unconditionally, three more flags gate periodic tasks that are absent from the list, and the filesystem watcher defers its rescans through the scheduler as well. Any of those left jobs sitting in the scheduler registry with no process to run them. On a default install the three cleanups never fire at all. Worse, a watcher running with every scheduled task disabled accumulates delayed scan_platforms entries permanently, and the concurrent scan guard reads those as a scan already queued, so every manual scan is refused with "A scan is already in progress" until Redis is cleared. Restarting does not help, because the entries are persisted state rather than a running process. Start the scheduler unconditionally, matching entrypoint.sh, which never gated it. Also stop counting a scheduler entry whose time passed long ago as a pending scan: an overdue entry means nothing is draining the registry, so treating it as queued cannot recover on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 80e58d5 commit 0681c86

3 files changed

Lines changed: 81 additions & 8 deletions

File tree

backend/endpoints/sockets/scan.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
from dataclasses import dataclass
5+
from datetime import datetime, timedelta, timezone
56
from itertools import batched, chain
67
from typing import Any, Final
78

@@ -70,6 +71,22 @@
7071

7172
STOP_SCAN_FLAG: Final = "scan:stop"
7273

74+
# How far past its scheduled time a delayed scan can be before it is treated as
75+
# orphaned rather than pending. Comfortably clear of the scheduler's own polling
76+
# interval, so a job merely waiting its turn is never discarded.
77+
SCHEDULED_SCAN_GRACE: Final = timedelta(minutes=15)
78+
79+
80+
def _as_utc(value: datetime) -> datetime:
81+
"""Read a scheduler timestamp as UTC.
82+
83+
rq-scheduler stores scheduled times as naive UTC, so they cannot be compared
84+
against an aware "now" without being localised first.
85+
"""
86+
if value.tzinfo is None:
87+
return value.replace(tzinfo=timezone.utc)
88+
return value.astimezone(timezone.utc)
89+
7390

7491
def _scan_platforms_func_name() -> str:
7592
"""Fully qualified name RQ records for a directly enqueued scan.
@@ -121,12 +138,19 @@ def _get_queued_scan_jobs() -> list[Job]:
121138
# The scheduler registry also holds the standing cron entry for the
122139
# scheduled rescan, which is a schedule rather than a pending scan, so only
123140
# delayed scan_platforms jobs count as queued here.
141+
#
142+
# An entry whose time passed long ago is not pending either: the scheduler
143+
# is not draining the registry, so nothing will ever run it. Counting those
144+
# blocks every manual scan for good, which is what a watcher running without
145+
# a scheduler used to cause, so treat them as orphaned instead.
124146
scan_platforms_func_name = _scan_platforms_func_name()
125-
for job in tasks_scheduler.get_jobs():
147+
overdue_cutoff = datetime.now(timezone.utc) - SCHEDULED_SCAN_GRACE
148+
for job, scheduled_for in tasks_scheduler.get_jobs(with_times=True):
126149
if (
127150
isinstance(job, Job)
128151
and get_job_func_name(job) == scan_platforms_func_name
129152
and job.get_status() in (JobStatus.SCHEDULED, JobStatus.QUEUED)
153+
and _as_utc(scheduled_for) > overdue_cutoff
130154
):
131155
jobs[job.id] = job
132156

backend/tests/endpoints/sockets/test_scan.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timedelta, timezone
12
from itertools import count
23
from unittest.mock import AsyncMock, MagicMock, Mock
34

@@ -945,9 +946,20 @@ def make_job(func_name: str, *, status=JobStatus.QUEUED):
945946

946947

947948
def patch_scan_jobs(
948-
mocker, *, running=None, high_queued=(), low_queued=(), scheduled=()
949+
mocker,
950+
*,
951+
running=None,
952+
high_queued=(),
953+
low_queued=(),
954+
scheduled=(),
955+
scheduled_at=None,
949956
):
950-
"""Point every place scan discovery looks at a fixed set of jobs."""
957+
"""Point every place scan discovery looks at a fixed set of jobs.
958+
959+
Scheduler entries carry the time they are due, which discovery uses to tell
960+
a scan waiting its turn from one nothing is going to run. They default to
961+
due shortly, as a naive UTC timestamp, which is what rq-scheduler stores.
962+
"""
951963
worker = MagicMock()
952964
worker.get_current_job.return_value = running
953965
mocker.patch.object(scan_module.Worker, "all", return_value=[worker])
@@ -957,8 +969,14 @@ def patch_scan_jobs(
957969
mocker.patch.object(
958970
scan_module.low_prio_queue, "get_jobs", return_value=list(low_queued)
959971
)
972+
if scheduled_at is None:
973+
scheduled_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
974+
minutes=5
975+
)
960976
mocker.patch.object(
961-
scan_module.tasks_scheduler, "get_jobs", return_value=list(scheduled)
977+
scan_module.tasks_scheduler,
978+
"get_jobs",
979+
return_value=[(job, scheduled_at) for job in scheduled],
962980
)
963981

964982

@@ -1052,6 +1070,36 @@ async def test_standing_rescan_cron_entry_does_not_block(self, mocker, emit):
10521070

10531071
enqueue.assert_called_once()
10541072

1073+
async def test_overdue_scheduled_scan_does_not_block(self, mocker, emit):
1074+
# A watcher deferring rescans while no scheduler runs leaves them in the
1075+
# registry with nothing to execute them. Long past due, they are
1076+
# orphaned, and counting them blocks every manual scan for good.
1077+
patch_scan_jobs(
1078+
mocker,
1079+
scheduled=[make_job(SCAN_PLATFORMS_FUNC, status=JobStatus.SCHEDULED)],
1080+
scheduled_at=datetime.now(timezone.utc).replace(tzinfo=None)
1081+
- timedelta(hours=6),
1082+
)
1083+
enqueue = mocker.patch.object(scan_module.high_prio_queue, "enqueue")
1084+
1085+
await scan_handler("sid", {"type": "quick"})
1086+
1087+
enqueue.assert_called_once()
1088+
1089+
async def test_aware_scheduler_timestamps_still_block(self, mocker, emit):
1090+
# rq-scheduler stores naive UTC, but an aware timestamp must compare the
1091+
# same way rather than raising on the mixed comparison.
1092+
patch_scan_jobs(
1093+
mocker,
1094+
scheduled=[make_job(SCAN_PLATFORMS_FUNC, status=JobStatus.SCHEDULED)],
1095+
scheduled_at=datetime.now(timezone.utc) + timedelta(minutes=5),
1096+
)
1097+
enqueue = mocker.patch.object(scan_module.high_prio_queue, "enqueue")
1098+
1099+
await scan_handler("sid", {"type": "quick"})
1100+
1101+
enqueue.assert_not_called()
1102+
10551103
async def test_ignores_unrelated_jobs(self, mocker, emit):
10561104
# Only scans block scans; a cleanup or metadata task must not.
10571105
patch_scan_jobs(

docker/init_scripts/init

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,11 @@ run_startup
376376
while ! ((exited)); do
377377
watchdog_process_pid gunicorn
378378

379-
# only start the scheduler if enabled
380-
if [[ ${ENABLE_SCHEDULED_RESCAN} == "true" || ${ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB} == "true" || ${ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA} == "true" || ${ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES} == "true" ]]; then
381-
watchdog_process_pid rq_scheduler
382-
fi
379+
# The scheduler always runs. startup.py registers the netplay, upload-tmp and
380+
# zip-cache cleanups unconditionally, and the watcher defers its rescans
381+
# through the scheduler, so gating it on the ENABLE_SCHEDULED_* flags left
382+
# those jobs queued with nothing to execute them.
383+
watchdog_process_pid rq_scheduler
383384

384385
watchdog_process_pid rq_worker
385386

0 commit comments

Comments
 (0)