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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Job wrapper for executing tracked ActiveJob instances.
5
+ #
6
+ # TrackedJob is a Postburner::Job subclass that deserializes and executes
7
+ # ActiveJob instances that opted-in to PostgreSQL tracking via the
8
+ # `Postburner::Tracked` concern.
9
+ #
10
+ # When an ActiveJob with `tracked` is enqueued, the adapter:
11
+ # 1. Creates a TrackedJob record in PostgreSQL
12
+ # 2. Stores the ActiveJob data in the `args` JSONB column
13
+ # 3. Queues a minimal payload to Beanstalkd with the TrackedJob ID
14
+ #
15
+ # When the worker executes the job:
16
+ # 1. Loads the TrackedJob record
17
+ # 2. Deserializes the ActiveJob from `args`
18
+ # 3. Executes it with full audit trail (logs, timing, errors)
19
+ #
20
+ # @example
21
+ # # User's ActiveJob (opts in to tracking)
22
+ # class ProcessPayment < ApplicationJob
23
+ # include Postburner::Tracked
24
+ # tracked
25
+ #
26
+ # def perform(payment_id)
27
+ # log "Processing payment #{payment_id}"
28
+ # # ...
29
+ # end
30
+ # end
31
+ #
32
+ # # ActiveJob adapter creates TrackedJob
33
+ # ProcessPayment.perform_later(123)
34
+ # # => Creates TrackedJob record, queues to Beanstalkd
35
+ #
36
+ # # Worker executes
37
+ # Postburner::Job.perform(tracked_job.id)
38
+ # # => Deserializes ProcessPayment job and executes with audit trail
39
+ #
40
+ class TrackedJob < Job
41
+ # Executes the wrapped ActiveJob instance.
42
+ #
43
+ # Deserializes the ActiveJob from args, restores metadata, sets up
44
+ # the bidirectional link for logging, and executes the job.
45
+ #
46
+ # @param args [Hash] JSONB args containing serialized ActiveJob data
47
+ #
48
+ # @return [void]
49
+ #
50
+ # @raise [Exception] Any exception raised by the ActiveJob is logged and re-raised
51
+ #
52
+ def perform(args)
53
+ # Extract ActiveJob metadata from args
54
+ job_class = args['job_class'].constantize
55
+ arguments = ::ActiveJob::Arguments.deserialize(args['arguments'])
56
+
57
+ # Instantiate the ActiveJob
58
+ job = job_class.new(*arguments)
59
+
60
+ # Restore ActiveJob metadata
61
+ job.job_id = args['job_id']
62
+ job.queue_name = args['queue_name']
63
+ job.priority = args['priority']
64
+ job.executions = args['executions'] || 0
65
+ job.exception_executions = args['exception_executions'] || {}
66
+ job.locale = args['locale']
67
+ job.timezone = args['timezone']
68
+
69
+ if args['enqueued_at']
70
+ job.enqueued_at = Time.iso8601(args['enqueued_at'])
71
+ end
72
+
73
+ # Give the ActiveJob access to this Postburner::Job for logging
74
+ if job.respond_to?(:postburner_job=)
75
+ job.postburner_job = self
76
+ end
77
+
78
+ # Execute the job (ActiveJob handles retry_on/discard_on)
79
+ # Exceptions are caught by Postburner::Job#perform! and logged to errata
80
+ job.perform_now
81
+ end
82
+ end
83
+ end
data/bin/postburner ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Postburner worker executable
5
+ #
6
+ # Loads configuration from YAML and starts the appropriate worker type.
7
+ #
8
+ # Usage:
9
+ # bin/postburner [--config PATH] [--env ENVIRONMENT]
10
+ #
11
+ # Examples:
12
+ # bin/postburner
13
+ # bin/postburner --config config/postburner.yml --env production
14
+ #
15
+
16
+ require 'optparse'
17
+
18
+ # Parse command-line options
19
+ options = {
20
+ config: 'config/postburner.yml',
21
+ env: ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development',
22
+ worker: nil,
23
+ queues: nil
24
+ }
25
+
26
+ OptionParser.new do |opts|
27
+ opts.banner = "Usage: bin/postburner [options]"
28
+
29
+ opts.on('-c', '--config PATH', 'Path to YAML configuration file (default: config/postburner.yml)') do |path|
30
+ options[:config] = path
31
+ end
32
+
33
+ opts.on('-e', '--env ENVIRONMENT', 'Environment (default: RAILS_ENV or development)') do |env|
34
+ options[:env] = env
35
+ end
36
+
37
+ opts.on('-w', '--worker WORKER', 'Worker name from config (required if multiple workers defined)') do |worker|
38
+ options[:worker] = worker
39
+ end
40
+
41
+ opts.on('-q', '--queues QUEUES', 'Comma-separated list of queues to process (default: all configured queues)') do |queues|
42
+ options[:queues] = queues.split(',').map(&:strip)
43
+ end
44
+
45
+ opts.on('-h', '--help', 'Show this help message') do
46
+ puts opts
47
+ exit
48
+ end
49
+ end.parse!
50
+
51
+ # Load Rails environment from current directory
52
+ # This executable should be run from your Rails application root
53
+ ENV['RAILS_ENV'] ||= options[:env]
54
+ require File.expand_path('config/environment', Dir.pwd)
55
+
56
+ # Postburner is loaded automatically via the Rails engine when the gem is in your Gemfile
57
+
58
+ # Load configuration
59
+ config_path = File.expand_path(options[:config], Dir.pwd)
60
+
61
+ begin
62
+ config = Postburner::Configuration.load_yaml(config_path, options[:env], options[:worker])
63
+ rescue ArgumentError => e
64
+ Rails.logger.error "[Postburner] ERROR: #{e.message}"
65
+ exit 1
66
+ end
67
+
68
+ # Filter queues if --queues option provided
69
+ if options[:queues]
70
+ # Validate that all specified queues exist in config
71
+ invalid_queues = options[:queues] - config.queue_names
72
+ unless invalid_queues.empty?
73
+ config.logger.error "[Postburner] ERROR: Unknown queue(s): #{invalid_queues.join(', ')}"
74
+ config.logger.error "[Postburner] Available queues: #{config.queue_names.join(', ')}"
75
+ exit 1
76
+ end
77
+
78
+ # Filter config to only include specified queues
79
+ filtered_queues = config.queues.select { |name, _| options[:queues].include?(name.to_s) }
80
+ config.queues = filtered_queues
81
+ end
82
+
83
+ config.logger.info "[Postburner] Configuration: #{config_path}"
84
+ config.logger.info "[Postburner] Environment: #{options[:env]}"
85
+ config.logger.info "[Postburner] Worker: #{options[:worker] || '(auto-selected)'}" if options[:worker] || options[:queues].nil?
86
+ config.logger.info "[Postburner] Queues: #{config.queue_names.join(', ')}"
87
+ config.logger.info "[Postburner] Defaults: forks=#{config.default_forks}, threads=#{config.default_threads}, gc_limit=#{config.default_gc_limit || 'none'}"
88
+
89
+ # Create and start worker
90
+ worker = Postburner::Workers::Worker.new(config)
91
+ worker.start
data/bin/rails ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" with Rails gems
3
+ # installed from the root of your application.
4
+
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/postburner/engine', __dir__)
7
+ APP_PATH = File.expand_path('../test/dummy/config/application', __dir__)
8
+
9
+ # Set up gems listed in the Gemfile.
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
11
+ require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
12
+
13
+ require "rails/all"
14
+ require "rails/engine/commands"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Leave me here for vim-rails to work in an engine.
@@ -0,0 +1,22 @@
1
+ development:
2
+ beanstalk_url: beanstalk://localhost:11300
3
+ worker_type: simple
4
+ queues:
5
+ default: {}
6
+
7
+ test:
8
+ beanstalk_url: beanstalk://localhost:11300
9
+ worker_type: simple
10
+ queues:
11
+ default: {}
12
+
13
+ production:
14
+ beanstalk_url: beanstalk://localhost:11300
15
+ worker_type: threads_on_fork
16
+ queues:
17
+ critical:
18
+ threads: 1
19
+ gc_limit: 100
20
+ default:
21
+ threads: 5
22
+ gc_limit: 500
@@ -0,0 +1,142 @@
1
+ # Postburner Configuration Example
2
+ #
3
+ # Copy this file to config/postburner.yml and customize for your environment.
4
+ #
5
+ # ## Named Workers Configuration
6
+ #
7
+ # Postburner uses named worker configurations to support different deployment patterns:
8
+ # - Single worker: bin/postburner (auto-selects the single worker)
9
+ # - Multiple workers: bin/postburner --worker <name> (must specify which worker)
10
+ #
11
+ # Each worker can have different fork/thread settings and process different queues.
12
+ # This enables running different queue groups in separate OS processes with distinct
13
+ # concurrency profiles.
14
+ #
15
+ # ## Puma-Style Architecture
16
+ #
17
+ # - **forks: 0** = Single process with thread pool (development/staging)
18
+ # - **forks: 1+** = Multiple processes with thread pools (production)
19
+ #
20
+ # Scale by adjusting forks and threads per worker:
21
+ # - Development: forks=0, threads=1 (single-threaded, easiest debugging)
22
+ # - Staging: forks=0, threads=10 (multi-threaded, moderate load)
23
+ # - Production: forks=4, threads=10 (40 concurrent jobs per queue)
24
+ #
25
+
26
+ default: &default
27
+ # Beanstalkd connection URL
28
+ # Override with ENV['BEANSTALK_URL'] if set
29
+ beanstalk_url: <%= ENV['BEANSTALK_URL'] || 'beanstalk://localhost:11300' %>
30
+
31
+ development:
32
+ <<: *default
33
+
34
+ workers:
35
+ default:
36
+ # Single-threaded, single process (simplest for debugging)
37
+ # Defaults: forks=0, threads=1, gc_limit=nil
38
+ queues:
39
+ - default
40
+ - mailers
41
+
42
+ test:
43
+ <<: *default
44
+
45
+ workers:
46
+ default:
47
+ # Test mode uses inline strategies automatically
48
+ # Defaults: forks=0, threads=1, gc_limit=nil
49
+ queues:
50
+ - default
51
+
52
+ staging:
53
+ <<: *default
54
+
55
+ workers:
56
+ default:
57
+ # Multi-threaded, single process (moderate concurrency)
58
+ default_threads: 10
59
+ default_gc_limit: 5000
60
+ queues:
61
+ - critical
62
+ - default
63
+ - mailers
64
+
65
+ production:
66
+ <<: *default
67
+
68
+ # Example 1: Single worker processing all queues with same settings
69
+ # Run: bin/postburner
70
+ #
71
+ # workers:
72
+ # default:
73
+ # default_forks: 4
74
+ # default_threads: 10
75
+ # default_gc_limit: 5000
76
+ # queues:
77
+ # - critical
78
+ # - default
79
+ # - mailers
80
+ # - imports
81
+
82
+ # Example 2: Multiple workers with different concurrency profiles
83
+ # Run separate processes:
84
+ # bin/postburner --worker imports (4 forks, 1 thread each)
85
+ # bin/postburner --worker general (2 forks, 100 threads each)
86
+ #
87
+ workers:
88
+ # Heavy, memory-intensive jobs - more processes, fewer threads
89
+ imports:
90
+ default_forks: 4
91
+ default_threads: 1
92
+ default_gc_limit: 500
93
+ queues:
94
+ - imports
95
+ - data_processing
96
+
97
+ # General jobs - fewer processes, many threads
98
+ general:
99
+ default_forks: 2
100
+ default_threads: 100
101
+ default_gc_limit: 5000
102
+ queues:
103
+ - default
104
+ - mailers
105
+ - notifications
106
+
107
+ # Example 3: Fine-grained control with multiple specialized workers
108
+ # Run separate processes:
109
+ # bin/postburner --worker critical
110
+ # bin/postburner --worker default
111
+ # bin/postburner --worker mailers
112
+ #
113
+ # workers:
114
+ # critical:
115
+ # default_forks: 1
116
+ # default_threads: 1
117
+ # default_gc_limit: 100
118
+ # queues:
119
+ # - critical
120
+ #
121
+ # default:
122
+ # default_forks: 4
123
+ # default_threads: 10
124
+ # default_gc_limit: 5000
125
+ # queues:
126
+ # - default
127
+ #
128
+ # mailers:
129
+ # default_forks: 2
130
+ # default_threads: 5
131
+ # default_gc_limit: 2000
132
+ # queues:
133
+ # - mailers
134
+
135
+ # Global Defaults (can be overridden per worker):
136
+ #
137
+ # default_queue: default # Default queue name (optional)
138
+ # default_priority: 65536 # Lower = higher priority (optional, 0 is highest)
139
+ # default_ttr: 300 # Time-to-run in seconds (optional)
140
+ # default_threads: 1 # Thread count per fork (optional, defaults to 1)
141
+ # default_forks: 0 # Fork count (optional, defaults to 0 = single process)
142
+ # default_gc_limit: nil # Exit after N jobs for restart (optional, nil = no limit)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module QueueAdapters
5
+ # Postburner adapter for ActiveJob.
6
+ #
7
+ # Provides dual-mode job execution:
8
+ # - **Default mode**: Fast execution via Beanstalkd only
9
+ # - **Tracked mode** (opt-in): Full PostgreSQL audit trail
10
+ #
11
+ # ## Usage
12
+ #
13
+ # @example Configure in Rails
14
+ # # config/application.rb
15
+ # config.active_job.queue_adapter = :postburner
16
+ #
17
+ # @example Default job (fast)
18
+ # class SendEmail < ApplicationJob
19
+ # def perform(user_id)
20
+ # # No PostgreSQL overhead, executes quickly
21
+ # end
22
+ # end
23
+ #
24
+ # @example Tracked job (opt-in for audit trail)
25
+ # class ProcessPayment < ApplicationJob
26
+ # include Postburner::Tracked
27
+ # tracked # ← Enables PostgreSQL tracking
28
+ #
29
+ # def perform(payment_id)
30
+ # log "Processing payment..."
31
+ # # Full audit trail: logs, timing, errors
32
+ # end
33
+ # end
34
+ #
35
+ class PostburnerAdapter
36
+ # Enqueues a job for immediate execution.
37
+ #
38
+ # @param job [ActiveJob::Base] The job to enqueue
39
+ #
40
+ # @return [void]
41
+ #
42
+ def enqueue(job)
43
+ enqueue_at(job, nil)
44
+ end
45
+
46
+ # Enqueues a job for execution at a specific time.
47
+ #
48
+ # @param job [ActiveJob::Base] The job to enqueue
49
+ # @param timestamp [Time, nil] When to execute the job (nil = immediate)
50
+ #
51
+ # @return [void]
52
+ #
53
+ def enqueue_at(job, timestamp)
54
+ if tracked?(job)
55
+ enqueue_tracked(job, timestamp)
56
+ else
57
+ enqueue_default(job, timestamp)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Checks if a job should be tracked in PostgreSQL.
64
+ #
65
+ # Detects tracking by checking if the job class includes the
66
+ # Postburner::Tracked module.
67
+ #
68
+ # @param job [ActiveJob::Base] The job instance
69
+ #
70
+ # @return [Boolean] true if tracked, false otherwise
71
+ #
72
+ def tracked?(job)
73
+ job.class.included_modules.include?(Postburner::Tracked)
74
+ end
75
+
76
+ # Enqueues a tracked job (with PostgreSQL audit trail).
77
+ #
78
+ # Creates a Postburner::TrackedJob record, then queues minimal payload
79
+ # to Beanstalkd with just the job ID reference.
80
+ #
81
+ # @param job [ActiveJob::Base] The job instance
82
+ # @param timestamp [Time, nil] When to execute the job
83
+ #
84
+ # @return [void]
85
+ #
86
+ def enqueue_tracked(job, timestamp)
87
+ # Create Postburner::TrackedJob record
88
+ tracked_job = Postburner::TrackedJob.create!(
89
+ args: Postburner::ActiveJob::Payload.serialize_for_tracked(job),
90
+ run_at: timestamp,
91
+ queued_at: Time.zone.now
92
+ )
93
+
94
+ # Calculate delay for Beanstalkd
95
+ delay = timestamp ? [(timestamp.to_f - Time.now.to_f).to_i, 0].max : 0
96
+
97
+ # Queue to Beanstalkd with minimal payload
98
+ Postburner.connected do |conn|
99
+ tube_name = expand_tube_name(job.queue_name)
100
+
101
+ # Get priority and TTR from class configuration or fall back to defaults
102
+ pri = job.class.respond_to?(:queue_priority) && job.class.queue_priority ||
103
+ Postburner.configuration.default_priority
104
+ ttr = job.class.respond_to?(:queue_ttr) && job.class.queue_ttr ||
105
+ Postburner.configuration.default_ttr
106
+
107
+ bkid = conn.tubes[tube_name].put(
108
+ Postburner::ActiveJob::Payload.tracked_payload(job, tracked_job.id),
109
+ pri: pri,
110
+ delay: delay,
111
+ ttr: ttr
112
+ )
113
+
114
+ # Update tracked_job with Beanstalkd ID
115
+ tracked_job.update_column(:bkid, bkid)
116
+ end
117
+ end
118
+
119
+ # Enqueues a default job (Beanstalkd only, no PostgreSQL).
120
+ #
121
+ # Queues full job data to Beanstalkd for fast execution without
122
+ # PostgreSQL overhead.
123
+ #
124
+ # @param job [ActiveJob::Base] The job instance
125
+ # @param timestamp [Time, nil] When to execute the job
126
+ #
127
+ # @return [void]
128
+ #
129
+ def enqueue_default(job, timestamp)
130
+ delay = timestamp ? [(timestamp.to_f - Time.now.to_f).to_i, 0].max : 0
131
+
132
+ Postburner.connected do |conn|
133
+ tube_name = expand_tube_name(job.queue_name)
134
+
135
+ # Get priority and TTR from class configuration or fall back to defaults
136
+ pri = job.class.respond_to?(:queue_priority) && job.class.queue_priority ||
137
+ Postburner.configuration.default_priority
138
+ ttr = job.class.respond_to?(:queue_ttr) && job.class.queue_ttr ||
139
+ Postburner.configuration.default_ttr
140
+
141
+ conn.tubes[tube_name].put(
142
+ Postburner::ActiveJob::Payload.default_payload(job),
143
+ pri: pri,
144
+ delay: delay,
145
+ ttr: ttr
146
+ )
147
+ end
148
+ end
149
+
150
+ # Expands queue name to full tube name with environment prefix.
151
+ #
152
+ # Delegates to Postburner::Configuration for consistent tube naming.
153
+ #
154
+ # @param queue_name [String] Queue name from ActiveJob
155
+ #
156
+ # @return [String] Full tube name (e.g., 'postburner.production.critical')
157
+ #
158
+ def expand_tube_name(queue_name)
159
+ Postburner.configuration.expand_tube_name(queue_name)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ module ActiveJob
5
+ # Handles execution of ActiveJob jobs from Beanstalkd payloads.
6
+ #
7
+ # Supports both default jobs (fast, no PostgreSQL) and tracked jobs
8
+ # (full audit trail). Also handles legacy Postburner::Job format
9
+ # for backward compatibility.
10
+ #
11
+ class Execution
12
+ class << self
13
+ # Executes a job from a Beanstalkd payload.
14
+ #
15
+ # Automatically detects payload type (default, tracked, or legacy)
16
+ # and routes to the appropriate execution path.
17
+ #
18
+ # @param payload_json [String] JSON payload from Beanstalkd
19
+ #
20
+ # @return [void]
21
+ #
22
+ # @raise [StandardError] if job execution fails
23
+ #
24
+ def execute(payload_json)
25
+ payload = Payload.parse(payload_json)
26
+
27
+ if Payload.legacy_format?(payload)
28
+ execute_legacy(payload)
29
+ elsif Payload.tracked?(payload)
30
+ execute_tracked(payload)
31
+ else
32
+ execute_default(payload)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Executes a tracked job (with PostgreSQL audit trail).
39
+ #
40
+ # Loads the Postburner::Job record and delegates to its perform! method,
41
+ # which handles all the audit trail logging, timing, and error tracking.
42
+ #
43
+ # @param payload [Hash] Parsed payload
44
+ #
45
+ # @return [void]
46
+ #
47
+ def execute_tracked(payload)
48
+ job_id = payload['postburner_job_id']
49
+
50
+ unless job_id
51
+ raise ArgumentError, "Tracked job missing postburner_job_id"
52
+ end
53
+
54
+ # Delegate to Postburner::Job.perform which handles the full lifecycle
55
+ Postburner::Job.perform(job_id)
56
+ end
57
+
58
+ # Executes a default job (Beanstalkd only, no PostgreSQL).
59
+ #
60
+ # Deserializes the ActiveJob, restores its metadata, and executes it.
61
+ # Handles retries via ActiveJob's retry_on/discard_on mechanisms.
62
+ #
63
+ # @param payload [Hash] Parsed payload
64
+ #
65
+ # @return [void]
66
+ #
67
+ def execute_default(payload)
68
+ job_class = payload['job_class'].constantize
69
+ arguments = ::ActiveJob::Arguments.deserialize(payload['arguments'])
70
+
71
+ # Instantiate the job
72
+ job = job_class.new(*arguments)
73
+
74
+ # Restore job metadata
75
+ job.job_id = payload['job_id']
76
+ job.queue_name = payload['queue_name']
77
+ job.priority = payload['priority']
78
+ job.executions = payload['executions'] || 0
79
+ job.exception_executions = payload['exception_executions'] || {}
80
+ job.locale = payload['locale']
81
+ job.timezone = payload['timezone']
82
+
83
+ if payload['enqueued_at']
84
+ job.enqueued_at = Time.iso8601(payload['enqueued_at'])
85
+ end
86
+
87
+ # Execute the job (ActiveJob handles retry_on/discard_on)
88
+ job.perform_now
89
+ end
90
+
91
+ # Executes a legacy Postburner::Job (backward compatibility).
92
+ #
93
+ # Legacy format: { "class" => "JobClassName", "args" => [job_id] }
94
+ #
95
+ # @param payload [Hash] Parsed legacy payload
96
+ #
97
+ # @return [void]
98
+ #
99
+ def execute_legacy(payload)
100
+ job_class = payload['class'].constantize
101
+ job_id = payload['args'].first
102
+
103
+ # Delegate to the class's perform method (Postburner::Job.perform)
104
+ job_class.perform(job_id)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end