Skip to content

feat(deferred): add periodic task scheduling#277

Open
Abishekcs wants to merge 1 commit intorage-rb:mainfrom
Abishekcs:feature/deferred-scheduled-tasks
Open

feat(deferred): add periodic task scheduling#277
Abishekcs wants to merge 1 commit intorage-rb:mainfrom
Abishekcs:feature/deferred-scheduled-tasks

Conversation

@Abishekcs
Copy link
Copy Markdown
Contributor

@Abishekcs Abishekcs commented Apr 18, 2026

#233

What this PR Does

Adds native recurring task scheduling to Rage::Deferred, allowing tasks to run on a fixed interval without relying on external tools like cron or third-party libraries.

Usage

Rage.configure do
  config.deferred.schedule do
    every 1.hour,   task: CleanupExpiredInvites
    every 1.minute, task: ResetCache
  end
end

Where CleanupExpiredInvites and ResetCache are standard Rage::Deferred::Task classes with a perform method.

Implementation

Uses Iodine's run_every timer primitive. No new threads or processes the scheduler runs within the existing Iodine runtime. When a timer fires, it calls task.enqueue which goes through the normal Rage::Deferred path WAL write, middleware, worker execution.

Schedule blocks are stored during configuration and evaluated at boot after all app constants are loaded, so task classes defined in app/tasks/ are always available.

Leader election uses File#flock the same pattern used by Rage::Cable. Each worker tries a non-blocking exclusive lock on a shared file at boot. The winner registers timers, others stand by. When the leader dies, the OS releases the lock and the next spawned worker takes over.

Design Decisions

  • Overlaps allowed by default matches Sidekiq, Que, and GoodJob behavior.
  • No arguments to perform scheduled tasks are stateless and discover their own data at runtime.
  • No polling loop boot-time election is enough since Iodine always respawns dead workers.
  • Timers reset on leader change no catch-up for missed intervals.

Screenrecording:

4 workers, 1 worker wins leader election and 2 task scheduled

4-workers-1-worker-wins-leader-election-2-task-scheduled_AWocXcyV.mp4

4 workers, 1 worker wins leader election and 2 task scheduled and leader worker dies after some time

4-workers-1-worker-wins-leader-election-2-task-scheduled-leader-worker-dies.mp4

@Abishekcs Abishekcs force-pushed the feature/deferred-scheduled-tasks branch from 54bb689 to 5d0f4de Compare April 18, 2026 14:42
@Abishekcs
Copy link
Copy Markdown
Contributor Author

I have kept the design proposal same as suggested by @ShashantNagpure and @pratyush07-hub

Copy link
Copy Markdown
Member

@rsamoilov rsamoilov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Abishekcs ,

Left several comments. Apart from that, this looks great!

Comment thread lib/rage/deferred/scheduler.rb Outdated
def self.start(tasks)
return if tasks.empty?

elect_leader { register_timers(tasks) }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already an existing Rage::Internal.pick_a_worker method - you can use it instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay.

Comment thread lib/rage/configuration.rb Outdated

# Registers a task to run on a fixed interval (in seconds)
def every(interval, task:)
@scheduled_tasks << { interval:, task: }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it makes sense to validate that task is a class that includes Rage::Deferred::Task?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, since it will prevent newbie users like me from passing anything other than Rage::Deferred::Task when using Rails with Rage.

Comment thread lib/rage/configuration.rb Outdated

# Evaluates the scheduling DSL block, making `every` available as a method
def schedule(&block)
instance_eval(&block)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work with user-level constants - they are loaded after the framework is configured. So if a background task is defined in app/tasks, it won't be available during the configuration phase.

To fix this you'll need to update schedule to only store the block, postponing the execution of this block to the time when the scheduler starts. Also, keep in mind there could be multiple schedule calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I will make the required changes.

@Abishekcs Abishekcs changed the title feat(deferred): add periodic task scheduling [WIP]: feat(deferred): add periodic task scheduling Apr 21, 2026
@Abishekcs Abishekcs marked this pull request as draft April 21, 2026 17:13
@Abishekcs Abishekcs force-pushed the feature/deferred-scheduled-tasks branch from 5d0f4de to 8d4ad8c Compare April 21, 2026 17:14
Adds native recurring task scheduling to Rage::Deferred via a simple DSL, without relying on external tools like cron or third-party libraries.

## Public API
Rage.configure do
  config.deferred.schedule do
    every 1.hour,   task: CleanupExpiredInvites
    every 1.minute, task: ResetCache
  end
end

## How It Works
- Uses Iodine's run_every timer primitive within the existing runtime.
- Schedule blocks are stored during configuration and evaluated at boot,
after all app constants are loaded. Multiple schedule calls are supported.

## Leader Election
Uses Rage::Internal.pick_a_worker with a fixed shared lock path so all workers compete on the same file. The winner registers timers, others stand by. When the leader dies, the OS releases the lock and the next worker to boot takes over. Timers reset on leader change.

## Design Decisions
- Overlaps allowed by default.
- First run waits for the first interval.
- Arguments not supported - tasks discover their own data at runtime.
- task must include Rage::Deferred::Task, validated at boot with ArgumentError.
- pick_a_worker updated to accept lock_path and handle Iodine.running?.

## Tests
Covers timer registration, task enqueue, lock path, and empty task list.
@Abishekcs Abishekcs force-pushed the feature/deferred-scheduled-tasks branch from 8d4ad8c to 9b8bc2a Compare April 21, 2026 17:18
@Abishekcs
Copy link
Copy Markdown
Contributor Author

Hi @rsamoilov, made the required changes based upon the comments you left, here is a quick summary:

  • I had to modify pick_a_worker because the old version always used Iodine.on_state(:on_start) to register the lock attempt. But deferred.rb calls __initialize immediately when Iodine.running? is true or it will call it's own Iodine.on_state(:on_start) meaning pick_a_worker would be called while Iodine is already running. In that case on_state(:on_start) never fires and the block never executes. The fix adds an Iodine.running? check inside pick_a_worker itself so it calls the block immediately when Iodine is already running, and falls back to on_state(:on_start) otherwise.

  • Changes are made to schedule method so it could only stores the scheduling block for later execution and added a class ScheduleDSL under class deferred in confiuration.rb to keep the DSL method every isolated in its own class rather than polluting Deferred with methods that don't belong there.

  • Updated the Screenrecording and PR details.

  • AI usage

    • spec/internal_spec.rb and spec/deferred/scheduler_spec.rb for suggestion test cases.

@Abishekcs Abishekcs changed the title [WIP]: feat(deferred): add periodic task scheduling feat(deferred): add periodic task scheduling Apr 21, 2026
@Abishekcs Abishekcs marked this pull request as ready for review April 21, 2026 18:10
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.

2 participants