postburner 0.8.0 → 0.9.0.rc.1
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/CHANGELOG.md +0 -22
- data/README.md +436 -30
- data/Rakefile +1 -1
- data/app/concerns/postburner/callbacks.rb +286 -0
- data/app/models/postburner/job.rb +609 -44
- data/lib/postburner/strategies/immediate_test_queue.rb +133 -0
- data/lib/postburner/strategies/nice_queue.rb +85 -0
- data/lib/postburner/strategies/null_queue.rb +132 -0
- data/lib/postburner/strategies/queue.rb +105 -0
- data/lib/postburner/strategies/test_queue.rb +128 -0
- data/lib/postburner/time_helpers.rb +75 -0
- data/lib/postburner/version.rb +1 -1
- data/lib/postburner.rb +359 -1
- metadata +26 -9
- data/lib/postburner/tube.rb +0 -53
|
@@ -1,26 +1,113 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Postburner
|
|
4
|
-
#
|
|
5
|
-
# doesn't complete.
|
|
4
|
+
# Base class for all Postburner background jobs using Single Table Inheritance (STI).
|
|
6
5
|
#
|
|
7
|
-
# Job
|
|
8
|
-
#
|
|
6
|
+
# Postburner::Job provides a PostgreSQL-backed job queue system built on Backburner
|
|
7
|
+
# and Beanstalkd. Every job is stored as an ActiveRecord model, enabling database
|
|
8
|
+
# queries, foreign key relationships, and full audit trails.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
# as well. Decided how to migrate existing jobs or allow both - Opt to
|
|
12
|
-
# allow both: rescue from ActiveJob::DeserializationError and use the
|
|
13
|
-
# plain hash, probably log it too.
|
|
10
|
+
# @abstract Subclass and implement {#perform} to define job behavior
|
|
14
11
|
#
|
|
15
|
-
#
|
|
12
|
+
# @example Creating and queueing a job
|
|
13
|
+
# class ProcessDonation < Postburner::Job
|
|
14
|
+
# queue 'critical'
|
|
15
|
+
# queue_priority 0
|
|
16
16
|
#
|
|
17
|
+
# def perform(args)
|
|
18
|
+
# donation = Donation.find(args['donation_id'])
|
|
19
|
+
# # ... process donation ...
|
|
20
|
+
# log "Processed donation #{donation.id}"
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# job = ProcessDonation.create!(args: { 'donation_id' => 123 })
|
|
25
|
+
# job.queue!(delay: 1.hour)
|
|
26
|
+
#
|
|
27
|
+
# @example With callbacks
|
|
28
|
+
# class SendEmail < Postburner::Job
|
|
29
|
+
# before_enqueue :validate_recipient
|
|
30
|
+
# after_processed :track_delivery
|
|
31
|
+
#
|
|
32
|
+
# def perform(args)
|
|
33
|
+
# UserMailer.welcome(args['email']).deliver_now
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# private
|
|
37
|
+
#
|
|
38
|
+
# def validate_recipient
|
|
39
|
+
# raise "Invalid email" unless args['email'].include?('@')
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# def track_delivery
|
|
43
|
+
# Analytics.track('email_sent', email: args['email'])
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# @note Job won't run unless queued_at is set and is prior to execution time
|
|
48
|
+
# @note Subclasses must implement the {#perform} method
|
|
49
|
+
#
|
|
50
|
+
# @todo Add support for ActiveJob::Arguments serialization
|
|
51
|
+
# @todo Add `cancelled_at` field to block job execution if present
|
|
52
|
+
#
|
|
53
|
+
# @attr_reader [Integer] id Primary key
|
|
54
|
+
# @attr_reader [String] type STI type column for job subclass
|
|
55
|
+
# @attr_reader [String] sid Unique UUID identifier
|
|
56
|
+
# @attr_reader [Integer] bkid Beanstalkd job ID (nil in test mode)
|
|
57
|
+
# @attr_reader [Hash] args JSONB arguments passed to perform
|
|
58
|
+
# @attr_reader [Time] run_at Scheduled execution time (nil for immediate)
|
|
59
|
+
# @attr_reader [Time] queued_at Time job was queued
|
|
60
|
+
# @attr_reader [Time] attempting_at Time first attempt started
|
|
61
|
+
# @attr_reader [Time] processing_at Time current/last processing started
|
|
62
|
+
# @attr_reader [Time] processed_at Time job completed successfully
|
|
63
|
+
# @attr_reader [Time] removed_at Time job was removed from queue
|
|
64
|
+
# @attr_reader [Float] lag Milliseconds between intended_at and attempting_at
|
|
65
|
+
# @attr_reader [Float] duration Milliseconds between processing_at and processed_at
|
|
66
|
+
# @attr_reader [Integer] attempt_count Number of execution attempts
|
|
67
|
+
# @attr_reader [Integer] error_count Number of errors encountered
|
|
68
|
+
# @attr_reader [Integer] log_count Number of log entries
|
|
69
|
+
# @attr_reader [Array<Time>] attempts Array of attempt timestamps
|
|
70
|
+
# @attr_reader [Array<Hash>] errata Array of error details with timestamps
|
|
71
|
+
# @attr_reader [Array<Hash>] logs Array of log entries with timestamps
|
|
72
|
+
#
|
|
73
|
+
# Module to override Backburner::Queue methods to return nil when setting values
|
|
74
|
+
# This prevents return values from interfering with callback definitions
|
|
75
|
+
module BackburnerQueueOverrides
|
|
76
|
+
def queue(name=nil)
|
|
77
|
+
result = super
|
|
78
|
+
name.nil? ? result : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def queue_priority(pri=nil)
|
|
82
|
+
result = super
|
|
83
|
+
pri.nil? ? result : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def queue_respond_timeout(ttr=nil)
|
|
87
|
+
result = super
|
|
88
|
+
ttr.nil? ? result : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def queue_max_job_retries(retries=nil)
|
|
92
|
+
result = super
|
|
93
|
+
retries.nil? ? result : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def queue_retry_delay(delay=nil)
|
|
97
|
+
result = super
|
|
98
|
+
delay.nil? ? result : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def queue_retry_delay_proc(proc=nil)
|
|
102
|
+
result = super
|
|
103
|
+
proc.nil? ? result : nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
17
107
|
class Job < ApplicationRecord
|
|
18
108
|
include Backburner::Queue
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
define_model_callbacks :attempt
|
|
22
|
-
define_model_callbacks :processing
|
|
23
|
-
define_model_callbacks :processed, only: :after
|
|
109
|
+
singleton_class.prepend BackburnerQueueOverrides
|
|
110
|
+
include Callbacks
|
|
24
111
|
|
|
25
112
|
LOG_LEVELS = [
|
|
26
113
|
:debug,
|
|
@@ -35,6 +122,97 @@ module Postburner
|
|
|
35
122
|
|
|
36
123
|
validates :sid, presence: {strict: true}
|
|
37
124
|
|
|
125
|
+
# Abstract method to be implemented by subclasses.
|
|
126
|
+
#
|
|
127
|
+
# This method contains the actual work logic for the job. It is called
|
|
128
|
+
# by {#perform!} within the processing callbacks. Any exceptions raised
|
|
129
|
+
# will be logged and re-raised, causing the job to fail.
|
|
130
|
+
#
|
|
131
|
+
# @abstract Subclasses must implement this method
|
|
132
|
+
#
|
|
133
|
+
# @param args [Hash] Job arguments from the args JSONB column
|
|
134
|
+
#
|
|
135
|
+
# @return [void]
|
|
136
|
+
#
|
|
137
|
+
# @raise [NotImplementedError] if subclass does not implement this method
|
|
138
|
+
#
|
|
139
|
+
# @example
|
|
140
|
+
# class ProcessPayment < Postburner::Job
|
|
141
|
+
# def perform(args)
|
|
142
|
+
# payment = Payment.find(args['payment_id'])
|
|
143
|
+
# payment.process!
|
|
144
|
+
# log "Payment #{payment.id} processed successfully"
|
|
145
|
+
# end
|
|
146
|
+
# end
|
|
147
|
+
#
|
|
148
|
+
# @note Use {#log} or {#log!} within perform to add entries to the job's audit trail
|
|
149
|
+
# @note Exceptions will be caught, logged to errata, and re-raised
|
|
150
|
+
#
|
|
151
|
+
# @see #perform!
|
|
152
|
+
# @see #log
|
|
153
|
+
# @see #log_exception
|
|
154
|
+
#
|
|
155
|
+
def perform(args)
|
|
156
|
+
raise NotImplementedError, "Subclasses must implement the perform method"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @!method destroy
|
|
160
|
+
# Destroys the job record and removes it from Beanstalkd queue.
|
|
161
|
+
#
|
|
162
|
+
# Uses before_destroy callback to call {#delete!} which removes the job
|
|
163
|
+
# from Beanstalkd before destroying the ActiveRecord model.
|
|
164
|
+
#
|
|
165
|
+
# @return [self] the destroyed job object
|
|
166
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails
|
|
167
|
+
# @see #delete!
|
|
168
|
+
# @see #remove!
|
|
169
|
+
# @note This is a standard Rails method with Beanstalkd integration via callback
|
|
170
|
+
|
|
171
|
+
# @!method destroy!
|
|
172
|
+
# Destroys the job record and removes it from Beanstalkd queue, raising on failure.
|
|
173
|
+
#
|
|
174
|
+
# Uses before_destroy callback to call {#delete!} which removes the job
|
|
175
|
+
# from Beanstalkd before destroying the ActiveRecord model.
|
|
176
|
+
#
|
|
177
|
+
# @return [self] the destroyed job object
|
|
178
|
+
# @raise [ActiveRecord::RecordNotDestroyed] if validations prevent destruction
|
|
179
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails
|
|
180
|
+
# @see #delete!
|
|
181
|
+
# @see #remove!
|
|
182
|
+
# @note This is a standard Rails method with Beanstalkd integration via callback
|
|
183
|
+
|
|
184
|
+
# Enqueues the job to Beanstalkd for processing.
|
|
185
|
+
#
|
|
186
|
+
# Sets queued_at timestamp and optionally run_at for scheduled execution.
|
|
187
|
+
# Triggers enqueue callbacks and inserts job into Beanstalkd via after_save_commit hook.
|
|
188
|
+
# In test mode, executes immediately instead of queueing to Beanstalkd.
|
|
189
|
+
#
|
|
190
|
+
# @param options [Hash] Queue options
|
|
191
|
+
# @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
|
|
192
|
+
# @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
|
|
193
|
+
# @option options [Integer] :pri Beanstalkd priority (lower = higher priority)
|
|
194
|
+
# @option options [Integer] :ttr Time-to-run in seconds before job times out
|
|
195
|
+
#
|
|
196
|
+
# @return [void]
|
|
197
|
+
#
|
|
198
|
+
# @raise [ActiveRecord::RecordInvalid] if job is not valid
|
|
199
|
+
# @raise [AlreadyProcessed] if job was already processed
|
|
200
|
+
#
|
|
201
|
+
# @example Queue immediately
|
|
202
|
+
# job.queue!
|
|
203
|
+
#
|
|
204
|
+
# @example Queue with delay
|
|
205
|
+
# job.queue!(delay: 1.hour)
|
|
206
|
+
#
|
|
207
|
+
# @example Queue at specific time
|
|
208
|
+
# job.queue!(at: Time.zone.now + 2.days)
|
|
209
|
+
#
|
|
210
|
+
# @example Queue with priority
|
|
211
|
+
# job.queue!(pri: 0, delay: 30.minutes)
|
|
212
|
+
#
|
|
213
|
+
# @see #requeue!
|
|
214
|
+
# @see Postburner.queue_strategy
|
|
215
|
+
#
|
|
38
216
|
def queue!(options={})
|
|
39
217
|
return if self.queued_at.present? && self.bkid.present?
|
|
40
218
|
raise ActiveRecord::RecordInvalid, "Can't queue unless valid." unless self.valid?
|
|
@@ -55,9 +233,33 @@ module Postburner
|
|
|
55
233
|
|
|
56
234
|
@_insert_options = options
|
|
57
235
|
|
|
58
|
-
|
|
236
|
+
run_callbacks :enqueue do
|
|
237
|
+
self.save!
|
|
238
|
+
end
|
|
59
239
|
end
|
|
60
240
|
|
|
241
|
+
# Re-queues an existing job by removing it from Beanstalkd and queueing again.
|
|
242
|
+
#
|
|
243
|
+
# Calls {#delete!} to remove from Beanstalkd, resets queuing metadata,
|
|
244
|
+
# then calls {#queue!} with new options.
|
|
245
|
+
#
|
|
246
|
+
# @param options [Hash] Queue options (same as {#queue!})
|
|
247
|
+
# @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
|
|
248
|
+
# @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
|
|
249
|
+
# @option options [Integer] :pri Beanstalkd priority
|
|
250
|
+
# @option options [Integer] :ttr Time-to-run in seconds
|
|
251
|
+
#
|
|
252
|
+
# @return [void]
|
|
253
|
+
#
|
|
254
|
+
# @raise [ActiveRecord::RecordInvalid] if job is not valid
|
|
255
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails
|
|
256
|
+
#
|
|
257
|
+
# @example Requeue with different delay
|
|
258
|
+
# job.requeue!(delay: 5.minutes)
|
|
259
|
+
#
|
|
260
|
+
# @see #queue!
|
|
261
|
+
# @see #delete!
|
|
262
|
+
#
|
|
61
263
|
def requeue!(options={})
|
|
62
264
|
self.delete!
|
|
63
265
|
self.bkid, self.queued_at = nil, nil
|
|
@@ -65,11 +267,47 @@ module Postburner
|
|
|
65
267
|
self.queue! options
|
|
66
268
|
end
|
|
67
269
|
|
|
270
|
+
# Checks if job is flagged for insertion into Beanstalkd.
|
|
271
|
+
#
|
|
272
|
+
# Set internally by {#queue!} to trigger insertion via after_save_commit hook.
|
|
273
|
+
#
|
|
274
|
+
# @return [Boolean] true if job will be inserted on save
|
|
275
|
+
# @api private
|
|
276
|
+
#
|
|
68
277
|
def will_insert?
|
|
69
278
|
@_insert_options.is_a? Hash
|
|
70
279
|
end
|
|
71
280
|
|
|
72
|
-
#
|
|
281
|
+
# Executes a job by ID, delegating to the current queue strategy.
|
|
282
|
+
#
|
|
283
|
+
# Loads the job from database by ID and delegates execution to the current
|
|
284
|
+
# queue strategy's {handle_perform!} method. This provides a unified API
|
|
285
|
+
# for job execution regardless of strategy (async, test, or null).
|
|
286
|
+
#
|
|
287
|
+
# Called automatically by Backburner workers in production. Can also be
|
|
288
|
+
# called manually for test/null strategies to trigger execution.
|
|
289
|
+
#
|
|
290
|
+
# @param id [Integer] Job ID to execute
|
|
291
|
+
# @param _ [Hash] Unused Backburner metadata parameter
|
|
292
|
+
#
|
|
293
|
+
# @return [void]
|
|
294
|
+
#
|
|
295
|
+
# @example Backburner automatic execution (production)
|
|
296
|
+
# # Jobs execute in tube: backburner.worker.queue.backburner-jobs
|
|
297
|
+
# # Backburner calls: Postburner::Job.perform(job_id)
|
|
298
|
+
#
|
|
299
|
+
# @example Manual execution with NullQueue
|
|
300
|
+
# Postburner.null_strategy!
|
|
301
|
+
# job = MyJob.create!(args: {})
|
|
302
|
+
# job.queue!(delay: 1.hour)
|
|
303
|
+
# Postburner::Job.perform(job.id) # Time travels and executes
|
|
304
|
+
#
|
|
305
|
+
# @note Strategy-aware: delegates to Postburner.queue_strategy.handle_perform!
|
|
306
|
+
# @note For NullQueue, automatically handles time travel for scheduled jobs
|
|
307
|
+
#
|
|
308
|
+
# @see #perform!
|
|
309
|
+
# @see Queue.handle_perform!
|
|
310
|
+
# @see NullQueue.handle_perform!
|
|
73
311
|
#
|
|
74
312
|
def self.perform(id, _={})
|
|
75
313
|
job = nil
|
|
@@ -80,12 +318,41 @@ module Postburner
|
|
|
80
318
|
[Postburner::Job] [#{id}] Not Found.
|
|
81
319
|
MSG
|
|
82
320
|
end
|
|
83
|
-
#job
|
|
84
|
-
|
|
321
|
+
#job.perform!(job.args)
|
|
322
|
+
Postburner.queue_strategy.handle_perform!(job)
|
|
85
323
|
end
|
|
86
324
|
|
|
325
|
+
# Executes the job with full lifecycle management and error handling.
|
|
326
|
+
#
|
|
327
|
+
# This is the main execution method called by Backburner workers or test strategies.
|
|
328
|
+
# Performs validation checks, executes callbacks, calls the subclass {#perform} method,
|
|
329
|
+
# tracks timing and statistics, and handles errors.
|
|
330
|
+
#
|
|
331
|
+
# Execution flow:
|
|
332
|
+
# 1. Runs attempt callbacks (fires on every retry)
|
|
333
|
+
# 2. Updates attempting metadata (attempting_at, attempts, lag)
|
|
334
|
+
# 3. Validates job state (queued, not processed, not removed, not premature)
|
|
335
|
+
# 4. Runs processing callbacks
|
|
336
|
+
# 5. Calls subclass {#perform} method
|
|
337
|
+
# 6. Runs processed callbacks (only on success)
|
|
338
|
+
# 7. Updates completion metadata (processed_at, duration)
|
|
339
|
+
# 8. Logs and tracks any exceptions
|
|
340
|
+
#
|
|
341
|
+
# @param args [Hash] Arguments to pass to {#perform} method
|
|
342
|
+
#
|
|
343
|
+
# @return [void]
|
|
344
|
+
#
|
|
345
|
+
# @raise [Exception] Any exception raised by {#perform} is logged and re-raised
|
|
346
|
+
#
|
|
347
|
+
# @note Does not execute if queued_at is nil, in the future, already processed, or removed
|
|
348
|
+
# @note Premature execution (before run_at) is delegated to queue strategy
|
|
349
|
+
#
|
|
350
|
+
# @see #perform
|
|
351
|
+
# @see Postburner.queue_strategy
|
|
352
|
+
# @see Callbacks
|
|
353
|
+
#
|
|
87
354
|
def perform!(args={})
|
|
88
|
-
|
|
355
|
+
run_callbacks :attempt do
|
|
89
356
|
self.attempting
|
|
90
357
|
|
|
91
358
|
self.update_columns(
|
|
@@ -118,15 +385,14 @@ module Postburner
|
|
|
118
385
|
return
|
|
119
386
|
end
|
|
120
387
|
|
|
121
|
-
if self.run_at && self.run_at > Time.zone.now
|
|
122
|
-
|
|
123
|
-
self.log! "PREMATURE; RE-INSERTED: #{response}"
|
|
388
|
+
if self.run_at && self.run_at.to_i > Time.zone.now.to_i
|
|
389
|
+
Postburner.queue_strategy.handle_premature_perform(self)
|
|
124
390
|
return
|
|
125
391
|
end
|
|
126
392
|
|
|
127
393
|
self.log!("START (bkid #{self.bkid})")
|
|
128
394
|
|
|
129
|
-
|
|
395
|
+
run_callbacks :processing do
|
|
130
396
|
begin
|
|
131
397
|
self.perform(args)
|
|
132
398
|
rescue Exception => exception
|
|
@@ -142,7 +408,7 @@ module Postburner
|
|
|
142
408
|
now = Time.zone.now
|
|
143
409
|
_duration = (now - self.processing_at) * 1000 rescue nil
|
|
144
410
|
|
|
145
|
-
|
|
411
|
+
run_callbacks :processed do
|
|
146
412
|
persist_metadata!(
|
|
147
413
|
processed_at: now,
|
|
148
414
|
duration: _duration,
|
|
@@ -162,6 +428,30 @@ module Postburner
|
|
|
162
428
|
|
|
163
429
|
end
|
|
164
430
|
|
|
431
|
+
# Deletes the job from the Beanstalkd queue.
|
|
432
|
+
#
|
|
433
|
+
# This is a Beanstalkd operation that removes the job from the queue but
|
|
434
|
+
# does NOT destroy the ActiveRecord model. Use {#destroy} or {#remove!} to
|
|
435
|
+
# also update the database record.
|
|
436
|
+
#
|
|
437
|
+
# Automatically retries with fresh connection if Beanstalkd connection is stale.
|
|
438
|
+
# Called automatically by before_destroy callback when using {#destroy}.
|
|
439
|
+
#
|
|
440
|
+
# @return [void]
|
|
441
|
+
#
|
|
442
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
|
|
443
|
+
#
|
|
444
|
+
# @note Does nothing if job has no bkid (e.g., in test mode)
|
|
445
|
+
# @note Does not modify ActiveRecord model - only affects Beanstalkd
|
|
446
|
+
#
|
|
447
|
+
# @example
|
|
448
|
+
# job.delete! # Removes from Beanstalkd queue only
|
|
449
|
+
# job.reload # Job still exists in database
|
|
450
|
+
#
|
|
451
|
+
# @see #remove!
|
|
452
|
+
# @see #destroy
|
|
453
|
+
# @see #beanstalk_job
|
|
454
|
+
#
|
|
165
455
|
def delete!
|
|
166
456
|
return unless self.beanstalk_job
|
|
167
457
|
begin
|
|
@@ -171,6 +461,26 @@ module Postburner
|
|
|
171
461
|
end
|
|
172
462
|
end
|
|
173
463
|
|
|
464
|
+
# Kicks a buried job back into the ready queue in Beanstalkd.
|
|
465
|
+
#
|
|
466
|
+
# This is a Beanstalkd operation used to retry jobs that were buried due to
|
|
467
|
+
# repeated failures or explicit burial. Does not modify the ActiveRecord model.
|
|
468
|
+
#
|
|
469
|
+
# Automatically retries with fresh connection if Beanstalkd connection is stale.
|
|
470
|
+
#
|
|
471
|
+
# @return [void]
|
|
472
|
+
#
|
|
473
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
|
|
474
|
+
#
|
|
475
|
+
# @note Does nothing if job has no bkid (e.g., in test mode)
|
|
476
|
+
# @note Only works on buried jobs - see Beanstalkd documentation
|
|
477
|
+
#
|
|
478
|
+
# @example
|
|
479
|
+
# job.kick! # Moves buried job back to ready queue
|
|
480
|
+
#
|
|
481
|
+
# @see #delete!
|
|
482
|
+
# @see #beanstalk_job
|
|
483
|
+
#
|
|
174
484
|
def kick!
|
|
175
485
|
return unless self.beanstalk_job
|
|
176
486
|
begin
|
|
@@ -180,12 +490,49 @@ module Postburner
|
|
|
180
490
|
end
|
|
181
491
|
end
|
|
182
492
|
|
|
493
|
+
# Soft-deletes the job by removing from Beanstalkd and setting removed_at timestamp.
|
|
494
|
+
#
|
|
495
|
+
# Unlike {#destroy}, this preserves the job record in the database for audit trails
|
|
496
|
+
# while removing it from the Beanstalkd queue and marking it as removed.
|
|
497
|
+
#
|
|
498
|
+
# @return [void]
|
|
499
|
+
#
|
|
500
|
+
# @raise [Beaneater::NotConnected] if Beanstalkd connection fails
|
|
501
|
+
#
|
|
502
|
+
# @note Idempotent - does nothing if already removed
|
|
503
|
+
# @note Does not destroy ActiveRecord model - only soft deletes
|
|
504
|
+
#
|
|
505
|
+
# @example
|
|
506
|
+
# job.remove!
|
|
507
|
+
# job.removed_at # => 2025-10-31 12:34:56 UTC
|
|
508
|
+
# job.persisted? # => true (still in database)
|
|
509
|
+
#
|
|
510
|
+
# @see #delete!
|
|
511
|
+
# @see #destroy
|
|
512
|
+
#
|
|
183
513
|
def remove!
|
|
184
514
|
return if self.removed_at
|
|
185
515
|
self.delete!
|
|
186
516
|
self.update_column(:removed_at, Time.zone.now)
|
|
187
517
|
end
|
|
188
518
|
|
|
519
|
+
# Returns the Beanstalkd job object for direct queue operations.
|
|
520
|
+
#
|
|
521
|
+
# Provides access to the underlying Beaneater job object for advanced
|
|
522
|
+
# Beanstalkd operations. Caches the job object in an instance variable.
|
|
523
|
+
#
|
|
524
|
+
# @return [Beaneater::Job, nil] Beanstalkd job object or nil if no bkid
|
|
525
|
+
#
|
|
526
|
+
# @example Direct Beanstalkd operations
|
|
527
|
+
# bk_job = job.beanstalk_job
|
|
528
|
+
# bk_job.stats # Get job statistics
|
|
529
|
+
# bk_job.bury # Bury the job
|
|
530
|
+
# bk_job.release(pri: 0) # Release with priority
|
|
531
|
+
#
|
|
532
|
+
# @see #beanstalk_job!
|
|
533
|
+
# @see #delete!
|
|
534
|
+
# @see #kick!
|
|
535
|
+
#
|
|
189
536
|
def beanstalk_job
|
|
190
537
|
return unless self.bkid.present?
|
|
191
538
|
return @_beanstalk_job if @_beanstalk_job
|
|
@@ -195,11 +542,43 @@ module Postburner
|
|
|
195
542
|
@_beanstalk_job
|
|
196
543
|
end
|
|
197
544
|
|
|
545
|
+
# Returns the Beanstalkd job object with cache invalidation.
|
|
546
|
+
#
|
|
547
|
+
# Same as {#beanstalk_job} but clears the cached instance variable first,
|
|
548
|
+
# forcing a fresh lookup from Beanstalkd. Use when job state may have changed.
|
|
549
|
+
#
|
|
550
|
+
# @return [Beaneater::Job, nil] Beanstalkd job object or nil if no bkid
|
|
551
|
+
#
|
|
552
|
+
# @example
|
|
553
|
+
# job.beanstalk_job! # Forces fresh lookup
|
|
554
|
+
#
|
|
555
|
+
# @see #beanstalk_job
|
|
556
|
+
#
|
|
198
557
|
def beanstalk_job!
|
|
199
558
|
@_beanstalk_job = nil
|
|
200
559
|
self.beanstalk_job
|
|
201
560
|
end
|
|
202
561
|
|
|
562
|
+
# Tracks an exception in the job's errata array.
|
|
563
|
+
#
|
|
564
|
+
# Appends exception details to the in-memory errata array with timestamp,
|
|
565
|
+
# class, message, and backtrace. Does NOT persist to database immediately.
|
|
566
|
+
# Use {#log_exception!} to persist immediately.
|
|
567
|
+
#
|
|
568
|
+
# @param exception [Exception] The exception to track
|
|
569
|
+
#
|
|
570
|
+
# @return [Array<Array>] Updated errata array
|
|
571
|
+
#
|
|
572
|
+
# @example
|
|
573
|
+
# begin
|
|
574
|
+
# # ... risky operation ...
|
|
575
|
+
# rescue => e
|
|
576
|
+
# log_exception(e)
|
|
577
|
+
# raise
|
|
578
|
+
# end
|
|
579
|
+
#
|
|
580
|
+
# @see #log_exception!
|
|
581
|
+
#
|
|
203
582
|
def log_exception(exception)
|
|
204
583
|
self.errata << [
|
|
205
584
|
Time.zone.now,
|
|
@@ -212,11 +591,53 @@ module Postburner
|
|
|
212
591
|
]
|
|
213
592
|
end
|
|
214
593
|
|
|
594
|
+
# Tracks an exception and immediately persists to database.
|
|
595
|
+
#
|
|
596
|
+
# Calls {#log_exception} to append exception details, then persists
|
|
597
|
+
# both errata and error_count to database via {#persist_metadata!}.
|
|
598
|
+
#
|
|
599
|
+
# @param exception [Exception] The exception to track
|
|
600
|
+
#
|
|
601
|
+
# @return [void]
|
|
602
|
+
#
|
|
603
|
+
# @example
|
|
604
|
+
# begin
|
|
605
|
+
# # ... risky operation ...
|
|
606
|
+
# rescue => e
|
|
607
|
+
# log_exception!(e)
|
|
608
|
+
# raise
|
|
609
|
+
# end
|
|
610
|
+
#
|
|
611
|
+
# @see #log_exception
|
|
612
|
+
#
|
|
215
613
|
def log_exception!(exception)
|
|
216
614
|
self.log_exception(exception)
|
|
217
|
-
self.
|
|
615
|
+
self.persist_metadata!
|
|
218
616
|
end
|
|
219
617
|
|
|
618
|
+
# Appends a log message to the job's logs array.
|
|
619
|
+
#
|
|
620
|
+
# Adds timestamped log entry to in-memory logs array with bkid, level,
|
|
621
|
+
# message, and elapsed time. Does NOT persist to database immediately.
|
|
622
|
+
# Use {#log!} to persist immediately.
|
|
623
|
+
#
|
|
624
|
+
# @param message [String] Log message to append
|
|
625
|
+
# @param options [Hash] Log options
|
|
626
|
+
# @option options [Symbol] :level Log level (:debug, :info, :warning, :error) - defaults to :info
|
|
627
|
+
#
|
|
628
|
+
# @return [Array<Array>] Updated logs array
|
|
629
|
+
#
|
|
630
|
+
# @note Invalid log levels are coerced to :error
|
|
631
|
+
#
|
|
632
|
+
# @example
|
|
633
|
+
# log "Processing started"
|
|
634
|
+
# log "Warning: rate limit approaching", level: :warning
|
|
635
|
+
# log "Critical error occurred", level: :error
|
|
636
|
+
#
|
|
637
|
+
# @see #log!
|
|
638
|
+
# @see #elapsed_ms
|
|
639
|
+
# @see LOG_LEVELS
|
|
640
|
+
#
|
|
220
641
|
def log(message, options={})
|
|
221
642
|
options[:level] ||= :info
|
|
222
643
|
options[:level] = :error unless LOG_LEVELS.member?(options[:level])
|
|
@@ -232,26 +653,131 @@ module Postburner
|
|
|
232
653
|
]
|
|
233
654
|
end
|
|
234
655
|
|
|
235
|
-
#
|
|
656
|
+
# Returns elapsed time in milliseconds since job execution started.
|
|
657
|
+
#
|
|
658
|
+
# Calculates milliseconds between attempting_at and current time.
|
|
659
|
+
# Used to track how long a job has been executing.
|
|
660
|
+
#
|
|
661
|
+
# @return [Float, nil] Milliseconds since attempting_at, or nil if not yet attempting
|
|
662
|
+
#
|
|
663
|
+
# @example
|
|
664
|
+
# elapsed_ms # => 1234.567 (job has been running for ~1.2 seconds)
|
|
665
|
+
#
|
|
666
|
+
# @see #log
|
|
236
667
|
#
|
|
237
668
|
def elapsed_ms
|
|
238
669
|
return unless self.attempting_at
|
|
239
670
|
(Time.zone.now - self.attempting_at) * 1000
|
|
240
671
|
end
|
|
241
672
|
|
|
673
|
+
# Appends a log message and immediately persists to database.
|
|
674
|
+
#
|
|
675
|
+
# Calls {#log} to append message, then persists logs array to database.
|
|
676
|
+
# Use this for important log messages that should be saved immediately.
|
|
677
|
+
#
|
|
678
|
+
# @param message [String] Log message to append
|
|
679
|
+
# @param options [Hash] Log options
|
|
680
|
+
# @option options [Symbol] :level Log level (:debug, :info, :warning, :error)
|
|
681
|
+
#
|
|
682
|
+
# @return [void]
|
|
683
|
+
#
|
|
684
|
+
# @example
|
|
685
|
+
# log! "Job started"
|
|
686
|
+
# log! "Payment processed successfully", level: :info
|
|
687
|
+
# log! "Retrying failed request", level: :warning
|
|
688
|
+
#
|
|
689
|
+
# @see #log
|
|
690
|
+
#
|
|
242
691
|
def log!(message, options={})
|
|
243
692
|
self.log(message, options)
|
|
244
693
|
self.update_column :logs, self.logs
|
|
245
694
|
end
|
|
246
695
|
|
|
696
|
+
# Returns the intended execution time for the job.
|
|
697
|
+
#
|
|
698
|
+
# Returns run_at if scheduled, otherwise returns queued_at.
|
|
699
|
+
# Used for calculating lag (delay between intended and actual execution).
|
|
700
|
+
#
|
|
701
|
+
# @return [Time] Intended execution timestamp
|
|
702
|
+
#
|
|
703
|
+
# @example
|
|
704
|
+
# job.queue!(delay: 1.hour)
|
|
705
|
+
# job.intended_at # => 1 hour from now (run_at)
|
|
706
|
+
#
|
|
707
|
+
# job.queue!
|
|
708
|
+
# job.intended_at # => now (queued_at)
|
|
709
|
+
#
|
|
710
|
+
# @see #lag
|
|
711
|
+
#
|
|
247
712
|
def intended_at
|
|
248
713
|
self.run_at ? self.run_at : self.queued_at
|
|
249
714
|
end
|
|
250
715
|
|
|
716
|
+
# Exception raised when attempting to queue a job that has already been processed.
|
|
717
|
+
#
|
|
718
|
+
# @example
|
|
719
|
+
# job.queue!
|
|
720
|
+
# # ... job completes ...
|
|
721
|
+
# job.queue! # => raises AlreadyProcessed
|
|
722
|
+
#
|
|
251
723
|
class AlreadyProcessed < StandardError; end
|
|
252
724
|
|
|
725
|
+
# Exception raised when a job is executed before its scheduled run_at time.
|
|
726
|
+
#
|
|
727
|
+
# In production (Queue/NiceQueue), this is handled by re-inserting with delay.
|
|
728
|
+
# In test mode (TestQueue), this exception is raised to force explicit time management.
|
|
729
|
+
#
|
|
730
|
+
# @example
|
|
731
|
+
# Postburner.inline_test_strategy!
|
|
732
|
+
# job.queue!(delay: 1.hour)
|
|
733
|
+
# # => raises PrematurePerform: "Job scheduled for ..."
|
|
734
|
+
#
|
|
735
|
+
# @see Postburner::TestQueue
|
|
736
|
+
# @see Postburner::ImmediateTestQueue
|
|
737
|
+
#
|
|
738
|
+
class PrematurePerform < StandardError; end
|
|
739
|
+
|
|
740
|
+
# Exception raised when a queue strategy returns an invalid response format.
|
|
741
|
+
#
|
|
742
|
+
# Queue strategies must return a Hash with an `:id` key when inserting jobs.
|
|
743
|
+
# The `:id` value represents the Beanstalkd job ID (can be nil for test strategies).
|
|
744
|
+
# This exception is raised in {#insert!} when a strategy returns a malformed response.
|
|
745
|
+
#
|
|
746
|
+
# @example Valid response formats
|
|
747
|
+
# { id: 12345, status: "INSERTED" } # Production with Beanstalkd ID
|
|
748
|
+
# { id: nil, status: "INLINE" } # Test strategy without Beanstalkd
|
|
749
|
+
#
|
|
750
|
+
# @example Invalid response (raises MalformedResponse)
|
|
751
|
+
# "success" # Not a Hash
|
|
752
|
+
# { status: "INSERTED" } # Missing :id key
|
|
753
|
+
# { "id" => 12345 } # String key instead of symbol
|
|
754
|
+
#
|
|
755
|
+
# @example Handling in custom strategy
|
|
756
|
+
# class MyStrategy
|
|
757
|
+
# def self.insert(job, options = {})
|
|
758
|
+
# # Must return Hash with :id key (symbol)
|
|
759
|
+
# { id: nil, status: "QUEUED" }
|
|
760
|
+
# end
|
|
761
|
+
# end
|
|
762
|
+
#
|
|
763
|
+
# @see #insert!
|
|
764
|
+
# @see Postburner.queue_strategy
|
|
765
|
+
#
|
|
766
|
+
class MalformedResponse < StandardError; end
|
|
767
|
+
|
|
253
768
|
private
|
|
254
769
|
|
|
770
|
+
# Persists job metadata (errata, logs, and counts) to database.
|
|
771
|
+
#
|
|
772
|
+
# Updates errata, error_count, logs, and log_count columns, plus any
|
|
773
|
+
# additional data passed in. Used internally for atomic metadata updates.
|
|
774
|
+
#
|
|
775
|
+
# @param data [Hash] Additional columns to update
|
|
776
|
+
#
|
|
777
|
+
# @return [void]
|
|
778
|
+
#
|
|
779
|
+
# @api private
|
|
780
|
+
#
|
|
255
781
|
def persist_metadata!(data={})
|
|
256
782
|
self.update_columns({
|
|
257
783
|
errata: self.errata,
|
|
@@ -261,35 +787,57 @@ module Postburner
|
|
|
261
787
|
}.merge(data))
|
|
262
788
|
end
|
|
263
789
|
|
|
790
|
+
# After-save-commit callback that inserts job into queue if flagged.
|
|
791
|
+
#
|
|
792
|
+
# Checks {#will_insert?} and calls {#insert!} if true. Triggered by
|
|
793
|
+
# after_save_commit callback after {#queue!} saves the record.
|
|
794
|
+
#
|
|
795
|
+
# @return [void]
|
|
796
|
+
#
|
|
797
|
+
# @api private
|
|
798
|
+
#
|
|
264
799
|
def insert_if_queued!
|
|
265
800
|
return unless self.will_insert?
|
|
266
|
-
|
|
267
|
-
Rails.logger.info "[Postburner] Backburner::Worker#enqueue for #{self.id}: #{backburner_response}"
|
|
268
|
-
true
|
|
801
|
+
insert!(@_insert_options)
|
|
269
802
|
end
|
|
270
803
|
|
|
804
|
+
# Inserts job into queue via current queue strategy.
|
|
805
|
+
#
|
|
806
|
+
# Delegates to Postburner.queue_strategy.insert which handles actual
|
|
807
|
+
# queueing (Beanstalkd in production, inline execution in test mode).
|
|
808
|
+
# Updates bkid if the strategy returns a Beanstalkd job ID.
|
|
809
|
+
#
|
|
810
|
+
# @param options [Hash] Queue options (delay, pri, ttr, etc.)
|
|
811
|
+
#
|
|
812
|
+
# @return [Hash, nil] Queue strategy response
|
|
813
|
+
#
|
|
814
|
+
# @api private
|
|
815
|
+
#
|
|
271
816
|
def insert!(options={})
|
|
272
|
-
response =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
self.id,
|
|
279
|
-
options
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
persist_metadata!(
|
|
283
|
-
bkid: response[:id],
|
|
284
|
-
)
|
|
285
|
-
end
|
|
817
|
+
response = Postburner.queue_strategy.insert(self, options)
|
|
818
|
+
|
|
819
|
+
# Response must be a hash with an :id key (value can be nil)
|
|
820
|
+
# Backburner returns symbol keys
|
|
821
|
+
unless response.is_a?(Hash) && response.key?(:id)
|
|
822
|
+
raise MalformedResponse, "Missing :id key in response: #{response.inspect}"
|
|
286
823
|
end
|
|
287
824
|
|
|
288
|
-
|
|
825
|
+
persist_metadata!(bkid: response[:id])
|
|
826
|
+
|
|
827
|
+
self.log("QUEUED: #{response}") if response
|
|
289
828
|
|
|
290
829
|
response
|
|
291
830
|
end
|
|
292
831
|
|
|
832
|
+
# Records an attempt and calculates execution lag.
|
|
833
|
+
#
|
|
834
|
+
# Appends current time to attempts array, sets attempting_at on first attempt,
|
|
835
|
+
# and calculates lag (delay between intended and actual execution time).
|
|
836
|
+
#
|
|
837
|
+
# @return [Time] Current timestamp
|
|
838
|
+
#
|
|
839
|
+
# @api private
|
|
840
|
+
#
|
|
293
841
|
def attempting
|
|
294
842
|
now = Time.zone.now
|
|
295
843
|
self.attempts << now
|
|
@@ -298,10 +846,27 @@ module Postburner
|
|
|
298
846
|
now
|
|
299
847
|
end
|
|
300
848
|
|
|
849
|
+
# Before-validation callback that ensures job has a unique sid.
|
|
850
|
+
#
|
|
851
|
+
# Generates a UUID for sid if not already set. Used for job identification
|
|
852
|
+
# independent of database ID.
|
|
853
|
+
#
|
|
854
|
+
# @return [void]
|
|
855
|
+
#
|
|
856
|
+
# @api private
|
|
857
|
+
#
|
|
301
858
|
def ensure_sid!
|
|
302
859
|
self.sid ||= SecureRandom.uuid
|
|
303
860
|
end
|
|
304
861
|
|
|
862
|
+
# Backburner message format for the job.
|
|
863
|
+
#
|
|
864
|
+
# Returns a simple string representation used by Backburner.
|
|
865
|
+
#
|
|
866
|
+
# @return [String] Message format
|
|
867
|
+
#
|
|
868
|
+
# @api private
|
|
869
|
+
#
|
|
305
870
|
def message
|
|
306
871
|
"Job: #{self.id}"
|
|
307
872
|
end
|