postburner 0.9.0.rc.1 → 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,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