Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions django_celery_beat/schedulers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Comment thread
auvipy marked this conversation as resolved.
Comment on lines +547 to +566

Copilot AI Jan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new DryRunDatabaseScheduler class lacks test coverage. Given that the repository has comprehensive test coverage for the DatabaseScheduler (in t/unit/test_schedulers.py), tests should be added to verify the dry-run behavior, including confirming that tasks are logged but not executed, and that the scheduler functions correctly without affecting task execution.

Copilot uses AI. Check for mistakes.
Comment on lines +547 to +566

Copilot AI Jan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation is missing for the new DryRunDatabaseScheduler. Consider adding usage examples to the documentation (such as in docs/includes/introduction.txt) showing how to configure and use the dry-run scheduler, similar to how the DatabaseScheduler is documented. Users should know how to enable this scheduler in their development environments.

Copilot uses AI. Check for mistakes.

def sync(self):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@auvipy , thanks for this suggestion.

I understand that we are skipping the metadata update in dry-run mode since the task wasn't actually executed. But don't we need to store the attempt of execution to prevent breaking the scheduler loop? For an hourly task, for example, don't we want to persist the execution attempt?

If not, it will only be stored in memory, right? Any refresh of the tasks in the event loop will confuse the scheduler.

"""
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.')
Comment on lines +568 to +580
62 changes: 62 additions & 0 deletions t/unit/test_schedulers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Comment on lines +1471 to +1472
@pytest.mark.django_db
class test_models(SchedulerCase):

Expand Down
Loading