Class: Postburner::ScheduleExecution

Inherits:
ApplicationRecord show all
Defined in:
app/models/postburner/schedule_execution.rb

Overview

ScheduleExecution represents a single scheduled run of a recurring job.

Executions are created in advance and immediately enqueued to Beanstalkd’s delayed queue. This ensures all future runs are already queued and ready to execute at the scheduled time, regardless of scheduler availability.

## Lifecycle

  1. Execution created with ‘pending` status and immediately enqueued to Beanstalkd

  2. Status changes to ‘scheduled` once enqueued

  3. At ‘run_at` time, Beanstalkd releases job to worker

  4. For Postburner::Job schedules: ‘before_attempt` callback creates next execution

  5. Job completes (tracked in job record, not execution)

  6. Watchdog periodically verifies future executions exist (safety net)

## Status Values

  • ‘pending` - Created but not yet enqueued to Beanstalkd

  • ‘scheduled` - Enqueued to Beanstalkd, waiting for execution

  • ‘skipped` - Cancelled by an operator before execution

  • ‘superseded` - Replaced by a recreate (config/grid drift, reconcile)

Note: We don’t track running/completed/failed states here because the actual job (Postburner::Job or ActiveJob) handles its own lifecycle. The execution’s job is done once it’s scheduled.

## Database Fields

  • ‘schedule_id` - Foreign key to parent schedule

  • ‘run_at` - Scheduled execution time

  • ‘next_run_at` - Pre-calculated next run time for validation

  • ‘enqueued_at` - When job was queued to Beanstalkd

  • ‘status` - Current status (pending, scheduled, skipped, superseded)

  • ‘beanstalk_job_id` - Beanstalkd job ID (for tracking/cancellation)

  • ‘job_id` - Postburner::Job ID (if using Postburner::Job subclass)

  • ‘cached_schedule` - JSONB snapshot of schedule at creation time

## Immediate Enqueue Architecture

Unlike traditional schedulers that poll for due jobs, Postburner immediately enqueues executions to Beanstalkd’s delayed queue when created. This means:

  • Jobs are already queued and will run at run_at regardless of scheduler status

  • No polling overhead - Beanstalkd handles delayed delivery

  • Watchdog only acts as safety net to ensure future executions exist

Examples:

Viewing execution details

execution = schedule.executions.last
execution.run_at        # => 2025-12-29 09:00:00 UTC
execution.enqueued_at   # => 2025-12-28 10:00:00 UTC
execution.status        # => "scheduled"
execution.beanstalk_job_id  # => 12345

Skipping a future execution

execution = schedule.executions.future.first
execution.skip!  # Cancels Beanstalkd job and marks as skipped

See Also:

Defined Under Namespace

Classes: NextExecutionRunAtConflict

Instance Method Summary collapse

Instance Method Details

#drifted?(schedule) ⇒ Boolean

Whether this execution’s cached snapshot has drifted from the schedule’s current configuration.

Compares the stored ‘cached_schedule` against the schedule’s live Postburner::Schedule#cacheable_attributes. BOTH operands are normalized through one identical JSON round-trip first, so differences that are purely representational — most importantly a Time-valued ‘anchor` versus its stored ISO-8601 string — do NOT register as drift. A real change to any cached attribute (job_class, args, queue, priority, timezone, anchor, interval, interval_unit, cron, catch_up, name) does.

Parameters:

  • schedule (Postburner::Schedule)

    the schedule to compare against (typically this execution’s own schedule)

Returns:

  • (Boolean)

    true if the snapshot no longer matches current config



273
274
275
# File 'app/models/postburner/schedule_execution.rb', line 273

def drifted?(schedule)
  normalize_snapshot(schedule.cacheable_attributes) != normalize_snapshot(cached_schedule)
end

#enqueue!void

Note:

Creating the next execution is the scheduler’s responsibility via Schedule#create_next_execution!, not this method’s responsibility.

This method returns an undefined value.

Enqueue this execution to Beanstalkd.

Creates the appropriate job (ActiveJob or Postburner::Job) and queues it to Beanstalkd at the scheduled run_at time with appropriate delay.

The job type is determined by the job_class in cached_schedule:

  • Postburner::Job subclasses: Creates tracked job with database record

  • ActiveJob classes: Enqueues directly without database tracking

This method is idempotent - calling it multiple times will only enqueue once.

Instruments with ActiveSupport::Notifications:

  • enqueue.schedule_execution.postburner: When execution is enqueued

Examples:

Enqueue an execution

execution = schedule.executions.build(run_at: 1.hour.from_now)
execution.save!
execution.enqueue!  # Creates and queues job to Beanstalkd


136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'app/models/postburner/schedule_execution.rb', line 136

def enqueue!
  return if enqueued?

  # Mark as scheduled BEFORE Beanstalkd operations to prevent duplicates.
  # If Beanstalkd put fails, we have a scheduled execution with no beanstalk_job_id,
  # which the watchdog can detect and re-queue. This is safer than having a
  # pending execution with a job already in Beanstalkd (which causes duplicates).
  #
  # BOTH enqueue paths defer the actual Beanstalkd insertion until this
  # transaction commits:
  #  - Postburner::Job#queue! inserts via an after-commit hook;
  #  - the ActiveJob adapter declares enqueue_after_transaction_commit?.
  # So the real Beanstalkd id (and, for tracked ActiveJobs, the TrackedJob
  # row) only exists AFTER the transaction below. We capture the job here and
  # link/record the Beanstalkd id afterward.
  tracked_job_instance = nil
  pending_activejob = nil
  transaction do
    update_columns(
      enqueued_at: Time.current,
      status: self.class.statuses[:scheduled]
    )

    if tracked_job?
      tracked_job_instance = enqueue_tracked_job!
    else
      pending_activejob = build_and_enqueue_activejob!
    end
  end

  # After commit: the deferred insertions have run, so the Beanstalkd id (and
  # any TrackedJob record) now exist. Record the handles on this execution.
  if tracked_job_instance
    update_columns(beanstalk_job_id: tracked_job_instance.reload.bkid)
  elsif pending_activejob
    link_activejob!(pending_activejob)
  end

  # Instrument enqueue event (after transaction commits)
  ActiveSupport::Notifications.instrument('enqueue.schedule_execution.postburner', {
    schedule: Postburner::Instrumentation.schedule_payload(schedule),
    execution: Postburner::Instrumentation.execution_payload(self.reload),
    beanstalk_job_id: beanstalk_job_id
  })
end

#enqueued?Boolean

Check if execution has been enqueued to Beanstalkd.

Returns:

  • (Boolean)

    true if execution has beanstalk_job_id and enqueued_at set



108
109
110
# File 'app/models/postburner/schedule_execution.rb', line 108

def enqueued?
  beanstalk_job_id.present? && enqueued_at.present?
end

#skip!Boolean

Skip this execution (“skip this one occurrence, keep the schedule running”).

Tears down the queued work (Beanstalkd job AND any Postburner::Job AR row) and marks the execution as skipped. Use this when an operator wants to cancel a single upcoming occurrence.

Skipping is a one-occurrence action, not a pause: a skipped row is excluded from the ‘future_live` scope, so the schedule simply resumes at the next grid point (via the watchdog / reconcile). Skipped executions cannot be unskipped.

Instruments with ActiveSupport::Notifications:

  • skip.schedule_execution.postburner: When execution is skipped

Examples:

Skip a future execution

execution = schedule.executions.future_live.first
execution.skip!  # Removes the Beanstalkd job + Job row, marks skipped

Returns:

  • (Boolean)

    true if skipped, false if already skipped/superseded



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
# File 'app/models/postburner/schedule_execution.rb', line 202

def skip!
  return false if skipped? || superseded?

  transaction do
    teardown_job!
    update!(status: :skipped, job_id: nil, beanstalk_job_id: nil)
  end

  # Instrument skip event (after transaction commits)
  ActiveSupport::Notifications.instrument('skip.schedule_execution.postburner', {
    schedule: Postburner::Instrumentation.schedule_payload(schedule),
    execution: Postburner::Instrumentation.execution_payload(self)
  })

  # Re-establish the next live future immediately rather than waiting for the
  # watchdog. reconcile! advances past this (now skipped) grid point, so the
  # cancelled occurrence is not recreated. skip! runs in an admin/web context,
  # so use a NON-BLOCKING lock: the occurrence is already torn down in our own
  # transaction above; if the scheduler lock is currently held, we skip the
  # inline reconcile and the watchdog converges on its next pass. No recursion:
  # reconcile! supersedes/creates, it never calls skip!.
  schedule.reconcile!(blocking: false) if schedule.enabled?

  true
end

#supersede!Boolean

Supersede this execution.

Identical teardown to #skip! (removes the Beanstalkd job AND any Postburner::Job AR row) but records a distinct ‘superseded` status: the execution was replaced by a recreate (e.g. config/grid drift resolved by reconciliation), not cancelled by an operator. Like skipped rows, a superseded row is excluded from `future_live`.

Instruments with ActiveSupport::Notifications:

  • supersede.schedule_execution.postburner: When execution is superseded

Returns:

  • (Boolean)

    true if superseded, false if already superseded/skipped



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'app/models/postburner/schedule_execution.rb', line 241

def supersede!
  return false if superseded? || skipped?

  transaction do
    teardown_job!
    update!(status: :superseded, job_id: nil, beanstalk_job_id: nil)
  end

  # Instrument supersede event (after transaction commits)
  ActiveSupport::Notifications.instrument('supersede.schedule_execution.postburner', {
    schedule: Postburner::Instrumentation.schedule_payload(schedule),
    execution: Postburner::Instrumentation.execution_payload(self)
  })

  true
end

#validate_next_execution!(next_execution) ⇒ Boolean

Validate that the next execution matches the cached next_run_at.

Similar to Flex::SubscriptionIteration validation. Ensures that the pre-calculated next_run_at in this execution matches the actual run_at of the next execution created.

This validation helps detect calculation bugs or manual tampering with execution records.

Examples:

Validate next execution

current = schedule.executions.first
next_exec = schedule.executions.second
current.validate_next_execution!(next_exec)  # raises if mismatch

Parameters:

Returns:

  • (Boolean)

    true if validation passes or next_run_at is nil

Raises:



295
296
297
298
299
300
301
302
303
304
305
# File 'app/models/postburner/schedule_execution.rb', line 295

def validate_next_execution!(next_execution)
  return true if next_run_at.nil?

  # Check if the run_at matches down to the second
  if next_execution.run_at.to_i == Time.parse(next_run_at.to_s).to_i
    return true
  end

  raise NextExecutionRunAtConflict,
    "ScheduleExecution #{id} has next_run_at #{next_run_at} but next execution has run_at #{next_execution.run_at}"
end