Skip to content

Updates to dynamic recurring tasks aren't picked up until the scheduler restarts #738

Description

@cupatea

When dynamic tasks enabled, only two cases are handled on each poll:

  • Creating new recurring task
  • Deleting existing recurring task

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:

task = SolidQueue.schedule_recurring_task(
  "platform-import-schedule-14",
  class: "PlatformlmportJob",
  args: [{ company_id: 14 }],
  schedule: "0 4 * * * Europe/Kyiv" # 04:00 EEST
)

# scheduler picks it up on the next poll

task.update!(schedule: "0 8 * * * Europe/Kyiv") # 08:00 EEST

# expected: jobs start at 08:00 EEST
# actual:   scheduler keeps the original "04:00 EEST" cadence until restart
Image

Activity

  1. ymendel commented on May 25, 2026

    @ymendel

    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):

    1. Active SolidQueue::RecurringTask (dynamic) for, say, */15 * * * *. Scheduler running with dynamic_tasks_enabled: true.
    2. task.update!(schedule: "0 * * * *") from a web request or console — DB row reflects new cadence immediately.
    3. Scheduler keeps firing on */15 indefinitely. 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 in scheduled_tasks
    • unschedule_deleted_dynamic_tasks — scheduled_tasks entries whose key is no longer in the DB

    A row whose key is present in both the DB and scheduled_tasks falls through both filters, regardless of whether its schedule (or class_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/deleted paths 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_task half of the change synchronous inside the after_commit callback, but defer the schedule_task half 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::RecordNotUnique to 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 */5 mid-hour (no pause) Fire at next 5-min boundary 20:10:02 UTC ✓
    3 */5 continues firing Fire 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 — */5 ticks 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.

  2. added 4 commits that reference this issue on Jul 30, 2026
    0b64928
    81bdc96
    f5bab54
    c2e5d68
  3. rosa commented on Jul 30, 2026

    @rosa
    Member

    Hey! Sorry I missed there was an issue for this. This is expected behaviour: #739 (comment)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions