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.
@@ -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,176 @@
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
+ # Indicates whether the adapter supports enqueuing after transaction commit.
62
+ #
63
+ # Returns true because Postburner uses Beanstalkd (external queue),
64
+ # which means jobs are safely enqueued outside the database transaction.
65
+ # This allows Rails to automatically defer job enqueuing until after
66
+ # the current database transaction commits.
67
+ #
68
+ # @return [Boolean] Always returns true
69
+ #
70
+ def enqueue_after_transaction_commit?
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ # Checks if a job should be tracked in PostgreSQL.
77
+ #
78
+ # Detects tracking by checking if the job class includes the
79
+ # Postburner::Tracked module.
80
+ #
81
+ # @param job [ActiveJob::Base] The job instance
82
+ #
83
+ # @return [Boolean] true if tracked, false otherwise
84
+ #
85
+ def tracked?(job)
86
+ job.class.included_modules.include?(Postburner::Tracked)
87
+ end
88
+
89
+ # Enqueues a tracked job (with PostgreSQL audit trail).
90
+ #
91
+ # Creates a Postburner::TrackedJob record, then queues minimal payload
92
+ # to Beanstalkd with just the job ID reference.
93
+ #
94
+ # @param job [ActiveJob::Base] The job instance
95
+ # @param timestamp [Time, nil] When to execute the job
96
+ #
97
+ # @return [void]
98
+ #
99
+ def enqueue_tracked(job, timestamp)
100
+ # Create Postburner::TrackedJob record
101
+ tracked_job = Postburner::TrackedJob.create!(
102
+ args: Postburner::ActiveJob::Payload.serialize_for_tracked(job),
103
+ run_at: timestamp,
104
+ queued_at: Time.zone.now
105
+ )
106
+
107
+ # Calculate delay for Beanstalkd
108
+ delay = timestamp ? [(timestamp.to_f - Time.now.to_f).to_i, 0].max : 0
109
+
110
+ # Queue to Beanstalkd with minimal payload
111
+ Postburner.connected do |conn|
112
+ tube_name = expand_tube_name(job.queue_name)
113
+
114
+ # Get priority and TTR from class configuration or fall back to defaults
115
+ pri = job.class.respond_to?(:queue_priority) && job.class.queue_priority ||
116
+ Postburner.configuration.default_priority
117
+ ttr = job.class.respond_to?(:queue_ttr) && job.class.queue_ttr ||
118
+ Postburner.configuration.default_ttr
119
+
120
+ bkid = conn.tubes[tube_name].put(
121
+ Postburner::ActiveJob::Payload.tracked_payload(job, tracked_job.id),
122
+ pri: pri,
123
+ delay: delay,
124
+ ttr: ttr
125
+ )
126
+
127
+ # Update tracked_job with Beanstalkd ID
128
+ tracked_job.update_column(:bkid, bkid)
129
+ end
130
+ end
131
+
132
+ # Enqueues a default job (Beanstalkd only, no PostgreSQL).
133
+ #
134
+ # Queues full job data to Beanstalkd for fast execution without
135
+ # PostgreSQL overhead.
136
+ #
137
+ # @param job [ActiveJob::Base] The job instance
138
+ # @param timestamp [Time, nil] When to execute the job
139
+ #
140
+ # @return [void]
141
+ #
142
+ def enqueue_default(job, timestamp)
143
+ delay = timestamp ? [(timestamp.to_f - Time.now.to_f).to_i, 0].max : 0
144
+
145
+ Postburner.connected do |conn|
146
+ tube_name = expand_tube_name(job.queue_name)
147
+
148
+ # Get priority and TTR from class configuration or fall back to defaults
149
+ pri = job.class.respond_to?(:queue_priority) && job.class.queue_priority ||
150
+ Postburner.configuration.default_priority
151
+ ttr = job.class.respond_to?(:queue_ttr) && job.class.queue_ttr ||
152
+ Postburner.configuration.default_ttr
153
+
154
+ conn.tubes[tube_name].put(
155
+ Postburner::ActiveJob::Payload.default_payload(job),
156
+ pri: pri,
157
+ delay: delay,
158
+ ttr: ttr
159
+ )
160
+ end
161
+ end
162
+
163
+ # Expands queue name to full tube name with environment prefix.
164
+ #
165
+ # Delegates to Postburner::Configuration for consistent tube naming.
166
+ #
167
+ # @param queue_name [String] Queue name from ActiveJob
168
+ #
169
+ # @return [String] Full tube name (e.g., 'postburner.production.critical')
170
+ #
171
+ def expand_tube_name(queue_name)
172
+ Postburner.configuration.expand_tube_name(queue_name)
173
+ end
174
+ end
175
+ end
176
+ 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
@@ -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