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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ module ActiveJob
5
+ # Handles serialization/deserialization of ActiveJob payloads for Beanstalkd.
6
+ #
7
+ # Provides consistent payload format for both default and tracked jobs, with
8
+ # versioning support for future format changes.
9
+ #
10
+ # ## Payload Format (V1)
11
+ #
12
+ # Both default and tracked jobs store the same ActiveJob data in Beanstalkd.
13
+ # The only difference is tracked jobs also persist to PostgreSQL and include
14
+ # a `postburner_job_id` reference.
15
+ #
16
+ # @example Default job payload
17
+ # {
18
+ # v: 1,
19
+ # tracked: false,
20
+ # postburner_job_id: nil,
21
+ # job_class: "SendEmail",
22
+ # job_id: "abc-123",
23
+ # queue_name: "mailers",
24
+ # arguments: [[1, 2, 3]],
25
+ # retry_count: 0,
26
+ # ...
27
+ # }
28
+ #
29
+ # @example Tracked job payload
30
+ # {
31
+ # v: 1,
32
+ # tracked: true,
33
+ # postburner_job_id: 456, # References postburner_jobs.id
34
+ # job_class: "ProcessPayment",
35
+ # job_id: "def-456",
36
+ # queue_name: "critical",
37
+ # arguments: [[789]],
38
+ # retry_count: 0,
39
+ # ...
40
+ # }
41
+ #
42
+ class Payload
43
+ VERSION = 1
44
+
45
+ class << self
46
+ # Generates payload structure for an ActiveJob.
47
+ #
48
+ # @param job [ActiveJob::Base] The ActiveJob instance
49
+ # @param tracked [Boolean] Whether this is a tracked job
50
+ # @param postburner_job_id [Integer, nil] Postburner::Job ID for tracked jobs
51
+ #
52
+ # @return [Hash] Payload hash
53
+ #
54
+ # @api private
55
+ #
56
+ def for_job(job, tracked: false, postburner_job_id: nil)
57
+ {
58
+ v: VERSION,
59
+ tracked: tracked,
60
+ postburner_job_id: postburner_job_id,
61
+ job_class: job.class.name,
62
+ job_id: job.job_id,
63
+ queue_name: job.queue_name,
64
+ priority: job.priority,
65
+ arguments: ::ActiveJob::Arguments.serialize(job.arguments),
66
+ executions: job.executions,
67
+ exception_executions: job.exception_executions || {},
68
+ locale: job.locale,
69
+ timezone: job.timezone,
70
+ enqueued_at: job.enqueued_at&.iso8601,
71
+ retry_count: 0 # For default job retry tracking
72
+ }
73
+ end
74
+
75
+ # Generates JSON payload for a default job.
76
+ #
77
+ # @param job [ActiveJob::Base] The ActiveJob instance
78
+ #
79
+ # @return [String] JSON-encoded payload
80
+ #
81
+ def default_payload(job)
82
+ JSON.generate(for_job(job, tracked: false))
83
+ end
84
+
85
+ # Generates JSON payload for a tracked job.
86
+ #
87
+ # @param job [ActiveJob::Base] The ActiveJob instance
88
+ # @param postburner_job_id [Integer] Postburner::Job ID
89
+ #
90
+ # @return [String] JSON-encoded payload
91
+ #
92
+ def tracked_payload(job, postburner_job_id)
93
+ JSON.generate(for_job(job, tracked: true, postburner_job_id: postburner_job_id))
94
+ end
95
+
96
+ # Serializes ActiveJob data for PostgreSQL storage (tracked jobs).
97
+ #
98
+ # Returns the same structure as for_job but as a plain Hash
99
+ # (not JSON string) for storage in Postburner::Job args column.
100
+ #
101
+ # @param job [ActiveJob::Base] The ActiveJob instance
102
+ #
103
+ # @return [Hash] Payload hash for PostgreSQL storage
104
+ #
105
+ def serialize_for_tracked(job)
106
+ for_job(job, tracked: true).stringify_keys
107
+ end
108
+
109
+ # Parses and validates a JSON payload from Beanstalkd.
110
+ #
111
+ # Handles version checking and returns the parsed data.
112
+ #
113
+ # @param json_string [String] JSON payload from Beanstalkd
114
+ #
115
+ # @return [Hash] Parsed payload with string keys
116
+ #
117
+ # @raise [ArgumentError] if payload version is unknown
118
+ #
119
+ def parse(json_string)
120
+ data = JSON.parse(json_string)
121
+
122
+ # Handle version
123
+ version = data['v'] || data[:v] || 1
124
+
125
+ case version
126
+ when 1
127
+ data.is_a?(Hash) ? data.stringify_keys : data
128
+ else
129
+ raise ArgumentError, "Unknown payload version: #{version}"
130
+ end
131
+ end
132
+
133
+ # Detects if a parsed payload is for a tracked job.
134
+ #
135
+ # @param payload [Hash] Parsed payload
136
+ #
137
+ # @return [Boolean] true if tracked, false otherwise
138
+ #
139
+ def tracked?(payload)
140
+ payload['tracked'] == true || payload[:tracked] == true
141
+ end
142
+
143
+ # Detects if a parsed payload is for a legacy Postburner::Job.
144
+ #
145
+ # Legacy format: { "class" => "JobClassName", "args" => [job_id] }
146
+ #
147
+ # @param payload [Hash] Parsed payload
148
+ #
149
+ # @return [Boolean] true if legacy format
150
+ #
151
+ def legacy_format?(payload)
152
+ payload.key?('class') && payload.key?('args') && !payload.key?('v')
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Beanstalkd-specific configuration DSL for ActiveJob classes using Postburner.
5
+ #
6
+ # Provides consistent API with Postburner::Job for setting Beanstalkd queue
7
+ # priority and TTR (time-to-run). Include this module in your ActiveJob classes
8
+ # to use Beanstalkd-specific configuration.
9
+ #
10
+ # @note Automatically included when you include Postburner::Tracked
11
+ #
12
+ # @example Default job with Beanstalkd config
13
+ # class ProcessPayment < ApplicationJob
14
+ # include Postburner::Beanstalkd
15
+ #
16
+ # queue_as :critical
17
+ # queue_priority 0 # Highest priority
18
+ # queue_ttr 300 # 5 minutes to complete
19
+ #
20
+ # def perform(payment_id)
21
+ # # ...
22
+ # end
23
+ # end
24
+ #
25
+ # @example With tracking (Beanstalkd automatically included)
26
+ # class ProcessPayment < ApplicationJob
27
+ # include Postburner::Tracked # Includes Beanstalkd automatically
28
+ #
29
+ # queue_priority 0
30
+ # queue_ttr 600
31
+ #
32
+ # def perform(payment_id)
33
+ # log "Processing payment"
34
+ # # ...
35
+ # end
36
+ # end
37
+ #
38
+ module Beanstalkd
39
+ extend ActiveSupport::Concern
40
+
41
+ included do
42
+ class_attribute :postburner_priority, default: nil
43
+ class_attribute :postburner_ttr, default: nil
44
+ end
45
+
46
+ class_methods do
47
+ # Sets or returns the queue priority.
48
+ #
49
+ # Lower numbers = higher priority in Beanstalkd (0 is highest).
50
+ # Falls back to Postburner.configuration.default_priority if not set.
51
+ #
52
+ # @param pri [Integer, nil] Priority to set (0-4294967295), or nil to get current value
53
+ #
54
+ # @return [Integer, nil] Current priority when getting, nil when setting
55
+ #
56
+ # @example Set priority
57
+ # queue_priority 0 # Highest priority
58
+ #
59
+ # @example Get priority
60
+ # ProcessPayment.queue_priority # => 0
61
+ #
62
+ def queue_priority(pri = nil)
63
+ if pri
64
+ self.postburner_priority = pri
65
+ nil
66
+ else
67
+ postburner_priority
68
+ end
69
+ end
70
+
71
+ # Sets or returns the queue TTR (time to run).
72
+ #
73
+ # Number of seconds Beanstalkd will wait for job completion before
74
+ # making it available again. Falls back to Postburner.configuration.default_ttr
75
+ # if not set.
76
+ #
77
+ # @param ttr [Integer, nil] Timeout in seconds, or nil to get current value
78
+ #
79
+ # @return [Integer, nil] Current TTR when getting, nil when setting
80
+ #
81
+ # @example Set TTR
82
+ # queue_ttr 300 # 5 minutes
83
+ #
84
+ # @example Get TTR
85
+ # ProcessPayment.queue_ttr # => 300
86
+ #
87
+ def queue_ttr(ttr = nil)
88
+ if ttr
89
+ self.postburner_ttr = ttr
90
+ nil
91
+ else
92
+ postburner_ttr
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Configuration system for Postburner workers and connections.
5
+ #
6
+ # Supports both programmatic configuration and YAML file loading.
7
+ # Configuration can be set per-environment and controls worker behavior,
8
+ # Beanstalkd connection, and execution strategies.
9
+ #
10
+ # @example Programmatic configuration
11
+ # Postburner.configure do |config|
12
+ # config.beanstalk_url = 'beanstalk://localhost:11300'
13
+ # config.worker_type = :threads_on_fork
14
+ # config.logger = Rails.logger
15
+ # end
16
+ #
17
+ # @example Loading from YAML
18
+ # config = Postburner::Configuration.load_yaml('config/postburner.yml', 'production')
19
+ #
20
+ class Configuration
21
+ attr_accessor :beanstalk_url, :logger, :queues, :default_queue, :default_priority, :default_ttr, :default_threads, :default_forks, :default_gc_limit
22
+
23
+ # @param options [Hash] Configuration options
24
+ # @option options [String] :beanstalk_url Beanstalkd URL (default: ENV['BEANSTALK_URL'] or localhost)
25
+ # @option options [Logger] :logger Logger instance (default: Rails.logger)
26
+ # @option options [Hash] :queues Queue configurations
27
+ # @option options [String] :default_queue Default queue name (default: 'default')
28
+ # @option options [Integer] :default_priority Default job priority (default: 65536, lower = higher priority)
29
+ # @option options [Integer] :default_ttr Default time-to-run in seconds (default: 300)
30
+ # @option options [Integer] :default_threads Default thread count per queue (default: 1)
31
+ # @option options [Integer] :default_forks Default fork count per queue (default: 0, single process)
32
+ # @option options [Integer] :default_gc_limit Default GC limit for worker restarts (default: nil, no limit)
33
+ #
34
+ def initialize(options = {})
35
+ @beanstalk_url = options[:beanstalk_url] || ENV['BEANSTALK_URL'] || 'beanstalk://localhost:11300'
36
+ @logger = options[:logger] || (defined?(Rails) ? Rails.logger : Logger.new(STDOUT))
37
+ @queues = options[:queues] || { 'default' => {} }
38
+ @default_queue = options[:default_queue] || 'default'
39
+ @default_priority = options[:default_priority] || 65536
40
+ @default_ttr = options[:default_ttr] || 300
41
+ @default_threads = options[:default_threads] || 1
42
+ @default_forks = options[:default_forks] || 0
43
+ @default_gc_limit = options[:default_gc_limit]
44
+ end
45
+
46
+ # Loads configuration from a YAML file.
47
+ #
48
+ # @param path [String] Path to YAML configuration file
49
+ # @param env [String] Environment name (e.g., 'production', 'development')
50
+ # @param worker_name [String, nil] Worker name to load (nil = auto-select if single worker)
51
+ #
52
+ # @return [Configuration] Configured instance
53
+ #
54
+ # @raise [Errno::ENOENT] if file doesn't exist
55
+ # @raise [ArgumentError] if environment not found in YAML
56
+ # @raise [ArgumentError] if multiple workers defined but worker_name not specified
57
+ # @raise [ArgumentError] if worker_name specified but not found
58
+ #
59
+ # @example Single worker (auto-selected)
60
+ # config = Postburner::Configuration.load_yaml('config/postburner.yml', 'production')
61
+ #
62
+ # @example Multiple workers (must specify)
63
+ # config = Postburner::Configuration.load_yaml('config/postburner.yml', 'production', 'imports')
64
+ #
65
+ def self.load_yaml(path, env = 'development', worker_name = nil)
66
+ yaml = YAML.load_file(path)
67
+ env_config = yaml[env.to_s] || yaml[env.to_sym]
68
+
69
+ raise ArgumentError, "Environment '#{env}' not found in #{path}" unless env_config
70
+
71
+ workers = env_config['workers']
72
+ raise ArgumentError, "No 'workers:' section found in #{path} for environment '#{env}'" unless workers
73
+
74
+ # Auto-select single worker or validate worker_name
75
+ if worker_name.nil?
76
+ if workers.size == 1
77
+ worker_name = workers.keys.first
78
+ else
79
+ raise ArgumentError, "Configuration has multiple workers, but --worker not specified\nAvailable workers: #{workers.keys.join(', ')}\nUsage: bin/postburner --worker <name>"
80
+ end
81
+ else
82
+ unless workers.key?(worker_name)
83
+ raise ArgumentError, "Worker '#{worker_name}' not found in #{path}\nAvailable workers: #{workers.keys.join(', ')}"
84
+ end
85
+ end
86
+
87
+ worker_config = workers[worker_name]
88
+
89
+ # Convert queue array to hash format expected by rest of system
90
+ queue_list = worker_config['queues'] || []
91
+ queues_hash = {}
92
+ queue_list.each do |queue_name|
93
+ queues_hash[queue_name] = {}
94
+ end
95
+
96
+ options = {
97
+ beanstalk_url: env_config['beanstalk_url'],
98
+ queues: queues_hash,
99
+ default_queue: worker_config['default_queue'] || env_config['default_queue'],
100
+ default_priority: worker_config['default_priority'] || env_config['default_priority'],
101
+ default_ttr: worker_config['default_ttr'] || env_config['default_ttr'],
102
+ default_threads: worker_config['default_threads'] || env_config['default_threads'],
103
+ default_forks: worker_config['default_forks'] || env_config['default_forks'],
104
+ default_gc_limit: worker_config['default_gc_limit'] || env_config['default_gc_limit']
105
+ }
106
+
107
+ new(options)
108
+ end
109
+
110
+ # Returns queue configuration for a specific queue name.
111
+ #
112
+ # @param queue_name [String, Symbol] Name of the queue
113
+ #
114
+ # @return [Hash] Queue configuration with threads, gc_limit, etc.
115
+ #
116
+ # @example
117
+ # config.queue_config('critical') # => { threads: 1, gc_limit: 100 }
118
+ #
119
+ def queue_config(queue_name)
120
+ @queues[queue_name.to_s] || @queues[queue_name.to_sym] || {}
121
+ end
122
+
123
+ # Returns array of all configured queue names.
124
+ #
125
+ # @return [Array<String>] Queue names
126
+ #
127
+ # @example
128
+ # config.queue_names # => ['default', 'critical', 'mailers']
129
+ #
130
+ def queue_names
131
+ @queues.keys.map(&:to_s)
132
+ end
133
+
134
+ # Expands queue name to full tube name with environment prefix.
135
+ #
136
+ # Converts a simple queue name (e.g., 'default', 'critical') to the full
137
+ # Beanstalkd tube name with environment namespace (e.g.,
138
+ # 'postburner.production.critical').
139
+ #
140
+ # @param queue_name [String, Symbol, nil] Base queue name (defaults to configured default_queue)
141
+ # @param env [String, Symbol, nil] Environment name (defaults to Rails.env or 'development')
142
+ #
143
+ # @return [String] Full tube name with environment prefix
144
+ #
145
+ # @example
146
+ # config.expand_tube_name('critical', 'production')
147
+ # # => "postburner.production.critical"
148
+ #
149
+ # @example With configured default
150
+ # config.default_queue = 'background'
151
+ # config.expand_tube_name
152
+ # # => "postburner.development.background"
153
+ #
154
+ def expand_tube_name(queue_name = nil, env = nil)
155
+ env ||= defined?(Rails) ? Rails.env : nil
156
+ queue_name ||= @default_queue
157
+ [
158
+ 'postburner',
159
+ env,
160
+ queue_name,
161
+ ].compact.join('.')
162
+ end
163
+
164
+
165
+ # Returns array of expanded tube names with environment prefix.
166
+ #
167
+ # @param env [String, Symbol, nil] Environment name (defaults to Rails.env or 'development')
168
+ #
169
+ # @return [Array<String>] Array of expanded tube names
170
+ #
171
+ # @example
172
+ # config.expanded_tube_names('production') # => ['postburner.production.default', 'postburner.production.critical']
173
+ def expanded_tube_names(env = nil)
174
+ queue_names.map { |q| expand_tube_name(q, env) }
175
+ end
176
+ end
177
+
178
+ # Returns the global configuration instance.
179
+ #
180
+ # @return [Configuration] Global configuration
181
+ #
182
+ def self.configuration
183
+ @configuration ||= Configuration.new
184
+ end
185
+
186
+ # Configures Postburner via block.
187
+ #
188
+ # @yield [config] Configuration instance
189
+ # @yieldparam config [Configuration] The configuration to modify
190
+ #
191
+ # @return [void]
192
+ #
193
+ # @example
194
+ # Postburner.configure do |config|
195
+ # config.beanstalk_url = 'beanstalk://localhost:11300'
196
+ # config.worker_type = :threads_on_fork
197
+ # end
198
+ #
199
+ def self.configure
200
+ yield(configuration)
201
+ end
202
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Wrapper around Beaneater connection with automatic reconnection.
5
+ #
6
+ # Provides a simplified interface to Beanstalkd via Beaneater, with
7
+ # automatic connection management and reconnection on failures.
8
+ #
9
+ # @example Direct usage
10
+ # conn = Postburner::Connection.new
11
+ # conn.tubes['default'].put('job data')
12
+ #
13
+ # @example With reconnection
14
+ # conn = Postburner::Connection.new
15
+ # conn.reconnect! # Force fresh connection
16
+ #
17
+ class Connection
18
+ attr_reader :url
19
+
20
+ # @param url [String] Beanstalkd URL (e.g., 'beanstalk://localhost:11300')
21
+ #
22
+ def initialize(url = nil)
23
+ @url = url || Postburner.configuration.beanstalk_url
24
+ @pool = nil
25
+ connect!
26
+ end
27
+
28
+ # Returns the tubes interface for Beanstalkd operations.
29
+ #
30
+ # Automatically ensures connection is active before returning.
31
+ #
32
+ # @return [Beaneater::Tubes] Tubes interface
33
+ #
34
+ # @raise [Beaneater::NotConnected] if connection fails
35
+ #
36
+ # @example
37
+ # conn.tubes['critical'].put('{"job": "data"}')
38
+ # conn.tubes.watch!('default', 'critical')
39
+ #
40
+ def tubes
41
+ ensure_connected!
42
+ @pool.tubes
43
+ end
44
+
45
+ # Returns the underlying Beaneater pool.
46
+ #
47
+ # @return [Beaneater::Pool] Beaneater connection pool
48
+ #
49
+ # @example
50
+ # conn.beanstalk.stats
51
+ #
52
+ def beanstalk
53
+ ensure_connected!
54
+ @pool
55
+ end
56
+
57
+ # Checks if currently connected to Beanstalkd.
58
+ #
59
+ # @return [Boolean] true if connected, false otherwise
60
+ #
61
+ def connected?
62
+ @pool && @pool.respond_to?(:connection) && @pool.connection
63
+ rescue
64
+ false
65
+ end
66
+
67
+ # Forces reconnection to Beanstalkd.
68
+ #
69
+ # Closes existing connection (if any) and establishes a fresh one.
70
+ # Use this when connection has gone stale or after network issues.
71
+ #
72
+ # @return [void]
73
+ #
74
+ # @example
75
+ # conn.reconnect!
76
+ #
77
+ def reconnect!
78
+ close
79
+ connect!
80
+ end
81
+
82
+ # Closes the Beanstalkd connection.
83
+ #
84
+ # @return [void]
85
+ #
86
+ def close
87
+ @pool&.close rescue nil
88
+ @pool = nil
89
+ end
90
+
91
+ private
92
+
93
+ # Establishes connection to Beanstalkd.
94
+ #
95
+ # @return [void]
96
+ #
97
+ # @raise [Beaneater::NotConnected] if connection fails
98
+ #
99
+ def connect!
100
+ # Beaneater expects 'host:port' format, not 'beanstalk://host:port'
101
+ clean_url = @url.sub(%r{^beanstalk://}, '')
102
+ @pool = Beaneater.new(clean_url)
103
+ end
104
+
105
+ # Ensures connection is active, reconnecting if necessary.
106
+ #
107
+ # @return [void]
108
+ #
109
+ def ensure_connected!
110
+ reconnect! unless connected?
111
+ end
112
+ end
113
+ end
@@ -1,4 +1,4 @@
1
- require 'backburner'
1
+ require 'beaneater'
2
2
  require 'haml-rails'
3
3
 
4
4
  module Postburner