postburner 0.8.0 → 1.0.0.pre.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,81 @@
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
+ #
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
16
72
  #
17
73
  class Job < ApplicationRecord
18
- include Backburner::Queue
74
+ include QueueConfig
75
+ include Callbacks
19
76
 
20
- define_model_callbacks :insert
21
- define_model_callbacks :attempt
22
- define_model_callbacks :processing
23
- define_model_callbacks :processed, only: :after
77
+ # Instance-level queue configuration (overrides class-level defaults)
78
+ attr_writer :queue_priority, :queue_ttr
24
79
 
25
80
  LOG_LEVELS = [
26
81
  :debug,
@@ -35,6 +90,97 @@ module Postburner
35
90
 
36
91
  validates :sid, presence: {strict: true}
37
92
 
93
+ # Abstract method to be implemented by subclasses.
94
+ #
95
+ # This method contains the actual work logic for the job. It is called
96
+ # by {#perform!} within the processing callbacks. Any exceptions raised
97
+ # will be logged and re-raised, causing the job to fail.
98
+ #
99
+ # @abstract Subclasses must implement this method
100
+ #
101
+ # @param args [Hash] Job arguments from the args JSONB column
102
+ #
103
+ # @return [void]
104
+ #
105
+ # @raise [NotImplementedError] if subclass does not implement this method
106
+ #
107
+ # @example
108
+ # class ProcessPayment < Postburner::Job
109
+ # def perform(args)
110
+ # payment = Payment.find(args['payment_id'])
111
+ # payment.process!
112
+ # log "Payment #{payment.id} processed successfully"
113
+ # end
114
+ # end
115
+ #
116
+ # @note Use {#log} or {#log!} within perform to add entries to the job's audit trail
117
+ # @note Exceptions will be caught, logged to errata, and re-raised
118
+ #
119
+ # @see #perform!
120
+ # @see #log
121
+ # @see #log_exception
122
+ #
123
+ def perform(args)
124
+ raise NotImplementedError, "Subclasses must implement the perform method"
125
+ end
126
+
127
+ # @!method destroy
128
+ # Destroys the job record and removes it from Beanstalkd queue.
129
+ #
130
+ # Uses before_destroy callback to call {#delete!} which removes the job
131
+ # from Beanstalkd before destroying the ActiveRecord model.
132
+ #
133
+ # @return [self] the destroyed job object
134
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
135
+ # @see #delete!
136
+ # @see #remove!
137
+ # @note This is a standard Rails method with Beanstalkd integration via callback
138
+
139
+ # @!method destroy!
140
+ # Destroys the job record and removes it from Beanstalkd queue, raising on failure.
141
+ #
142
+ # Uses before_destroy callback to call {#delete!} which removes the job
143
+ # from Beanstalkd before destroying the ActiveRecord model.
144
+ #
145
+ # @return [self] the destroyed job object
146
+ # @raise [ActiveRecord::RecordNotDestroyed] if validations prevent destruction
147
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
148
+ # @see #delete!
149
+ # @see #remove!
150
+ # @note This is a standard Rails method with Beanstalkd integration via callback
151
+
152
+ # Enqueues the job to Beanstalkd for processing.
153
+ #
154
+ # Sets queued_at timestamp and optionally run_at for scheduled execution.
155
+ # Triggers enqueue callbacks and inserts job into Beanstalkd via after_save_commit hook.
156
+ # In test mode, executes immediately instead of queueing to Beanstalkd.
157
+ #
158
+ # @param options [Hash] Queue options
159
+ # @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
160
+ # @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
161
+ # @option options [Integer] :pri Beanstalkd priority (lower = higher priority)
162
+ # @option options [Integer] :ttr Time-to-run in seconds before job times out
163
+ #
164
+ # @return [void]
165
+ #
166
+ # @raise [ActiveRecord::RecordInvalid] if job is not valid
167
+ # @raise [AlreadyProcessed] if job was already processed
168
+ #
169
+ # @example Queue immediately
170
+ # job.queue!
171
+ #
172
+ # @example Queue with delay
173
+ # job.queue!(delay: 1.hour)
174
+ #
175
+ # @example Queue at specific time
176
+ # job.queue!(at: Time.zone.now + 2.days)
177
+ #
178
+ # @example Queue with priority
179
+ # job.queue!(pri: 0, delay: 30.minutes)
180
+ #
181
+ # @see #requeue!
182
+ # @see Postburner.queue_strategy
183
+ #
38
184
  def queue!(options={})
39
185
  return if self.queued_at.present? && self.bkid.present?
40
186
  raise ActiveRecord::RecordInvalid, "Can't queue unless valid." unless self.valid?
@@ -55,9 +201,33 @@ module Postburner
55
201
 
56
202
  @_insert_options = options
57
203
 
58
- self.save!
204
+ run_callbacks :enqueue do
205
+ self.save!
206
+ end
59
207
  end
60
208
 
209
+ # Re-queues an existing job by removing it from Beanstalkd and queueing again.
210
+ #
211
+ # Calls {#delete!} to remove from Beanstalkd, resets queuing metadata,
212
+ # then calls {#queue!} with new options.
213
+ #
214
+ # @param options [Hash] Queue options (same as {#queue!})
215
+ # @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
216
+ # @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
217
+ # @option options [Integer] :pri Beanstalkd priority
218
+ # @option options [Integer] :ttr Time-to-run in seconds
219
+ #
220
+ # @return [void]
221
+ #
222
+ # @raise [ActiveRecord::RecordInvalid] if job is not valid
223
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
224
+ #
225
+ # @example Requeue with different delay
226
+ # job.requeue!(delay: 5.minutes)
227
+ #
228
+ # @see #queue!
229
+ # @see #delete!
230
+ #
61
231
  def requeue!(options={})
62
232
  self.delete!
63
233
  self.bkid, self.queued_at = nil, nil
@@ -65,11 +235,47 @@ module Postburner
65
235
  self.queue! options
66
236
  end
67
237
 
238
+ # Checks if job is flagged for insertion into Beanstalkd.
239
+ #
240
+ # Set internally by {#queue!} to trigger insertion via after_save_commit hook.
241
+ #
242
+ # @return [Boolean] true if job will be inserted on save
243
+ # @api private
244
+ #
68
245
  def will_insert?
69
246
  @_insert_options.is_a? Hash
70
247
  end
71
248
 
72
- # tube: backburner.worker.queue.backburner-jobs
249
+ # Executes a job by ID, delegating to the current queue strategy.
250
+ #
251
+ # Loads the job from database by ID and delegates execution to the current
252
+ # queue strategy's {handle_perform!} method. This provides a unified API
253
+ # for job execution regardless of strategy (async, test, or null).
254
+ #
255
+ # Called automatically by Backburner workers in production. Can also be
256
+ # called manually for test/null strategies to trigger execution.
257
+ #
258
+ # @param id [Integer] Job ID to execute
259
+ # @param _ [Hash] Unused Backburner metadata parameter
260
+ #
261
+ # @return [void]
262
+ #
263
+ # @example Backburner automatic execution (production)
264
+ # # Jobs execute in tube: backburner.worker.queue.backburner-jobs
265
+ # # Backburner calls: Postburner::Job.perform(job_id)
266
+ #
267
+ # @example Manual execution with NullQueue
268
+ # Postburner.null_strategy!
269
+ # job = MyJob.create!(args: {})
270
+ # job.queue!(delay: 1.hour)
271
+ # Postburner::Job.perform(job.id) # Time travels and executes
272
+ #
273
+ # @note Strategy-aware: delegates to Postburner.queue_strategy.handle_perform!
274
+ # @note For NullQueue, automatically handles time travel for scheduled jobs
275
+ #
276
+ # @see #perform!
277
+ # @see Queue.handle_perform!
278
+ # @see NullQueue.handle_perform!
73
279
  #
74
280
  def self.perform(id, _={})
75
281
  job = nil
@@ -80,12 +286,41 @@ module Postburner
80
286
  [Postburner::Job] [#{id}] Not Found.
81
287
  MSG
82
288
  end
83
- #job&.perform!(job.args)
84
- job.perform!(job.args)
289
+ #job.perform!(job.args)
290
+ Postburner.queue_strategy.handle_perform!(job)
85
291
  end
86
292
 
293
+ # Executes the job with full lifecycle management and error handling.
294
+ #
295
+ # This is the main execution method called by Backburner workers or test strategies.
296
+ # Performs validation checks, executes callbacks, calls the subclass {#perform} method,
297
+ # tracks timing and statistics, and handles errors.
298
+ #
299
+ # Execution flow:
300
+ # 1. Runs attempt callbacks (fires on every retry)
301
+ # 2. Updates attempting metadata (attempting_at, attempts, lag)
302
+ # 3. Validates job state (queued, not processed, not removed, not premature)
303
+ # 4. Runs processing callbacks
304
+ # 5. Calls subclass {#perform} method
305
+ # 6. Runs processed callbacks (only on success)
306
+ # 7. Updates completion metadata (processed_at, duration)
307
+ # 8. Logs and tracks any exceptions
308
+ #
309
+ # @param args [Hash] Arguments to pass to {#perform} method
310
+ #
311
+ # @return [void]
312
+ #
313
+ # @raise [Exception] Any exception raised by {#perform} is logged and re-raised
314
+ #
315
+ # @note Does not execute if queued_at is nil, in the future, already processed, or removed
316
+ # @note Premature execution (before run_at) is delegated to queue strategy
317
+ #
318
+ # @see #perform
319
+ # @see Postburner.queue_strategy
320
+ # @see Callbacks
321
+ #
87
322
  def perform!(args={})
88
- _run_attempt_callbacks do
323
+ run_callbacks :attempt do
89
324
  self.attempting
90
325
 
91
326
  self.update_columns(
@@ -118,15 +353,14 @@ module Postburner
118
353
  return
119
354
  end
120
355
 
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}"
356
+ if self.run_at && self.run_at.to_i > Time.zone.now.to_i
357
+ Postburner.queue_strategy.handle_premature_perform(self)
124
358
  return
125
359
  end
126
360
 
127
361
  self.log!("START (bkid #{self.bkid})")
128
362
 
129
- _run_processing_callbacks do
363
+ run_callbacks :processing do
130
364
  begin
131
365
  self.perform(args)
132
366
  rescue Exception => exception
@@ -142,7 +376,7 @@ module Postburner
142
376
  now = Time.zone.now
143
377
  _duration = (now - self.processing_at) * 1000 rescue nil
144
378
 
145
- _run_processed_callbacks do
379
+ run_callbacks :processed do
146
380
  persist_metadata!(
147
381
  processed_at: now,
148
382
  duration: _duration,
@@ -162,6 +396,30 @@ module Postburner
162
396
 
163
397
  end
164
398
 
399
+ # Deletes the job from the Beanstalkd queue.
400
+ #
401
+ # This is a Beanstalkd operation that removes the job from the queue but
402
+ # does NOT destroy the ActiveRecord model. Use {#destroy} or {#remove!} to
403
+ # also update the database record.
404
+ #
405
+ # Automatically retries with fresh connection if Beanstalkd connection is stale.
406
+ # Called automatically by before_destroy callback when using {#destroy}.
407
+ #
408
+ # @return [void]
409
+ #
410
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
411
+ #
412
+ # @note Does nothing if job has no bkid (e.g., in test mode)
413
+ # @note Does not modify ActiveRecord model - only affects Beanstalkd
414
+ #
415
+ # @example
416
+ # job.delete! # Removes from Beanstalkd queue only
417
+ # job.reload # Job still exists in database
418
+ #
419
+ # @see #remove!
420
+ # @see #destroy
421
+ # @see #beanstalk_job
422
+ #
165
423
  def delete!
166
424
  return unless self.beanstalk_job
167
425
  begin
@@ -171,6 +429,26 @@ module Postburner
171
429
  end
172
430
  end
173
431
 
432
+ # Kicks a buried job back into the ready queue in Beanstalkd.
433
+ #
434
+ # This is a Beanstalkd operation used to retry jobs that were buried due to
435
+ # repeated failures or explicit burial. Does not modify the ActiveRecord model.
436
+ #
437
+ # Automatically retries with fresh connection if Beanstalkd connection is stale.
438
+ #
439
+ # @return [void]
440
+ #
441
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
442
+ #
443
+ # @note Does nothing if job has no bkid (e.g., in test mode)
444
+ # @note Only works on buried jobs - see Beanstalkd documentation
445
+ #
446
+ # @example
447
+ # job.kick! # Moves buried job back to ready queue
448
+ #
449
+ # @see #delete!
450
+ # @see #beanstalk_job
451
+ #
174
452
  def kick!
175
453
  return unless self.beanstalk_job
176
454
  begin
@@ -180,13 +458,50 @@ module Postburner
180
458
  end
181
459
  end
182
460
 
461
+ # Soft-deletes the job by removing from Beanstalkd and setting removed_at timestamp.
462
+ #
463
+ # Unlike {#destroy}, this preserves the job record in the database for audit trails
464
+ # while removing it from the Beanstalkd queue and marking it as removed.
465
+ #
466
+ # @return [void]
467
+ #
468
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
469
+ #
470
+ # @note Idempotent - does nothing if already removed
471
+ # @note Does not destroy ActiveRecord model - only soft deletes
472
+ #
473
+ # @example
474
+ # job.remove!
475
+ # job.removed_at # => 2025-10-31 12:34:56 UTC
476
+ # job.persisted? # => true (still in database)
477
+ #
478
+ # @see #delete!
479
+ # @see #destroy
480
+ #
183
481
  def remove!
184
482
  return if self.removed_at
185
483
  self.delete!
186
484
  self.update_column(:removed_at, Time.zone.now)
187
485
  end
188
486
 
189
- def beanstalk_job
487
+ # Returns the Beanstalkd job object for direct queue operations.
488
+ #
489
+ # Provides access to the underlying Beaneater job object for advanced
490
+ # Beanstalkd operations. Caches the job object in an instance variable.
491
+ #
492
+ # @return [Beaneater::Job, nil] Beanstalkd job object or nil if no bkid
493
+ #
494
+ # @example Direct Beanstalkd operations
495
+ # bk_job = job.bk
496
+ # bk_job.stats # Get job statistics
497
+ # bk_job.bury # Bury the job
498
+ # bk_job.release(pri: 0) # Release with priority
499
+ #
500
+ # @see #beanstalk_job!
501
+ # @see #delete!
502
+ # @see #kick!
503
+ #
504
+ def bk
190
505
  return unless self.bkid.present?
191
506
  return @_beanstalk_job if @_beanstalk_job
192
507
 
@@ -195,11 +510,197 @@ module Postburner
195
510
  @_beanstalk_job
196
511
  end
197
512
 
198
- def beanstalk_job!
513
+ alias_method :beanstalk_job, :bk
514
+
515
+ # Returns the Beanstalkd job object with cache invalidation.
516
+ #
517
+ # Same as {#beanstalk_job} but clears the cached instance variable first,
518
+ # forcing a fresh lookup from Beanstalkd. Use when job state may have changed.
519
+ #
520
+ # @return [Beaneater::Job, nil] Beanstalkd job object or nil if no bkid
521
+ #
522
+ # @example
523
+ # job.beanstalk_job! # Forces fresh lookup
524
+ #
525
+ # @see #beanstalk_job
526
+ #
527
+ def bk!
199
528
  @_beanstalk_job = nil
200
529
  self.beanstalk_job
201
530
  end
202
531
 
532
+ alias_method :beanstalk_job!, :bk!
533
+
534
+ # Returns job statistics including Beanstalkd job state.
535
+ #
536
+ # Fetches current job state from Beanstalkd and returns combined statistics
537
+ # about the job's PostgreSQL record and its current Beanstalkd status.
538
+ #
539
+ # @return [Hash] Statistics hash with the following keys:
540
+ # - id: PostgreSQL job ID
541
+ # - bkid: Beanstalkd job ID
542
+ # - queue: Queue name configured for this job class (e.g., 'sleep-jobs')
543
+ # - tube: Derived tube name with environment prefix (e.g., 'postburner.development.sleep-jobs')
544
+ # - watched: Boolean indicating if tube is in configured watch list
545
+ # - beanstalk: Hash of Beanstalkd job statistics:
546
+ # - id: Beanstalkd job ID
547
+ # - tube: Tube name where job resides
548
+ # - state: Job state (ready, reserved, delayed, buried)
549
+ # - pri: Priority (0 = highest, 4294967295 = lowest)
550
+ # - age: Seconds since job was created
551
+ # - delay: Seconds remaining before job becomes ready (0 if ready now)
552
+ # - ttr: Time-to-run in seconds (time allowed for job processing)
553
+ # - time_left: Seconds remaining before job times out (0 if not reserved)
554
+ # - file: Binlog file number containing job
555
+ # - reserves: Number of times job has been reserved
556
+ # - timeouts: Number of times job has timed out during processing
557
+ # - releases: Number of times job has been released back to ready
558
+ # - buries: Number of times job has been buried
559
+ # - kicks: Number of times job has been kicked from buried/delayed
560
+ #
561
+ # @raise [Beaneater::NotFoundError] if job no longer exists in Beanstalkd
562
+ #
563
+ # @example
564
+ # job.stats
565
+ # # => {
566
+ # # id: 5,
567
+ # # bkid: 1,
568
+ # # queue: "sleep-jobs",
569
+ # # tube: "postburner.development.sleep-jobs",
570
+ # # watched: false,
571
+ # # beanstalk: {
572
+ # # id: 1,
573
+ # # tube: "postburner.development.sleep-jobs",
574
+ # # state: "ready",
575
+ # # pri: 50,
576
+ # # age: 1391,
577
+ # # delay: 0,
578
+ # # ttr: 120,
579
+ # # time_left: 0,
580
+ # # file: 0,
581
+ # # reserves: 0,
582
+ # # timeouts: 0,
583
+ # # releases: 0,
584
+ # # buries: 0,
585
+ # # kicks: 0
586
+ # # }
587
+ # # }
588
+ #
589
+ def stats
590
+ # Get configured watched tubes (expanded with environment prefix)
591
+ watched = Postburner.watched_tube_names.include?(self.tube_name)
592
+
593
+ {
594
+ id: self.id,
595
+ bkid: self.bkid,
596
+ queue: queue_name,
597
+ tube: tube_name,
598
+ watched: watched,
599
+ beanstalk: self.bk.stats.to_h.symbolize_keys,
600
+ }
601
+ end
602
+
603
+ alias_method :beanstalk_job_stats, :stats
604
+
605
+ # Returns the queue name for this job instance.
606
+ #
607
+ # Checks instance-level override first, then falls back to class-level configuration.
608
+ #
609
+ # @return [String] Queue name
610
+ #
611
+ # @example Class-level configuration
612
+ # class MyJob < Postburner::Job
613
+ # queue 'critical'
614
+ # end
615
+ # job = MyJob.create!(args: {})
616
+ # job.queue_name # => 'critical'
617
+ #
618
+ def queue_name
619
+ self.class.queue
620
+ end
621
+
622
+ # Returns the queue priority for this job instance.
623
+ #
624
+ # Checks instance-level override first, then falls back to class-level configuration.
625
+ #
626
+ # @return [Integer, nil] Priority (lower = higher priority)
627
+ #
628
+ # @example Instance-level override
629
+ # job = MyJob.create!(args: {}, queue_priority: 1500)
630
+ # job.queue_priority # => 1500
631
+ #
632
+ def queue_priority
633
+ @queue_priority || self.class.queue_priority
634
+ end
635
+
636
+ # Returns the queue TTR (time-to-run) for this job instance.
637
+ #
638
+ # Checks instance-level override first, then falls back to class-level configuration.
639
+ #
640
+ # @return [Integer, nil] TTR in seconds
641
+ #
642
+ # @example Instance-level override
643
+ # job = MyJob.create!(args: {}, queue_ttr: 600)
644
+ # job.queue_ttr # => 600
645
+ #
646
+ def queue_ttr
647
+ @queue_ttr || self.class.queue_ttr
648
+ end
649
+
650
+ def tube_name
651
+ Postburner.configuration.expand_tube_name(queue_name)
652
+ end
653
+
654
+ # Extends the job's time-to-run (TTR) in Beanstalkd.
655
+ #
656
+ # Calls touch on the Beanstalkd job, extending the TTR by the original
657
+ # TTR value. Use this during long-running operations to prevent the job
658
+ # from timing out.
659
+ #
660
+ # @return [void]
661
+ #
662
+ # @note Does nothing if job has no bkid (e.g., in test mode)
663
+ #
664
+ # @example Process large file line by line
665
+ # def perform(args)
666
+ # file = File.find(args['file_id'])
667
+ # file.each_line do |line|
668
+ # # ... process line ...
669
+ # extend! # Extend TTR to prevent timeout
670
+ # end
671
+ # end
672
+ #
673
+ # @see #bk
674
+ #
675
+ def extend!
676
+ return unless self.bk
677
+ begin
678
+ self.bk.touch
679
+ rescue Beaneater::NotConnected => e
680
+ self.bk!.touch
681
+ end
682
+ end
683
+
684
+ # Tracks an exception in the job's errata array.
685
+ #
686
+ # Appends exception details to the in-memory errata array with timestamp,
687
+ # class, message, and backtrace. Does NOT persist to database immediately.
688
+ # Use {#log_exception!} to persist immediately.
689
+ #
690
+ # @param exception [Exception] The exception to track
691
+ #
692
+ # @return [Array<Array>] Updated errata array
693
+ #
694
+ # @example
695
+ # begin
696
+ # # ... risky operation ...
697
+ # rescue => e
698
+ # log_exception(e)
699
+ # raise
700
+ # end
701
+ #
702
+ # @see #log_exception!
703
+ #
203
704
  def log_exception(exception)
204
705
  self.errata << [
205
706
  Time.zone.now,
@@ -212,11 +713,53 @@ module Postburner
212
713
  ]
213
714
  end
214
715
 
716
+ # Tracks an exception and immediately persists to database.
717
+ #
718
+ # Calls {#log_exception} to append exception details, then persists
719
+ # both errata and error_count to database via {#persist_metadata!}.
720
+ #
721
+ # @param exception [Exception] The exception to track
722
+ #
723
+ # @return [void]
724
+ #
725
+ # @example
726
+ # begin
727
+ # # ... risky operation ...
728
+ # rescue => e
729
+ # log_exception!(e)
730
+ # raise
731
+ # end
732
+ #
733
+ # @see #log_exception
734
+ #
215
735
  def log_exception!(exception)
216
736
  self.log_exception(exception)
217
- self.update_column :errata, self.errata
737
+ self.persist_metadata!
218
738
  end
219
739
 
740
+ # Appends a log message to the job's logs array.
741
+ #
742
+ # Adds timestamped log entry to in-memory logs array with bkid, level,
743
+ # message, and elapsed time. Does NOT persist to database immediately.
744
+ # Use {#log!} to persist immediately.
745
+ #
746
+ # @param message [String] Log message to append
747
+ # @param options [Hash] Log options
748
+ # @option options [Symbol] :level Log level (:debug, :info, :warning, :error) - defaults to :info
749
+ #
750
+ # @return [Array<Array>] Updated logs array
751
+ #
752
+ # @note Invalid log levels are coerced to :error
753
+ #
754
+ # @example
755
+ # log "Processing started"
756
+ # log "Warning: rate limit approaching", level: :warning
757
+ # log "Critical error occurred", level: :error
758
+ #
759
+ # @see #log!
760
+ # @see #elapsed_ms
761
+ # @see LOG_LEVELS
762
+ #
220
763
  def log(message, options={})
221
764
  options[:level] ||= :info
222
765
  options[:level] = :error unless LOG_LEVELS.member?(options[:level])
@@ -232,26 +775,131 @@ module Postburner
232
775
  ]
233
776
  end
234
777
 
235
- # ms from attempting_at
778
+ # Returns elapsed time in milliseconds since job execution started.
779
+ #
780
+ # Calculates milliseconds between attempting_at and current time.
781
+ # Used to track how long a job has been executing.
782
+ #
783
+ # @return [Float, nil] Milliseconds since attempting_at, or nil if not yet attempting
784
+ #
785
+ # @example
786
+ # elapsed_ms # => 1234.567 (job has been running for ~1.2 seconds)
787
+ #
788
+ # @see #log
236
789
  #
237
790
  def elapsed_ms
238
791
  return unless self.attempting_at
239
792
  (Time.zone.now - self.attempting_at) * 1000
240
793
  end
241
794
 
795
+ # Appends a log message and immediately persists to database.
796
+ #
797
+ # Calls {#log} to append message, then persists logs array to database.
798
+ # Use this for important log messages that should be saved immediately.
799
+ #
800
+ # @param message [String] Log message to append
801
+ # @param options [Hash] Log options
802
+ # @option options [Symbol] :level Log level (:debug, :info, :warning, :error)
803
+ #
804
+ # @return [void]
805
+ #
806
+ # @example
807
+ # log! "Job started"
808
+ # log! "Payment processed successfully", level: :info
809
+ # log! "Retrying failed request", level: :warning
810
+ #
811
+ # @see #log
812
+ #
242
813
  def log!(message, options={})
243
814
  self.log(message, options)
244
815
  self.update_column :logs, self.logs
245
816
  end
246
817
 
818
+ # Returns the intended execution time for the job.
819
+ #
820
+ # Returns run_at if scheduled, otherwise returns queued_at.
821
+ # Used for calculating lag (delay between intended and actual execution).
822
+ #
823
+ # @return [Time] Intended execution timestamp
824
+ #
825
+ # @example
826
+ # job.queue!(delay: 1.hour)
827
+ # job.intended_at # => 1 hour from now (run_at)
828
+ #
829
+ # job.queue!
830
+ # job.intended_at # => now (queued_at)
831
+ #
832
+ # @see #lag
833
+ #
247
834
  def intended_at
248
835
  self.run_at ? self.run_at : self.queued_at
249
836
  end
250
837
 
838
+ # Exception raised when attempting to queue a job that has already been processed.
839
+ #
840
+ # @example
841
+ # job.queue!
842
+ # # ... job completes ...
843
+ # job.queue! # => raises AlreadyProcessed
844
+ #
251
845
  class AlreadyProcessed < StandardError; end
252
846
 
847
+ # Exception raised when a job is executed before its scheduled run_at time.
848
+ #
849
+ # In production (Queue/NiceQueue), this is handled by re-inserting with delay.
850
+ # In test mode (TestQueue), this exception is raised to force explicit time management.
851
+ #
852
+ # @example
853
+ # Postburner.inline_test_strategy!
854
+ # job.queue!(delay: 1.hour)
855
+ # # => raises PrematurePerform: "Job scheduled for ..."
856
+ #
857
+ # @see Postburner::TestQueue
858
+ # @see Postburner::ImmediateTestQueue
859
+ #
860
+ class PrematurePerform < StandardError; end
861
+
862
+ # Exception raised when a queue strategy returns an invalid response format.
863
+ #
864
+ # Queue strategies must return a Hash with an `:id` key when inserting jobs.
865
+ # The `:id` value represents the Beanstalkd job ID (can be nil for test strategies).
866
+ # This exception is raised in {#insert!} when a strategy returns a malformed response.
867
+ #
868
+ # @example Valid response formats
869
+ # { id: 12345, status: "INSERTED" } # Production with Beanstalkd ID
870
+ # { id: nil, status: "INLINE" } # Test strategy without Beanstalkd
871
+ #
872
+ # @example Invalid response (raises MalformedResponse)
873
+ # "success" # Not a Hash
874
+ # { status: "INSERTED" } # Missing :id key
875
+ # { "id" => 12345 } # String key instead of symbol
876
+ #
877
+ # @example Handling in custom strategy
878
+ # class MyStrategy
879
+ # def self.insert(job, options = {})
880
+ # # Must return Hash with :id key (symbol)
881
+ # { id: nil, status: "QUEUED" }
882
+ # end
883
+ # end
884
+ #
885
+ # @see #insert!
886
+ # @see Postburner.queue_strategy
887
+ #
888
+ class MalformedResponse < StandardError; end
889
+
253
890
  private
254
891
 
892
+ # Persists job metadata (errata, logs, and counts) to database.
893
+ #
894
+ # Updates errata, error_count, logs, and log_count columns, plus any
895
+ # additional data passed in. Used internally for atomic metadata updates.
896
+ #
897
+ # @param data [Hash] Additional columns to update
898
+ #
899
+ # @return [void]
900
+ #
901
+ # @api private
902
+ #
255
903
  def persist_metadata!(data={})
256
904
  self.update_columns({
257
905
  errata: self.errata,
@@ -261,35 +909,59 @@ module Postburner
261
909
  }.merge(data))
262
910
  end
263
911
 
912
+ # After-save-commit callback that inserts job into queue if flagged.
913
+ #
914
+ # Checks {#will_insert?} and calls {#insert!} if true. Triggered by
915
+ # after_save_commit callback after {#queue!} saves the record.
916
+ #
917
+ # @return [void]
918
+ #
919
+ # @api private
920
+ #
264
921
  def insert_if_queued!
922
+ #debugger
265
923
  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
924
+ insert!(@_insert_options)
269
925
  end
270
926
 
927
+ # Inserts job into queue via current queue strategy.
928
+ #
929
+ # Delegates to Postburner.queue_strategy.insert which handles actual
930
+ # queueing (Beanstalkd in production, inline execution in test mode).
931
+ # Updates bkid if the strategy returns a Beanstalkd job ID.
932
+ #
933
+ # @param options [Hash] Queue options (delay, pri, ttr, etc.)
934
+ #
935
+ # @return [Hash, nil] Queue strategy response
936
+ #
937
+ # @api private
938
+ #
271
939
  def insert!(options={})
272
- response = nil
273
-
274
- _run_insert_callbacks do
275
- Job.transaction do
276
- response = Backburner::Worker.enqueue(
277
- self.class,
278
- self.id,
279
- options
280
- )
281
-
282
- persist_metadata!(
283
- bkid: response[:id],
284
- )
285
- end
940
+ response = Postburner.queue_strategy.insert(self, options)
941
+ #debugger
942
+
943
+ # Response must be a hash with an :id key (value can be nil)
944
+ # Backburner returns symbol keys
945
+ unless response.is_a?(Hash) && response.key?(:id)
946
+ raise MalformedResponse, "Missing :id key in response: #{response.inspect}"
286
947
  end
287
948
 
288
- self.log("QUEUED: #{response}")
949
+ persist_metadata!(bkid: response[:id])
950
+
951
+ self.log("QUEUED: #{response}") if response
289
952
 
290
953
  response
291
954
  end
292
955
 
956
+ # Records an attempt and calculates execution lag.
957
+ #
958
+ # Appends current time to attempts array, sets attempting_at on first attempt,
959
+ # and calculates lag (delay between intended and actual execution time).
960
+ #
961
+ # @return [Time] Current timestamp
962
+ #
963
+ # @api private
964
+ #
293
965
  def attempting
294
966
  now = Time.zone.now
295
967
  self.attempts << now
@@ -298,10 +970,27 @@ module Postburner
298
970
  now
299
971
  end
300
972
 
973
+ # Before-validation callback that ensures job has a unique sid.
974
+ #
975
+ # Generates a UUID for sid if not already set. Used for job identification
976
+ # independent of database ID.
977
+ #
978
+ # @return [void]
979
+ #
980
+ # @api private
981
+ #
301
982
  def ensure_sid!
302
983
  self.sid ||= SecureRandom.uuid
303
984
  end
304
985
 
986
+ # Backburner message format for the job.
987
+ #
988
+ # Returns a simple string representation used by Backburner.
989
+ #
990
+ # @return [String] Message format
991
+ #
992
+ # @api private
993
+ #
305
994
  def message
306
995
  "Job: #{self.id}"
307
996
  end