Skip to content

Feat: optional scheduled Automatic Full Sync - #33

Open
sbcrumb wants to merge 1 commit into
PiratesIRC:mainfrom
sbcrumb:feat/scheduled-full-sync
Open

sbcrumb wants to merge 1 commit into
PiratesIRC:mainfrom
sbcrumb:feat/scheduled-full-sync

Conversation

@sbcrumb

@sbcrumb sbcrumb commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fixes Feature Request: Scheduled / Automatic Full Sync #31. Adds settings under a new "Automation" section: Automatic Full Sync (Off / Daily / Weekly / Monthly / Custom), Sync Time, Sync Day (Weekly), Sync Day Of Month (Monthly), Custom Cron Expression (Custom - a standard 5-field cron string, validated with celery's own crontab parser before being saved), and Time Zone (an IANA name, pre-filled from Dispatcharr's own DISPATCHARR_TIME_ZONE / Django TIME_ZONE so a fresh install doesn't need to already know it).
  • Registers a django-celery-beat PeriodicTask that fires the exact same Full Sync path as the manual button (groups -> channels -> stream matching -> EPG matching -> logo assignment).
  • Queue routing: the PeriodicTask row is created with queue="dvr", per the issue discussion - a plugin task left on Dispatcharr's default celery queue is registered and Beat's run counter still increments, but a worker never picks it up.
  • Time Zone is explicit, not inferred - confirmed live that a schedule created without one fires on UTC instead of the box's actual local time, silently missing the intended run.
  • Locking (the significant finding from live testing): the scheduled path must NOT wrap _do_full_sync in its own operation-lock acquisition. _do_full_sync's own sub-steps (stream match, EPG match, logo assignment) already take that same lock around their own work, exactly like the manual Full Sync button. An outer acquisition holds the lock across all of them, so each sub-step sees it already held by this same run and rejects itself - measured live as exactly three "Operation locked" warnings from one single invocation, with no sync ever completing. run_scheduled_full_sync now only peeks (read-only, never acquires) before deciding whether to start at all, then calls _do_full_sync directly and unwrapped, same as the manual path.
  • Logging: the scheduled task logs through Dispatcharr's own apps.plugins.loader logger - the same one manual actions receive via context["logger"] - so a scheduled run's log output looks and lands identically to a manual Full Sync's, rather than under a separate, easy-to-miss logger name.
  • Off by default, and the last scheduled run's outcome (ok / error / skipped, with a timestamp and message) is written to its own state file and surfaced as a row in both Validate Settings and Plugin Status.

Design note worth a look

Dispatcharr's plugin API has no "settings changed" hook, so there's no single moment to apply a schedule edit. I reconcile the PeriodicTask (and the settings snapshot its Celery task runs with - a bare Celery task has no other way to read "whatever is currently saved in the UI") at the top of every action dispatch (run()) instead of requiring a dedicated "Apply Schedule" button. It's a single cheap idempotent query per action. Happy to switch to an explicit button if you'd rather not have every action touch this.

I did not change OPERATION_LOCK_TIMEOUT_MINUTES (currently 10 minutes) - your issue reply flagged it might need raising or refreshing for a sync that runs long. Observed Full Sync duration on a 227-channel lineup is ~20s, well under that, so I left it as-is rather than guess.

Test plan

  • pytest tests/ - 1128 passed (tests/test_scheduled_full_sync.py: queue routing, off-by-default, time/day/day-of-month/cron validation, the lock-reentrancy regression specifically, status-row visibility, timezone auto-detection, and that a broken schedule reconciliation never blocks the actual requested action)
  • .github/scripts/validate_plugin.py
  • .github/scripts/check_core_parity.py / check_client_parity.py / check_matcher_golden.py (untouched)
  • scripts/check_version_sync.py
  • ruff check . --select E9,F63,F7,F82
  • python -m py_compile Lineuparr/*.py
  • Verified live end-to-end against a real install (Dispatcharr 0.31.0, django-celery-beat installed, dedicated dvr queue worker running): the schedule registers, Beat picks it up and fires it at the correct configured time zone, the task runs on the dvr queue, the lock fix resolved the real reentrancy bug described above, and a completed scheduled run produced the same CSV/report output as a manual Full Sync, logged identically via apps.plugins.loader.

Neither celery nor django-celery-beat are dependencies of this plugin's own test suite (they're Dispatcharr's), so the scheduling logic is unit-tested against fakes for django_celery_beat.models and celery.schedules.crontab; the live verification above is what confirms the real integration.

Opening as a draft for your read on the reconcile-on-every-action design and the lock-timeout question above.

@sbcrumb
sbcrumb force-pushed the feat/scheduled-full-sync branch 2 times, most recently from 0357d80 to a41781a Compare September 29, 2026 21:12
@sbcrumb
sbcrumb marked this pull request as ready for review September 29, 2026 21:13
@PiratesIRC

Copy link
Copy Markdown
Owner

Thanks for this. Off by default, the dvr queue on the Beat row, and the last run in Plugin Status are all there, and the suite and gates pass on a41781a. Three things need to change before I can merge it.

1. Take the operation lock instead of checking it. run_scheduled_full_sync calls _is_operation_locked() and then runs _do_full_sync, but never acquires the lock. Between the check and the run, a manual action can start, and the groups and channels steps of _do_full_sync take no lock of their own, so a scheduled run can work alongside a manual Sync Channels. I understand the reason given in the docstring: the sub-steps acquire the same lock and would refuse to run. Could the scheduled run acquire the lock with an owner token, and have the sub-steps accept a lock already held by that owner? The test at tests/test_scheduled_full_sync.py:388 asserts the current no-lock behaviour, so it would need to change with it.

A related case: the lock goes stale after 10 minutes. With the lock held only by sub-steps, a stream match that runs past that lets the next scheduled run start while the first is still going.

2. The default time zone comes out as UTC on a normal install. _detected_timezone returns Django's settings.TIME_ZONE first. In Dispatcharr that is UTC (measured on a 0.32.0 container whose system time zone is America/Chicago), and UTC is a valid zone, so the function returns before it looks at anything else. The default 03:00 then runs at 22:00 Central. Dispatcharr's own setting is CoreSettings.get_system_time_zone(); this plugin already reads it in progress_status.py. Please use that first, and add a test where Django's TIME_ZONE is UTC and the system time zone is not.

3. No database write on every action. run() calls _reconcile_schedule on every action, including Plugin Status, which means a DELETE (schedule off) or an update_or_create (schedule on) per button press. Plugin actions are on the request path here and should not write on every call. Could the reconcile run only when the schedule settings change, for example by comparing against a stored hash of the schedule fields, and otherwise skip the query?

Smaller points:

  • The comment at plugin.py:4497 says the Celery task decorator is routed to dvr, but the decorator passes no queue. The Beat row does carry queue: dvr on every reconcile, which is what matters, so only the comment is wrong.
  • This repository avoids contractions in code comments and help text. There are a few: "can't" near plugin.py:15, 162 and 428, and "that's" and "can't" in two help texts near lines 74 and 108.
  • The Plugin Status line is appended to the toast message, which Dispatcharr cuts at about 280 characters, so it may not be visible when the status text is long.

@sbcrumb

sbcrumb commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review on this one too. #32 came together quickly because once you gave the exact repro, the fix was narrow — call the same helper from the one other place that needed it. This one's a different scale: the locking semantics especially deserve more than a quick pass, so I want to actually sit with your owner-token suggestion rather than rush something that only looks right. Planning to spend some time on it this weekend and have an update early next week.

Adds settings under a new "Automation" section (Automatic Full Sync:
Off/Daily/Weekly/Monthly/Custom, Sync Time, Sync Day, Sync Day Of Month,
Custom Cron Expression, Time Zone) that register a django-celery-beat
periodic task reusing the exact manual Full Sync path (groups -> channels
-> stream matching -> EPG matching -> logo assignment).

Per the issue discussion and this PR's review:

- The periodic task is routed to Dispatcharr's "dvr" Celery queue (set on
  the PeriodicTask row itself, not on the Celery task decorator), not the
  default queue a plugin task silently never runs on.
- A scheduled run acquires the operation lock itself, as the owner for
  the whole run, and skips without raising if a different Plugin instance
  (a manual action) already holds it. _do_full_sync's own groups, channels,
  stream match, EPG match and logo assignment steps each reacquire that
  same lock on this same Plugin instance and succeed - reentrant, via an
  owner token _acquire_lock stores in the lock file - instead of finding
  it already held and rejecting themselves. An earlier version of this
  only peeked at the lock without holding it, which let a manual action
  start mid-run and touch the same groups/channels rows (neither of which
  took a lock of their own before this), and let the lock go stale and a
  second scheduled run start while the first was still going if any one
  sub-step ran past the timeout. A reentrant reacquire now refreshes the
  lock's timestamp, closing that gap too.
- Off by default. The last scheduled run's outcome (ok / error / skipped,
  with a timestamp and message) is recorded to its own state file and shown
  as a row in both Validate Settings and Plugin Status.
- Time Zone is explicit rather than inferred (tested live: a schedule
  created without one fired on UTC instead of the box's actual local
  time, silently missing the intended run), but its default is pre-filled
  from Dispatcharr's own configured system time zone
  (core.models.CoreSettings.get_system_time_zone(), the same source
  progress_status.py already uses for display), not Django's
  settings.TIME_ZONE - Dispatcharr pins that to UTC regardless of the
  admin-configured zone, so checking it first always returned a valid
  but wrong default.
- Monthly (a specific day of month) and Custom (a raw 5-field cron
  expression, validated with celery's own crontab parser before being
  saved) cover schedules the Daily/Weekly presets cannot express.
- The scheduled task logs through Dispatcharr's own "apps.plugins.loader"
  logger - the same one manual actions use via context["logger"] - so a
  scheduled run's log lines look and land identically to a manual Full
  Sync's, rather than under a separate, easy-to-miss logger name.

Dispatcharr has no "settings changed" hook for plugins, so there is no single
moment to apply a schedule edit. Reconciling the periodic task (and the
settings snapshot its Celery task runs with, since a Celery task has no
other way to read "whatever is currently saved") happens at the top of every
action dispatch instead. That previously meant a database write on every
single action, including read-only ones like Plugin Status; it now hashes
what it is about to write and skips the query entirely when nothing
relevant has changed since the last reconcile, while still refreshing the
settings snapshot on an unrelated settings change (e.g. a different lineup
file) rather than letting it go stale.

The actual scheduled-run logic lives in a plain Plugin method
(run_scheduled_full_sync) rather than inline in the @shared_task function,
so it stays unit-testable in an environment without Celery installed - the
same reason the module-level task registration is wrapped to fail soft if
Celery/django-celery-beat aren't present.

Verified live end-to-end against a real install (Dispatcharr 0.31.0,
django-celery-beat installed, dvr queue worker running): the schedule
registers, Beat picks it up on the correct time zone, the task fires on the
dvr queue, a completed run produces the same CSV/report output as a manual
Full Sync, and no self-rejection warnings appear during a run that
previously produced exactly three of them.

Fixes PiratesIRC#31
@sbcrumb
sbcrumb force-pushed the feat/scheduled-full-sync branch from a41781a to e9878b3 Compare October 12, 2026 14:45
@sbcrumb

sbcrumb commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Went with your owner-token idea for the locking - run_scheduled_full_sync grabs the lock itself now for the whole run, and the groups/channels/stream/EPG/logo steps just reuse it instead of fighting over it. Turns out groups and channels weren't taking a lock at all before, so that's fixed too. Reacquiring also bumps the timestamp so a slow step doesn't make the lock look stale partway through.

Timezone now pulls from CoreSettings.get_system_time_zone() like progress_status.py already does, instead of Django's TIME_ZONE which was always coming back UTC.

And the reconcile function hashes what it's about to write so it skips the DB hit when nothing's actually changed, but still updates the snapshot if something unrelated changes so it doesn't go stale.

Also cleaned up the smaller stuff you flagged (queue comment, contractions, status line length). Tests and gates all still green.

Let me know if anything looks off.

@PiratesIRC

Copy link
Copy Markdown
Owner

Thanks for working through all of this. I re-read e9878b3 against the three points from my last review:

  • Time zone: fixed. _detected_timezone reads CoreSettings.get_system_time_zone() first.
  • Database write per action: fixed. With the schedule off, the reconcile issues one DELETE and then skips; with it on, it writes only when the hash changes.
  • Lock: the scheduled run now holds the lock for the whole Full Sync, and Sync Groups and Sync Channels take it too. That part is right. But the way the reentrancy is keyed introduces a regression, described below.

The suite (1185 passed), the validator and the matcher golden gate all pass on this commit. I found no contractions or non-ASCII characters in the diff.

The lock owner is per instance, and Dispatcharr keeps one instance per worker

_lock_owner and _lock_depth live on the Plugin instance. Dispatcharr creates one instance per plugin when it discovers plugins (apps/plugins/loader.py, instance = plugin_cls()), and every button press calls run() on that same lp.instance. So every manual action in a worker process shares one owner token.

The result is that, within a worker, a second manual action is no longer refused while the first holds the lock: it reads the lock file, sees its own owner, and treats itself as a nested call. For example, while a manual Full Sync runs its stream match step on the background thread, pressing Sync Channels or Apply Stream Match goes ahead at the same time instead of returning "Another operation is in progress."

I checked this with a small test. It passes on main and fails on this branch:

def test_second_manual_action_on_same_instance_is_rejected(tmp_path, monkeypatch):
    monkeypatch.setattr(PluginConfig, "OPERATION_LOCK_FILE", str(tmp_path / "op.lock"))
    log = logging.getLogger("t")
    shared = Plugin()  # Dispatcharr keeps one instance per worker
    assert shared._acquire_lock(log) is True   # action 1 holds the lock
    assert shared._acquire_lock(log) is False  # action 2 on the same instance

On this branch the second call returns True. Your existing test with two separate Plugin() objects still passes, which is why the suite did not catch it.

A suggested fix: key the owner to the call stack instead of the instance. If _lock_owner and _lock_depth live in a threading.local() (held on the instance, created in __init__), the scheduled run and its nested steps share them because they run on the same thread, and each button press gets its own. Dispatcharr's uWSGI workers monkey-patch with gevent early, so threading.local there is per greenlet, which gives each request its own copy too. The manual Full Sync's sub-steps run one after another on the one thread _try_start_thread starts, so they are unaffected. Passing an explicit owner from run_scheduled_full_sync into _do_full_sync would also work if you prefer that.

Please add the test above (or your own version of it) so this stays covered.

Smaller points

  • The Time Zone help text says it "falls back to UTC if left blank", but _reconcile_schedule falls back to _detected_timezone() when the field is blank, and returns without updating the schedule when the value is not a real zone name. It would help to describe that.
  • Because the reconcile now skips whenever the hash matches, a schedule row deleted outside the plugin (for example in the Django admin) is not recreated until a setting changes. That is a reasonable trade-off for avoiding a write on every action. A line in the Automation help text, or in Validate Settings, would make it discoverable.
  • The pull request description still mentions DISPATCHARR_TIME_ZONE / Django TIME_ZONE as the source, and says it was opened as a draft. Could you update it so it matches the code?

Once the lock owner is fixed and the test is in, I expect this to be ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Scheduled / Automatic Full Sync

2 participants