postburner 0.9.0.rc.1 → 1.0.0.pre.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1082 -507
- data/app/models/postburner/job.rb +163 -39
- data/app/models/postburner/tracked_job.rb +83 -0
- data/bin/postburner +91 -0
- data/bin/rails +14 -0
- data/config/environment.rb +3 -0
- data/config/postburner.yml +22 -0
- data/config/postburner.yml.example +142 -0
- data/lib/generators/postburner/install/install_generator.rb +10 -0
- data/lib/generators/postburner/install/templates/config/postburner.yml +142 -0
- data/lib/postburner/active_job/adapter.rb +176 -0
- data/lib/postburner/active_job/execution.rb +109 -0
- data/lib/postburner/active_job/payload.rb +157 -0
- data/lib/postburner/beanstalkd.rb +97 -0
- data/lib/postburner/configuration.rb +202 -0
- data/lib/postburner/connection.rb +113 -0
- data/lib/postburner/engine.rb +1 -1
- data/lib/postburner/queue_config.rb +151 -0
- data/lib/postburner/strategies/queue.rb +20 -6
- data/lib/postburner/tracked.rb +171 -0
- data/lib/postburner/version.rb +1 -1
- data/lib/postburner/workers/base.rb +210 -0
- data/lib/postburner/workers/worker.rb +480 -0
- data/lib/postburner.rb +78 -7
- metadata +44 -11
|
@@ -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
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module Postburner
|
|
6
|
+
module Workers
|
|
7
|
+
# Puma-style worker with configurable forks and threads per queue.
|
|
8
|
+
#
|
|
9
|
+
# This is the universal Postburner worker that scales from development
|
|
10
|
+
# to production using forks and threads configuration. Just like Puma:
|
|
11
|
+
# - **0 forks** = Single process with thread pool
|
|
12
|
+
# - **1+ forks** = Multiple processes (forks) with thread pools
|
|
13
|
+
#
|
|
14
|
+
# ## Architecture
|
|
15
|
+
#
|
|
16
|
+
# ### Single Process Mode (forks: 0)
|
|
17
|
+
# ```
|
|
18
|
+
# Main Process
|
|
19
|
+
# ├─ Queue 'default' Thread Pool (10 threads)
|
|
20
|
+
# ├─ Queue 'critical' Thread Pool (1 thread)
|
|
21
|
+
# └─ Queue 'mailers' Thread Pool (5 threads)
|
|
22
|
+
# ```
|
|
23
|
+
#
|
|
24
|
+
# ### Multi-Process Mode (forks: 1+)
|
|
25
|
+
# ```
|
|
26
|
+
# Parent Process
|
|
27
|
+
# ├─ Fork 0 (queue: critical)
|
|
28
|
+
# │ └─ Thread 1
|
|
29
|
+
# ├─ Fork 0 (queue: default)
|
|
30
|
+
# │ ├─ Thread 1-10
|
|
31
|
+
# ├─ Fork 1 (queue: default) # Puma-style: multiple forks of same queue
|
|
32
|
+
# │ ├─ Thread 1-10
|
|
33
|
+
# ├─ Fork 2 (queue: default)
|
|
34
|
+
# │ └─ Thread 1-10
|
|
35
|
+
# ├─ Fork 3 (queue: default)
|
|
36
|
+
# │ └─ Thread 1-10
|
|
37
|
+
# │ └─ Total: 40 concurrent jobs for 'default' (4 forks × 10 threads)
|
|
38
|
+
# └─ Fork 0 (queue: mailers)
|
|
39
|
+
# └─ Thread 1-5
|
|
40
|
+
# ```
|
|
41
|
+
#
|
|
42
|
+
# ## Scaling Strategy
|
|
43
|
+
#
|
|
44
|
+
# **Development:**
|
|
45
|
+
# ```yaml
|
|
46
|
+
# default_forks: 0
|
|
47
|
+
# default_threads: 1
|
|
48
|
+
# ```
|
|
49
|
+
# Single-threaded, single-process (simplest debugging)
|
|
50
|
+
#
|
|
51
|
+
# **Staging:**
|
|
52
|
+
# ```yaml
|
|
53
|
+
# default_forks: 0
|
|
54
|
+
# default_threads: 10
|
|
55
|
+
# ```
|
|
56
|
+
# Multi-threaded, single-process (moderate concurrency)
|
|
57
|
+
#
|
|
58
|
+
# **Production:**
|
|
59
|
+
# ```yaml
|
|
60
|
+
# default_forks: 4
|
|
61
|
+
# default_threads: 10
|
|
62
|
+
# ```
|
|
63
|
+
# 4 processes × 10 threads = 40 concurrent jobs per queue
|
|
64
|
+
#
|
|
65
|
+
# ## Configuration
|
|
66
|
+
#
|
|
67
|
+
# @example Development (single-threaded)
|
|
68
|
+
# development:
|
|
69
|
+
# default_forks: 0
|
|
70
|
+
# default_threads: 1
|
|
71
|
+
# queues:
|
|
72
|
+
# default: {}
|
|
73
|
+
# mailers: {}
|
|
74
|
+
#
|
|
75
|
+
# @example Staging (multi-threaded, single process)
|
|
76
|
+
# staging:
|
|
77
|
+
# default_forks: 0
|
|
78
|
+
# default_threads: 10
|
|
79
|
+
# default_gc_limit: 5000
|
|
80
|
+
# queues:
|
|
81
|
+
# critical:
|
|
82
|
+
# threads: 1 # Single-threaded for critical jobs
|
|
83
|
+
# default:
|
|
84
|
+
# threads: 10 # 10 concurrent threads
|
|
85
|
+
# mailers:
|
|
86
|
+
# threads: 5 # 5 concurrent emails
|
|
87
|
+
#
|
|
88
|
+
# @example Production (Puma-style: forks × threads)
|
|
89
|
+
# production:
|
|
90
|
+
# default_forks: 2
|
|
91
|
+
# default_threads: 10
|
|
92
|
+
# default_gc_limit: 5000
|
|
93
|
+
# queues:
|
|
94
|
+
# critical:
|
|
95
|
+
# forks: 1 # Single fork
|
|
96
|
+
# threads: 1 # Single thread = 1 concurrent job
|
|
97
|
+
# default:
|
|
98
|
+
# forks: 4 # 4 forks (Puma-style)
|
|
99
|
+
# threads: 10 # 10 threads per fork = 40 total concurrent jobs
|
|
100
|
+
# mailers:
|
|
101
|
+
# forks: 2
|
|
102
|
+
# threads: 5 # 2×5 = 10 total email senders
|
|
103
|
+
#
|
|
104
|
+
class Worker < Base
|
|
105
|
+
# Starts the worker.
|
|
106
|
+
#
|
|
107
|
+
# Detects whether to run in single-process mode (forks: 0) or
|
|
108
|
+
# multi-process mode (forks: 1+) and starts accordingly.
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
#
|
|
112
|
+
def start
|
|
113
|
+
logger.info "[Postburner::Worker] Starting..."
|
|
114
|
+
logger.info "[Postburner::Worker] Queues: #{config.queue_names.join(', ')}"
|
|
115
|
+
logger.info "[Postburner] #{config.beanstalk_url} watching tubes: #{config.expanded_tube_names.join(', ')}"
|
|
116
|
+
|
|
117
|
+
# Detect mode based on fork configuration
|
|
118
|
+
if using_forks?
|
|
119
|
+
start_forked_mode
|
|
120
|
+
else
|
|
121
|
+
start_single_process_mode
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Checks if any queue is configured to use forks.
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean] true if any queue has forks > 0
|
|
130
|
+
#
|
|
131
|
+
def using_forks?
|
|
132
|
+
config.queue_names.any? do |queue_name|
|
|
133
|
+
queue_config = config.queue_config(queue_name)
|
|
134
|
+
fork_count = queue_config['forks'] || queue_config[:forks] || config.default_forks
|
|
135
|
+
fork_count > 0
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Starts worker in single-process mode (forks: 0).
|
|
140
|
+
#
|
|
141
|
+
# Creates thread pools for each queue in the main process.
|
|
142
|
+
# Suitable for development and moderate concurrency needs.
|
|
143
|
+
#
|
|
144
|
+
# @return [void]
|
|
145
|
+
#
|
|
146
|
+
def start_single_process_mode
|
|
147
|
+
logger.info "[Postburner::Worker] Mode: Single process (forks: 0)"
|
|
148
|
+
|
|
149
|
+
# Track total jobs processed across all threads
|
|
150
|
+
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
151
|
+
@gc_limit = config.default_gc_limit
|
|
152
|
+
|
|
153
|
+
# Create thread pools for each queue
|
|
154
|
+
@pools = {}
|
|
155
|
+
config.queue_names.each do |queue_name|
|
|
156
|
+
spawn_queue_threads(queue_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Monitor for shutdown or GC limit
|
|
160
|
+
until shutdown? || (@gc_limit && @jobs_processed.value >= @gc_limit)
|
|
161
|
+
sleep 0.5
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Shutdown pools gracefully
|
|
165
|
+
logger.info "[Postburner::Worker] Shutting down..."
|
|
166
|
+
shutdown_pools
|
|
167
|
+
|
|
168
|
+
if @gc_limit && @jobs_processed.value >= @gc_limit
|
|
169
|
+
logger.info "[Postburner::Worker] Reached GC limit (#{@jobs_processed.value} jobs), exiting for restart..."
|
|
170
|
+
exit 99 # Special exit code for GC restart
|
|
171
|
+
else
|
|
172
|
+
logger.info "[Postburner::Worker] Shutdown complete"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Spawns thread pool for a specific queue in single-process mode.
|
|
177
|
+
#
|
|
178
|
+
# @param queue_name [String] Name of the queue to process
|
|
179
|
+
#
|
|
180
|
+
# @return [void]
|
|
181
|
+
#
|
|
182
|
+
def spawn_queue_threads(queue_name)
|
|
183
|
+
queue_config = config.queue_config(queue_name)
|
|
184
|
+
thread_count = queue_config['threads'] || queue_config[:threads] || config.default_threads
|
|
185
|
+
|
|
186
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}': #{thread_count} threads"
|
|
187
|
+
|
|
188
|
+
# Create thread pool
|
|
189
|
+
pool = Concurrent::FixedThreadPool.new(thread_count)
|
|
190
|
+
@pools[queue_name] = pool
|
|
191
|
+
|
|
192
|
+
# Spawn worker threads
|
|
193
|
+
thread_count.times do
|
|
194
|
+
pool.post do
|
|
195
|
+
process_jobs_in_single_process(queue_name)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Processes jobs in a single thread (single-process mode).
|
|
201
|
+
#
|
|
202
|
+
# Each thread has its own Beanstalkd connection and reserves jobs
|
|
203
|
+
# from the specified queue.
|
|
204
|
+
#
|
|
205
|
+
# @param queue_name [String] Name of the queue to process
|
|
206
|
+
#
|
|
207
|
+
# @return [void]
|
|
208
|
+
#
|
|
209
|
+
def process_jobs_in_single_process(queue_name)
|
|
210
|
+
connection = Postburner::Connection.new
|
|
211
|
+
|
|
212
|
+
# Watch only this queue
|
|
213
|
+
watch_queues(connection, queue_name)
|
|
214
|
+
|
|
215
|
+
until shutdown? || (@gc_limit && @jobs_processed.value >= @gc_limit)
|
|
216
|
+
begin
|
|
217
|
+
# Reserve with short timeout
|
|
218
|
+
job = connection.beanstalk.tubes.reserve(timeout: 1)
|
|
219
|
+
|
|
220
|
+
if job
|
|
221
|
+
logger.debug "[Postburner::Worker] Thread #{Thread.current.object_id} reserved job #{job.id}"
|
|
222
|
+
execute_job(job)
|
|
223
|
+
@jobs_processed.increment
|
|
224
|
+
end
|
|
225
|
+
rescue Beaneater::TimedOutError
|
|
226
|
+
# Normal timeout, continue
|
|
227
|
+
next
|
|
228
|
+
rescue Beaneater::NotConnected => e
|
|
229
|
+
logger.error "[Postburner::Worker] Thread disconnected: #{e.message}"
|
|
230
|
+
sleep 1
|
|
231
|
+
connection.reconnect!
|
|
232
|
+
rescue => e
|
|
233
|
+
logger.error "[Postburner::Worker] Thread error: #{e.class} - #{e.message}"
|
|
234
|
+
logger.error e.backtrace.join("\n")
|
|
235
|
+
sleep 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
ensure
|
|
239
|
+
connection&.close rescue nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Gracefully shuts down all thread pools (single-process mode).
|
|
243
|
+
#
|
|
244
|
+
# @return [void]
|
|
245
|
+
#
|
|
246
|
+
def shutdown_pools
|
|
247
|
+
@pools.each do |queue_name, pool|
|
|
248
|
+
pool.shutdown
|
|
249
|
+
pool.wait_for_termination(30)
|
|
250
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}' shutdown complete"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Starts worker in forked mode (forks: 1+).
|
|
255
|
+
#
|
|
256
|
+
# Forks multiple child processes for each queue, each running
|
|
257
|
+
# a thread pool. Parent process monitors children and restarts them when they exit.
|
|
258
|
+
#
|
|
259
|
+
# @return [void]
|
|
260
|
+
#
|
|
261
|
+
def start_forked_mode
|
|
262
|
+
logger.info "[Postburner::Worker] Mode: Multi-process (forks: 1+)"
|
|
263
|
+
|
|
264
|
+
# Track children: { pid => { queue: 'name', fork_num: 0 } }
|
|
265
|
+
@children = {}
|
|
266
|
+
|
|
267
|
+
# Spawn configured number of forks for each queue
|
|
268
|
+
config.queue_names.each do |queue_name|
|
|
269
|
+
spawn_queue_workers(queue_name)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Parent process monitors children
|
|
273
|
+
until shutdown?
|
|
274
|
+
begin
|
|
275
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
276
|
+
|
|
277
|
+
if pid
|
|
278
|
+
child_info = @children.delete(pid)
|
|
279
|
+
exit_code = status.exitstatus
|
|
280
|
+
queue_name = child_info[:queue]
|
|
281
|
+
fork_num = child_info[:fork_num]
|
|
282
|
+
|
|
283
|
+
if exit_code == 99
|
|
284
|
+
# GC restart - this is normal
|
|
285
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} reached GC limit, restarting..."
|
|
286
|
+
spawn_queue_worker(queue_name, fork_num) unless shutdown?
|
|
287
|
+
else
|
|
288
|
+
logger.error "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} exited unexpectedly (code: #{exit_code})"
|
|
289
|
+
spawn_queue_worker(queue_name, fork_num) unless shutdown?
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
sleep 0.5
|
|
294
|
+
rescue Errno::ECHILD
|
|
295
|
+
# No children left
|
|
296
|
+
break
|
|
297
|
+
rescue => e
|
|
298
|
+
logger.error "[Postburner::Worker] Monitor error: #{e.message}"
|
|
299
|
+
sleep 1
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Shutdown - wait for all children
|
|
304
|
+
logger.info "[Postburner::Worker] Shutting down, waiting for children..."
|
|
305
|
+
shutdown_children
|
|
306
|
+
logger.info "[Postburner::Worker] Shutdown complete"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Spawns all forked worker processes for a specific queue.
|
|
310
|
+
#
|
|
311
|
+
# @param queue_name [String] Name of the queue to process
|
|
312
|
+
#
|
|
313
|
+
# @return [void]
|
|
314
|
+
#
|
|
315
|
+
def spawn_queue_workers(queue_name)
|
|
316
|
+
queue_config = config.queue_config(queue_name)
|
|
317
|
+
fork_count = queue_config['forks'] || queue_config[:forks] || config.default_forks
|
|
318
|
+
thread_count = queue_config['threads'] || queue_config[:threads] || config.default_threads
|
|
319
|
+
|
|
320
|
+
# Skip if this queue has 0 forks (shouldn't happen in forked mode, but be defensive)
|
|
321
|
+
return if fork_count == 0
|
|
322
|
+
|
|
323
|
+
total_concurrency = fork_count * thread_count
|
|
324
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}': #{fork_count} forks × #{thread_count} threads = #{total_concurrency} total concurrency"
|
|
325
|
+
|
|
326
|
+
fork_count.times do |fork_num|
|
|
327
|
+
spawn_queue_worker(queue_name, fork_num)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Spawns a single forked worker process for a specific queue.
|
|
332
|
+
#
|
|
333
|
+
# @param queue_name [String] Name of the queue to process
|
|
334
|
+
# @param fork_num [Integer] Fork number (0-indexed)
|
|
335
|
+
#
|
|
336
|
+
# @return [void]
|
|
337
|
+
#
|
|
338
|
+
def spawn_queue_worker(queue_name, fork_num)
|
|
339
|
+
pid = fork do
|
|
340
|
+
# Child process
|
|
341
|
+
run_queue_worker(queue_name, fork_num)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
@children[pid] = { queue: queue_name, fork_num: fork_num }
|
|
345
|
+
logger.info "[Postburner::Worker] Spawned worker for queue '#{queue_name}' fork #{fork_num} (pid: #{pid})"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Runs the thread pool worker for a specific queue fork.
|
|
349
|
+
#
|
|
350
|
+
# This runs in the child process. Creates a thread pool and processes
|
|
351
|
+
# jobs until GC limit is reached or shutdown is requested.
|
|
352
|
+
#
|
|
353
|
+
# @param queue_name [String] Name of the queue to process
|
|
354
|
+
# @param fork_num [Integer] Fork number (for logging)
|
|
355
|
+
#
|
|
356
|
+
# @return [void]
|
|
357
|
+
#
|
|
358
|
+
def run_queue_worker(queue_name, fork_num)
|
|
359
|
+
queue_config = config.queue_config(queue_name)
|
|
360
|
+
thread_count = queue_config['threads'] || queue_config[:threads] || config.default_threads
|
|
361
|
+
gc_limit = queue_config['gc_limit'] || queue_config[:gc_limit] || config.default_gc_limit
|
|
362
|
+
|
|
363
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num}: #{thread_count} threads, GC limit #{gc_limit || 'unlimited'}"
|
|
364
|
+
|
|
365
|
+
# Track jobs processed in this fork
|
|
366
|
+
jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
367
|
+
|
|
368
|
+
# Create thread pool
|
|
369
|
+
pool = Concurrent::FixedThreadPool.new(thread_count)
|
|
370
|
+
|
|
371
|
+
# Each thread needs its own Beanstalkd connection
|
|
372
|
+
thread_count.times do
|
|
373
|
+
pool.post do
|
|
374
|
+
process_jobs_in_fork(queue_name, fork_num, jobs_processed, gc_limit)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Wait for shutdown or GC limit
|
|
379
|
+
until shutdown? || (gc_limit && jobs_processed.value >= gc_limit)
|
|
380
|
+
sleep 0.5
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Shutdown pool gracefully
|
|
384
|
+
pool.shutdown
|
|
385
|
+
pool.wait_for_termination(30)
|
|
386
|
+
|
|
387
|
+
if gc_limit && jobs_processed.value >= gc_limit
|
|
388
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} reached GC limit (#{jobs_processed.value} jobs), exiting for restart..."
|
|
389
|
+
exit 99 # Special exit code for GC restart
|
|
390
|
+
else
|
|
391
|
+
logger.info "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} shutting down gracefully..."
|
|
392
|
+
exit 0
|
|
393
|
+
end
|
|
394
|
+
rescue => e
|
|
395
|
+
logger.error "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} error: #{e.message}"
|
|
396
|
+
logger.error e.backtrace.join("\n")
|
|
397
|
+
exit 1
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Processes jobs in a single thread within a fork (forked mode).
|
|
401
|
+
#
|
|
402
|
+
# Each thread has its own Beanstalkd connection and reserves jobs
|
|
403
|
+
# from the specified queue.
|
|
404
|
+
#
|
|
405
|
+
# @param queue_name [String] Name of the queue to process
|
|
406
|
+
# @param fork_num [Integer] Fork number (for logging)
|
|
407
|
+
# @param jobs_processed [Concurrent::AtomicFixnum] Shared counter of jobs processed
|
|
408
|
+
# @param gc_limit [Integer, nil] Maximum jobs before triggering GC restart (nil = unlimited)
|
|
409
|
+
#
|
|
410
|
+
# @return [void]
|
|
411
|
+
#
|
|
412
|
+
def process_jobs_in_fork(queue_name, fork_num, jobs_processed, gc_limit)
|
|
413
|
+
connection = Postburner::Connection.new
|
|
414
|
+
|
|
415
|
+
# Watch only this queue
|
|
416
|
+
watch_queues(connection, queue_name)
|
|
417
|
+
|
|
418
|
+
until shutdown? || (gc_limit && jobs_processed.value >= gc_limit)
|
|
419
|
+
begin
|
|
420
|
+
# Reserve with short timeout
|
|
421
|
+
job = connection.beanstalk.tubes.reserve(timeout: 1)
|
|
422
|
+
|
|
423
|
+
if job
|
|
424
|
+
logger.debug "[Postburner::Worker] Queue '#{queue_name}' fork #{fork_num} thread #{Thread.current.object_id} reserved job #{job.id}"
|
|
425
|
+
execute_job(job)
|
|
426
|
+
jobs_processed.increment
|
|
427
|
+
end
|
|
428
|
+
rescue Beaneater::TimedOutError
|
|
429
|
+
# Normal timeout, continue
|
|
430
|
+
next
|
|
431
|
+
rescue Beaneater::NotConnected => e
|
|
432
|
+
logger.error "[Postburner::Worker] Thread disconnected: #{e.message}"
|
|
433
|
+
sleep 1
|
|
434
|
+
connection.reconnect!
|
|
435
|
+
rescue => e
|
|
436
|
+
logger.error "[Postburner::Worker] Thread error: #{e.class} - #{e.message}"
|
|
437
|
+
logger.error e.backtrace.join("\n")
|
|
438
|
+
sleep 1
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
ensure
|
|
442
|
+
connection&.close rescue nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Gracefully shuts down all child processes (forked mode).
|
|
446
|
+
#
|
|
447
|
+
# Sends TERM signal to all children and waits for them to exit.
|
|
448
|
+
#
|
|
449
|
+
# @return [void]
|
|
450
|
+
#
|
|
451
|
+
def shutdown_children
|
|
452
|
+
@children.keys.each do |pid|
|
|
453
|
+
begin
|
|
454
|
+
Process.kill('TERM', pid)
|
|
455
|
+
rescue Errno::ESRCH
|
|
456
|
+
# Process already exited
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Wait up to 30 seconds for children to exit
|
|
461
|
+
timeout = Time.now + 30
|
|
462
|
+
until @children.empty? || Time.now > timeout
|
|
463
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
464
|
+
@children.delete(pid) if pid
|
|
465
|
+
sleep 0.5
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Force kill any remaining children
|
|
469
|
+
@children.keys.each do |pid|
|
|
470
|
+
begin
|
|
471
|
+
Process.kill('KILL', pid)
|
|
472
|
+
Process.wait(pid)
|
|
473
|
+
rescue Errno::ESRCH
|
|
474
|
+
# Already exited
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|