diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 74898f40..8148b195 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -542,3 +542,39 @@ def schedule(self): repr(entry) for entry in self._schedule.values()), ) return self._schedule + + +class DryRunDatabaseScheduler(DatabaseScheduler): + """ + DatabaseScheduler in dry-run mode. + + The Scheduler reads Periodic Tasks from the database but does not execute them, + only logging when they would have been triggered. + Useful in environments where tasks should not actually run, but the scheduler + must remain operational. + """ + + def apply_entry(self, entry, producer=None): + """ + Overwritten method to log the triggered tasks instead of actually running them. + """ + debug( + 'Dry-run mode: Skipping task %s %s %s', + entry.task, + entry.args, + entry.kwargs + ) + + def sync(self): + """ + Override sync to avoid persisting execution metadata in dry-run mode. + + In DatabaseScheduler, sync() saves dirty entries (including updated + last_run_at and total_run_count) back to the database. For a dry-run + scheduler this would be misleading, since tasks are never actually run. + + By overriding sync as a no-op, we ensure that dry-run operation does not + modify PeriodicTask records in the database while still allowing the + scheduler machinery to operate normally. + """ + debug('Dry-run mode: Skipping database sync of scheduled tasks.') diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index 85c6694f..b773ffe3 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -56,6 +56,18 @@ def save(self): raise RuntimeError('this is expected') +class DryRunTrackingScheduler(schedulers.DryRunDatabaseScheduler): + Entry = EntryTrackSave + + def __init__(self, *args, **kwargs): + self.flushed = 0 + schedulers.DryRunDatabaseScheduler.__init__(self, *args, **kwargs) + + def sync(self): + self.flushed += 1 + schedulers.DryRunDatabaseScheduler.sync(self) + + class TrackingScheduler(schedulers.DatabaseScheduler): Entry = EntryTrackSave @@ -1408,6 +1420,56 @@ def test_scheduler_valid_hours(self): assert 0 <= hour_value <= 23 +@pytest.mark.django_db +class test_DryRunDatabaseScheduler(SchedulerCase): + Scheduler = DryRunTrackingScheduler + + @pytest.fixture(autouse=True) + def setup_scheduler(self, app): + self.app = app + self.app.conf.beat_schedule = {} + + self.m1 = self.create_model_interval( + schedule(timedelta(seconds=10)), + last_run_at=self.app.now() - timedelta(days=1), + ) + self.m1.save() + + self.s = self.Scheduler(app=self.app) + + def test_apply_entry_logs_without_dispatching(self): + entry = self.s.schedule[self.m1.name] + self.s.apply_async = MagicMock() + + with patch('django_celery_beat.schedulers.debug') as mock_debug: + self.s.apply_entry(entry) + + self.s.apply_async.assert_not_called() + mock_debug.assert_called_once_with( + 'Dry-run mode: Skipping task %s %s %s', + entry.task, + entry.args, + entry.kwargs, + ) + + def test_sync_does_not_persist_run_metadata(self): + entry = self.s.schedule[self.m1.name] + initial_flushes = self.s.flushed + original_last_run_at = entry.model.last_run_at + original_total_run_count = entry.model.total_run_count + + entry.model.last_run_at = entry.last_run_at + timedelta(hours=1) + entry.model.total_run_count += 1 + self.s._dirty.add(entry.name) + + self.s.sync() + self.m1.refresh_from_db() + + assert self.s.flushed == initial_flushes + 1 + assert self.m1.last_run_at == original_last_run_at + assert self.m1.total_run_count == original_total_run_count + + @pytest.mark.django_db class test_models(SchedulerCase):