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,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