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)
Constant Summary collapse
- RECONCILE_TRIGGER_ATTRS =
Schedule attributes whose change should trigger reconciliation. Mirrors the cacheable_attributes set plus :enabled (enable/disable drives reconcile).
%w[ enabled job_class args queue priority timezone anchor interval interval_unit cron catch_up name ].freeze
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.
-
#disable! ⇒ Boolean
Disable this schedule (and reconcile via after_update_commit, which supersedes the future execution so zero future executions remain).
-
#enable! ⇒ Boolean
Enable this schedule (and reconcile via after_update_commit).
-
#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.
-
#reconcile!(lock: true, blocking: true) ⇒ ScheduleExecution?
Converge this schedule’s executions to its target invariants.
-
#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.
398 399 400 |
# File 'app/models/postburner/schedule.rb', line 398 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.
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 |
# File 'app/models/postburner/schedule.rb', line 427 def cacheable_attributes { name: name, job_class: job_class, args: args, queue: queue, priority: priority, timezone: timezone, # Emit anchor as a deterministic ISO-8601 string (ms precision) rather # than a raw Time, so the cached snapshot and a live cacheable_attributes # hash compare equal under ScheduleExecution#drifted? without a # Time-vs-string false drift. Anchors are second/minute aligned in # practice, so millisecond precision is more than sufficient. anchor: anchor&.iso8601(3), 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)
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 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'app/models/postburner/schedule.rb', line 202 def create_next_execution!(after: nil) # Check if a LIVE future execution already exists. # # Only pending/scheduled rows count: a skipped or superseded row must NOT # block creation of the next live future, so this matches the same # "live future" notion reconcile! and future_live use. # # 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.live.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 # Compute the next slot from the chosen base time using the SAME skip-aware # computation reconcile! uses, so the before_attempt chaining path can never # place a live execution onto a grid point that carries a skipped (cancelled) # occurrence. With no skipped slots this is identical to the next grid point # strictly after `after_time` (i.e. unchanged behavior). desired = next_grid_point_skipping_cancelled(after: after_time) return nil if desired.nil? create_execution!(at: desired) 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.
391 392 393 |
# File 'app/models/postburner/schedule.rb', line 391 def cron? cron.present? end |
#disable! ⇒ Boolean
Disable this schedule (and reconcile via after_update_commit, which supersedes the future execution so zero future executions remain).
321 322 323 |
# File 'app/models/postburner/schedule.rb', line 321 def disable! update!(enabled: false) end |
#enable! ⇒ Boolean
Enable this schedule (and reconcile via after_update_commit).
313 314 315 |
# File 'app/models/postburner/schedule.rb', line 313 def enable! update!(enabled: true) 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
368 369 370 |
# File 'app/models/postburner/schedule.rb', line 368 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.
341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'app/models/postburner/schedule.rb', line 341 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 |
#reconcile!(lock: true, blocking: true) ⇒ ScheduleExecution?
Converge this schedule’s executions to its target invariants. Idempotent.
This is the single convergence path that owns:
1. exactly one LIVE future execution per ENABLED schedule,
2. zero future executions for a DISABLED schedule,
3. the future execution sits on the current grid with a non-drifted
cached snapshot.
Always future-only: it never touches past or in-flight executions. Always resumes from the next grid point after now; it does NOT honor catch_up to backfill a gap (that is the before_attempt chaining path’s concern, not reconcile’s).
Concurrency: mutual exclusion is provided by the SCHEDULER_LOCK_KEY advisory lock (NOT a wrapping AR transaction, so enqueue can happen after commit). Each supersede!/create runs in its own small transaction; the live-only partial unique index on (schedule_id, run_at) is the hard backstop.
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 |
# File 'app/models/postburner/schedule.rb', line 289 def reconcile!(lock: true, blocking: true) return reconcile_unlocked! unless lock acquired = false result = Postburner::AdvisoryLock.with_lock( Postburner::AdvisoryLock::SCHEDULER_LOCK_KEY, blocking: blocking ) do acquired = true reconcile_unlocked! end unless acquired Rails.logger.debug( "[Postburner::Schedule] reconcile! skipped for '#{name}': scheduler lock " \ "held; the watchdog will converge on its next pass" ) end result 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.
168 169 170 171 172 |
# File 'app/models/postburner/schedule.rb', line 168 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).
384 385 386 |
# File 'app/models/postburner/schedule.rb', line 384 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.
413 414 415 |
# File 'app/models/postburner/schedule.rb', line 413 def tz @tz ||= Time.find_zone(timezone) end |