pgbus 0.4.1 → 0.5.0

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,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pgbus
6
+ module Generators
7
+ # Converts a +config/pgbus.yml+ file into a Ruby initializer
8
+ # (+config/initializers/pgbus.rb+) using the Ruby DSL added in
9
+ # Pgbus 0.5+ — capsule string DSL, ActiveSupport::Duration coercion,
10
+ # auto-tuned pool size, named capsules, etc.
11
+ #
12
+ # Drops settings that:
13
+ # - match the gem default (no point restating it)
14
+ # - are deprecated (e.g. pool_size, which is now auto-tuned)
15
+ #
16
+ # Converts seconds to durations when they evenly divide into a clean
17
+ # unit (7 days, 30 days, 10 minutes). Falls back to the raw integer
18
+ # otherwise.
19
+ #
20
+ # When the YAML has multiple environments with different values for
21
+ # the same setting, emits Rails.env-aware code:
22
+ #
23
+ # - 2 envs with same value → unconditional line
24
+ # - 2 envs, one differs → `unless Rails.env.X?` modifier
25
+ # - 3+ envs with differences → `case Rails.env when ... end`
26
+ #
27
+ # Backwards compatible: the original YAML file is NOT touched. The
28
+ # generator's CLI wrapper writes the new initializer and tells the
29
+ # user to delete the YAML when ready.
30
+ class ConfigConverter
31
+ class Error < StandardError; end
32
+
33
+ # Setters that accept ActiveSupport::Duration (PR 5).
34
+ DURATION_SETTINGS = %w[
35
+ visibility_timeout archive_retention idempotency_ttl
36
+ outbox_retention stats_retention recurring_execution_retention
37
+ ].freeze
38
+
39
+ # Settings that no longer exist in the public API. The converter
40
+ # silently drops these from the generated initializer so users on
41
+ # legacy YAML get a clean migration.
42
+ #
43
+ # - pool_size -> auto-tuned from worker thread counts
44
+ # - notify_throttle_ms -> Pgbus::Client::NOTIFY_THROTTLE_MS
45
+ # - circuit_breaker_* -> Pgbus::CircuitBreaker constants
46
+ # - archive_compaction_* -> Pgbus::Process::Dispatcher constants
47
+ # - dead_letter_queue_suffix -> Pgbus::DEAD_LETTER_SUFFIX (frozen)
48
+ DEPRECATED_SETTINGS = %w[
49
+ pool_size
50
+ notify_throttle_ms
51
+ circuit_breaker_threshold circuit_breaker_base_backoff circuit_breaker_max_backoff
52
+ archive_compaction_interval archive_compaction_batch_size
53
+ dead_letter_queue_suffix
54
+ ].freeze
55
+
56
+ # Settings whose default we know how to compute by inspecting
57
+ # Pgbus::Configuration.new. Any setting not listed here is emitted
58
+ # as-is (we can't tell if it matches the default).
59
+ KNOWN_SETTINGS = %w[
60
+ queue_prefix default_queue pool_timeout listen_notify
61
+ visibility_timeout max_retries idempotency_ttl
62
+ max_jobs_per_worker max_memory_mb max_worker_lifetime
63
+ dispatch_interval prefetch_limit
64
+ circuit_breaker_enabled
65
+ archive_retention
66
+ outbox_enabled outbox_poll_interval outbox_batch_size outbox_retention
67
+ stats_enabled stats_retention
68
+ recurring_schedule_interval recurring_execution_retention skip_recurring
69
+ polling_interval default_priority priority_levels
70
+ return_to_app_url workers
71
+ ].freeze
72
+
73
+ def self.from_yaml(path)
74
+ raise Error, "config file not found: #{path}" unless File.exist?(path)
75
+
76
+ parsed = YAML.safe_load_file(path, aliases: true, permitted_classes: [Symbol])
77
+ from_hash(parsed)
78
+ end
79
+
80
+ def self.from_hash(envs_hash)
81
+ new(envs_hash).render
82
+ end
83
+
84
+ def initialize(envs_hash)
85
+ @envs = (envs_hash || {}).reject { |env, _| env.start_with?("default") }
86
+ @envs = { "production" => envs_hash } if @envs.empty? && envs_hash
87
+ @defaults = build_defaults
88
+ end
89
+
90
+ def render
91
+ lines = []
92
+ lines << "# frozen_string_literal: true"
93
+ lines << "#"
94
+ lines << "# Generated by `rails generate pgbus:update` from config/pgbus.yml."
95
+ lines << "# Review and adjust as needed, then delete config/pgbus.yml."
96
+ lines << ""
97
+ lines << "Pgbus.configure do |c|"
98
+
99
+ body = render_body
100
+ body.each { |line| lines << " #{line}" }
101
+
102
+ lines << "end"
103
+ "#{lines.join("\n")}\n"
104
+ end
105
+
106
+ private
107
+
108
+ def build_defaults
109
+ config = Pgbus::Configuration.new
110
+ KNOWN_SETTINGS.each_with_object({}) do |key, h|
111
+ h[key] = config.public_send(key) if config.respond_to?(key)
112
+ end
113
+ end
114
+
115
+ def render_body
116
+ all_settings = collect_all_settings
117
+ return [] if all_settings.empty?
118
+
119
+ constant_settings, varying_settings = partition_by_variance(all_settings)
120
+ special_keys = %w[workers]
121
+
122
+ lines = []
123
+ # Special: workers comes first (most user-visible)
124
+ special_keys.each do |key|
125
+ rendered = render_workers(constant_settings[key], varying_settings[key])
126
+ lines.concat(rendered) if rendered
127
+ end
128
+
129
+ constant_settings.each do |key, value|
130
+ next if special_keys.include?(key)
131
+ next if drop?(key, value)
132
+
133
+ lines << render_setting(key, value)
134
+ end
135
+
136
+ varying_settings.each do |key, env_values|
137
+ next if special_keys.include?(key)
138
+ next if env_values.values.all? { |v| drop?(key, v) }
139
+
140
+ lines.concat(render_varying_setting(key, env_values))
141
+ end
142
+
143
+ lines
144
+ end
145
+
146
+ def collect_all_settings
147
+ all_keys = @envs.values.flat_map { |env_settings| env_settings&.keys || [] }.uniq
148
+ all_keys.to_h do |key|
149
+ [key, @envs.transform_values { |env_settings| env_settings&.fetch(key, :__missing__) }]
150
+ end
151
+ end
152
+
153
+ # Returns [constant_settings, varying_settings].
154
+ # constant_settings: { "key" => value } (same value across all envs)
155
+ # varying_settings: { "key" => { env => value, ... } }
156
+ def partition_by_variance(all_settings)
157
+ constant = {}
158
+ varying = {}
159
+ all_settings.each do |key, env_values|
160
+ present_values = env_values.reject { |_, v| v == :__missing__ }
161
+ unique_values = present_values.values.uniq
162
+ if unique_values.size <= 1
163
+ constant[key] = unique_values.first
164
+ else
165
+ varying[key] = present_values
166
+ end
167
+ end
168
+ [constant, varying]
169
+ end
170
+
171
+ def drop?(key, value)
172
+ return true if DEPRECATED_SETTINGS.include?(key)
173
+ return true if value == :__missing__
174
+ return true if @defaults.key?(key) && @defaults[key] == value
175
+
176
+ false
177
+ end
178
+
179
+ def render_setting(key, value)
180
+ "c.#{key} = #{render_value(key, value)}"
181
+ end
182
+
183
+ def render_varying_setting(key, env_values)
184
+ envs = env_values.keys
185
+ if envs.size == 2 && envs.include?("development")
186
+ # Special case: "everything except dev" — common pattern
187
+ non_dev_value = env_values.except("development").values.first
188
+ dev_value = env_values["development"]
189
+ return ["c.#{key} = #{render_value(key, non_dev_value)} unless Rails.env.development?"] if dev_value.nil? && non_dev_value
190
+ end
191
+
192
+ # Fallback: case Rails.env block — `when` clauses indented one
193
+ # level inside the case, `end` flush with `c.X` (standard Ruby
194
+ # formatting for assigned case expressions).
195
+ lines = ["c.#{key} = case Rails.env"]
196
+ env_values.each do |env, value|
197
+ lines << " when \"#{env}\" then #{render_value(key, value)}"
198
+ end
199
+ lines << "end"
200
+ lines
201
+ end
202
+
203
+ def render_workers(constant_workers, varying_workers)
204
+ if constant_workers && !varying_workers
205
+ rendered = render_workers_value(constant_workers)
206
+ return rendered unless rendered.nil? || rendered.empty?
207
+ end
208
+
209
+ if varying_workers
210
+ # Different worker config per env. when clauses indented one
211
+ # level inside the case, end flush with c.workers (standard
212
+ # Ruby formatting for assigned case expressions).
213
+ lines = ["c.workers = case Rails.env"]
214
+ varying_workers.each do |env, workers|
215
+ string_form = workers_as_string(workers)
216
+ value = string_form ? string_form.inspect : workers.inspect
217
+ lines << " when \"#{env}\" then #{value}"
218
+ end
219
+ lines << "end"
220
+ return lines
221
+ end
222
+
223
+ nil
224
+ end
225
+
226
+ def render_workers_value(workers)
227
+ return nil if workers.nil? || workers == :__missing__
228
+
229
+ # Drop if matches the gem default ([{queues: %w[default], threads: 5}]).
230
+ # Compare via normalized form (string keys both sides) so YAML's
231
+ # string-keyed hashes match the symbol-keyed default.
232
+ return nil if normalize_workers(workers) == normalize_workers(@defaults["workers"])
233
+
234
+ if workers_simple?(workers)
235
+ string_form = workers_as_string(workers)
236
+ ["c.workers = #{string_form.inspect}"]
237
+ else
238
+ workers.map.with_index do |capsule, idx|
239
+ render_capsule_call(capsule, idx)
240
+ end
241
+ end
242
+ end
243
+
244
+ # Normalize a workers array to symbol-keyed hashes with array-of-string
245
+ # queues for stable comparison. Doesn't mutate input.
246
+ def normalize_workers(workers)
247
+ return nil if workers.nil?
248
+
249
+ workers.map do |capsule|
250
+ {
251
+ queues: Array(capsule[:queues] || capsule["queues"]).map(&:to_s),
252
+ threads: (capsule[:threads] || capsule["threads"] || 5).to_i
253
+ }
254
+ end
255
+ end
256
+
257
+ def workers_simple?(workers)
258
+ workers.all? do |capsule|
259
+ extras = capsule.keys.map(&:to_s) - %w[queues threads]
260
+ extras.empty?
261
+ end
262
+ end
263
+
264
+ def workers_as_string(workers)
265
+ workers.map do |capsule|
266
+ queues = (capsule["queues"] || capsule[:queues]).join(", ")
267
+ threads = capsule["threads"] || capsule[:threads] || 5
268
+ "#{queues}: #{threads}"
269
+ end.join("; ")
270
+ end
271
+
272
+ def render_capsule_call(capsule, _idx)
273
+ queues = capsule["queues"] || capsule[:queues]
274
+ threads = capsule["threads"] || capsule[:threads] || 5
275
+ name = capsule["name"] || capsule[:name] || queues.first
276
+ opts = capsule.reject { |k, _| %w[queues threads name].include?(k.to_s) }
277
+
278
+ parts = ["queues: #{queues.inspect}", "threads: #{threads}"]
279
+ opts.each { |k, v| parts << "#{k}: #{v.inspect}" }
280
+
281
+ "c.capsule :#{name}, #{parts.join(", ")}"
282
+ end
283
+
284
+ def render_value(key, value)
285
+ return "nil" if value.nil?
286
+
287
+ if DURATION_SETTINGS.include?(key) && value.is_a?(Integer) && value.positive?
288
+ duration_form = format_duration(value)
289
+ return duration_form if duration_form
290
+ end
291
+
292
+ if value.is_a?(Integer) && value >= 1000
293
+ format_integer_with_underscores(value)
294
+ else
295
+ value.inspect
296
+ end
297
+ end
298
+
299
+ def format_duration(seconds)
300
+ units = [
301
+ [86_400, "day", "days"],
302
+ [3600, "hour", "hours"],
303
+ [60, "minute", "minutes"],
304
+ [1, "second", "seconds"]
305
+ ]
306
+
307
+ units.each do |unit_seconds, singular, plural|
308
+ next unless (seconds % unit_seconds).zero?
309
+
310
+ count = seconds / unit_seconds
311
+ unit_name = count == 1 ? singular : plural
312
+ return "#{count}.#{unit_name}"
313
+ end
314
+
315
+ nil
316
+ end
317
+
318
+ def format_integer_with_underscores(int)
319
+ int.to_s.reverse.scan(/\d{1,3}/).join("_").reverse
320
+ end
321
+ end
322
+ end
323
+ end
@@ -16,6 +16,12 @@ module Pgbus
16
16
  JOB_LOCK_CLEANUP_INTERVAL = 300 # Run job lock cleanup every 5 minutes
17
17
  STATS_CLEANUP_INTERVAL = 3600 # Run stats cleanup every hour
18
18
 
19
+ # Page size for archive compaction. Each cycle deletes up to this
20
+ # many archived rows per queue. Tuned via constant rather than
21
+ # configuration because the value rarely needs adjusting and a
22
+ # too-small value just delays cleanup, never breaks anything.
23
+ ARCHIVE_COMPACTION_BATCH_SIZE = 1000
24
+
19
25
  attr_reader :config
20
26
 
21
27
  def initialize(config: Pgbus.configuration)
@@ -72,7 +78,7 @@ module Pgbus
72
78
  run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
73
79
  run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
74
80
  run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
75
- run_if_due(now, :@last_archive_compaction_at, archive_compaction_interval) { compact_archives }
81
+ run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
76
82
  run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
77
83
  run_if_due(now, :@last_job_lock_cleanup_at, JOB_LOCK_CLEANUP_INTERVAL) { cleanup_job_locks }
78
84
  run_if_due(now, :@last_stats_cleanup_at, STATS_CLEANUP_INTERVAL) { cleanup_stats }
@@ -211,16 +217,12 @@ module Pgbus
211
217
  Pgbus.logger.warn { "[Pgbus] Outbox cleanup failed: #{e.message}" }
212
218
  end
213
219
 
214
- def archive_compaction_interval
215
- config.archive_compaction_interval || ARCHIVE_COMPACTION_INTERVAL
216
- end
217
-
218
220
  def compact_archives
219
221
  retention = config.archive_retention
220
222
  return unless retention&.positive?
221
223
 
222
224
  cutoff = Time.current - retention
223
- batch_size = config.archive_compaction_batch_size || 1000
225
+ batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
224
226
  prefix = config.queue_prefix
225
227
 
226
228
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
@@ -42,22 +42,17 @@ module Pgbus
42
42
  private
43
43
 
44
44
  def boot_processes
45
- # Boot workers
46
- config.workers.each do |worker_config|
47
- fork_worker(worker_config)
48
- end
49
-
50
- # Boot dispatcher
51
- fork_dispatcher
52
-
53
- # Boot recurring scheduler if configured
54
- boot_scheduler
55
-
56
- # Boot event consumers if configured
57
- boot_consumers
58
-
59
- # Boot outbox poller if configured
60
- boot_outbox_poller
45
+ # Boot workers (workers may be nil for scheduler-only or
46
+ # dispatcher-only deployments via --workers-only / --scheduler-only /
47
+ # --dispatcher-only CLI flags). Each role is gated by
48
+ # config.role_enabled?, which returns true unless +config.roles+ has
49
+ # been narrowed.
50
+ Array(config.workers).each { |worker_config| fork_worker(worker_config) } if config.role_enabled?(:workers)
51
+
52
+ fork_dispatcher if config.role_enabled?(:dispatcher)
53
+ boot_scheduler if config.role_enabled?(:scheduler)
54
+ boot_consumers if config.role_enabled?(:consumers)
55
+ boot_outbox_poller if config.role_enabled?(:outbox)
61
56
  end
62
57
 
63
58
  def fork_worker(worker_config)
@@ -217,7 +217,7 @@ module Pgbus
217
217
  def resolve_wildcard_queues
218
218
  return unless @wildcard
219
219
 
220
- dlq_suffix = config.dead_letter_queue_suffix
220
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
221
221
  prefix = "#{config.queue_prefix}_"
222
222
 
223
223
  conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -16,7 +16,7 @@ module Pgbus
16
16
  queues = queues_with_metrics
17
17
  total_depth = queues.sum { |q| q[:queue_length] }
18
18
  total_visible = queues.sum { |q| q[:queue_visible_length] }
19
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
19
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
20
20
  dlq_depth = queues.select { |q| q[:name].end_with?(dlq_suffix) }.sum { |q| q[:queue_length] }
21
21
 
22
22
  throughput = compute_throughput(queues)
@@ -120,7 +120,7 @@ module Pgbus
120
120
  end
121
121
 
122
122
  def discard_all_enqueued
123
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
123
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
124
124
  queues = queues_with_metrics.reject { |q| q[:name].end_with?(dlq_suffix) }
125
125
  total = 0
126
126
 
@@ -259,7 +259,7 @@ module Pgbus
259
259
  # Note: DLQ queue names from queues_with_metrics are already fully qualified
260
260
  # (e.g., "pgbus_default_dlq"), so we use them directly without re-prefixing.
261
261
  def dlq_messages(page: 1, per_page: 25)
262
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
262
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
263
263
  queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
264
264
  offset = (page - 1) * per_page
265
265
 
@@ -274,7 +274,7 @@ module Pgbus
274
274
  end
275
275
 
276
276
  def dlq_message_detail(msg_id)
277
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
277
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
278
278
  queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
279
279
  queues.each do |q|
280
280
  row = connection.select_one(
@@ -292,7 +292,7 @@ module Pgbus
292
292
 
293
293
  def retry_dlq_message(queue_name, msg_id)
294
294
  # queue_name here is the full DLQ name (already prefixed)
295
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
295
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
296
296
  original_queue = queue_name.delete_suffix(dlq_suffix)
297
297
 
298
298
  row = connection.select_one(
@@ -664,7 +664,7 @@ module Pgbus
664
664
  end
665
665
 
666
666
  def all_queue_messages(limit, offset)
667
- dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix
667
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
668
668
  queues = queues_with_metrics.reject { |q| q[:name].end_with?(dlq_suffix) }
669
669
  messages = queues.flat_map do |q|
670
670
  query_queue_messages_raw(q[:name], limit + offset, 0)
data/lib/pgbus.rb CHANGED
@@ -3,6 +3,13 @@
3
3
  require "zeitwerk"
4
4
 
5
5
  module Pgbus
6
+ # Suffix appended to a queue name to derive its dead-letter companion
7
+ # (e.g. "pgbus_default" -> "pgbus_default_dlq"). Hard-coded here because
8
+ # changing it on a running deployment would orphan every existing DLQ
9
+ # message; nothing in the codebase or in user reports has ever needed
10
+ # this to be configurable.
11
+ DEAD_LETTER_SUFFIX = "_dlq"
12
+
6
13
  class Error < StandardError; end
7
14
  class ConfigurationError < Error; end
8
15
  class SerializationError < Error; end
@@ -24,7 +31,12 @@ module Pgbus
24
31
  def loader
25
32
  @loader ||= begin
26
33
  loader = Zeitwerk::Loader.for_gem
27
- loader.inflector.inflect("pgbus" => "Pgbus", "cli" => "CLI", "dsl" => "DSL")
34
+ loader.inflector.inflect(
35
+ "pgbus" => "Pgbus",
36
+ "cli" => "CLI",
37
+ "dsl" => "DSL",
38
+ "capsule_dsl" => "CapsuleDSL"
39
+ )
28
40
  loader.ignore("#{__dir__}/generators")
29
41
  loader.ignore("#{__dir__}/active_job")
30
42
  loader
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -221,6 +221,7 @@ files:
221
221
  - lib/generators/pgbus/templates/pgbus_binstub.erb
222
222
  - lib/generators/pgbus/templates/recurring.yml.erb
223
223
  - lib/generators/pgbus/templates/upgrade_pgmq.rb.erb
224
+ - lib/generators/pgbus/update_generator.rb
224
225
  - lib/generators/pgbus/upgrade_pgmq_generator.rb
225
226
  - lib/pgbus.rb
226
227
  - lib/pgbus/active_job/adapter.rb
@@ -235,6 +236,7 @@ files:
235
236
  - lib/pgbus/concurrency/semaphore.rb
236
237
  - lib/pgbus/config_loader.rb
237
238
  - lib/pgbus/configuration.rb
239
+ - lib/pgbus/configuration/capsule_dsl.rb
238
240
  - lib/pgbus/dedup_cache.rb
239
241
  - lib/pgbus/engine.rb
240
242
  - lib/pgbus/event.rb
@@ -243,6 +245,7 @@ files:
243
245
  - lib/pgbus/event_bus/registry.rb
244
246
  - lib/pgbus/event_bus/subscriber.rb
245
247
  - lib/pgbus/failed_event_recorder.rb
248
+ - lib/pgbus/generators/config_converter.rb
246
249
  - lib/pgbus/instrumentation.rb
247
250
  - lib/pgbus/outbox.rb
248
251
  - lib/pgbus/outbox/poller.rb