Class: Postburner::Schedule

Inherits:
ApplicationRecord show all
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**:

  1. When an execution is created, it’s immediately enqueued to Beanstalkd’s delayed queue with the appropriate delay until run_at

  2. For Postburner::Job-based schedules, a before_attempt callback creates the next execution when the current job runs - providing immediate pickup without waiting

  3. 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)

Examples:

Anchor-based schedule (daily at 9:30 AM)

schedule = Postburner::Schedule.create!(
  name: 'daily_cleanup',
  job_class: 'CleanupJob',
  anchor: Time.zone.parse('2025-01-01 09:30:00'),
  interval: 1,
  interval_unit: 'days',
  timezone: 'America/New_York',
  args: { report_type: 'daily' }
)
schedule.start!  # Optional: immediate pickup

Cron-based schedule (weekdays at 8 AM)

schedule = Postburner::Schedule.create!(
  name: 'weekday_standup',
  job_class: 'StandupReminderJob',
  cron: '0 8 * * 1-5',
  timezone: 'America/Chicago'
)

With catch-up enabled

schedule = Postburner::Schedule.create!(
  name: 'billing_job',
  job_class: 'BillingJob',
  interval: 1,
  interval_unit: 'hours',
  catch_up: true  # Run all missed hours if worker was down
)

See Also:

Instance Method Summary collapse

Instance Method Details

#anchor?Boolean

Check if schedule uses anchor mode.

Returns:

  • (Boolean)

    true if anchor time is set, false otherwise



302
303
304
# File 'app/models/postburner/schedule.rb', line 302

def anchor?
  anchor.present?
end

#cacheable_attributesHash

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.

Returns:

  • (Hash)

    Hash of schedule attributes to cache



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?

Note:

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)

Examples:

Create next execution after current

last_execution = schedule.executions.last
schedule.create_next_execution!(after: last_execution)

With catch_up disabled (default)

schedule.catch_up = false
schedule.create_next_execution!  # Calculates from Time.current

Parameters:

  • after (ScheduleExecution, Time, nil) (defaults to: nil)

    The execution or time to calculate next from. If nil, behavior depends on catch_up setting. If ScheduleExecution, uses its run_at.

Returns:

  • (ScheduleExecution, nil)

    The created execution, or nil if one already exists or if a race condition occurred



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.message}"
  nil
end

#cron?Boolean

Check if schedule uses cron mode.

Returns:

  • (Boolean)

    true if cron expression is set, false otherwise



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

Examples:

Get next run time

schedule.next_run_at
# => 2025-12-29 09:00:00 UTC

Parameters:

  • after (Time, nil) (defaults to: nil)

    Calculate time after this (default: Time.current)

Returns:

  • (Time, nil)

    Next run time, or nil if no more runs



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.

Examples:

Preview next 5 runs

schedule.next_run_at_times(count: 5)
# => [2025-12-29 09:00:00 UTC, 2025-12-30 09:00:00 UTC, ...]

Calculate from specific time

schedule.next_run_at_times(after: 1.week.from_now, count: 3)

Parameters:

  • after (Time, nil) (defaults to: nil)

    Calculate times after this time (default: Time.current)

  • count (Integer) (defaults to: 1)

    Number of times to calculate (default: 1)

Returns:

  • (Array<Time>)

    Array of future run times in schedule’s timezone



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.

Examples:

Start a new schedule

schedule = Postburner::Schedule.create!(
  name: 'daily_cleanup',
  job_class: 'CleanupJob',
  anchor: Time.zone.now,
  interval: 1,
  interval_unit: 'days',
  timezone: 'UTC'
)
schedule.start!  # Creates and enqueues first execution

Returns:

Raises:

  • (ActiveRecord::RecordInvalid)

    If execution creation fails



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

Examples:

Check if schedule is started

schedule.started?  # => false
schedule.start!
schedule.started?  # => true

Returns:

  • (Boolean)

    true if schedule has any executions, false otherwise



288
289
290
# File 'app/models/postburner/schedule.rb', line 288

def started?
  executions.exists?
end

#tzActiveSupport::TimeZone

Get timezone object.

Returns an ActiveSupport::TimeZone instance for the schedule’s timezone. The timezone object is cached in an instance variable.

Examples:

schedule.timezone = 'America/New_York'
schedule.tz  # => #<ActiveSupport::TimeZone:0x... @name="America/New_York">

Returns:

  • (ActiveSupport::TimeZone)

    The timezone object



317
318
319
# File 'app/models/postburner/schedule.rb', line 317

def tz
  @tz ||= Time.find_zone(timezone)
end