pgbus 0.4.1 → 0.5.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.
@@ -13,9 +13,7 @@ module Pgbus
13
13
 
14
14
  def retry
15
15
  queue_name = params[:queue_name].to_s
16
- unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
17
- return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue."
18
- end
16
+ return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue." unless queue_name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
19
17
 
20
18
  if data_source.retry_dlq_message(queue_name, params[:id])
21
19
  redirect_to dead_letter_index_path, notice: "Message re-enqueued to original queue."
@@ -26,9 +24,7 @@ module Pgbus
26
24
 
27
25
  def discard
28
26
  queue_name = params[:queue_name].to_s
29
- unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
30
- return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue."
31
- end
27
+ return redirect_to dead_letter_index_path, alert: "Invalid DLQ queue." unless queue_name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
32
28
 
33
29
  if data_source.discard_dlq_message(queue_name, params[:id])
34
30
  redirect_to dead_letter_index_path, notice: "Message discarded."
@@ -57,7 +53,7 @@ module Pgbus
57
53
  count = 0
58
54
  selections.each do |sel|
59
55
  queue_name = sel[:queue_name].to_s
60
- next unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
56
+ next unless queue_name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
61
57
 
62
58
  count += 1 if data_source.discard_dlq_message(queue_name, sel[:msg_id])
63
59
  end
@@ -24,8 +24,7 @@
24
24
  <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
25
25
  <% @messages.each do |m| %>
26
26
  <% payload = pgbus_parse_message(m[:message]) %>
27
- <% dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix %>
28
- <% source_queue = m[:queue_name].to_s.delete_suffix(dlq_suffix) %>
27
+ <% source_queue = m[:queue_name].to_s.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX) %>
29
28
  <tr>
30
29
  <td class="w-10 px-4 py-3 align-top">
31
30
  <input type="checkbox" data-bulk-item data-pgbus-action="bulk-row-toggle"
@@ -8,13 +8,15 @@ default: &default
8
8
  # Default queue name (without prefix)
9
9
  default_queue: default
10
10
 
11
- # Connection pool for PGMQ client
12
- pool_size: 5
11
+ # Connection pool for PGMQ client.
12
+ # pool_size auto-tunes from your worker thread counts:
13
+ # sum(workers.threads) + sum(event_consumers.threads) + 2
14
+ # Set explicitly only if you need a tighter or looser pool than that.
15
+ # pool_size: 5
13
16
  pool_timeout: 5
14
17
 
15
18
  # Use PostgreSQL LISTEN/NOTIFY for instant job wake-up
16
19
  listen_notify: true
17
- notify_throttle_ms: 250
18
20
 
19
21
  # Visibility timeout in seconds (how long a message is invisible after being read)
20
22
  visibility_timeout: 30
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "pgbus/generators/config_converter"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ # Converts an existing config/pgbus.yml to a Ruby initializer at
9
+ # config/initializers/pgbus.rb using the modern DSL.
10
+ #
11
+ # The original YAML file is left in place — the user reviews the
12
+ # generated initializer and deletes the YAML when ready.
13
+ #
14
+ # Usage:
15
+ #
16
+ # bin/rails generate pgbus:update
17
+ # bin/rails generate pgbus:update --force # overwrite existing initializer
18
+ # bin/rails generate pgbus:update --source=path/to/pgbus.yml
19
+ class UpdateGenerator < Rails::Generators::Base
20
+ desc "Convert config/pgbus.yml to config/initializers/pgbus.rb using the Ruby DSL"
21
+
22
+ class_option :source,
23
+ type: :string,
24
+ default: "config/pgbus.yml",
25
+ desc: "Path to the existing YAML config (default: config/pgbus.yml)"
26
+
27
+ class_option :destination,
28
+ type: :string,
29
+ default: "config/initializers/pgbus.rb",
30
+ desc: "Path to the generated initializer (default: config/initializers/pgbus.rb)"
31
+
32
+ def convert
33
+ source_path = File.expand_path(options[:source], destination_root)
34
+ destination_path = File.expand_path(options[:destination], destination_root)
35
+
36
+ # Thor::Error is the idiomatic way to abort a Rails generator. Thor
37
+ # catches it, prints the message in red, and exits with status 1
38
+ # without a Ruby backtrace. exit 1 would skip the framework's
39
+ # cleanup hooks and is hard to test.
40
+ raise Thor::Error, "Source file not found: #{options[:source]}" unless File.exist?(source_path)
41
+
42
+ ruby_source = load_and_convert(source_path)
43
+ create_file destination_path, ruby_source
44
+ end
45
+
46
+ def display_post_install
47
+ say ""
48
+ say "Pgbus initializer generated at #{options[:destination]}!", :green
49
+ say ""
50
+ say "Next steps:"
51
+ say " 1. Review the generated initializer for correctness"
52
+ say " 2. Boot your app and verify everything still works"
53
+ say " 3. Delete #{options[:source]} when satisfied (Pgbus will stop reading it)"
54
+ say ""
55
+ say "If you spot a setting that didn't translate cleanly, please open an issue:"
56
+ say " https://github.com/mhenrixon/pgbus/issues", :cyan
57
+ say ""
58
+ end
59
+
60
+ private
61
+
62
+ # Wrap converter and YAML errors as Thor::Error so the generator
63
+ # surfaces them with the standard "in red, no backtrace, exit 1"
64
+ # behavior. Catches:
65
+ # - ConfigConverter::Error (validation, missing file race)
66
+ # - Psych::Exception (malformed YAML, disallowed types)
67
+ # - Errno::ENOENT / Errno::EACCES (file disappeared / not readable)
68
+ def load_and_convert(source_path)
69
+ ConfigConverter.from_yaml(source_path)
70
+ rescue ConfigConverter::Error, Psych::Exception, Errno::ENOENT, Errno::EACCES => e
71
+ raise Thor::Error, "Failed to convert #{options[:source]}: #{e.message}"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -4,6 +4,20 @@ require "concurrent"
4
4
 
5
5
  module Pgbus
6
6
  class CircuitBreaker
7
+ # Number of consecutive failures on a queue before the breaker trips
8
+ # and pauses the queue. Tuned via constants rather than configuration
9
+ # because the value rarely needs adjusting and exposing it as a setting
10
+ # never proved useful in practice.
11
+ THRESHOLD = 5
12
+
13
+ # Initial backoff (seconds) on the first trip. Doubles on each
14
+ # subsequent trip up to MAX_BACKOFF.
15
+ BASE_BACKOFF = 30
16
+
17
+ # Cap on the exponential backoff (seconds). After ~5 trips the curve
18
+ # plateaus here so a perpetually-failing queue stops spamming retries.
19
+ MAX_BACKOFF = 600
20
+
7
21
  attr_reader :config
8
22
 
9
23
  def initialize(config: Pgbus.configuration)
@@ -22,7 +36,7 @@ module Pgbus
22
36
 
23
37
  count = @failure_counts.compute(queue_name) { |val| (val || 0) + 1 }
24
38
 
25
- return unless count >= config.circuit_breaker_threshold
39
+ return unless count >= THRESHOLD
26
40
 
27
41
  trip!(queue_name, count)
28
42
  end
@@ -105,8 +119,8 @@ module Pgbus
105
119
  end
106
120
 
107
121
  def calculate_backoff(trip_count)
108
- backoff = config.circuit_breaker_base_backoff * (2**(trip_count - 1))
109
- [backoff, config.circuit_breaker_max_backoff].min
122
+ backoff = BASE_BACKOFF * (2**(trip_count - 1))
123
+ [backoff, MAX_BACKOFF].min
110
124
  end
111
125
  end
112
126
  end
data/lib/pgbus/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "optparse"
4
+
3
5
  module Pgbus
4
6
  module CLI
5
7
  module_function
@@ -9,7 +11,7 @@ module Pgbus
9
11
 
10
12
  case command
11
13
  when "start"
12
- start_supervisor
14
+ start_supervisor(args[1..] || [])
13
15
  when "status"
14
16
  show_status
15
17
  when "queues"
@@ -25,13 +27,89 @@ module Pgbus
25
27
  end
26
28
  end
27
29
 
28
- def start_supervisor
30
+ def start_supervisor(args = [])
31
+ apply_start_options(args)
29
32
  Pgbus.logger.info { "[Pgbus] Starting Pgbus #{Pgbus::VERSION}..." }
30
33
 
31
34
  supervisor = Process::Supervisor.new
32
35
  supervisor.run
33
36
  end
34
37
 
38
+ # Parses CLI flags for `pgbus start` and applies them to the global
39
+ # configuration before the supervisor boots. Designed to override the
40
+ # initializer config without requiring a redeploy.
41
+ def apply_start_options(args)
42
+ options = parse_start_options(args)
43
+
44
+ Pgbus.configuration.workers = options[:queues] if options[:queues]
45
+ apply_capsule_filter(options[:capsule]) if options[:capsule]
46
+ apply_role_filter(options)
47
+ end
48
+
49
+ def parse_start_options(args)
50
+ options = {}
51
+ OptionParser.new do |opts|
52
+ opts.banner = "Usage: pgbus start [options]"
53
+
54
+ opts.on("--queues STRING", "Override worker capsules (e.g. \"critical: 5; default: 10\")") do |v|
55
+ options[:queues] = v
56
+ end
57
+
58
+ opts.on("--capsule NAME", "Run only the named capsule from the configured workers") do |v|
59
+ options[:capsule] = v
60
+ end
61
+
62
+ opts.on("--workers-only", "Run only the worker processes (no scheduler/dispatcher/consumers/outbox)") do
63
+ options[:workers_only] = true
64
+ end
65
+
66
+ opts.on("--scheduler-only", "Run only the recurring scheduler (the cron pod pattern)") do
67
+ options[:scheduler_only] = true
68
+ end
69
+
70
+ opts.on("--dispatcher-only", "Run only the dispatcher (the maintenance pod pattern)") do
71
+ options[:dispatcher_only] = true
72
+ end
73
+ end.parse!(args.dup)
74
+ options
75
+ end
76
+
77
+ ROLE_FLAG_TO_ROLE = {
78
+ workers_only: :workers,
79
+ scheduler_only: :scheduler,
80
+ dispatcher_only: :dispatcher
81
+ }.freeze
82
+ private_constant :ROLE_FLAG_TO_ROLE
83
+
84
+ # Translates --workers-only / --scheduler-only / --dispatcher-only into
85
+ # the corresponding +Pgbus.configuration.roles+ array. Mutually exclusive —
86
+ # passing more than one of the three flags raises ArgumentError.
87
+ def apply_role_filter(options)
88
+ role_flags = options.slice(*ROLE_FLAG_TO_ROLE.keys).compact
89
+ return if role_flags.empty?
90
+
91
+ if role_flags.size > 1
92
+ raise ArgumentError,
93
+ "--workers-only, --scheduler-only, and --dispatcher-only are mutually exclusive " \
94
+ "(got: #{role_flags.keys.map { |k| "--#{k.to_s.tr("_", "-")}" }.join(", ")})"
95
+ end
96
+
97
+ Pgbus.configuration.roles = [ROLE_FLAG_TO_ROLE.fetch(role_flags.keys.first)]
98
+ end
99
+
100
+ def apply_capsule_filter(name)
101
+ capsule = Pgbus.configuration.capsule_named(name)
102
+ unless capsule
103
+ available = (Pgbus.configuration.workers || []).filter_map { |c| c[:name] || c["name"] }
104
+ raise ArgumentError,
105
+ "no capsule named #{name.inspect} (available: #{available.join(", ")})"
106
+ end
107
+
108
+ # Go through the public setter so any future normalization/validation
109
+ # in workers= is applied consistently to the CLI override path too.
110
+ Pgbus.configuration.workers = [capsule]
111
+ end
112
+
35
113
  def show_status
36
114
  processes = ProcessEntry.order(:kind, :created_at)
37
115
  .select(:kind, :hostname, :pid, :metadata, :last_heartbeat_at)
@@ -65,7 +143,7 @@ module Pgbus
65
143
 
66
144
  def print_help
67
145
  puts <<~HELP
68
- Usage: pgbus <command>
146
+ Usage: pgbus <command> [options]
69
147
 
70
148
  Commands:
71
149
  start Start the Pgbus supervisor (workers + dispatcher)
@@ -73,6 +151,20 @@ module Pgbus
73
151
  queues List queues with metrics
74
152
  version Show version
75
153
  help Show this help
154
+
155
+ Options for `start`:
156
+ --queues STRING Override worker capsules from the CLI
157
+ (e.g. "critical: 5; default: 10")
158
+ --capsule NAME Run only the named capsule from the configured
159
+ workers (useful for one-capsule-per-process
160
+ deployments)
161
+ --workers-only Run only worker processes (no scheduler/
162
+ dispatcher/consumers/outbox — for worker-only
163
+ containers)
164
+ --scheduler-only Run only the recurring scheduler (the cron pod
165
+ pattern — exactly one of these per deployment)
166
+ --dispatcher-only Run only the dispatcher (the maintenance pod
167
+ pattern)
76
168
  HELP
77
169
  end
78
170
  end
data/lib/pgbus/client.rb CHANGED
@@ -9,6 +9,14 @@ module Pgbus
9
9
  PGMQ_REQUIRE_MUTEX = Mutex.new
10
10
  private_constant :PGMQ_REQUIRE_MUTEX
11
11
 
12
+ # Throttle window for PGMQ's enable_notify_insert trigger. Postgres
13
+ # NOTIFYs are coalesced into one wake-up per window, so a value of 250ms
14
+ # means: at most 4 broadcasts/sec per queue, regardless of insert rate.
15
+ # The trigger is a Postgres-level concern; exposing it as a setting
16
+ # never came up in practice and changing it on the fly would require
17
+ # re-running the trigger DDL on every queue.
18
+ NOTIFY_THROTTLE_MS = 250
19
+
12
20
  def initialize(config = Pgbus.configuration)
13
21
  # Define the PGMQ module before requiring the gem so that Zeitwerk's
14
22
  # eager_load (called inside pgmq.rb) can resolve the constant.
@@ -33,9 +41,10 @@ module Pgbus
33
41
  else
34
42
  # With a String URL or Hash params, pgmq-ruby creates its own dedicated
35
43
  # PG::Connection per pool slot — no shared state with ActiveRecord.
36
- # Use the configured pool_size and let pgmq-ruby's connection_pool handle
44
+ # Use the resolved pool size (auto-tuned from worker thread counts
45
+ # unless explicitly set) and let pgmq-ruby's connection_pool handle
37
46
  # concurrency internally (no mutex needed).
38
- @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.pool_size, pool_timeout: config.pool_timeout)
47
+ @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
39
48
  @pgmq_mutex = nil
40
49
  end
41
50
 
@@ -431,7 +440,7 @@ module Pgbus
431
440
  @queues_created.compute_if_absent(full_name) do
432
441
  synchronized do
433
442
  @pgmq.create(full_name)
434
- @pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
443
+ @pgmq.enable_notify_insert(full_name, throttle_interval_ms: NOTIFY_THROTTLE_MS) if config.listen_notify
435
444
  end
436
445
  true
437
446
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Configuration
5
+ # Parses the capsule string DSL into the internal worker config array.
6
+ #
7
+ # The DSL lets users describe worker thread pools (capsules) compactly:
8
+ #
9
+ # "*: 5" # one capsule, all queues, 5 threads
10
+ # "critical: 5; default: 10" # two capsules
11
+ # "critical, default: 5" # one capsule, list = strict priority
12
+ # "default, mailers: 10; *: 2" # specialized + fallback wildcard
13
+ # "staging_*: 3" # prefix wildcard
14
+ #
15
+ # Operators:
16
+ # , queue separator within a capsule (list order = strict priority)
17
+ # ; capsule separator (each becomes its own thread pool)
18
+ # :N thread count for the capsule (default 5)
19
+ # * wildcard, matches all queues
20
+ # *_ trailing wildcard, prefix match (e.g. "staging_*")
21
+ #
22
+ # Returns +Array<Hash>+ in the same shape as the legacy +workers:+ array,
23
+ # so the rest of the codebase can consume it without changes:
24
+ #
25
+ # [{ queues: ["critical"], threads: 5 },
26
+ # { queues: ["default", "mailers"], threads: 10 }]
27
+ #
28
+ # Validation runs at parse time. Errors include the offending input and
29
+ # a clear message naming the rule that was violated.
30
+ class CapsuleDSL
31
+ DEFAULT_THREADS = 5
32
+ WILDCARD = "*"
33
+
34
+ # Valid queue tokens accepted by the DSL:
35
+ # "*" bare wildcard (matches all queues at runtime)
36
+ # "default" bare queue name
37
+ # "staging_*" prefix wildcard (matches queues by prefix at runtime)
38
+ #
39
+ # The character class for queue names — alphanumerics and underscores
40
+ # only — intentionally matches Pgbus::QueueNameValidator::VALID_QUEUE_NAME_PATTERN.
41
+ # Hyphens, dots, and other punctuation are NOT permitted because PGMQ
42
+ # interpolates queue names directly into SQL identifiers (q_<name>,
43
+ # a_<name>) and only those characters are safe there. If you change
44
+ # this pattern, also update QueueNameValidator to keep them in sync.
45
+ #
46
+ # The "*" tokens (bare and trailing) are DSL-only — they get expanded
47
+ # to concrete queue names at runtime before reaching QueueNameValidator.
48
+ QUEUE_NAME_PATTERN = /\A(?:\*|[a-zA-Z0-9_]+\*?)\z/
49
+ THREAD_COUNT_PATTERN = /\A\d+\z/
50
+
51
+ class ParseError < ArgumentError; end
52
+
53
+ def self.parse(input)
54
+ new(input).parse
55
+ end
56
+
57
+ def initialize(input)
58
+ @input = input
59
+ end
60
+
61
+ def parse
62
+ validate_input_type!
63
+ validate_input_not_empty!
64
+
65
+ # Pure tokenization: split, parse each capsule, return them in order.
66
+ # Cross-capsule overlap rules live in Pgbus::Configuration#workers=
67
+ # because they depend on whether the resulting capsules are named or
68
+ # anonymous, and naming is a Configuration concern (not a parser one).
69
+ # Within-capsule duplicate-queue checks still happen in parse_capsule.
70
+ split_capsules(@input).map { |segment| parse_capsule(segment) }
71
+ end
72
+
73
+ private
74
+
75
+ def validate_input_type!
76
+ return if @input.is_a?(String)
77
+
78
+ raise ParseError,
79
+ "expected String, got #{@input.class} (#{@input.inspect})"
80
+ end
81
+
82
+ def validate_input_not_empty!
83
+ return if @input.strip != ""
84
+
85
+ raise ParseError, "empty capsule string — must declare at least one queue"
86
+ end
87
+
88
+ # Splits on `;` and trims whitespace, dropping a trailing empty segment
89
+ # so `"a; b;"` is treated the same as `"a; b"`. Leading-empty segments
90
+ # ("; a") are kept and rejected by parse_capsule with a clearer error.
91
+ def split_capsules(string)
92
+ segments = string.strip.split(";").map(&:strip)
93
+ segments.pop if segments.last == "" # trailing semicolon
94
+ segments
95
+ end
96
+
97
+ def parse_capsule(segment)
98
+ if segment == ""
99
+ raise ParseError,
100
+ "empty capsule in #{@input.inspect} — leading or doubled semicolons are not allowed"
101
+ end
102
+
103
+ queues_part, threads_part = split_on_threads(segment)
104
+ queues = parse_queue_list(queues_part, segment)
105
+ threads = parse_thread_count(threads_part, segment)
106
+ validate_no_duplicates_within_capsule!(queues, segment)
107
+
108
+ { queues: queues, threads: threads }
109
+ end
110
+
111
+ # Splits a capsule segment on the LAST `:` so queue names containing
112
+ # underscores or digits are unaffected. Returns [queues_part, threads_part]
113
+ # where threads_part is nil when no `:` is present.
114
+ def split_on_threads(segment)
115
+ idx = segment.rindex(":")
116
+ return [segment, nil] unless idx
117
+
118
+ [segment[0...idx].strip, segment[(idx + 1)..].strip]
119
+ end
120
+
121
+ def parse_queue_list(queues_part, original_segment)
122
+ if queues_part.empty?
123
+ raise ParseError,
124
+ "expected queue name before ':' in #{original_segment.inspect}"
125
+ end
126
+
127
+ queues = queues_part.split(",").map(&:strip)
128
+ queues.each { |q| validate_queue_name!(q, original_segment) }
129
+ queues
130
+ end
131
+
132
+ def validate_queue_name!(name, original_segment)
133
+ return if name.match?(QUEUE_NAME_PATTERN)
134
+
135
+ raise ParseError,
136
+ "invalid character in queue name #{name.inspect} (in #{original_segment.inspect}) — " \
137
+ "queue names must match /[a-zA-Z0-9_]+\\*?/"
138
+ end
139
+
140
+ def parse_thread_count(threads_part, original_segment)
141
+ return DEFAULT_THREADS if threads_part.nil?
142
+
143
+ if threads_part.empty?
144
+ raise ParseError,
145
+ "expected thread count after ':' in #{original_segment.inspect}"
146
+ end
147
+
148
+ unless threads_part.match?(THREAD_COUNT_PATTERN)
149
+ raise ParseError,
150
+ "invalid thread count #{threads_part.inspect} in #{original_segment.inspect} — " \
151
+ "must be a positive integer"
152
+ end
153
+
154
+ n = threads_part.to_i
155
+ if n.zero?
156
+ raise ParseError,
157
+ "thread count must be a positive integer, got 0 in #{original_segment.inspect}"
158
+ end
159
+
160
+ n
161
+ end
162
+
163
+ def validate_no_duplicates_within_capsule!(queues, original_segment)
164
+ seen = {}
165
+ queues.each do |q|
166
+ if seen[q]
167
+ raise ParseError,
168
+ "queue #{q.inspect} listed twice in capsule #{original_segment.inspect} — " \
169
+ "duplicate queues within a capsule are not allowed"
170
+ end
171
+ seen[q] = true
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end