From e61539fc807aa1cd0f3fdbadba448ec51019889a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 13 May 2026 17:02:34 -0500 Subject: [PATCH] fix: Update plugin discovery comments and improve weakref handling in tests. Update changelog. --- CHANGELOG.md | 1 + dispatcharr/celery.py | 14 +++----------- tests/test_celery_plugin_discovery.py | 5 +++-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3ef334..58243cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recurring rule edit modal showed a blank Channel field.** The `RecurringRuleModal` read channel data from a Zustand store slice (`channels`) that nothing populates - the channels page and all other consumers switched to on-demand summary fetches in a prior release. Opening the edit modal from the DVR page always produced an empty Channel select. The modal now fetches channels via `API.getChannelsSummary()` when it opens, matching the lightweight approach used by the one-time recording form and other modals. - **DVR section count badges appeared at the far right of the screen.** The "Currently Recording", "Upcoming Recordings", and "Previously Recorded" section headers wrapped the title and badge in a `Group justify="space-between"`, which pushed the badge to the opposite edge of the full-width container. Changed to `Group gap="xs" align="center"` so each badge sits inline with its heading. - **Plugin event dispatch aborted silently on first disabled plugin.** `trigger_event` in `apps/connect/utils.py` iterated `pm.list_plugins()` and, for disabled plugins, logged a debug message using `plugin.key` / `plugin.name` (attribute access). Because `list_plugins()` returns dicts, this raised `AttributeError` on the first disabled plugin encountered. Fixed by changing the two accesses to `plugin['key']` / `plugin['name']`. (Fixes #1231) - Thanks [@R3XCHRIS](https://github.com/R3XCHRIS) +- **Plugin periodic tasks silently missed the first beat tick after every Celery worker restart.** Plugin modules live outside `INSTALLED_APPS` so `autodiscover_tasks()` never imports them, and worker startup skips plugin discovery via `should_skip_initialization()`. Any plugin using module-level `@shared_task` had its tasks unregistered with the worker until a lazy event import warmed the module; beat fired on schedule but the worker rejected with `Received unregistered task` and advanced `last_run_at` anyway, hiding the miss. A `worker_ready` hook in `dispatcharr/celery.py` now eagerly calls `PluginManager.discover_plugins(sync_db=False)` on every worker boot so plugin tasks are registered before beat starts firing. (Fixes #1244) - Thanks [@R3XCHRIS](https://github.com/R3XCHRIS) ### Performance diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index c8811cd0..81f26fe2 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -39,17 +39,9 @@ app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() -# Plugins live in /data/plugins//plugin.py — outside INSTALLED_APPS, -# so `app.autodiscover_tasks()` never imports them. Any plugin using -# module-level `@shared_task` for cron work would therefore have its task -# go unregistered on the worker side after every restart, until something -# else (a `trigger_event` call, etc.) lazily imported the plugin module. -# Beat would fire the periodic task on time and the worker would reject it -# with `Received unregistered task`. (#1244) -# -# Eagerly call `PluginManager.discover_plugins` on worker startup so every -# plugin's `@shared_task` is registered with this worker's Celery app -# before beat starts firing. +# Plugins live outside INSTALLED_APPS, so autodiscover_tasks() never imports +# them. Without an eager import, workers reject plugin @shared_tasks with +# "Received unregistered task" until a lazy event import warms the module. @worker_ready.connect(weak=False) def discover_plugins_on_worker_ready(**_kwargs): try: diff --git a/tests/test_celery_plugin_discovery.py b/tests/test_celery_plugin_discovery.py index aa5575f6..f001b56f 100644 --- a/tests/test_celery_plugin_discovery.py +++ b/tests/test_celery_plugin_discovery.py @@ -9,6 +9,7 @@ tick after every worker restart — the worker logs `Received unregistered task` and beat advances `last_run_at` anyway, hiding the failure. See #1244. """ +import weakref from unittest.mock import MagicMock, patch from django.test import SimpleTestCase @@ -67,8 +68,8 @@ class WorkerReadyPluginDiscoveryTests(SimpleTestCase): # The handler is registered with weak=False so the function appears # directly (not as a dead weakref). receivers = [r for _, r in worker_ready.receivers] - # Handle both weakref-wrapped and direct receiver shapes. - callables = [r() if hasattr(r, "__call__") and not callable(getattr(r, "__name__", None)) else r for r in receivers] + # Dereference weakrefs; pass direct references through as-is. + callables = [r() if isinstance(r, weakref.ref) else r for r in receivers] assert discover_plugins_on_worker_ready in receivers or \ any(getattr(c, "__wrapped__", c) is discover_plugins_on_worker_ready for c in callables), ( "discover_plugins_on_worker_ready was not connected to "