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.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Test queue strategy with strict scheduling enforcement.
5
+ #
6
+ # This strategy executes jobs inline/synchronously without Beanstalkd, making
7
+ # tests fast and predictable. It raises {Postburner::Job::PrematurePerform} if
8
+ # a job has a future run_at, forcing you to use explicit time travel in tests.
9
+ #
10
+ # @note Auto-detected when Rails.env.test? and ActiveJob adapter is :test
11
+ #
12
+ # ## When to Use TestQueue
13
+ #
14
+ # Choose this strategy for test environments where you want:
15
+ # - **Explicit time management:** Forces you to use `travel_to` for scheduled jobs
16
+ # - **Strict testing:** Catches scheduling bugs by failing loudly
17
+ # - **Synchronous execution:** Jobs run immediately on `queue!` call
18
+ # - **No Beanstalkd:** Tests run without external dependencies
19
+ # - **Predictable timing:** Full control over when jobs execute
20
+ #
21
+ # This is ideal for unit/integration tests where you want to verify job
22
+ # scheduling logic and maintain explicit control over time progression.
23
+ #
24
+ # ## Strategy Behavior
25
+ #
26
+ # - **Execution:** Synchronous/inline (no Beanstalkd required)
27
+ # - **Testing mode:** Returns true
28
+ # - **Premature execution:** Raises {Postburner::Job::PrematurePerform} exception
29
+ # - **Beanstalkd:** Not used (bkid remains nil)
30
+ # - **Time travel:** Requires explicit `travel_to` for scheduled jobs
31
+ #
32
+ # ## Usage
33
+ #
34
+ # @example Automatically activated in Rails test environment
35
+ # # In test_helper.rb or rails_helper.rb
36
+ # # TestQueue is automatically set if Rails.env.test?
37
+ # # and ActiveJob.queue_adapter == :test
38
+ #
39
+ # @example Explicitly activate TestQueue
40
+ # Postburner.inline_test_strategy!
41
+ # job = MyJob.create!(args: { user_id: 123 })
42
+ # job.queue!
43
+ # # Job executes immediately and synchronously
44
+ # assert job.reload.processed_at
45
+ #
46
+ # @example Scheduled jobs require explicit time travel
47
+ # Postburner.inline_test_strategy!
48
+ # job = MyJob.create!(args: {})
49
+ #
50
+ # # This will raise PrematurePerform
51
+ # # job.queue!(delay: 1.hour)
52
+ #
53
+ # # Use time travel instead
54
+ # job.queue!(delay: 1.hour)
55
+ # travel_to(job.run_at) do
56
+ # # Job executes within the time travel block
57
+ # end
58
+ #
59
+ # @example Testing with scheduled jobs
60
+ # test "processes payment after delay" do
61
+ # Postburner.inline_test_strategy!
62
+ # job = ProcessPayment.create!(args: { payment_id: 123 })
63
+ #
64
+ # future_time = 2.hours.from_now
65
+ # job.queue!(at: future_time)
66
+ #
67
+ # travel_to(future_time) do
68
+ # # Job executes here
69
+ # assert job.reload.processed_at
70
+ # end
71
+ # end
72
+ #
73
+ # @see ImmediateTestQueue Test strategy with automatic time travel
74
+ # @see NiceQueue Default production strategy
75
+ # @see Postburner.inline_test_strategy!
76
+ #
77
+ class TestQueue < Queue
78
+ class << self
79
+ # Returns whether this strategy is for testing.
80
+ #
81
+ # @return [Boolean] Always true for test strategies
82
+ #
83
+ def testing
84
+ true
85
+ end
86
+
87
+ # Executes job inline/synchronously without Beanstalkd.
88
+ #
89
+ # Called automatically via after_save_commit hook when {Postburner::Job#queue!}
90
+ # is invoked. Executes the job immediately in the same process. If the job
91
+ # has a future run_at, raises {Postburner::Job::PrematurePerform} to force
92
+ # explicit time management with `travel_to`.
93
+ #
94
+ # @param job [Postburner::Job] The job to execute
95
+ # @param options [Hash] Unused in test mode
96
+ #
97
+ # @return [Hash] Status hash with :status => 'INLINE', :id => nil
98
+ #
99
+ # @raise [Postburner::Job::PrematurePerform] if job has future run_at
100
+ #
101
+ # @api private
102
+ #
103
+ def insert(job, options = {})
104
+ # Execute immediately in test mode
105
+ # Will raise PrematurePerform if run_at is in the future
106
+ job.perform!(job.args)
107
+
108
+ # Return format matching Backburner response (symbol keys)
109
+ { status: 'INLINE', id: nil }
110
+ end
111
+
112
+ # Handles jobs executed before their scheduled run_at time.
113
+ #
114
+ # This strategy raises an exception with a helpful error message,
115
+ # forcing developers to use explicit `travel_to` calls in tests.
116
+ #
117
+ # @param job [Postburner::Job] The job being executed prematurely
118
+ #
119
+ # @return [void]
120
+ #
121
+ # @raise [Postburner::Job::PrematurePerform] Always raises with helpful message
122
+ #
123
+ def handle_premature_perform(job)
124
+ raise Postburner::Job::PrematurePerform, "Job scheduled for #{job.run_at} (#{((job.run_at - Time.zone.now) / 60).round(1)} minutes from now). Use `travel_to(job.run_at)` in your test, or set `Postburner.inline_immediate_test_strategy!` to execute scheduled jobs immediately."
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Provides time travel utilities for testing without requiring test context.
5
+ #
6
+ # This module wraps ActiveSupport::Testing::TimeHelpers to enable time travel
7
+ # from non-test contexts (like class methods or service objects). It creates
8
+ # a helper object extended with Rails' TimeHelpers and delegates time travel
9
+ # operations to it.
10
+ #
11
+ # @note Requires ActiveSupport::Testing::TimeHelpers (Rails testing framework)
12
+ # @note Time travel affects global time via Time.stub - use only in test environments
13
+ #
14
+ # ## Usage
15
+ #
16
+ # @example Basic time travel
17
+ # include Postburner::TimeHelpers
18
+ #
19
+ # travel_to(2.days.from_now) do
20
+ # # Code here executes as if it's 2 days in the future
21
+ # Time.zone.now # => 2 days from now
22
+ # end
23
+ #
24
+ # @example In a class method
25
+ # class MyService
26
+ # extend Postburner::TimeHelpers
27
+ #
28
+ # def self.process_scheduled_task(time)
29
+ # travel_to(time) do
30
+ # # Execute task at specified time
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # @see ActiveSupport::Testing::TimeHelpers
36
+ #
37
+ module TimeHelpers
38
+ # Travels to specified time for block execution using Rails time helpers.
39
+ #
40
+ # Creates a helper object extended with ActiveSupport::Testing::TimeHelpers
41
+ # and uses it to stub global time. This is necessary for time travel outside
42
+ # of test method contexts where TimeHelpers aren't available directly.
43
+ #
44
+ # The travel_to call stubs Time globally (using Time.stub), so all time-based
45
+ # operations within the block execute as if they're happening at the specified
46
+ # time. After the block completes, time returns to normal.
47
+ #
48
+ # @param time [Time, DateTime, ActiveSupport::TimeWithZone] The time to travel to
49
+ # @param block [Proc] Block to execute at the specified time
50
+ #
51
+ # @return [Object] The return value of the block
52
+ #
53
+ # @raise [RuntimeError] if ActiveSupport::Testing::TimeHelpers not available
54
+ #
55
+ # @example Travel to specific time
56
+ # travel_to(Time.zone.parse('2025-12-25 00:00:00')) do
57
+ # puts Time.zone.now # => 2025-12-25 00:00:00
58
+ # end
59
+ #
60
+ # @example Travel relative to now
61
+ # travel_to(1.hour.from_now) do
62
+ # # Execute code as if 1 hour has passed
63
+ # end
64
+ #
65
+ def travel_to(time, &block)
66
+ unless defined?(ActiveSupport::Testing::TimeHelpers)
67
+ raise "ActiveSupport::Testing::TimeHelpers not available. " \
68
+ "Postburner::TimeHelpers requires Rails testing helpers for time travel."
69
+ end
70
+
71
+ helper = Object.new.extend(ActiveSupport::Testing::TimeHelpers)
72
+ helper.travel_to(time, &block)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Concern for ActiveJob classes to opt-in to PostgreSQL tracking.
5
+ #
6
+ # Simply include this module in your ActiveJob class to enable full audit
7
+ # trail persistence in PostgreSQL. Without this, jobs execute as "default" jobs
8
+ # (Beanstalkd only, no PostgreSQL overhead).
9
+ #
10
+ # @example Opt-in to tracking
11
+ # class ProcessPayment < ApplicationJob
12
+ # include Postburner::Tracked # ← Enables PostgreSQL audit trail
13
+ #
14
+ # def perform(payment_id)
15
+ # log "Processing payment #{payment_id}"
16
+ # # ... work ...
17
+ # log! "Payment processed successfully"
18
+ # end
19
+ # end
20
+ #
21
+ # @example Without tracking (default mode)
22
+ # class SendEmail < ApplicationJob
23
+ # # No Postburner::Tracked - executes as default job
24
+ #
25
+ # def perform(email)
26
+ # # Fast execution, no PostgreSQL overhead
27
+ # end
28
+ # end
29
+ #
30
+ module Tracked
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ # Include Beanstalkd configuration DSL automatically
35
+ include Postburner::Beanstalkd
36
+
37
+ # Reference to Postburner::Job during execution (set by worker)
38
+ attr_accessor :postburner_job
39
+ end
40
+
41
+ # Appends a log message to the job's audit trail.
42
+ #
43
+ # Only available for tracked jobs. Logs are stored in the
44
+ # Postburner::Job record's `logs` JSONB array.
45
+ #
46
+ # @param message [String] Log message
47
+ # @param level [Symbol] Log level (:debug, :info, :warning, :error)
48
+ #
49
+ # @return [void]
50
+ #
51
+ # @example
52
+ # def perform(user_id)
53
+ # log "Starting user processing"
54
+ # # ... work ...
55
+ # log "Completed successfully", level: :info
56
+ # end
57
+ #
58
+ def log(message, level: :info)
59
+ postburner_job&.log(message, level: level)
60
+ end
61
+
62
+ # Appends a log message and immediately persists to database.
63
+ #
64
+ # Use this for important log messages that should be saved immediately
65
+ # rather than batched with other updates.
66
+ #
67
+ # @param message [String] Log message
68
+ # @param level [Symbol] Log level (:debug, :info, :warning, :error)
69
+ #
70
+ # @return [void]
71
+ #
72
+ # @example
73
+ # def perform(payment_id)
74
+ # log! "Critical: Processing high-value payment #{payment_id}"
75
+ # end
76
+ #
77
+ def log!(message, level: :info)
78
+ postburner_job&.log!(message, level: level)
79
+ end
80
+
81
+ # Tracks an exception in the job's errata array.
82
+ #
83
+ # Appends exception details (class, message, backtrace) to the
84
+ # in-memory errata array. Does NOT persist immediately.
85
+ #
86
+ # @param exception [Exception] The exception to track
87
+ #
88
+ # @return [void]
89
+ #
90
+ # @example
91
+ # def perform(user_id)
92
+ # process_user(user_id)
93
+ # rescue => e
94
+ # log_exception(e)
95
+ # raise
96
+ # end
97
+ #
98
+ def log_exception(exception)
99
+ postburner_job&.log_exception(exception)
100
+ end
101
+
102
+ # Tracks an exception and immediately persists to database.
103
+ #
104
+ # @param exception [Exception] The exception to track
105
+ #
106
+ # @return [void]
107
+ #
108
+ # @example
109
+ # def perform(user_id)
110
+ # process_user(user_id)
111
+ # rescue CriticalError => e
112
+ # log_exception!(e)
113
+ # raise
114
+ # end
115
+ #
116
+ def log_exception!(exception)
117
+ postburner_job&.log_exception!(exception)
118
+ end
119
+
120
+ # Returns the Beanstalkd job object for direct queue operations.
121
+ #
122
+ # Provides access to the underlying Beaneater job object through the
123
+ # TrackedJob wrapper. Use this to perform Beanstalkd operations like
124
+ # touch, bury, release, etc.
125
+ #
126
+ # @return [Beaneater::Job, nil] Beanstalkd job object or nil if not available
127
+ #
128
+ # @example Extend TTR during long operation
129
+ # def perform(file_id)
130
+ # file = File.find(file_id)
131
+ # file.each_line do |line|
132
+ # # ... process line ...
133
+ # bk&.touch # Extend TTR
134
+ # end
135
+ # end
136
+ #
137
+ # @example Other Beanstalkd operations
138
+ # bk&.bury # Bury the job
139
+ # bk&.release(pri: 0) # Release with priority
140
+ # bk&.stats # Get job statistics
141
+ #
142
+ # @see #extend!
143
+ #
144
+ def bk
145
+ postburner_job&.bk
146
+ end
147
+
148
+ # Extends the job's time-to-run (TTR) in Beanstalkd.
149
+ #
150
+ # Convenience method that calls touch on the Beanstalkd job, extending
151
+ # the TTR by the original TTR value. Use this during long-running operations
152
+ # to prevent the job from timing out.
153
+ #
154
+ # @return [void]
155
+ #
156
+ # @example Process large file line by line
157
+ # def perform(file_id)
158
+ # file = File.find(file_id)
159
+ # file.each_line do |line|
160
+ # # ... process line ...
161
+ # extend! # Extend TTR to prevent timeout
162
+ # end
163
+ # end
164
+ #
165
+ # @see #bk
166
+ #
167
+ def extend!
168
+ postburner_job&.extend!
169
+ end
170
+ end
171
+ end
@@ -1,3 +1,3 @@
1
1
  module Postburner
2
- VERSION = '0.8.0'
2
+ VERSION = '1.0.0.pre.1'
3
3
  end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ module Workers
5
+ # Base worker class with shared functionality for all worker types.
6
+ #
7
+ # Provides common methods for signal handling, job execution, error handling,
8
+ # and retry logic. Subclasses implement the specific execution strategy
9
+ # (simple, forking, threads_on_fork).
10
+ #
11
+ class Base
12
+ attr_reader :config, :logger
13
+
14
+ # @param config [Postburner::Configuration] Worker configuration
15
+ #
16
+ def initialize(config)
17
+ @config = config
18
+ @logger = config.logger
19
+ @shutdown = false
20
+ setup_signal_handlers
21
+ end
22
+
23
+ # Starts the worker loop.
24
+ #
25
+ # Subclasses must implement this method to define their execution strategy.
26
+ #
27
+ # @return [void]
28
+ #
29
+ # @raise [NotImplementedError] if not implemented by subclass
30
+ #
31
+ def start
32
+ raise NotImplementedError, "Subclasses must implement #start"
33
+ end
34
+
35
+ # Initiates graceful shutdown.
36
+ #
37
+ # Sets shutdown flag to stop processing new jobs. Current jobs
38
+ # are allowed to finish.
39
+ #
40
+ # @return [void]
41
+ #
42
+ def shutdown
43
+ @shutdown = true
44
+ end
45
+
46
+ # Checks if shutdown has been requested.
47
+ #
48
+ # @return [Boolean] true if shutdown requested, false otherwise
49
+ #
50
+ def shutdown?
51
+ @shutdown
52
+ end
53
+
54
+ protected
55
+
56
+ # Sets up signal handlers for graceful shutdown.
57
+ #
58
+ # TERM and INT signals trigger graceful shutdown.
59
+ #
60
+ # @return [void]
61
+ #
62
+ def setup_signal_handlers
63
+ Signal.trap('TERM') { shutdown }
64
+ Signal.trap('INT') { shutdown }
65
+ end
66
+
67
+ # Expands queue name to full tube name with environment prefix.
68
+ #
69
+ # Delegates to Postburner::Configuration#expand_tube_name.
70
+ #
71
+ # @param queue_name [String] Base queue name
72
+ #
73
+ # @return [String] Full tube name (e.g., 'postburner.production.critical')
74
+ #
75
+ def expand_tube_name(queue_name)
76
+ config.expand_tube_name(queue_name)
77
+ end
78
+
79
+ # Executes a job from Beanstalkd.
80
+ #
81
+ # Delegates to Postburner::ActiveJob::Execution to handle default,
82
+ # tracked, and legacy job formats.
83
+ #
84
+ # @param beanstalk_job [Beaneater::Job] Job from Beanstalkd
85
+ #
86
+ # @return [void]
87
+ #
88
+ def execute_job(beanstalk_job)
89
+ logger.info "[Postburner] Executing #{beanstalk_job.class.name} #{beanstalk_job.id}"
90
+ Postburner::ActiveJob::Execution.execute(beanstalk_job.body)
91
+ logger.info "[Postburner] Deleting #{beanstalk_job.class.name} #{beanstalk_job.id} (success)"
92
+ beanstalk_job.delete
93
+ rescue => e
94
+ handle_error(beanstalk_job, e)
95
+ end
96
+
97
+ # Handles job execution errors with retry logic.
98
+ #
99
+ # Implements retry strategy:
100
+ # - Parses payload to determine job type
101
+ # - For default jobs: manages retry count, re-queues with backoff
102
+ # - For tracked jobs: buries job (Postburner::Job handles retries)
103
+ # - For legacy jobs: buries job
104
+ #
105
+ # @param beanstalk_job [Beaneater::Job] Job from Beanstalkd
106
+ # @param error [Exception] The exception that was raised
107
+ #
108
+ # @return [void]
109
+ #
110
+ def handle_error(beanstalk_job, error)
111
+ logger.error "[Postburner] Job failed: #{error.class} - #{error.message}"
112
+ logger.error error.backtrace.join("\n")
113
+
114
+ begin
115
+ payload = JSON.parse(beanstalk_job.body)
116
+
117
+ if payload['tracked'] || Postburner::ActiveJob::Payload.legacy_format?(payload)
118
+ # Tracked and legacy jobs: bury for inspection
119
+ # (Postburner::Job has its own retry logic)
120
+ logger.info "[Postburner] Burying tracked/legacy job for inspection"
121
+ beanstalk_job.bury
122
+ else
123
+ # Default job: handle retry logic
124
+ handle_default_retry(beanstalk_job, payload, error)
125
+ end
126
+ rescue => retry_error
127
+ logger.error "[Postburner] Error handling failure: #{retry_error.message}"
128
+ beanstalk_job.bury rescue nil
129
+ end
130
+ end
131
+
132
+ # Handles retry logic for default jobs.
133
+ #
134
+ # Checks ActiveJob's retry_on configuration, increments retry count,
135
+ # and re-queues with exponential backoff if retries remaining.
136
+ #
137
+ # @param beanstalk_job [Beaneater::Job] Job from Beanstalkd
138
+ # @param payload [Hash] Parsed job payload
139
+ # @param error [Exception] The exception that was raised
140
+ #
141
+ # @return [void]
142
+ #
143
+ def handle_default_retry(beanstalk_job, payload, error)
144
+ retry_count = payload['retry_count'] || 0
145
+ job_class = payload['job_class'].constantize
146
+
147
+ # Check if job class wants to retry this error
148
+ # (This is simplified - full implementation would check retry_on config)
149
+ max_retries = 5 # Default max retries
150
+
151
+ if retry_count < max_retries
152
+ # Increment retry count
153
+ payload['retry_count'] = retry_count + 1
154
+ payload['executions'] = (payload['executions'] || 0) + 1
155
+
156
+ # Calculate backoff delay (exponential: 1s, 2s, 4s, 8s, 16s...)
157
+ delay = calculate_backoff(retry_count)
158
+
159
+ # Delete old job and insert new one with updated payload
160
+ beanstalk_job.delete
161
+
162
+ Postburner.connected do |conn|
163
+ tube_name = expand_tube_name(payload['queue_name'])
164
+ conn.tubes[tube_name].put(
165
+ JSON.generate(payload),
166
+ pri: payload['priority'] || 0,
167
+ delay: delay,
168
+ ttr: 120
169
+ )
170
+ end
171
+
172
+ logger.info "[Postburner] Retrying default job #{payload['job_id']}, attempt #{retry_count + 1} in #{delay}s"
173
+ else
174
+ # Max retries exceeded
175
+ logger.error "[Postburner] Discarding default job #{payload['job_id']} after #{retry_count} retries"
176
+ beanstalk_job.delete
177
+ # TODO: Call after_discard callback if configured
178
+ end
179
+ end
180
+
181
+ # Calculates exponential backoff delay for retries.
182
+ #
183
+ # @param retry_count [Integer] Number of retries so far
184
+ #
185
+ # @return [Integer] Delay in seconds (capped at 1 hour)
186
+ #
187
+ def calculate_backoff(retry_count)
188
+ # Exponential backoff: 2^retry_count, capped at 3600 seconds (1 hour)
189
+ [2 ** retry_count, 3600].min
190
+ end
191
+
192
+ # Watches the configured queues in Beanstalkd.
193
+ #
194
+ # @param connection [Postburner::Connection] Beanstalkd connection
195
+ # @param queue_name [String, nil] Optional specific queue name to watch (watches all if nil)
196
+ #
197
+ # @return [void]
198
+ #
199
+ def watch_queues(connection, queue_name = nil)
200
+ if queue_name
201
+ tube_name = config.expand_tube_name(queue_name)
202
+ connection.beanstalk.tubes.watch!(tube_name)
203
+ else
204
+ tube_names = config.expanded_tube_names
205
+ connection.beanstalk.tubes.watch!(*tube_names)
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end