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 admin before execution

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)

  • ‘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

#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


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/models/postburner/schedule_execution.rb', line 115

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).
  transaction do
    update_columns(
      enqueued_at: Time.current,
      status: self.class.statuses[:scheduled]
    )

    if tracked_job?
      enqueue_tracked_job!
    else
      enqueue_default_job!
    end
  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



87
88
89
# File 'app/models/postburner/schedule_execution.rb', line 87

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

#skip!Boolean

Skip this execution.

Cancels the Beanstalkd job if already enqueued and marks the execution as skipped. Use this when an admin wants to cancel a scheduled execution.

This is a destructive operation - skipped executions cannot be unskipped. A new execution must be created if needed.

Instruments with ActiveSupport::Notifications:

  • skip.schedule_execution.postburner: When execution is skipped

Examples:

Skip a future execution

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

Returns:

  • (Boolean)

    true if skipped successfully, false if already skipped



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/models/postburner/schedule_execution.rb', line 160

def skip!
  return false if skipped?

  transaction do
    # Cancel the Beanstalkd job if it exists
    if beanstalk_job_id.present?
      cancel_beanstalk_job!
    end

    update!(status: :skipped)
  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)
  })

  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:



199
200
201
202
203
204
205
206
207
208
209
# File 'app/models/postburner/schedule_execution.rb', line 199

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