postburner 0.7.2 → 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.
@@ -1,26 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Postburner
4
- # Must implement a perform method, if an exception is raised the job
5
- # doesn't complete.
4
+ # Base class for all Postburner background jobs using Single Table Inheritance (STI).
6
5
  #
7
- # Job won't run unless queued_at is set, and is set to a time prior to
8
- # the time the job runs.
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
- # TODO Mailer uses ActiveJob::Arguments... probably should use that here
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
- # Add `cancelled_at` that blocks jobs performing if present.
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
- define_model_callbacks :insert
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
- self.save!
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
- # tube: backburner.worker.queue.backburner-jobs
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&.perform!(job.args)
84
- job.perform!(job.args)
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
- _run_attempt_callbacks do
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
- response = self.insert! delay: self.run_at - Time.zone.now
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
- _run_processing_callbacks do
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
- _run_processed_callbacks do
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.update_column :errata, self.errata
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
- # ms from attempting_at
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
- backburner_response = insert!(@_insert_options)
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 = nil
273
-
274
- _run_insert_callbacks do
275
- Job.transaction do
276
- response = Backburner::Worker.enqueue(
277
- Postburner::Job,
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
- self.log("QUEUED: #{response}")
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