From 85a700ae0a798e6120ba2132d0c5ff48004f307f Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Thu, 30 Apr 2026 15:45:17 +0200 Subject: [PATCH 1/7] feat: disable PeriodicTasks removed from beat_schedule Track tasks imported from CELERY_BEAT_SCHEDULE with a new from_configuration boolean on PeriodicTask. On beat startup, rows flagged from_configuration=True whose name is no longer in the config are disabled (not deleted) so history is preserved and admin/ORM created tasks are never touched. Surface the flag in the admin and warn editors that changes to imported tasks are reverted on the next beat restart. Closes #248, #654. Co-Authored-By: Claude Opus 4.7 --- django_celery_beat/admin.py | 21 ++- .../0020_periodictask_from_configuration.py | 28 ++++ django_celery_beat/models.py | 10 ++ django_celery_beat/schedulers.py | 35 ++++- docs/includes/introduction.txt | 23 ++++ t/unit/test_schedulers.py | 120 ++++++++++++++++++ 6 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 django_celery_beat/migrations/0020_periodictask_from_configuration.py diff --git a/django_celery_beat/admin.py b/django_celery_beat/admin.py index f02df397..f742b45f 100644 --- a/django_celery_beat/admin.py +++ b/django_celery_beat/admin.py @@ -115,13 +115,15 @@ class PeriodicTaskAdmin(admin.ModelAdmin): celery_app = current_app date_hierarchy = 'start_time' list_display = ('name', 'enabled', 'scheduler', 'interval', 'start_time', - 'last_run_at', 'one_off') - list_filter = ['enabled', 'one_off', 'task', 'start_time', 'last_run_at'] + 'last_run_at', 'one_off', 'from_configuration') + list_filter = ['enabled', 'one_off', 'task', 'start_time', 'last_run_at', + 'from_configuration'] actions = ('enable_tasks', 'disable_tasks', 'toggle_tasks', 'run_tasks') search_fields = ('name', 'task',) fieldsets = ( (None, { - 'fields': ('name', 'regtask', 'task', 'enabled', 'description',), + 'fields': ('name', 'regtask', 'task', 'enabled', + 'from_configuration', 'description',), 'classes': ('extrapretty', 'wide'), }), (_('Schedule'), { @@ -140,7 +142,7 @@ class PeriodicTaskAdmin(admin.ModelAdmin): }), ) readonly_fields = ( - 'last_run_at', 'crontab_translation', + 'last_run_at', 'crontab_translation', 'from_configuration', ) def crontab_translation(self, obj): @@ -156,6 +158,17 @@ def changeform_view(self, request, object_id=None, form_url='', for crontab in crontabs: crontab_dict[crontab.id] = crontab.human_readable extra_context['readable_crontabs'] = crontab_dict + if object_id and request.method == 'GET': + obj = self.get_object(request, object_id) + if obj is not None and obj.from_configuration: + messages.warning( + request, + _('This task was imported from CELERY_BEAT_SCHEDULE. ' + 'Any changes saved here will be reverted the next ' + 'time celery beat restarts. To make changes durable, ' + 'edit the task in your application configuration, or ' + 'remove it from CELERY_BEAT_SCHEDULE first.'), + ) return super().changeform_view(request, object_id, extra_context=extra_context) diff --git a/django_celery_beat/migrations/0020_periodictask_from_configuration.py b/django_celery_beat/migrations/0020_periodictask_from_configuration.py new file mode 100644 index 00000000..fbcf120d --- /dev/null +++ b/django_celery_beat/migrations/0020_periodictask_from_configuration.py @@ -0,0 +1,28 @@ +# flake8: noqa +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_celery_beat', '0019_alter_periodictasks_options'), + ] + + operations = [ + migrations.AddField( + model_name='periodictask', + name='from_configuration', + field=models.BooleanField( + default=False, + editable=False, + help_text=( + 'Set automatically when the task is imported from the ' + 'celery beat_schedule configuration. Edits made here ' + 'will be reverted on the next celery beat startup. ' + 'Remove the entry from beat_schedule to manage this ' + 'task only via the database.' + ), + verbose_name='From configuration', + ), + ), + ] diff --git a/django_celery_beat/models.py b/django_celery_beat/models.py index 6f6fdc40..42cbfbd3 100644 --- a/django_celery_beat/models.py +++ b/django_celery_beat/models.py @@ -569,6 +569,16 @@ class PeriodicTask(models.Model): verbose_name=_('Enabled'), help_text=_('Set to False to disable the schedule'), ) + from_configuration = models.BooleanField( + default=False, + editable=False, + verbose_name=_('From configuration'), + help_text=_( + 'Set automatically when the task is imported from the celery ' + 'beat_schedule configuration. Edits made here will be reverted ' + 'on the next celery beat startup. Remove the entry from ' + 'beat_schedule to manage this task only via the database.'), + ) last_run_at = models.DateTimeField( auto_now=False, auto_now_add=False, editable=False, blank=True, null=True, diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 74898f40..33e0fafa 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -189,8 +189,10 @@ def to_model_schedule(cls, schedule): @classmethod def from_entry(cls, name, app=None, **entry): + defaults = cls._unpack_fields(**entry) + defaults['from_configuration'] = True obj, created = PeriodicTask._default_manager.update_or_create( - name=name, defaults=cls._unpack_fields(**entry), + name=name, defaults=defaults, ) return cls(obj, app=app) @@ -255,8 +257,10 @@ def __init__(self, *args, **kwargs): or DEFAULT_MAX_INTERVAL) def setup_schedule(self): - self.install_default_entries(self.schedule) - self.update_from_dict(self.app.conf.beat_schedule) + installed_names = set() + installed_names |= self.install_default_entries(self.schedule) + installed_names |= self.update_from_dict(self.app.conf.beat_schedule) + self._disable_removed_from_configuration(installed_names) def all_as_schedule(self): debug('DatabaseScheduler: Fetching database schedule') @@ -471,17 +475,20 @@ def sync(self): def update_from_dict(self, mapping): s = {} + installed = set() for name, entry_fields in mapping.items(): try: entry = self.Entry.from_entry(name, app=self.app, **entry_fields) + installed.add(name) if entry.model.enabled: s[name] = entry except Exception as exc: logger.exception(ADD_ENTRY_ERROR, name, exc, entry_fields) self.schedule.update(s) + return installed def install_default_entries(self, data): entries = {} @@ -493,7 +500,27 @@ def install_default_entries(self, data): 'options': {'expire_seconds': 12 * 3600}, }, ) - self.update_from_dict(entries) + return self.update_from_dict(entries) + + def _disable_removed_from_configuration(self, installed_names): + """Disable rows imported from config whose names are no longer there. + + Tasks created directly (admin/ORM) have ``from_configuration=False`` + and are left untouched. We disable rather than delete so history + (last_run_at, total_run_count) is preserved and admins can review + what disappeared. + """ + qs = self.Model.objects.filter( + from_configuration=True, enabled=True, + ).exclude(name__in=installed_names) + removed = list(qs.values_list('name', flat=True)) + if removed: + qs.update(enabled=False, last_run_at=None) + PeriodicTasks.update_changed() + info( + 'DatabaseScheduler: Disabled %d task(s) removed from ' + 'configuration: %s', len(removed), ', '.join(removed), + ) def schedules_equal(self, *args, **kwargs): if self._heap_invalidated: diff --git a/docs/includes/introduction.txt b/docs/includes/introduction.txt index 01850ea0..55dc61c9 100644 --- a/docs/includes/introduction.txt +++ b/docs/includes/introduction.txt @@ -179,6 +179,29 @@ You can use the ``enabled`` flag to temporarily disable a periodic task: >>> periodic_task.save() +Tasks imported from ``CELERY_BEAT_SCHEDULE`` +-------------------------------------------- + +Tasks defined in ``CELERY_BEAT_SCHEDULE`` (or ``app.conf.beat_schedule``) are +imported into the database when the **celery beat** service starts. Each +imported row is flagged with ``from_configuration=True`` so the scheduler can +tell it apart from tasks created directly via the admin or the ORM. + +Two consequences follow: + +* If you remove an entry from ``CELERY_BEAT_SCHEDULE`` and restart **beat**, + the corresponding ``PeriodicTask`` row is automatically **disabled** (not + deleted) on next startup. Tasks created by hand via the admin or the ORM + are never touched. + +* Edits made in the Django admin to a task imported from configuration are + **reverted on the next beat restart**, because the configuration is + re-imported via ``update_or_create``. The admin shows a warning banner on + the change form for these tasks. To make changes durable, edit the task + in your application configuration, or remove it from + ``CELERY_BEAT_SCHEDULE`` first. + + Example running periodic tasks ------------------------------ diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index 85c6694f..a15b349b 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -611,6 +611,92 @@ def test_periodic_task_model_schedule_type_change(self): assert self.m1.interval assert self.m1.crontab is None + def test_imported_task_marked_from_configuration(self): + self.Scheduler(app=self.app) + task = PeriodicTask.objects.get(name=self.entry_name) + assert task.from_configuration is True + backend_cleanup = PeriodicTask.objects.get( + name='celery.backend_cleanup') + assert backend_cleanup.from_configuration is True + + +@pytest.mark.django_db +class test_DatabaseSchedulerRemovedFromConfiguration(SchedulerCase): + """Tasks removed from beat_schedule are auto-disabled (#248, #654).""" + + Scheduler = TrackingScheduler + + @pytest.fixture(autouse=True) + def setup_scheduler(self, app): + self.app = app + self.entry_name, entry = self.create_conf_entry() + self.app.conf.beat_schedule = {self.entry_name: entry} + + def test_disables_task_when_removed_from_configuration(self): + # First run: task imported from config, row created and enabled. + self.Scheduler(app=self.app) + task = PeriodicTask.objects.get(name=self.entry_name) + assert task.enabled is True + assert task.from_configuration is True + task.last_run_at = timezone.now() + task.save() + + # Remove from configuration and re-run the scheduler. + self.app.conf.beat_schedule = {} + self.Scheduler(app=self.app) + + # Row preserved, but disabled. last_run_at cleared. + task = PeriodicTask.objects.get(name=self.entry_name) + assert task.enabled is False + assert task.from_configuration is True + assert task.last_run_at is None + + def test_does_not_touch_orm_created_tasks(self): + # Drop config entirely; only an ORM-created task exists. + self.app.conf.beat_schedule = {} + orm_task = self.create_model_interval(schedule(timedelta(seconds=30))) + orm_task.name = 'orm-only-task' + orm_task.save() + + self.Scheduler(app=self.app) + + orm_task.refresh_from_db() + assert orm_task.enabled is True + assert orm_task.from_configuration is False + + def test_backend_cleanup_disabled_when_result_expires_cleared(self): + # First run with result_expires set: backend_cleanup is enabled. + self.app.conf.result_expires = 3600 + self.Scheduler(app=self.app) + cleanup = PeriodicTask.objects.get(name='celery.backend_cleanup') + assert cleanup.enabled is True + assert cleanup.from_configuration is True + + # Clear result_expires: install_default_entries no longer adds it, + # so the orphan-disable step disables the existing row. + self.app.conf.result_expires = 0 + self.app.conf.beat_schedule = {} + self.Scheduler(app=self.app) + + cleanup.refresh_from_db() + assert cleanup.enabled is False + assert cleanup.from_configuration is True + + def test_re_added_task_keeps_manual_disable(self): + # Imported, then user manually disables in admin/ORM. + self.Scheduler(app=self.app) + task = PeriodicTask.objects.get(name=self.entry_name) + task.enabled = False + task.save() + + # Re-running with the entry still in config must not re-enable; + # update_or_create only writes the fields in defaults, and enabled + # is intentionally not among them. + self.Scheduler(app=self.app) + task.refresh_from_db() + assert task.enabled is False + assert task.from_configuration is True + @pytest.mark.django_db class test_DatabaseScheduler(SchedulerCase): @@ -1648,6 +1734,40 @@ def mock_apply_async(*args, **kwargs): assert 'periodic_task_name' in self.captured_headers assert self.captured_headers['periodic_task_name'] == self.m1.name + def test_changeform_warns_for_from_configuration_task(self, monkeypatch): + # Bypass the parent's template rendering; we only care about the + # message side effect. + monkeypatch.setattr( + 'django.contrib.admin.ModelAdmin.changeform_view', + lambda self, request, object_id=None, form_url='', + extra_context=None: None, + ) + self.m1.from_configuration = True + self.m1.save() + + ma = PeriodicTaskAdmin(PeriodicTask, self.site) + self.request = self.patch_request(self.request_factory.get('/')) + ma.changeform_view(self.request, object_id=str(self.m1.pk)) + + queued = self.request._messages._queued_messages + assert len(queued) == 1 + assert 'CELERY_BEAT_SCHEDULE' in str(queued[0].message) + + def test_changeform_no_warning_for_regular_task(self, monkeypatch): + monkeypatch.setattr( + 'django.contrib.admin.ModelAdmin.changeform_view', + lambda self, request, object_id=None, form_url='', + extra_context=None: None, + ) + # m1 was created without going through config import. + assert self.m1.from_configuration is False + + ma = PeriodicTaskAdmin(PeriodicTask, self.site) + self.request = self.patch_request(self.request_factory.get('/')) + ma.changeform_view(self.request, object_id=str(self.m1.pk)) + + assert self.request._messages._queued_messages == [] + @pytest.mark.django_db class test_timezone_offset_handling: From f0759c7e3695fcde530826c3df8348d87593e427 Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Thu, 30 Apr 2026 16:25:24 +0200 Subject: [PATCH 2/7] feat: re-enable PeriodicTask when re-added to beat_schedule Make CELERY_BEAT_SCHEDULE the source of truth for the enabled flag on imported tasks: removing-then-re-adding a task to the configuration re-enables the existing row, instead of leaving it stuck in the auto-disabled state from the previous orphan-cleanup pass. The admin warning banner already advertises this revert-on-restart behavior. Co-Authored-By: Claude Opus 4.7 --- django_celery_beat/schedulers.py | 4 ++++ docs/includes/introduction.txt | 13 +++++++------ t/unit/test_schedulers.py | 33 +++++++++++++++++++------------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 33e0fafa..d32cab7f 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -191,6 +191,10 @@ def to_model_schedule(cls, schedule): def from_entry(cls, name, app=None, **entry): defaults = cls._unpack_fields(**entry) defaults['from_configuration'] = True + # Config is the source of truth: re-add to beat_schedule re-enables a + # row we previously auto-disabled, and overrides any manual disable + # done in the admin (the admin warns about this). + defaults['enabled'] = True obj, created = PeriodicTask._default_manager.update_or_create( name=name, defaults=defaults, ) diff --git a/docs/includes/introduction.txt b/docs/includes/introduction.txt index 55dc61c9..3a694871 100644 --- a/docs/includes/introduction.txt +++ b/docs/includes/introduction.txt @@ -191,15 +191,16 @@ Two consequences follow: * If you remove an entry from ``CELERY_BEAT_SCHEDULE`` and restart **beat**, the corresponding ``PeriodicTask`` row is automatically **disabled** (not - deleted) on next startup. Tasks created by hand via the admin or the ORM - are never touched. + deleted) on next startup. Re-adding the entry to ``CELERY_BEAT_SCHEDULE`` + and restarting **beat** re-enables the row. Tasks created by hand via the + admin or the ORM are never touched. * Edits made in the Django admin to a task imported from configuration are **reverted on the next beat restart**, because the configuration is - re-imported via ``update_or_create``. The admin shows a warning banner on - the change form for these tasks. To make changes durable, edit the task - in your application configuration, or remove it from - ``CELERY_BEAT_SCHEDULE`` first. + re-imported via ``update_or_create`` and ``enabled`` is restored to + ``True``. The admin shows a warning banner on the change form for these + tasks. To make changes durable, edit the task in your application + configuration, or remove it from ``CELERY_BEAT_SCHEDULE`` first. Example running periodic tasks diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index a15b349b..43083a95 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -590,16 +590,20 @@ def test_periodic_task_model_enabled_schedule(self): assert e.model.expires is None assert e.model.expire_seconds == 12 * 3600 - def test_periodic_task_model_disabled_schedule(self): - self.m1.enabled = False + def test_periodic_task_model_disabled_schedule_is_re_enabled(self): + # Disabling a row that is still in beat_schedule does not stick: + # config is the source of truth, so the import re-enables it. + # (The admin warns that edits to imported tasks revert on restart.) self.m1.save() + PeriodicTask.objects.filter(name=self.entry_name).update(enabled=False) s = self.Scheduler(app=self.app) sched = s.schedule - assert sched - assert len(sched) == 1 + assert len(sched) == 2 assert 'celery.backend_cleanup' in sched - assert self.entry_name not in sched + assert self.entry_name in sched + task = PeriodicTask.objects.get(name=self.entry_name) + assert task.enabled is True def test_periodic_task_model_schedule_type_change(self): self.m1.interval = None @@ -682,19 +686,22 @@ def test_backend_cleanup_disabled_when_result_expires_cleared(self): assert cleanup.enabled is False assert cleanup.from_configuration is True - def test_re_added_task_keeps_manual_disable(self): - # Imported, then user manually disables in admin/ORM. + def test_re_adding_task_to_configuration_re_enables_it(self): + # First run: imported and enabled. + self.Scheduler(app=self.app) + + # Remove from config and re-run: orphan-disable kicks in. + original_schedule = dict(self.app.conf.beat_schedule) + self.app.conf.beat_schedule = {} self.Scheduler(app=self.app) task = PeriodicTask.objects.get(name=self.entry_name) - task.enabled = False - task.save() + assert task.enabled is False - # Re-running with the entry still in config must not re-enable; - # update_or_create only writes the fields in defaults, and enabled - # is intentionally not among them. + # Re-add to config and re-run: row is re-enabled. + self.app.conf.beat_schedule = original_schedule self.Scheduler(app=self.app) task.refresh_from_db() - assert task.enabled is False + assert task.enabled is True assert task.from_configuration is True From 1ed0a7cb7181af7abf49dbfbe99e54de177ebac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asif=20Saif=20Uddin=20=7B=22Auvi=22=3A=22=E0=A6=85?= =?UTF-8?q?=E0=A6=AD=E0=A6=BF=22=7D?= Date: Wed, 13 May 2026 14:56:51 +0600 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- django_celery_beat/schedulers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index d32cab7f..7a24238f 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -265,6 +265,13 @@ def setup_schedule(self): installed_names |= self.install_default_entries(self.schedule) installed_names |= self.update_from_dict(self.app.conf.beat_schedule) self._disable_removed_from_configuration(installed_names) + # Accessing ``self.schedule`` above may populate the in-memory cache + # from database rows that were enabled before + # ``_disable_removed_from_configuration`` ran. Invalidate the cached + # schedule and heap so the next read rebuilds from the updated DB + # state and won't dispatch removed tasks on startup. + self._schedule = None + self._heap_invalidated = True def all_as_schedule(self): debug('DatabaseScheduler: Fetching database schedule') From f5893e7ead8fe7e83a400274e79042dadd713f23 Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Fri, 15 May 2026 10:54:25 +0200 Subject: [PATCH 4/7] add missing assert in test --- t/unit/test_schedulers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index 43083a95..ccf057c4 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -638,7 +638,7 @@ def setup_scheduler(self, app): def test_disables_task_when_removed_from_configuration(self): # First run: task imported from config, row created and enabled. - self.Scheduler(app=self.app) + scheduler = self.Scheduler(app=self.app) task = PeriodicTask.objects.get(name=self.entry_name) assert task.enabled is True assert task.from_configuration is True @@ -654,6 +654,7 @@ def test_disables_task_when_removed_from_configuration(self): assert task.enabled is False assert task.from_configuration is True assert task.last_run_at is None + assert self.entry_name not in scheduler.schedule def test_does_not_touch_orm_created_tasks(self): # Drop config entirely; only an ORM-created task exists. From bed8e46774d0c0fc7d2ae4ee760b99743b07d7e2 Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Fri, 15 May 2026 11:30:15 +0200 Subject: [PATCH 5/7] fix: self._schedule is not expected to be None --- django_celery_beat/schedulers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 7a24238f..eed29338 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -270,7 +270,7 @@ def setup_schedule(self): # ``_disable_removed_from_configuration`` ran. Invalidate the cached # schedule and heap so the next read rebuilds from the updated DB # state and won't dispatch removed tasks on startup. - self._schedule = None + self._schedule = {} self._heap_invalidated = True def all_as_schedule(self): From 324f021c443ca37caa07b1e697074c06753d877a Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Fri, 15 May 2026 12:24:12 +0200 Subject: [PATCH 6/7] test: add regression for invalid config edit and simplify reprocess pattern Add a regression test for the case where a previously valid beat_schedule entry is edited into an invalid form: the existing DB row must be disabled, not left running on the now-stale schedule. While here, refactor the surrounding tests in the class to instantiate the scheduler once and call setup_schedule() to reprocess config changes, rather than re-instantiating the Scheduler each time. Co-Authored-By: Claude Opus 4.7 --- t/unit/test_schedulers.py | 47 ++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index ccf057c4..02043c57 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -637,7 +637,6 @@ def setup_scheduler(self, app): self.app.conf.beat_schedule = {self.entry_name: entry} def test_disables_task_when_removed_from_configuration(self): - # First run: task imported from config, row created and enabled. scheduler = self.Scheduler(app=self.app) task = PeriodicTask.objects.get(name=self.entry_name) assert task.enabled is True @@ -645,12 +644,12 @@ def test_disables_task_when_removed_from_configuration(self): task.last_run_at = timezone.now() task.save() - # Remove from configuration and re-run the scheduler. + # Remove from configuration and reprocess. self.app.conf.beat_schedule = {} - self.Scheduler(app=self.app) + scheduler.setup_schedule() # Row preserved, but disabled. last_run_at cleared. - task = PeriodicTask.objects.get(name=self.entry_name) + task.refresh_from_db() assert task.enabled is False assert task.from_configuration is True assert task.last_run_at is None @@ -670,9 +669,8 @@ def test_does_not_touch_orm_created_tasks(self): assert orm_task.from_configuration is False def test_backend_cleanup_disabled_when_result_expires_cleared(self): - # First run with result_expires set: backend_cleanup is enabled. self.app.conf.result_expires = 3600 - self.Scheduler(app=self.app) + scheduler = self.Scheduler(app=self.app) cleanup = PeriodicTask.objects.get(name='celery.backend_cleanup') assert cleanup.enabled is True assert cleanup.from_configuration is True @@ -681,30 +679,53 @@ def test_backend_cleanup_disabled_when_result_expires_cleared(self): # so the orphan-disable step disables the existing row. self.app.conf.result_expires = 0 self.app.conf.beat_schedule = {} - self.Scheduler(app=self.app) + scheduler.setup_schedule() cleanup.refresh_from_db() assert cleanup.enabled is False assert cleanup.from_configuration is True def test_re_adding_task_to_configuration_re_enables_it(self): - # First run: imported and enabled. - self.Scheduler(app=self.app) + scheduler = self.Scheduler(app=self.app) - # Remove from config and re-run: orphan-disable kicks in. + # Remove from config and reprocess: orphan-disable kicks in. original_schedule = dict(self.app.conf.beat_schedule) self.app.conf.beat_schedule = {} - self.Scheduler(app=self.app) + scheduler.setup_schedule() task = PeriodicTask.objects.get(name=self.entry_name) assert task.enabled is False - # Re-add to config and re-run: row is re-enabled. + # Re-add to config and reprocess: row is re-enabled. self.app.conf.beat_schedule = original_schedule - self.Scheduler(app=self.app) + scheduler.setup_schedule() task.refresh_from_db() assert task.enabled is True assert task.from_configuration is True + def test_invalid_edit_disables_task(self): + # An entry edited to an invalid form (e.g. unrecognized schedule + # type) must NOT silently keep running on the old stale schedule. + # The existing DB row gets disabled; the error is logged. + scheduler = self.Scheduler(app=self.app) + task = PeriodicTask.objects.get(name=self.entry_name) + assert task.enabled is True + + broken_entry = dict( + task='djcelery.unittest.add', + schedule=object(), + args=(), + relative=False, + kwargs={}, + options={'queue': 'extra_queue'}, + ) + self.app.conf.beat_schedule = {self.entry_name: broken_entry} + scheduler.setup_schedule() + + task.refresh_from_db() + assert task.enabled is False + assert task.from_configuration is True + assert self.entry_name not in scheduler.schedule + @pytest.mark.django_db class test_DatabaseScheduler(SchedulerCase): From f611c515a92001f4a1bebea6c6ef2852f90574bc Mon Sep 17 00:00:00 2001 From: Sergio LIVI Date: Fri, 15 May 2026 14:55:38 +0200 Subject: [PATCH 7/7] fix(schedulers): drop stale cache reset in setup_schedule The trailing ``self._schedule = {}`` / ``self._heap_invalidated = True`` in ``setup_schedule`` intended to force a rebuild after ``_disable_removed_from_configuration``, but it left the scheduler in a broken state. By the time those lines ran, the earlier ``self.schedule`` access from ``install_default_entries`` had already flipped ``_initial_read`` to False. Clearing ``_schedule`` alone is not a rebuild trigger -- on the next access the property goes through the ``elif schedule_changed()`` branch, which returns False (``_last_timestamp`` was just set to the current ts, so ``ts > ts`` is False), so the empty dict was returned. That made 10 tests in ``test_DatabaseScheduler`` / ``test_DatabaseSchedulerFromAppConf`` fail with ``KeyError`` / ``assert {}`` on what should be a populated schedule. Invalidation is already handled correctly: ``_disable_removed_from_configuration`` calls ``PeriodicTasks.update_changed()`` whenever it disables something, which bumps the change timestamp and makes the next ``schedule_changed()`` return True -- triggering the normal rebuild path that also clears the heap. Co-Authored-By: Claude Opus 4.7 --- django_celery_beat/schedulers.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index eed29338..d32cab7f 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -265,13 +265,6 @@ def setup_schedule(self): installed_names |= self.install_default_entries(self.schedule) installed_names |= self.update_from_dict(self.app.conf.beat_schedule) self._disable_removed_from_configuration(installed_names) - # Accessing ``self.schedule`` above may populate the in-memory cache - # from database rows that were enabled before - # ``_disable_removed_from_configuration`` ran. Invalidate the cached - # schedule and heap so the next read rebuilds from the updated DB - # state and won't dispatch removed tasks on startup. - self._schedule = {} - self._heap_invalidated = True def all_as_schedule(self): debug('DatabaseScheduler: Fetching database schedule')