Repository navigation
Updates to dynamic recurring tasks aren't picked up until the scheduler restarts #738
Description
Activity
Confirming this against solid_queue v1.4.0 on Postgres, with a minimal reproduction and the application-side workaround that turned out to be reliable.
Reproduction (Rails 8.1 + Postgres):
- Active
SolidQueue::RecurringTask(dynamic) for, say,*/15 * * * *. Scheduler running withdynamic_tasks_enabled: true. task.update!(schedule: "0 * * * *")from a web request or console — DB row reflects new cadence immediately.- Scheduler keeps firing on
*/15indefinitely. No log, no error — just the original cron until the worker process restarts.
Looking at
Scheduler::RecurringSchedule#reschedule_dynamic_tasks, the polling cycle handles exactly two cases:schedule_created_dynamic_tasks— rows whose key is not inscheduled_tasksunschedule_deleted_dynamic_tasks—scheduled_tasksentries whose key is no longer in the DB
A row whose key is present in both the DB and
scheduled_tasksfalls through both filters, regardless of whether itsschedule(orclass_name,args,queue_name, etc.) has changed.Asymmetric consequence worth flagging. This affects "update in place" but not "destroy + recreate with the same key", provided enough wall-clock time passes between the destroy and the recreate for at least one polling cycle (~5s with defaults) to see the row missing. So in an application that destroys + immediately recreates a task with the same key inside a single transaction or a single after-commit callback, the destroy + recreate combo is functionally an update from the scheduler's point of view — and silently ignored. But two separate user actions seconds apart (e.g. pause-save followed by unpause-save) work correctly, because the scheduler does observe the gap.
Application-side workaround, verified end-to-end:
- Pause the recurring task (destroy the row).
- Wait >
polling_interval(default 5s; one form-submission round-trip handily clears this). - Update the schedule on whatever the application's domain model is, and recreate the task in the same operation.
That is: rely on the existing
created/deletedpaths and split the update into two distinct DB transactions with a polling cycle between them.Code-level variant for apps that want a single user-facing edit (not a three-step pause/edit/unpause flow): keep the
unschedule_taskhalf of the change synchronous inside theafter_commitcallback, but defer theschedule_taskhalf through an Active Job:RescheduleTaskJob.set(wait: 6.seconds).perform_later(id)
Six seconds exceeds the default 5s polling interval, so by the time the job runs and recreates the row, the scheduler's previous poll has already observed the deletion and cancelled the in-memory entry. The next poll then sees a new row and schedules it normally. The job no-ops if the underlying record has been deleted or paused in the meantime, and rescues
ActiveRecord::RecordNotUniqueto absorb stacked rapid updates. Trade-off: ~6–11s end-to-end before the new cron is active. Acceptable for an interactive form edit; not appropriate when cadence changes need to be near-instant.Empirical confirmation (Rails 8.1, Postgres 17, solid_queue 1.4.0, single active monitor with the deferred-job pattern above):
Step Action Expected Observed 1 Baseline 0 * * * *Fire at top of hour 20:00:23 UTC ✓ 2 Switch to */5mid-hour (no pause)Fire at next 5-min boundary 20:10:02 UTC ✓ 3 */5continues firingFire again 5 min later 20:15:09 UTC ✓ 4 Switch back to 0 * * * *(no pause)No run at the next 5-min boundary No run through 20:24 UTC ✓ Step 4 is the strongest signal —
*/5ticks stopped firing after the cadence switch back, proving the in-memory schedule was cancelled-and-rescheduled rather than left stale on the prior cadence.Suggested shape for the fix (very loose — happy to PR if it'd be useful):
def reschedule_dynamic_tasks wrap_in_app_executor do reload_dynamic_tasks schedule_created_dynamic_tasks unschedule_deleted_dynamic_tasks reschedule_updated_dynamic_tasks end end private def reschedule_updated_dynamic_tasks RecurringTask.dynamic.where(key: scheduled_tasks.keys).each do |task| in_memory = scheduled_tasks[task.key] next if in_memory.nil? # Compare on whatever fields define equivalence — schedule plus any others # that affect job enqueue: class_name, queue_name, priority, args. if in_memory.task != task in_memory.cancel scheduled_tasks.delete(task.key) schedule_task(task) end end end
The "what counts as a meaningful difference" question is the interesting one — comparing all the attributes that affect job enqueuing is the safe answer.
Happy to elaborate or take a stab at a PR.
- Active
- added 4 commits that reference this issue
on Jul 30, 2026 Hey! Sorry I missed there was an issue for this. This is expected behaviour: #739 (comment)
Reacted by Yossef Mendelssohn- added a commit that references this issue
on Jul 30, 2026
When dynamic tasks enabled, only two cases are handled on each poll:
The third case, when task updated is not handled. If dynamic task's schedule changed, arguments, queue_name, priority, class_name, etc., the running scheduler keeps firing against the pre-update version until the process is restarted.
Example: