Class: Postburner::Schedule
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Postburner::Schedule
- Defined in:
- app/models/postburner/schedule.rb
Overview
Schedule model for recurring job execution with fixed-rate, grid-aligned scheduling.
Postburner’s scheduler provides predictable execution times with no drift, like subscription billing. Executions are calculated as anchor + N*interval, maintaining alignment to the grid regardless of actual execution times.
## Architecture
The scheduler uses **immediate enqueue** combined with a **watchdog safety net**:
-
When an execution is created, it’s immediately enqueued to Beanstalkd’s delayed queue with the appropriate delay until run_at
-
For Postburner::Job-based schedules, a before_attempt callback creates the next execution when the current job runs - providing immediate pickup without waiting
-
A lightweight watchdog in the ‘scheduler’ tube acts as a safety net, ensuring every schedule has a future execution queued
This design requires no dedicated scheduler process - existing workers handle everything.
## Scheduling Modes
**Anchor-based (recommended):** Define a start time, interval, and unit (like subscriptions)
-
Supports: seconds, minutes, hours, days, weeks, months, years
-
Grid-aligned: Always snaps to anchor + N*interval, never drifts
-
Example: Daily at 9:00 AM, weekly on Saturday, monthly on the 1st
Cron-based: Use standard cron expressions (requires fugit gem)
-
Power user feature for complex schedules
-
Example: Weekdays at 8 AM, every 15 minutes, etc.
## Database Fields
-
‘name` - Unique identifier for the schedule
-
‘job_class` - ActiveJob or Postburner::Job class name
-
‘anchor` - Start time for interval calculation (anchor mode)
-
‘interval` - Number of interval units (anchor mode)
-
‘interval_unit` - Unit type: seconds/minutes/hours/days/weeks/months/years
-
‘cron` - Cron expression (cron mode)
-
‘timezone` - Timezone for calculations (default: UTC)
-
‘args` - JSONB arguments passed to each job execution
-
‘queue` - Override default queue name
-
‘priority` - Override default Beanstalkd priority
-
‘enabled` - Enable/disable schedule
-
‘catch_up` - Skip missed executions (false) or run all (true)
-
‘last_audit_at` - Last time watchdog processed this schedule
## Catch-Up Policy
The catch_up attribute controls behavior when worker is down:
-
‘catch_up: false` (default) - Skip missed executions, resume from next future time
-
‘catch_up: true` - Run all missed executions when worker restarts
## Configuration
Add to config/postburner.yml:
production:
default_scheduler_interval: 300 # Check every 5 minutes
default_scheduler_priority: 100 # Watchdog priority
## Starting Schedules
**Explicit start (immediate):**
schedule.start! # Creates and enqueues first execution to Beanstalkd
**Auto-bootstrap (eventual):**
# Watchdog auto-bootstraps on next run (adds up to one interval of delay)
Instance Method Summary collapse
-
#anchor? ⇒ Boolean
Check if schedule uses anchor mode.
-
#cacheable_attributes ⇒ Hash
private
Attributes to cache in schedule executions.
-
#create_next_execution!(after: nil) ⇒ ScheduleExecution?
Create the next execution if one doesn’t already exist.
-
#cron? ⇒ Boolean
Check if schedule uses cron mode.
-
#next_run_at(after: nil) ⇒ Time?
Calculate the single next run time.
-
#next_run_at_times(after: nil, count: 1) ⇒ Array<Time>
Calculate next N run times.
-
#start! ⇒ ScheduleExecution?
Start the schedule by creating the first execution.
-
#started? ⇒ Boolean
Check if the schedule has been started.
-
#tz ⇒ ActiveSupport::TimeZone
Get timezone object.
Instance Method Details
#anchor? ⇒ Boolean
Check if schedule uses anchor mode.
302 303 304 |
# File 'app/models/postburner/schedule.rb', line 302 def anchor? anchor.present? end |
#cacheable_attributes ⇒ Hash
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Attributes to cache in schedule executions.
Returns a hash of schedule attributes that are cached in each ScheduleExecution’s cached_schedule column. This allows executions to run even if the schedule is modified or deleted after creation.
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 |
# File 'app/models/postburner/schedule.rb', line 331 def cacheable_attributes { name: name, job_class: job_class, args: args, queue: queue, priority: priority, timezone: timezone, anchor: anchor, interval: interval, interval_unit: interval_unit, cron: cron, catch_up: catch_up } end |
#create_next_execution!(after: nil) ⇒ ScheduleExecution?
This method handles race conditions gracefully - if two threads/processes try to create an execution simultaneously, one will succeed and the other will return nil with a warning logged.
Create the next execution if one doesn’t already exist.
Idempotent method that ensures exactly one future execution is scheduled. Used by Postburner::Job callback to provide immediate pickup without waiting for the scheduler watchdog. Safe to call multiple times - will only create an execution if none exists in the future.
The catch_up attribute controls behavior when worker is down:
-
catch_up: true -> calculates from last execution (may be in past, runs immediately)
-
catch_up: false -> calculates from Time.current (skips missed executions)
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'app/models/postburner/schedule.rb', line 185 def create_next_execution!(after: nil) # Check if a future execution already exists (any status - including skipped). # # TIME PRECISION: When called from a job callback during time travel # (e.g., ImmediateTestQueue), Time.current and execution.run_at can differ # by microseconds: # # Time.current = 2025-12-29 06:54:58.000000 UTC (traveled, truncated) # execution.run_at = 2025-12-29 06:54:58.185940 UTC (database precision) # # Using Time.current would incorrectly find the CURRENT execution as "future" # (since 185940µs > 0µs), causing this method to return nil. By using # after.run_at when an execution is provided, we correctly exclude the # current execution from the future check. check_time = after.is_a?(ScheduleExecution) ? after.run_at : Time.current return nil if executions.where('run_at > ?', check_time).exists? # Determine base time for calculating next execution. # # TIME PRECISION: When after is a current/future ScheduleExecution, we # must use its run_at for calculation. If we used Time.current instead # (which may be microseconds behind), next_run_at() could return the SAME # time as the current execution, causing a duplicate key error. # # For past executions, respect the catch_up setting: # catch_up: true -> calculate from last execution (runs missed jobs) # catch_up: false -> calculate from Time.current (skips missed jobs) after_time = if after.is_a?(ScheduleExecution) && after.run_at >= Time.current after.run_at elsif catch_up after.is_a?(ScheduleExecution) ? after.run_at : after else Time.current end create_execution!(after: after_time) rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e # Race condition - another process/thread created it between our check and insert # RecordNotUnique: PostgreSQL constraint violation # RecordInvalid: Rails validation error (uniqueness validation catches it first) Rails.logger.warn "[Postburner::Schedule] Expected race condition creating next execution for '#{name}' (job: #{id}): #{e.}" nil end |
#cron? ⇒ Boolean
Check if schedule uses cron mode.
295 296 297 |
# File 'app/models/postburner/schedule.rb', line 295 def cron? cron.present? end |
#next_run_at(after: nil) ⇒ Time?
Calculate the single next run time.
Convenience method that returns only the next run time instead of an array. Equivalent to calling next_run_at_times(after: after, count: 1).first
272 273 274 |
# File 'app/models/postburner/schedule.rb', line 272 def next_run_at(after: nil) next_run_at_times(after: after, count: 1).first end |
#next_run_at_times(after: nil, count: 1) ⇒ Array<Time>
Calculate next N run times.
Uses either cron or anchor-based calculation depending on schedule mode. All times are calculated in the schedule’s timezone and returned as Time objects.
245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'app/models/postburner/schedule.rb', line 245 def next_run_at_times(after: nil, count: 1) after ||= Time.current times = [] if cron? # Cron-based calculation times = calculate_cron_times(after: after, count: count) else # Anchor-based calculation times = calculate_anchor_times(after: after, count: count) end times end |
#start! ⇒ ScheduleExecution?
Start the schedule by creating the first execution.
Use this when you need the schedule to be picked up immediately (within the next scheduler interval). Without calling start!, the scheduler will auto-bootstrap the schedule on its next run, but that adds an extra interval of delay.
This is an idempotent operation - calling it multiple times will only create the first execution once.
151 152 153 154 155 |
# File 'app/models/postburner/schedule.rb', line 151 def start! return nil if started? create_execution! end |
#started? ⇒ Boolean
Check if the schedule has been started.
A schedule is considered started if it has any executions (pending, scheduled, or skipped).
288 289 290 |
# File 'app/models/postburner/schedule.rb', line 288 def started? executions.exists? end |
#tz ⇒ ActiveSupport::TimeZone
Get timezone object.
Returns an ActiveSupport::TimeZone instance for the schedule’s timezone. The timezone object is cached in an instance variable.
317 318 319 |
# File 'app/models/postburner/schedule.rb', line 317 def tz @tz ||= Time.find_zone(timezone) end |