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:

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

Instance Method Details

#anchor?Boolean

Check if schedule uses anchor mode.

Returns:

  • (Boolean)

    true if anchor time is set, false otherwise



398
399
400
# File 'app/models/postburner/schedule.rb', line 398

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



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?

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



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

#cron?Boolean

Check if schedule uses cron mode.

Returns:

  • (Boolean)

    true if cron expression is set, false otherwise



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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

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



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.

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



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.

Parameters:

  • lock (Boolean) (defaults to: true)

    acquire SCHEDULER_LOCK_KEY. Pass false when the caller (the watchdog) already holds it.

  • blocking (Boolean) (defaults to: true)

    when acquiring the lock, wait for it (true) or give up immediately if it’s held (false). Admin-triggered paths (skip!, the after_update_commit edit hook) pass false so a web request never blocks for the duration of a watchdog sweep — if the lock is busy they skip the inline reconcile and let the watchdog converge on its next pass. Ignored when lock: false.

Returns:

  • (ScheduleExecution, nil)

    the execution created this run, or nil if the schedule was already stable/disabled OR (non-blocking) the lock was held. Callers use this to populate the ‘execution_created` instrumentation key.



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.

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



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

Examples:

Check if schedule is started

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

Returns:

  • (Boolean)

    true if schedule has any executions, false otherwise



384
385
386
# File 'app/models/postburner/schedule.rb', line 384

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



413
414
415
# File 'app/models/postburner/schedule.rb', line 413

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