Skip to content

Commit 77dc6b1

Browse files
committed
Fix schedule reload: scheduler now syncs from DB directly (v2.6.63)
Schedule create/update/delete dispatched reload_schedules_task via Celery, but prefork workers execute tasks in pool children while APScheduler runs only in the scheduler-lock-holding main process. The reload always landed in a child and was skipped, so schedule changes never reached the running scheduler without a container restart. The scheduler process now re-syncs its jobs from the database every 60 seconds, gated on a fingerprint of schedule definitions so jobs are only rebuilt on actual change. next_run is computed at schedule creation so the UI shows the upcoming fire before the first run.
1 parent fee9c28 commit 77dc6b1

5 files changed

Lines changed: 129 additions & 2 deletions

File tree

CHANGELOG.MD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).
77

8+
## [2.6.63] - 2026-07-09
9+
10+
### Fixed
11+
12+
- **Created or edited schedules now take effect within a minute instead of requiring a container restart.** Schedule CRUD dispatched `reload_schedules_task` through Celery, but prefork workers execute tasks in pool children while APScheduler runs only in the scheduler-lock-holding main process - the reload always landed in a child, logged "Scheduler not running in this worker", and was skipped, so no schedule change since the mechanism was added ever reached the running scheduler without a restart (verified live: a freshly created `file_changes` schedule never fired at its cron time). The scheduler process now re-syncs its jobs directly from the database every 60 seconds, gated on a fingerprint of the schedule definitions so jobs are only rebuilt when something actually changed. New schedules also get `next_run` computed at creation so the UI shows the upcoming fire immediately instead of after the first run.
13+
814
## [2.6.62] - 2026-07-09
915

1016
### Added

pixelprobe/api/admin_routes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,13 @@ def create_schedule():
382382
is_active=True,
383383
created_at=datetime.now(timezone.utc)
384384
)
385+
# Populate next_run immediately so the UI shows it before the first
386+
# fire (the scheduler's db-sync job registers the actual APScheduler
387+
# job within a minute)
388+
try:
389+
schedule.next_run = calculate_next_run(schedule.cron_expression)
390+
except Exception as e:
391+
logger.warning(f"Could not calculate next_run for new schedule: {e}")
385392
db.session.add(schedule)
386393
db.session.commit()
387394

pixelprobe/scheduler.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ def __init__(self, app=None):
6262

6363
self.pending_retries: Dict[str, int] = {}
6464
self._retry_lock = threading.Lock()
65+
# Snapshot of saved-schedule definitions; the db-sync job reloads jobs
66+
# only when this changes (see _sync_schedules_from_db)
67+
self._schedules_fp = None
6568
self.retry_delay_minutes = self._load_positive_int_env(
6669
'SCHEDULE_RETRY_DELAY_MINUTES', self.DEFAULT_RETRY_DELAY_MINUTES, min_value=1
6770
)
@@ -277,10 +280,31 @@ def init_app(self, app):
277280
misfire_grace_time=60
278281
)
279282
logger.info("Scheduled stuck scan detection to run every 5 minutes")
280-
283+
284+
# Re-sync saved schedules from the database every 60 seconds.
285+
# Schedule create/update/delete happens in gunicorn workers, but
286+
# APScheduler runs only in the process holding the scheduler lock; the
287+
# Celery reload task always executes in a prefork pool child and can
288+
# never reach it, so changes only took effect after a restart. The id
289+
# must not start with 'schedule_' or update_schedules would remove it.
290+
self.scheduler.add_job(
291+
func=self._sync_schedules_from_db,
292+
trigger="interval",
293+
seconds=60,
294+
id="db_schedule_sync",
295+
name="Sync saved schedules from database",
296+
misfire_grace_time=30,
297+
coalesce=True
298+
)
299+
logger.info("Scheduled database schedule sync every 60 seconds")
300+
281301
# Load saved schedules from database
282302
with app.app_context():
283303
self._load_saved_schedules()
304+
try:
305+
self._schedules_fp = self._schedule_fingerprint()
306+
except Exception as e:
307+
logger.warning(f"Could not compute initial schedule fingerprint: {e}")
284308

285309
def _load_exclusions(self):
286310
"""Load path and extension exclusions from environment variables"""
@@ -743,6 +767,38 @@ def update_schedules(self):
743767
# Reload from database
744768
with self.app.app_context():
745769
self._load_saved_schedules()
770+
self._schedules_fp = self._schedule_fingerprint()
771+
772+
def _schedule_fingerprint(self):
773+
"""Snapshot of every field that affects job registration.
774+
775+
Must be called inside an app context.
776+
"""
777+
return tuple(db.session.query(
778+
ScanSchedule.id, ScanSchedule.name, ScanSchedule.cron_expression,
779+
ScanSchedule.scan_type, ScanSchedule.scan_paths,
780+
ScanSchedule.force_rescan, ScanSchedule.time_budget_minutes,
781+
ScanSchedule.is_active
782+
).order_by(ScanSchedule.id).all())
783+
784+
def _sync_schedules_from_db(self):
785+
"""Reload saved schedules when their definitions change in the database.
786+
787+
Schedule CRUD runs in gunicorn workers; APScheduler runs only in the
788+
process holding the scheduler lock. The Celery reload task executes in
789+
a prefork pool child where the scheduler is never running, so it was
790+
always skipped and new/edited schedules never registered until a
791+
restart. Polling a fingerprint keeps the job store in sync without
792+
cross-process messaging, and only churns jobs when something changed.
793+
"""
794+
try:
795+
with self.app.app_context():
796+
fingerprint = self._schedule_fingerprint()
797+
if fingerprint != self._schedules_fp:
798+
logger.info("Schedule definitions changed in database; reloading scheduler jobs")
799+
self.update_schedules()
800+
except Exception as e:
801+
logger.error(f"Schedule database sync failed: {e}")
746802

747803
def _check_stuck_scans(self):
748804
"""Check for stuck scans and mark them as crashed"""

pixelprobe/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Default version - this is the single source of truth
55

66

7-
_DEFAULT_VERSION = '2.6.62'
7+
_DEFAULT_VERSION = '2.6.63'
88

99

1010
# Allow override via environment variable for CI/CD, but default to the hardcoded version

tests/test_scheduler.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime, timedelta, timezone
2+
from unittest.mock import patch
23

34
import pytest
45

@@ -225,6 +226,63 @@ def test_update_schedules_removes_and_reloads(self, scheduler, app, db):
225226
# at least verify the method runs without error
226227

227228

229+
class TestScheduleDbSync:
230+
"""The db-sync job is how schedule CRUD reaches the scheduler process.
231+
232+
The Celery reload task always executes in a prefork pool child where the
233+
scheduler is not running, so it is skipped; without the sync job a created
234+
or edited schedule only registered after a container restart.
235+
"""
236+
237+
@pytest.fixture
238+
def scheduler(self, app, db):
239+
scheduler = MediaScheduler()
240+
scheduler.init_app(app)
241+
yield scheduler
242+
scheduler.shutdown()
243+
244+
def test_sync_job_registered(self, scheduler):
245+
job = scheduler.scheduler.get_job('db_schedule_sync')
246+
assert job is not None
247+
# update_schedules removes every job whose id starts with 'schedule_';
248+
# the sync job must never match that prefix or it would delete itself
249+
assert not job.id.startswith('schedule_')
250+
251+
def test_sync_reloads_when_definitions_change(self, scheduler, app, db):
252+
with app.app_context():
253+
schedule = ScanSchedule(name='Sync Test', cron_expression='0 2 * * *',
254+
scan_type='file_changes', time_budget_minutes=10,
255+
is_active=True)
256+
db.session.add(schedule)
257+
db.session.commit()
258+
259+
with patch.object(scheduler, 'update_schedules') as update:
260+
scheduler._sync_schedules_from_db()
261+
update.assert_called_once()
262+
263+
def test_sync_skips_when_unchanged(self, scheduler, app, db):
264+
with app.app_context():
265+
scheduler._schedules_fp = scheduler._schedule_fingerprint()
266+
with patch.object(scheduler, 'update_schedules') as update:
267+
scheduler._sync_schedules_from_db()
268+
update.assert_not_called()
269+
270+
def test_budget_change_triggers_reload(self, scheduler, app, db):
271+
with app.app_context():
272+
schedule = ScanSchedule(name='Budget Sync', cron_expression='0 2 * * *',
273+
scan_type='file_changes', time_budget_minutes=10,
274+
is_active=True)
275+
db.session.add(schedule)
276+
db.session.commit()
277+
scheduler._schedules_fp = scheduler._schedule_fingerprint()
278+
schedule.time_budget_minutes = 30
279+
db.session.commit()
280+
281+
with patch.object(scheduler, 'update_schedules') as update:
282+
scheduler._sync_schedules_from_db()
283+
update.assert_called_once()
284+
285+
228286
class TestQueueConflictRetry:
229287
"""Test that skipped scheduled scans get a one-shot retry queued."""
230288

0 commit comments

Comments
 (0)