Class: Postburner::ScheduleExecution
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Postburner::ScheduleExecution
- 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
-
Execution created with ‘pending` status and immediately enqueued to Beanstalkd
-
Status changes to ‘scheduled` once enqueued
-
At ‘run_at` time, Beanstalkd releases job to worker
-
For Postburner::Job schedules: ‘before_attempt` callback creates next execution
-
Job completes (tracked in job record, not execution)
-
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
Defined Under Namespace
Classes: NextExecutionRunAtConflict
Instance Method Summary collapse
-
#drifted?(schedule) ⇒ Boolean
Whether this execution’s cached snapshot has drifted from the schedule’s current configuration.
-
#enqueue! ⇒ void
Enqueue this execution to Beanstalkd.
-
#enqueued? ⇒ Boolean
Check if execution has been enqueued to Beanstalkd.
-
#skip! ⇒ Boolean
Skip this execution (“skip this one occurrence, keep the schedule running”).
-
#supersede! ⇒ Boolean
Supersede this execution.
-
#validate_next_execution!(next_execution) ⇒ Boolean
Validate that the next execution matches the cached next_run_at.
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.
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
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
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.
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
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
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.
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 |