postburner 1.0.0.pre.11 → 1.0.0.pre.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +961 -555
- data/app/concerns/postburner/commands.rb +1 -1
- data/app/concerns/postburner/execution.rb +11 -11
- data/app/concerns/postburner/insertion.rb +1 -1
- data/app/concerns/postburner/logging.rb +2 -2
- data/app/concerns/postburner/statistics.rb +1 -1
- data/app/models/postburner/job.rb +27 -4
- data/app/models/postburner/mailer.rb +1 -1
- data/app/models/postburner/schedule.rb +703 -0
- data/app/models/postburner/schedule_execution.rb +353 -0
- data/app/views/postburner/jobs/show.html.haml +3 -3
- data/lib/generators/postburner/install/install_generator.rb +1 -0
- data/lib/generators/postburner/install/templates/config/postburner.yml +15 -6
- data/lib/generators/postburner/install/templates/migrations/create_postburner_schedules.rb.erb +71 -0
- data/lib/postburner/active_job/adapter.rb +3 -3
- data/lib/postburner/active_job/payload.rb +5 -0
- data/lib/postburner/advisory_lock.rb +123 -0
- data/lib/postburner/configuration.rb +43 -7
- data/lib/postburner/connection.rb +7 -6
- data/lib/postburner/runner.rb +26 -3
- data/lib/postburner/scheduler.rb +427 -0
- data/lib/postburner/strategies/immediate_test_queue.rb +24 -7
- data/lib/postburner/strategies/nice_queue.rb +1 -1
- data/lib/postburner/strategies/null_queue.rb +2 -2
- data/lib/postburner/strategies/test_queue.rb +2 -2
- data/lib/postburner/time_helpers.rb +4 -2
- data/lib/postburner/tube.rb +9 -1
- data/lib/postburner/version.rb +1 -1
- data/lib/postburner/worker.rb +684 -0
- data/lib/postburner.rb +32 -13
- metadata +7 -3
- data/lib/postburner/workers/base.rb +0 -205
- data/lib/postburner/workers/worker.rb +0 -396
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postburner
|
|
4
|
+
# ScheduleExecution represents a single scheduled run of a recurring job.
|
|
5
|
+
#
|
|
6
|
+
# Executions are created in advance and immediately enqueued to Beanstalkd's delayed
|
|
7
|
+
# queue. This ensures all future runs are already queued and ready to execute at the
|
|
8
|
+
# scheduled time, regardless of scheduler availability.
|
|
9
|
+
#
|
|
10
|
+
# ## Lifecycle
|
|
11
|
+
#
|
|
12
|
+
# 1. Execution created with `pending` status and immediately enqueued to Beanstalkd
|
|
13
|
+
# 2. Status changes to `scheduled` once enqueued
|
|
14
|
+
# 3. At `run_at` time, Beanstalkd releases job to worker
|
|
15
|
+
# 4. For Postburner::Job schedules: `before_attempt` callback creates next execution
|
|
16
|
+
# 5. Job completes (tracked in job record, not execution)
|
|
17
|
+
# 6. Watchdog periodically verifies future executions exist (safety net)
|
|
18
|
+
#
|
|
19
|
+
# ## Status Values
|
|
20
|
+
#
|
|
21
|
+
# - `pending` - Created but not yet enqueued to Beanstalkd
|
|
22
|
+
# - `scheduled` - Enqueued to Beanstalkd, waiting for execution
|
|
23
|
+
# - `skipped` - Cancelled by admin before execution
|
|
24
|
+
#
|
|
25
|
+
# Note: We don't track running/completed/failed states here because the
|
|
26
|
+
# actual job (Postburner::Job or ActiveJob) handles its own lifecycle.
|
|
27
|
+
# The execution's job is done once it's scheduled.
|
|
28
|
+
#
|
|
29
|
+
# ## Database Fields
|
|
30
|
+
#
|
|
31
|
+
# - `schedule_id` - Foreign key to parent schedule
|
|
32
|
+
# - `run_at` - Scheduled execution time
|
|
33
|
+
# - `next_run_at` - Pre-calculated next run time for validation
|
|
34
|
+
# - `enqueued_at` - When job was queued to Beanstalkd
|
|
35
|
+
# - `status` - Current status (pending, scheduled, skipped)
|
|
36
|
+
# - `beanstalk_job_id` - Beanstalkd job ID (for tracking/cancellation)
|
|
37
|
+
# - `job_id` - Postburner::Job ID (if using Postburner::Job subclass)
|
|
38
|
+
# - `cached_schedule` - JSONB snapshot of schedule at creation time
|
|
39
|
+
#
|
|
40
|
+
# ## Immediate Enqueue Architecture
|
|
41
|
+
#
|
|
42
|
+
# Unlike traditional schedulers that poll for due jobs, Postburner immediately
|
|
43
|
+
# enqueues executions to Beanstalkd's delayed queue when created. This means:
|
|
44
|
+
# - Jobs are already queued and will run at run_at regardless of scheduler status
|
|
45
|
+
# - No polling overhead - Beanstalkd handles delayed delivery
|
|
46
|
+
# - Watchdog only acts as safety net to ensure future executions exist
|
|
47
|
+
#
|
|
48
|
+
# @example Viewing execution details
|
|
49
|
+
# execution = schedule.executions.last
|
|
50
|
+
# execution.run_at # => 2025-12-29 09:00:00 UTC
|
|
51
|
+
# execution.enqueued_at # => 2025-12-28 10:00:00 UTC
|
|
52
|
+
# execution.status # => "scheduled"
|
|
53
|
+
# execution.beanstalk_job_id # => 12345
|
|
54
|
+
#
|
|
55
|
+
# @example Skipping a future execution
|
|
56
|
+
# execution = schedule.executions.future.first
|
|
57
|
+
# execution.skip! # Cancels Beanstalkd job and marks as skipped
|
|
58
|
+
#
|
|
59
|
+
# @see Postburner::Schedule
|
|
60
|
+
# @see Postburner::Scheduler
|
|
61
|
+
#
|
|
62
|
+
class ScheduleExecution < ApplicationRecord
|
|
63
|
+
# Status enum - simplified to only what ScheduleExecution needs to know
|
|
64
|
+
enum :status, {
|
|
65
|
+
pending: 0, # Created, waiting to be enqueued
|
|
66
|
+
scheduled: 11, # Enqueued to Beanstalkd
|
|
67
|
+
skipped: 101 # Cancelled before execution
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Associations
|
|
71
|
+
belongs_to :schedule
|
|
72
|
+
belongs_to :job, optional: true
|
|
73
|
+
|
|
74
|
+
# Validations
|
|
75
|
+
validates :run_at, presence: true, uniqueness: { scope: :schedule_id }
|
|
76
|
+
validates :status, presence: true
|
|
77
|
+
validate :validate_next_run_at!
|
|
78
|
+
|
|
79
|
+
# Scopes
|
|
80
|
+
scope :due, -> { pending.where('run_at <= ?', Time.current).where(enqueued_at: nil) }
|
|
81
|
+
scope :future, -> { where('run_at > ?', Time.current).order(run_at: :asc) }
|
|
82
|
+
scope :past, -> { where('run_at <= ?', Time.current).order(run_at: :desc) }
|
|
83
|
+
|
|
84
|
+
# Check if execution has been enqueued to Beanstalkd.
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean] true if execution has beanstalk_job_id and enqueued_at set
|
|
87
|
+
def enqueued?
|
|
88
|
+
beanstalk_job_id.present? && enqueued_at.present?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Enqueue this execution to Beanstalkd.
|
|
92
|
+
#
|
|
93
|
+
# Creates the appropriate job (ActiveJob or Postburner::Job) and queues it
|
|
94
|
+
# to Beanstalkd at the scheduled run_at time with appropriate delay.
|
|
95
|
+
#
|
|
96
|
+
# The job type is determined by the job_class in cached_schedule:
|
|
97
|
+
# - Postburner::Job subclasses: Creates tracked job with database record
|
|
98
|
+
# - ActiveJob classes: Enqueues directly without database tracking
|
|
99
|
+
#
|
|
100
|
+
# This method is idempotent - calling it multiple times will only enqueue once.
|
|
101
|
+
#
|
|
102
|
+
# @return [void]
|
|
103
|
+
#
|
|
104
|
+
# @note Creating the next execution is the scheduler's responsibility via
|
|
105
|
+
# Schedule#create_next_execution!, not this method's responsibility.
|
|
106
|
+
#
|
|
107
|
+
# @example Enqueue an execution
|
|
108
|
+
# execution = schedule.executions.build(run_at: 1.hour.from_now)
|
|
109
|
+
# execution.save!
|
|
110
|
+
# execution.enqueue! # Creates and queues job to Beanstalkd
|
|
111
|
+
#
|
|
112
|
+
def enqueue!
|
|
113
|
+
return if enqueued?
|
|
114
|
+
|
|
115
|
+
# Mark as scheduled BEFORE Beanstalkd operations to prevent duplicates.
|
|
116
|
+
# If Beanstalkd put fails, we have a scheduled execution with no beanstalk_job_id,
|
|
117
|
+
# which the watchdog can detect and re-queue. This is safer than having a
|
|
118
|
+
# pending execution with a job already in Beanstalkd (which causes duplicates).
|
|
119
|
+
transaction do
|
|
120
|
+
update_columns(
|
|
121
|
+
enqueued_at: Time.current,
|
|
122
|
+
status: self.class.statuses[:scheduled]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if tracked_job?
|
|
126
|
+
enqueue_tracked_job!
|
|
127
|
+
else
|
|
128
|
+
enqueue_default_job!
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Skip this execution.
|
|
134
|
+
#
|
|
135
|
+
# Cancels the Beanstalkd job if already enqueued and marks the execution
|
|
136
|
+
# as skipped. Use this when an admin wants to cancel a scheduled execution.
|
|
137
|
+
#
|
|
138
|
+
# This is a destructive operation - skipped executions cannot be unskipped.
|
|
139
|
+
# A new execution must be created if needed.
|
|
140
|
+
#
|
|
141
|
+
# @return [Boolean] true if skipped successfully, false if already skipped
|
|
142
|
+
#
|
|
143
|
+
# @example Skip a future execution
|
|
144
|
+
# execution = schedule.executions.future.first
|
|
145
|
+
# execution.skip! # Cancels Beanstalkd job and marks as skipped
|
|
146
|
+
#
|
|
147
|
+
def skip!
|
|
148
|
+
return false if skipped?
|
|
149
|
+
|
|
150
|
+
transaction do
|
|
151
|
+
# Cancel the Beanstalkd job if it exists
|
|
152
|
+
if beanstalk_job_id.present?
|
|
153
|
+
cancel_beanstalk_job!
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
update!(status: :skipped)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Validate that the next execution matches the cached next_run_at.
|
|
163
|
+
#
|
|
164
|
+
# Similar to Flex::SubscriptionIteration validation. Ensures that the
|
|
165
|
+
# pre-calculated next_run_at in this execution matches the actual run_at
|
|
166
|
+
# of the next execution created.
|
|
167
|
+
#
|
|
168
|
+
# This validation helps detect calculation bugs or manual tampering with
|
|
169
|
+
# execution records.
|
|
170
|
+
#
|
|
171
|
+
# @param next_execution [ScheduleExecution] The next execution to validate
|
|
172
|
+
# @return [Boolean] true if validation passes or next_run_at is nil
|
|
173
|
+
# @raise [NextExecutionRunAtConflict] if times don't match
|
|
174
|
+
#
|
|
175
|
+
# @example Validate next execution
|
|
176
|
+
# current = schedule.executions.first
|
|
177
|
+
# next_exec = schedule.executions.second
|
|
178
|
+
# current.validate_next_execution!(next_exec) # raises if mismatch
|
|
179
|
+
#
|
|
180
|
+
def validate_next_execution!(next_execution)
|
|
181
|
+
return true if next_run_at.nil?
|
|
182
|
+
|
|
183
|
+
# Check if the run_at matches down to the second
|
|
184
|
+
if next_execution.run_at.to_i == Time.parse(next_run_at.to_s).to_i
|
|
185
|
+
return true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
raise NextExecutionRunAtConflict,
|
|
189
|
+
"ScheduleExecution #{id} has next_run_at #{next_run_at} but next execution has run_at #{next_execution.run_at}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# Check if this job is a Postburner::Job subclass (always tracked).
|
|
195
|
+
#
|
|
196
|
+
# @return [Boolean] true if job_class is a Postburner::Job subclass
|
|
197
|
+
#
|
|
198
|
+
# @api private
|
|
199
|
+
#
|
|
200
|
+
def tracked_job?
|
|
201
|
+
klass = cached_schedule['job_class'].constantize
|
|
202
|
+
klass < Postburner::Job
|
|
203
|
+
rescue NameError
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Check if this job is an ActiveJob that includes Postburner::Tracked.
|
|
208
|
+
#
|
|
209
|
+
# @return [Boolean] true if job_class includes Postburner::Tracked
|
|
210
|
+
#
|
|
211
|
+
# @api private
|
|
212
|
+
#
|
|
213
|
+
def tracked_activejob?
|
|
214
|
+
klass = cached_schedule['job_class'].constantize
|
|
215
|
+
klass.included_modules.include?(Postburner::Tracked)
|
|
216
|
+
rescue NameError
|
|
217
|
+
false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Enqueue a tracked Postburner::Job.
|
|
221
|
+
#
|
|
222
|
+
# Creates a Postburner::Job record, configures it with cached schedule
|
|
223
|
+
# attributes, and enqueues it to Beanstalkd with the scheduled run_at time.
|
|
224
|
+
#
|
|
225
|
+
# @return [void]
|
|
226
|
+
#
|
|
227
|
+
# @api private
|
|
228
|
+
#
|
|
229
|
+
def enqueue_tracked_job!
|
|
230
|
+
klass = cached_schedule['job_class'].constantize
|
|
231
|
+
job = klass.create!(args: cached_schedule['args'] || {})
|
|
232
|
+
|
|
233
|
+
# Set optional configuration
|
|
234
|
+
job.priority = cached_schedule['priority'] if cached_schedule['priority']
|
|
235
|
+
job.queue_name = cached_schedule['queue'] if cached_schedule['queue']
|
|
236
|
+
|
|
237
|
+
# Link execution to job BEFORE queueing so the schedule_next_execution
|
|
238
|
+
# callback can find the schedule_execution association during perform!
|
|
239
|
+
update_columns(job_id: job.id)
|
|
240
|
+
|
|
241
|
+
# Queue the job
|
|
242
|
+
bkid = job.queue!(at: run_at)
|
|
243
|
+
|
|
244
|
+
# Update beanstalk_job_id after queue
|
|
245
|
+
update_columns(beanstalk_job_id: bkid)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Enqueue a default ActiveJob.
|
|
249
|
+
#
|
|
250
|
+
# Creates and enqueues an ActiveJob instance with the scheduled run_at time.
|
|
251
|
+
# Handles both Hash args (passed as keyword arguments) and Array args (splatted).
|
|
252
|
+
#
|
|
253
|
+
# For tracked ActiveJobs (those including Postburner::Tracked), the adapter
|
|
254
|
+
# creates a TrackedJob record. We link that record back to this execution
|
|
255
|
+
# to enable the schedule_next_execution callback.
|
|
256
|
+
#
|
|
257
|
+
# @return [void]
|
|
258
|
+
#
|
|
259
|
+
# @api private
|
|
260
|
+
#
|
|
261
|
+
def enqueue_default_job!
|
|
262
|
+
klass = cached_schedule['job_class'].constantize
|
|
263
|
+
args = cached_schedule['args']
|
|
264
|
+
is_tracked = tracked_activejob?
|
|
265
|
+
|
|
266
|
+
# Build job with configuration
|
|
267
|
+
# Hash args are passed as keyword arguments, Array args are splatted
|
|
268
|
+
job = if args.is_a?(Hash) && args.any?
|
|
269
|
+
klass.new(**args.symbolize_keys)
|
|
270
|
+
elsif args.is_a?(Array)
|
|
271
|
+
klass.new(*args)
|
|
272
|
+
else
|
|
273
|
+
klass.new
|
|
274
|
+
end
|
|
275
|
+
job.queue_name = cached_schedule['queue'] if cached_schedule['queue']
|
|
276
|
+
job.priority = cached_schedule['priority'] if cached_schedule['priority']
|
|
277
|
+
|
|
278
|
+
# Enqueue with scheduled time (use instance method enqueue, not class method perform_later)
|
|
279
|
+
delay = (run_at - Time.current).to_i
|
|
280
|
+
delay = 0 if delay < 0
|
|
281
|
+
|
|
282
|
+
if delay > 0
|
|
283
|
+
job.enqueue(wait: delay)
|
|
284
|
+
else
|
|
285
|
+
job.enqueue
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# For tracked ActiveJobs, link to the TrackedJob created by the adapter
|
|
289
|
+
if is_tracked
|
|
290
|
+
tracked_job = Postburner::TrackedJob.where(
|
|
291
|
+
"args->>'job_id' = ?", job.job_id
|
|
292
|
+
).order(created_at: :desc).first
|
|
293
|
+
|
|
294
|
+
if tracked_job
|
|
295
|
+
update_columns(
|
|
296
|
+
job_id: tracked_job.id,
|
|
297
|
+
beanstalk_job_id: tracked_job.bkid
|
|
298
|
+
)
|
|
299
|
+
else
|
|
300
|
+
update_columns(beanstalk_job_id: nil)
|
|
301
|
+
end
|
|
302
|
+
else
|
|
303
|
+
update_columns(beanstalk_job_id: nil)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Cancel the Beanstalkd job.
|
|
308
|
+
#
|
|
309
|
+
# Uses Postburner::Job's API for tracked jobs, falls back to direct
|
|
310
|
+
# Beanstalkd deletion for ActiveJobs. Handles NotFoundError gracefully
|
|
311
|
+
# (job may have already been processed or deleted).
|
|
312
|
+
#
|
|
313
|
+
# @return [void]
|
|
314
|
+
#
|
|
315
|
+
# @api private
|
|
316
|
+
#
|
|
317
|
+
def cancel_beanstalk_job!
|
|
318
|
+
if job_id.present? && job.present?
|
|
319
|
+
# Tracked job - use Job's delete! method
|
|
320
|
+
job.delete!
|
|
321
|
+
elsif beanstalk_job_id.present?
|
|
322
|
+
# ActiveJob - delete directly from Beanstalkd
|
|
323
|
+
Postburner.connected do |conn|
|
|
324
|
+
begin
|
|
325
|
+
bk_job = conn.beanstalk.jobs.find(beanstalk_job_id)
|
|
326
|
+
bk_job&.delete
|
|
327
|
+
rescue Beaneater::NotFoundError
|
|
328
|
+
# Job already processed or deleted - that's fine
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Validate next_run_at is after run_at.
|
|
335
|
+
#
|
|
336
|
+
# Ensures that if next_run_at is set, it's chronologically after run_at.
|
|
337
|
+
# This catches calculation bugs where next_run_at is incorrectly set.
|
|
338
|
+
#
|
|
339
|
+
# @return [void]
|
|
340
|
+
#
|
|
341
|
+
# @api private
|
|
342
|
+
#
|
|
343
|
+
def validate_next_run_at!
|
|
344
|
+
return if next_run_at.nil?
|
|
345
|
+
|
|
346
|
+
if next_run_at <= run_at
|
|
347
|
+
errors.add(:next_run_at, 'must be after run_at')
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
class NextExecutionRunAtConflict < StandardError; end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
%h1 Job #{@job.id} / #{@job.bkid} / #{@job.sid}
|
|
2
2
|
|
|
3
|
-
- if @job.run_at && @job.run_at > Time.
|
|
3
|
+
- if @job.run_at && @job.run_at > Time.current
|
|
4
4
|
Running in
|
|
5
|
-
= distance_of_time_in_words Time.
|
|
6
|
-
(#{@job.run_at.to_i - Time.
|
|
5
|
+
= distance_of_time_in_words Time.current, @job.run_at
|
|
6
|
+
(#{@job.run_at.to_i - Time.current.to_i} seconds)
|
|
7
7
|
|
|
8
8
|
%h3 Stats
|
|
9
9
|
- if @job.bk
|
|
@@ -28,6 +28,13 @@ default: &default
|
|
|
28
28
|
# Override with ENV['BEANSTALK_URL'] if set
|
|
29
29
|
beanstalk_url: <%= ENV['BEANSTALK_URL'] || 'beanstalk://localhost:11300' %>
|
|
30
30
|
|
|
31
|
+
# Scheduler configuration
|
|
32
|
+
# The scheduler runs as a lightweight "watchdog" job that checks for due
|
|
33
|
+
# schedule executions and enqueues them. All workers automatically watch
|
|
34
|
+
# the scheduler queue and will process the watchdog when it becomes due.
|
|
35
|
+
default_scheduler_interval: 300 # Check for due schedules every 5 minutes (300 seconds)
|
|
36
|
+
default_scheduler_priority: 100 # Scheduler watchdog priority (lower = higher priority)
|
|
37
|
+
|
|
31
38
|
development:
|
|
32
39
|
<<: *default
|
|
33
40
|
|
|
@@ -136,9 +143,11 @@ production: # <- environment config, i.e. defaults, NOT worker config
|
|
|
136
143
|
|
|
137
144
|
# Env-Level Defaults (can be overridden per worker):
|
|
138
145
|
#
|
|
139
|
-
# default_queue: default
|
|
140
|
-
# default_priority: 65536
|
|
141
|
-
# default_ttr: 300
|
|
142
|
-
# default_threads: 1
|
|
143
|
-
# default_forks: 0
|
|
144
|
-
# default_gc_limit: nil
|
|
146
|
+
# default_queue: default # Default queue name (optional)
|
|
147
|
+
# default_priority: 65536 # Lower = higher priority (optional, 0 is highest)
|
|
148
|
+
# default_ttr: 300 # Time-to-run in seconds (optional)
|
|
149
|
+
# default_threads: 1 # Thread count per fork (optional, defaults to 1)
|
|
150
|
+
# default_forks: 0 # Fork count (optional, defaults to 0 = single process)
|
|
151
|
+
# default_gc_limit: nil # Exit after N jobs for restart (optional, nil = no limit)
|
|
152
|
+
# default_scheduler_interval: 300 # Scheduler check interval in seconds (optional, default: 300)
|
|
153
|
+
# default_scheduler_priority: 100 # Scheduler watchdog priority (optional, default: 100)
|
data/lib/generators/postburner/install/templates/migrations/create_postburner_schedules.rb.erb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
class CreatePostburnerSchedules < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :postburner_schedules do |t|
|
|
6
|
+
# Identity
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.string :job_class, null: false
|
|
9
|
+
t.jsonb :args, default: {}
|
|
10
|
+
|
|
11
|
+
# Configuration
|
|
12
|
+
t.string :queue
|
|
13
|
+
t.integer :priority
|
|
14
|
+
t.string :timezone, default: 'UTC', null: false
|
|
15
|
+
|
|
16
|
+
# Anchor-based scheduling (primary)
|
|
17
|
+
t.datetime :anchor
|
|
18
|
+
t.integer :interval
|
|
19
|
+
t.string :interval_unit
|
|
20
|
+
|
|
21
|
+
# Cron-based (power user, optional)
|
|
22
|
+
t.string :cron
|
|
23
|
+
|
|
24
|
+
# Control
|
|
25
|
+
t.boolean :enabled, default: true, null: false
|
|
26
|
+
t.boolean :catch_up, default: false, null: false
|
|
27
|
+
t.datetime :last_audit_at
|
|
28
|
+
t.text :description
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
|
|
32
|
+
t.index :name, unique: true
|
|
33
|
+
t.index :job_class
|
|
34
|
+
t.index :enabled
|
|
35
|
+
t.index :last_audit_at
|
|
36
|
+
t.index :args, using: :gin
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
create_table :postburner_schedule_executions do |t|
|
|
40
|
+
# Relationship
|
|
41
|
+
t.bigint :schedule_id, null: false
|
|
42
|
+
|
|
43
|
+
# Timing
|
|
44
|
+
t.datetime :run_at, null: false
|
|
45
|
+
t.datetime :next_run_at
|
|
46
|
+
t.datetime :enqueued_at
|
|
47
|
+
|
|
48
|
+
# Status: 0=pending, 1=scheduled, 2=skipped
|
|
49
|
+
t.integer :status, default: 0, null: false
|
|
50
|
+
|
|
51
|
+
# Job tracking
|
|
52
|
+
t.bigint :beanstalk_job_id
|
|
53
|
+
t.bigint :job_id
|
|
54
|
+
|
|
55
|
+
# Audit
|
|
56
|
+
t.jsonb :cached_schedule, default: {}
|
|
57
|
+
|
|
58
|
+
t.timestamps
|
|
59
|
+
|
|
60
|
+
t.index :schedule_id
|
|
61
|
+
t.index [:schedule_id, :run_at], unique: true
|
|
62
|
+
t.index [:status, :run_at], name: 'index_postburner_schedule_executions_on_status_and_run_at'
|
|
63
|
+
t.index :beanstalk_job_id
|
|
64
|
+
t.index :job_id
|
|
65
|
+
t.index :run_at
|
|
66
|
+
|
|
67
|
+
t.foreign_key :postburner_schedules, column: :schedule_id, on_delete: :cascade
|
|
68
|
+
t.foreign_key :postburner_jobs, column: :job_id, on_delete: :nullify
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -121,11 +121,11 @@ module ActiveJob
|
|
|
121
121
|
tracked_job = Postburner::TrackedJob.create!(
|
|
122
122
|
args: Postburner::ActiveJob::Payload.serialize_for_tracked(job),
|
|
123
123
|
run_at: timestamp ? Time.zone.at(timestamp) : nil,
|
|
124
|
-
queued_at: Time.
|
|
124
|
+
queued_at: Time.current
|
|
125
125
|
)
|
|
126
126
|
|
|
127
127
|
# Calculate delay for Beanstalkd
|
|
128
|
-
delay = timestamp ? [
|
|
128
|
+
delay = timestamp ? [timestamp.to_i - Time.current.to_i, 0].max : 0
|
|
129
129
|
|
|
130
130
|
# Queue to Beanstalkd with minimal payload
|
|
131
131
|
Postburner.connected do |conn|
|
|
@@ -160,7 +160,7 @@ module ActiveJob
|
|
|
160
160
|
# @return [void]
|
|
161
161
|
#
|
|
162
162
|
def enqueue_default(job, timestamp)
|
|
163
|
-
delay = timestamp ? [
|
|
163
|
+
delay = timestamp ? [timestamp.to_i - Time.current.to_i, 0].max : 0
|
|
164
164
|
|
|
165
165
|
Postburner.connected do |conn|
|
|
166
166
|
tube_name = expand_tube_name(job.queue_name)
|
|
@@ -54,6 +54,10 @@ module Postburner
|
|
|
54
54
|
# @api private
|
|
55
55
|
#
|
|
56
56
|
def for_job(job, tracked: false, postburner_job_id: nil)
|
|
57
|
+
# Get TTR from job class if available, otherwise use default
|
|
58
|
+
ttr = job.class.respond_to?(:postburner_ttr) && job.class.postburner_ttr ||
|
|
59
|
+
Postburner.configuration.default_ttr
|
|
60
|
+
|
|
57
61
|
{
|
|
58
62
|
v: VERSION,
|
|
59
63
|
tracked: tracked,
|
|
@@ -62,6 +66,7 @@ module Postburner
|
|
|
62
66
|
job_id: job.job_id,
|
|
63
67
|
queue_name: job.queue_name,
|
|
64
68
|
priority: job.priority,
|
|
69
|
+
ttr: ttr,
|
|
65
70
|
arguments: ::ActiveJob::Arguments.serialize(job.arguments),
|
|
66
71
|
executions: job.executions,
|
|
67
72
|
exception_executions: job.exception_executions || {},
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postburner
|
|
4
|
+
# PostgreSQL advisory lock support for coordinating distributed processes.
|
|
5
|
+
#
|
|
6
|
+
# Advisory locks allow multiple scheduler processes to coordinate without
|
|
7
|
+
# race conditions. Only one process can hold a specific lock at a time.
|
|
8
|
+
#
|
|
9
|
+
# @example Using with a block
|
|
10
|
+
# Postburner::AdvisoryLock.with_lock('postburner_scheduler') do
|
|
11
|
+
# # Only one process executes this block at a time
|
|
12
|
+
# process_due_schedules
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Manual lock/unlock
|
|
16
|
+
# lock = Postburner::AdvisoryLock.new('postburner_scheduler')
|
|
17
|
+
# if lock.acquire
|
|
18
|
+
# begin
|
|
19
|
+
# process_due_schedules
|
|
20
|
+
# ensure
|
|
21
|
+
# lock.release
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class AdvisoryLock
|
|
26
|
+
# Lock key for the scheduler process
|
|
27
|
+
SCHEDULER_LOCK_KEY = 'postburner_scheduler'
|
|
28
|
+
|
|
29
|
+
attr_reader :key, :connection
|
|
30
|
+
|
|
31
|
+
# Initialize a new advisory lock
|
|
32
|
+
#
|
|
33
|
+
# @param key [String] Unique lock identifier
|
|
34
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
35
|
+
def initialize(key, connection = nil)
|
|
36
|
+
@key = key
|
|
37
|
+
@connection = connection || ActiveRecord::Base.connection
|
|
38
|
+
@acquired = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Acquire the advisory lock (blocking)
|
|
42
|
+
#
|
|
43
|
+
# This will wait until the lock is available.
|
|
44
|
+
#
|
|
45
|
+
# @return [Boolean] true if lock acquired
|
|
46
|
+
def acquire
|
|
47
|
+
return true if @acquired
|
|
48
|
+
|
|
49
|
+
lock_id = generate_lock_id(key)
|
|
50
|
+
result = connection.execute("SELECT pg_advisory_lock(#{lock_id})")
|
|
51
|
+
@acquired = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Try to acquire the advisory lock (non-blocking)
|
|
55
|
+
#
|
|
56
|
+
# Returns immediately, indicating whether lock was acquired.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean] true if lock acquired, false if already held
|
|
59
|
+
def try_acquire
|
|
60
|
+
return true if @acquired
|
|
61
|
+
|
|
62
|
+
lock_id = generate_lock_id(key)
|
|
63
|
+
result = connection.execute("SELECT pg_try_advisory_lock(#{lock_id})").first
|
|
64
|
+
value = result['pg_try_advisory_lock']
|
|
65
|
+
@acquired = value == true || value == 't'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Release the advisory lock
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if lock was held and released
|
|
71
|
+
def release
|
|
72
|
+
return false unless @acquired
|
|
73
|
+
|
|
74
|
+
lock_id = generate_lock_id(key)
|
|
75
|
+
result = connection.execute("SELECT pg_advisory_unlock(#{lock_id})").first
|
|
76
|
+
@acquired = false
|
|
77
|
+
value = result['pg_advisory_unlock']
|
|
78
|
+
value == true || value == 't'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if this lock instance has acquired the lock
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def acquired?
|
|
85
|
+
@acquired
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Execute a block with an advisory lock
|
|
89
|
+
#
|
|
90
|
+
# @param key [String] Lock identifier
|
|
91
|
+
# @param blocking [Boolean] If true, wait for lock. If false, return immediately if unavailable.
|
|
92
|
+
# @yield Block to execute while holding lock
|
|
93
|
+
# @return [Object, nil] Returns block result if lock acquired, nil otherwise
|
|
94
|
+
def self.with_lock(key, blocking: true)
|
|
95
|
+
lock = new(key)
|
|
96
|
+
|
|
97
|
+
acquired = blocking ? lock.acquire : lock.try_acquire
|
|
98
|
+
return nil unless acquired
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
yield
|
|
102
|
+
ensure
|
|
103
|
+
lock.release
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Generate a numeric lock ID from a string key
|
|
110
|
+
#
|
|
111
|
+
# PostgreSQL advisory locks use bigint IDs. We generate a consistent
|
|
112
|
+
# numeric ID from the string key using a hash function.
|
|
113
|
+
#
|
|
114
|
+
# @param key [String] Lock key
|
|
115
|
+
# @return [Integer] Numeric lock ID
|
|
116
|
+
def generate_lock_id(key)
|
|
117
|
+
# Use CRC32 to generate a consistent numeric ID from the string
|
|
118
|
+
# This gives us a 32-bit integer which is well within PostgreSQL's bigint range
|
|
119
|
+
require 'zlib'
|
|
120
|
+
Zlib.crc32(key)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|