postburner 1.0.0.pre.2 → 1.0.0.pre.4

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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Concern providing Beanstalkd command methods for Postburner jobs.
5
+ #
6
+ # Provides methods for interacting with Beanstalkd queue operations such as
7
+ # deleting, kicking, and extending TTR. All methods handle connection retries
8
+ # and gracefully handle missing bkid (e.g., in test mode).
9
+ #
10
+ # @example Basic Beanstalkd operations
11
+ # class MyJob < Postburner::Job
12
+ # def perform(args)
13
+ # # ... work ...
14
+ # extend! # Extend TTR during long operation
15
+ # end
16
+ # end
17
+ #
18
+ # job.delete! # Remove from Beanstalkd
19
+ # job.kick! # Retry buried job
20
+ #
21
+ module Commands
22
+ extend ActiveSupport::Concern
23
+
24
+ # Kicks a buried job back into the ready queue in Beanstalkd.
25
+ #
26
+ # This is a Beanstalkd operation used to retry jobs that were buried due to
27
+ # repeated failures or explicit burial. Does not modify the ActiveRecord model.
28
+ #
29
+ # Automatically retries with fresh connection if Beanstalkd connection is stale.
30
+ #
31
+ # @return [void]
32
+ #
33
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
34
+ #
35
+ # @note Does nothing if job has no bkid (e.g., in test mode)
36
+ # @note Only works on buried jobs - see Beanstalkd documentation
37
+ #
38
+ # @example
39
+ # job.kick! # Moves buried job back to ready queue
40
+ #
41
+ # @see #delete!
42
+ # @see #bk
43
+ #
44
+ def kick!
45
+ return unless self.bk
46
+ begin
47
+ self.bk.kick
48
+ rescue Beaneater::NotConnected => e
49
+ self.bk!.kick
50
+ end
51
+ end
52
+
53
+ # Extends the job's time-to-run (TTR) in Beanstalkd.
54
+ #
55
+ # Calls touch on the Beanstalkd job, extending the TTR by the original
56
+ # TTR value. Use this during long-running operations to prevent the job
57
+ # from timing out.
58
+ #
59
+ # @return [void]
60
+ #
61
+ # @note Does nothing if job has no bkid (e.g., in test mode)
62
+ #
63
+ # @example Process large file line by line
64
+ # def perform(args)
65
+ # file = File.find(args['file_id'])
66
+ # file.each_line do |line|
67
+ # # ... process line ...
68
+ # extend! # Extend TTR to prevent timeout
69
+ # end
70
+ # end
71
+ #
72
+ # @see #bk
73
+ #
74
+ def extend!
75
+ return unless self.bk
76
+ begin
77
+ self.bk.touch
78
+ rescue Beaneater::NotConnected => e
79
+ self.bk!.touch
80
+ end
81
+ end
82
+
83
+ # Deletes the job from the Beanstalkd queue.
84
+ #
85
+ # This is a Beanstalkd operation that removes the job from the queue but
86
+ # does NOT destroy the ActiveRecord model. Use {#destroy} or {#remove!} to
87
+ # also update the database record.
88
+ #
89
+ # Automatically retries with fresh connection if Beanstalkd connection is stale.
90
+ # Called automatically by before_destroy callback when using {#destroy}.
91
+ #
92
+ # @return [void]
93
+ #
94
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails after retry
95
+ #
96
+ # @note Does nothing if job has no bkid (e.g., in test mode)
97
+ # @note Does not modify ActiveRecord model - only affects Beanstalkd
98
+ #
99
+ # @example
100
+ # job.delete! # Removes from Beanstalkd queue only
101
+ # job.reload # Job still exists in database
102
+ #
103
+ # @see #remove!
104
+ # @see #destroy
105
+ # @see #bk
106
+ #
107
+ def delete!
108
+ return unless self.bk
109
+ begin
110
+ self.bk.delete
111
+ rescue Beaneater::NotConnected => e
112
+ self.bk!.delete
113
+ end
114
+ end
115
+
116
+ # Soft-deletes the job by removing from Beanstalkd and setting removed_at timestamp.
117
+ #
118
+ # Unlike {#destroy}, this preserves the job record in the database for audit trails
119
+ # while removing it from the Beanstalkd queue and marking it as removed.
120
+ #
121
+ # @return [void]
122
+ #
123
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
124
+ #
125
+ # @note Idempotent - does nothing if already removed
126
+ # @note Does not destroy ActiveRecord model - only soft deletes
127
+ #
128
+ # @example
129
+ # job.remove!
130
+ # job.removed_at # => 2025-10-31 12:34:56 UTC
131
+ # job.persisted? # => true (still in database)
132
+ #
133
+ # @see #delete!
134
+ # @see #destroy
135
+ #
136
+ def remove!
137
+ return if self.removed_at
138
+ self.delete!
139
+ self.update_column(:removed_at, Time.zone.now)
140
+ end
141
+
142
+ end
143
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Concern providing job execution methods for Postburner jobs.
5
+ #
6
+ # Handles the full job execution lifecycle including validation, callbacks,
7
+ # timing tracking, error handling, and state management. Provides both the
8
+ # class-level entry point for workers and the instance-level execution logic.
9
+ #
10
+ # @example Direct execution
11
+ # Postburner::Job.perform(job.id)
12
+ #
13
+ # @example Instance execution
14
+ # job.perform!(job.args)
15
+ #
16
+ module Execution
17
+ extend ActiveSupport::Concern
18
+
19
+ class_methods do
20
+ # Executes a job by ID, delegating to the current queue strategy.
21
+ #
22
+ # Loads the job from database by ID and delegates execution to the current
23
+ # queue strategy's {handle_perform!} method. This provides a unified API
24
+ # for job execution regardless of strategy (async, test, or null).
25
+ #
26
+ # Called automatically by Backburner workers in production. Can also be
27
+ # called manually for test/null strategies to trigger execution.
28
+ #
29
+ # @param id [Integer] Job ID to execute
30
+ # @param _ [Hash] Unused Backburner metadata parameter
31
+ #
32
+ # @return [void]
33
+ #
34
+ # @example Backburner automatic execution (production)
35
+ # # Jobs execute in tube: backburner.worker.queue.backburner-jobs
36
+ # # Backburner calls: Postburner::Job.perform(job_id)
37
+ #
38
+ # @example Manual execution with NullQueue
39
+ # Postburner.null_strategy!
40
+ # job = MyJob.create!(args: {})
41
+ # job.queue!(delay: 1.hour)
42
+ # Postburner::Job.perform(job.id) # Time travels and executes
43
+ #
44
+ # @note Strategy-aware: delegates to Postburner.queue_strategy.handle_perform!
45
+ # @note For NullQueue, automatically handles time travel for scheduled jobs
46
+ #
47
+ # @see #perform!
48
+ # @see Queue.handle_perform!
49
+ # @see NullQueue.handle_perform!
50
+ #
51
+ def perform(id, _={})
52
+ job = nil
53
+ begin
54
+ job = self.find(id)
55
+ rescue ActiveRecord::RecordNotFound => e
56
+ Rails.logger.warn <<-MSG
57
+ [Postburner::Job] [#{id}] Not Found.
58
+ MSG
59
+ end
60
+ #job.perform!(job.args)
61
+ Postburner.queue_strategy.handle_perform!(job)
62
+ end
63
+ end
64
+
65
+ # Executes the job with full lifecycle management and error handling.
66
+ #
67
+ # This is the main execution method called by Backburner workers or test strategies.
68
+ # Performs validation checks, executes callbacks, calls the subclass {#perform} method,
69
+ # tracks timing and statistics, and handles errors.
70
+ #
71
+ # Execution flow:
72
+ # 1. Runs attempt callbacks (fires on every retry)
73
+ # 2. Updates attempting metadata (attempting_at, attempts, lag)
74
+ # 3. Validates job state (queued, not processed, not removed, not premature)
75
+ # 4. Runs processing callbacks
76
+ # 5. Calls subclass {#perform} method
77
+ # 6. Runs processed callbacks (only on success)
78
+ # 7. Updates completion metadata (processed_at, duration)
79
+ # 8. Logs and tracks any exceptions
80
+ #
81
+ # @param args [Hash] Arguments to pass to {#perform} method
82
+ #
83
+ # @return [void]
84
+ #
85
+ # @raise [Exception] Any exception raised by {#perform} is logged and re-raised
86
+ #
87
+ # @note Does not execute if queued_at is nil, in the future, already processed, or removed
88
+ # @note Premature execution (before run_at) is delegated to queue strategy
89
+ #
90
+ # @see #perform
91
+ # @see Postburner.queue_strategy
92
+ # @see Callbacks
93
+ #
94
+ def perform!(args={})
95
+ run_callbacks :attempt do
96
+ self.attempting
97
+
98
+ self.update_columns(
99
+ attempting_at: self.attempting_at,
100
+ attempts: self.attempts,
101
+ attempt_count: self.attempts.length,
102
+ lag: self.lag,
103
+ processing_at: Time.zone.now,
104
+ )
105
+
106
+ begin
107
+ if self.queued_at.nil?
108
+ self.log! "Not Queued", level: :error
109
+ return
110
+ end
111
+
112
+ if self.queued_at > Time.zone.now
113
+ self.log! "Future Queued", level: :error
114
+ return
115
+ end
116
+
117
+ if self.processed_at.present?
118
+ self.log! "Already Processed", level: :error
119
+ self.delete!
120
+ return
121
+ end
122
+
123
+ if self.removed_at.present?
124
+ self.log! "Removed", level: :error
125
+ return
126
+ end
127
+
128
+ if self.run_at && self.run_at.to_i > Time.zone.now.to_i
129
+ Postburner.queue_strategy.handle_premature_perform(self)
130
+ return
131
+ end
132
+
133
+ self.log!("START (bkid #{self.bkid})")
134
+
135
+ run_callbacks :processing do
136
+ begin
137
+ self.perform(args)
138
+ rescue Exception => exception
139
+ self.persist_metadata!
140
+ self.log! '[Postburner] Exception raised during perform prevented completion.'
141
+ raise exception
142
+ end
143
+ end
144
+
145
+ self.log!("DONE (bkid #{self.bkid})")
146
+
147
+ begin
148
+ now = Time.zone.now
149
+ _duration = (now - self.processing_at) * 1000 rescue nil
150
+
151
+ run_callbacks :processed do
152
+ persist_metadata!(
153
+ processed_at: now,
154
+ duration: _duration,
155
+ )
156
+ end
157
+ rescue Exception => e
158
+ self.log_exception!(e)
159
+ self.log! '[Postburner] Could not set data after processing.'
160
+ # TODO README doesn't retry if Postburner is to blame
161
+ end
162
+
163
+ rescue Exception => exception
164
+ self.log_exception!(exception)
165
+ raise exception
166
+ end
167
+ end # run_callbacks :attempt
168
+
169
+ end
170
+
171
+ private
172
+
173
+ # Records an attempt and calculates execution lag.
174
+ #
175
+ # Appends current time to attempts array, sets attempting_at on first attempt,
176
+ # and calculates lag (delay between intended and actual execution time).
177
+ #
178
+ # @return [Time] Current timestamp
179
+ #
180
+ # @api private
181
+ #
182
+ def attempting
183
+ now = Time.zone.now
184
+ self.attempts << now
185
+ self.attempting_at ||= now
186
+ self.lag ||= (self.attempting_at - self.intended_at) * 1000 rescue nil
187
+ now
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Concern providing queue insertion methods for Postburner jobs.
5
+ #
6
+ # Handles enqueuing jobs to Beanstalkd (or test strategies), including
7
+ # scheduling, re-queueing, and the internal insertion mechanics via
8
+ # after_save_commit callbacks.
9
+ #
10
+ # @example Basic queueing
11
+ # class MyJob < Postburner::Job
12
+ # def perform(args)
13
+ # # ... work ...
14
+ # end
15
+ # end
16
+ #
17
+ # job = MyJob.create!(args: { foo: 'bar' })
18
+ # job.queue!
19
+ #
20
+ # @example Scheduled queueing
21
+ # job.queue!(delay: 1.hour)
22
+ # job.queue!(at: 2.days.from_now)
23
+ #
24
+ module Insertion
25
+ extend ActiveSupport::Concern
26
+
27
+ # Enqueues the job to Beanstalkd for processing.
28
+ #
29
+ # Sets queued_at timestamp and optionally run_at for scheduled execution.
30
+ # Triggers enqueue callbacks and inserts job into Beanstalkd via after_save_commit hook.
31
+ # In test mode, executes immediately instead of queueing to Beanstalkd.
32
+ #
33
+ # @param options [Hash] Queue options
34
+ # @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
35
+ # @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
36
+ # @option options [Integer] :pri Beanstalkd priority (lower = higher priority)
37
+ # @option options [Integer] :ttr Time-to-run in seconds before job times out
38
+ #
39
+ # @return [void]
40
+ #
41
+ # @raise [ActiveRecord::RecordInvalid] if job is not valid
42
+ # @raise [AlreadyProcessed] if job was already processed
43
+ #
44
+ # @example Queue immediately
45
+ # job.queue!
46
+ #
47
+ # @example Queue with delay
48
+ # job.queue!(delay: 1.hour)
49
+ #
50
+ # @example Queue at specific time
51
+ # job.queue!(at: Time.zone.now + 2.days)
52
+ #
53
+ # @example Queue with priority
54
+ # job.queue!(pri: 0, delay: 30.minutes)
55
+ #
56
+ # @see #requeue!
57
+ # @see Postburner.queue_strategy
58
+ #
59
+ def queue!(options={})
60
+ return if self.queued_at.present? && self.bkid.present?
61
+ raise ActiveRecord::RecordInvalid, "Can't queue unless valid." unless self.valid?
62
+ raise AlreadyProcessed, "Processed at #{self.processed_at}" if self.processed_at
63
+
64
+ at = options.delete(:at)
65
+ now = Time.zone.now
66
+
67
+ self.queued_at = now
68
+ self.run_at = case
69
+ when at.present?
70
+ # this is rudimentary, add error handling
71
+ options[:delay] ||= at.to_i - now.to_i
72
+ at
73
+ when options[:delay].present?
74
+ now + options[:delay].seconds
75
+ end
76
+
77
+ @_insert_options = options
78
+
79
+ run_callbacks :enqueue do
80
+ self.save!
81
+ end
82
+ end
83
+
84
+ # Re-queues an existing job by removing it from Beanstalkd and queueing again.
85
+ #
86
+ # Calls {#delete!} to remove from Beanstalkd, resets queuing metadata,
87
+ # then calls {#queue!} with new options.
88
+ #
89
+ # @param options [Hash] Queue options (same as {#queue!})
90
+ # @option options [Time, ActiveSupport::Duration] :at Absolute time to run the job
91
+ # @option options [Integer, ActiveSupport::Duration] :delay Seconds to delay execution
92
+ # @option options [Integer] :pri Beanstalkd priority
93
+ # @option options [Integer] :ttr Time-to-run in seconds
94
+ #
95
+ # @return [void]
96
+ #
97
+ # @raise [ActiveRecord::RecordInvalid] if job is not valid
98
+ # @raise [Beaneater::NotConnected] if Beanstalkd connection fails
99
+ #
100
+ # @example Requeue with different delay
101
+ # job.requeue!(delay: 5.minutes)
102
+ #
103
+ # @see #queue!
104
+ # @see #delete!
105
+ #
106
+ def requeue!(options={})
107
+ self.delete!
108
+ self.bkid, self.queued_at = nil, nil
109
+
110
+ self.queue! options
111
+ end
112
+
113
+ # Checks if job is flagged for insertion into Beanstalkd.
114
+ #
115
+ # Set internally by {#queue!} to trigger insertion via after_save_commit hook.
116
+ #
117
+ # @return [Boolean] true if job will be inserted on save
118
+ # @api private
119
+ #
120
+ def will_insert?
121
+ @_insert_options.is_a? Hash
122
+ end
123
+
124
+ private
125
+
126
+ # After-save-commit callback that inserts job into queue if flagged.
127
+ #
128
+ # Checks {#will_insert?} and calls {#insert!} if true. Triggered by
129
+ # after_save_commit callback after {#queue!} saves the record.
130
+ #
131
+ # @return [void]
132
+ #
133
+ # @api private
134
+ #
135
+ def insert_if_queued!
136
+ #debugger
137
+ return unless self.will_insert?
138
+ insert!(@_insert_options)
139
+ end
140
+
141
+ # Inserts job into queue via current queue strategy.
142
+ #
143
+ # Delegates to Postburner.queue_strategy.insert which handles actual
144
+ # queueing (Beanstalkd in production, inline execution in test mode).
145
+ # Updates bkid if the strategy returns a Beanstalkd job ID.
146
+ #
147
+ # @param options [Hash] Queue options (delay, pri, ttr, etc.)
148
+ #
149
+ # @return [Hash, nil] Queue strategy response
150
+ #
151
+ # @api private
152
+ #
153
+ def insert!(options={})
154
+ response = Postburner.queue_strategy.insert(self, options)
155
+ #debugger
156
+
157
+ # Response must be a hash with an :id key (value can be nil)
158
+ # Backburner returns symbol keys
159
+ unless response.is_a?(Hash) && response.key?(:id)
160
+ raise MalformedResponse, "Missing :id key in response: #{response.inspect}"
161
+ end
162
+
163
+ persist_metadata!(bkid: response[:id])
164
+
165
+ self.log("QUEUED: #{response}") if response
166
+
167
+ response
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Concern providing logging and exception tracking for Postburner jobs.
5
+ #
6
+ # Provides methods for adding timestamped log entries and tracking exceptions
7
+ # in the job's PostgreSQL audit trail. All logs are stored in JSONB arrays
8
+ # with timestamps, levels, and elapsed time tracking.
9
+ #
10
+ # @example Basic logging
11
+ # class MyJob < Postburner::Job
12
+ # def perform(args)
13
+ # log "Starting processing"
14
+ # # ... work ...
15
+ # log! "Critical checkpoint reached"
16
+ # end
17
+ # end
18
+ #
19
+ # @example Exception tracking
20
+ # class MyJob < Postburner::Job
21
+ # def perform(args)
22
+ # process_data(args)
23
+ # rescue => e
24
+ # log_exception!(e)
25
+ # raise
26
+ # end
27
+ # end
28
+ #
29
+ module Logging
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ # Valid log levels in order of severity
34
+ LOG_LEVELS = [
35
+ :debug,
36
+ :info,
37
+ :warning,
38
+ :error
39
+ ].freeze
40
+ end
41
+
42
+ # Tracks an exception in the job's errata array.
43
+ #
44
+ # Appends exception details to the in-memory errata array with timestamp,
45
+ # class, message, and backtrace. Does NOT persist to database immediately.
46
+ # Use {#log_exception!} to persist immediately.
47
+ #
48
+ # @param exception [Exception] The exception to track
49
+ #
50
+ # @return [Array<Array>] Updated errata array
51
+ #
52
+ # @example
53
+ # begin
54
+ # # ... risky operation ...
55
+ # rescue => e
56
+ # log_exception(e)
57
+ # raise
58
+ # end
59
+ #
60
+ # @see #log_exception!
61
+ #
62
+ def log_exception(exception)
63
+ self.errata << [
64
+ Time.zone.now,
65
+ {
66
+ bkid: self.bkid,
67
+ class: exception.class,
68
+ message: exception.message,
69
+ backtrace: exception.backtrace,
70
+ }
71
+ ]
72
+ end
73
+
74
+ # Tracks an exception and immediately persists to database.
75
+ #
76
+ # Calls {#log_exception} to append exception details, then persists
77
+ # both errata and error_count to database via {#persist_metadata!}.
78
+ #
79
+ # @param exception [Exception] The exception to track
80
+ #
81
+ # @return [void]
82
+ #
83
+ # @example
84
+ # begin
85
+ # # ... risky operation ...
86
+ # rescue => e
87
+ # log_exception!(e)
88
+ # raise
89
+ # end
90
+ #
91
+ # @see #log_exception
92
+ #
93
+ def log_exception!(exception)
94
+ self.log_exception(exception)
95
+ self.persist_metadata!
96
+ end
97
+
98
+ # Appends a log message to the job's logs array.
99
+ #
100
+ # Adds timestamped log entry to in-memory logs array with bkid, level,
101
+ # message, and elapsed time. Does NOT persist to database immediately.
102
+ # Use {#log!} to persist immediately.
103
+ #
104
+ # @param message [String] Log message to append
105
+ # @param options [Hash] Log options
106
+ # @option options [Symbol] :level Log level (:debug, :info, :warning, :error) - defaults to :info
107
+ #
108
+ # @return [Array<Array>] Updated logs array
109
+ #
110
+ # @note Invalid log levels are coerced to :error
111
+ #
112
+ # @example
113
+ # log "Processing started"
114
+ # log "Warning: rate limit approaching", level: :warning
115
+ # log "Critical error occurred", level: :error
116
+ #
117
+ # @see #log!
118
+ # @see #elapsed_ms
119
+ # @see LOG_LEVELS
120
+ #
121
+ def log(message, options={})
122
+ options[:level] ||= :info
123
+ options[:level] = :error unless LOG_LEVELS.member?(options[:level])
124
+
125
+ self.logs << [
126
+ Time.zone.now, # time
127
+ {
128
+ bkid: self.bkid,
129
+ level: options[:level], # level
130
+ message: message, # message
131
+ elapsed: self.elapsed_ms, # ms from start
132
+ }
133
+ ]
134
+ end
135
+
136
+ # Appends a log message and immediately persists to database.
137
+ #
138
+ # Calls {#log} to append message, then persists logs array to database.
139
+ # Use this for important log messages that should be saved immediately.
140
+ #
141
+ # @param message [String] Log message to append
142
+ # @param options [Hash] Log options
143
+ # @option options [Symbol] :level Log level (:debug, :info, :warning, :error)
144
+ #
145
+ # @return [void]
146
+ #
147
+ # @example
148
+ # log! "Job started"
149
+ # log! "Payment processed successfully", level: :info
150
+ # log! "Retrying failed request", level: :warning
151
+ #
152
+ # @see #log
153
+ #
154
+ def log!(message, options={})
155
+ self.log(message, options)
156
+ self.update_column :logs, self.logs
157
+ end
158
+
159
+ private
160
+
161
+ # Persists job metadata (errata, logs, and counts) to database.
162
+ #
163
+ # Updates errata, error_count, logs, and log_count columns, plus any
164
+ # additional data passed in. Used internally for atomic metadata updates.
165
+ #
166
+ # @param data [Hash] Additional columns to update
167
+ #
168
+ # @return [void]
169
+ #
170
+ # @api private
171
+ #
172
+ def persist_metadata!(data={})
173
+ self.update_columns({
174
+ errata: self.errata,
175
+ error_count: self.errata.length,
176
+ logs: self.logs,
177
+ log_count: self.logs.length,
178
+ }.merge(data))
179
+ end
180
+ end
181
+ end