pgbus 0.4.0 → 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.
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
 
@@ -221,6 +230,49 @@ module Pgbus
221
230
  result
222
231
  end
223
232
 
233
+ # Check whether a message exists in the given queue.
234
+ #
235
+ # Pass either +msg_id+ for a fast primary-key lookup, or +uniqueness_key+
236
+ # to scan the queue for any message whose payload carries that key in the
237
+ # +pgbus_uniqueness_key+ JSONB field. The latter is used by the dispatcher
238
+ # reaper to determine if a uniqueness lock with msg_id=0 (placeholder)
239
+ # still has a corresponding queue message.
240
+ #
241
+ # +queue_name+ may be either a logical name (e.g. "default") or an already
242
+ # prefixed physical name (e.g. "pgbus_default"). The client normalizes both.
243
+ #
244
+ # Returns:
245
+ # true — the message definitely exists in the queue
246
+ # false — the message definitely does not exist
247
+ # nil — could not determine (e.g. queue table missing or unknown error).
248
+ # Callers MUST treat nil as "exists" for safety.
249
+ def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
250
+ has_msg_id = !msg_id.nil?
251
+ has_uniqueness_key = !uniqueness_key.nil?
252
+ raise ArgumentError, "pass exactly one of msg_id or uniqueness_key" unless has_msg_id ^ has_uniqueness_key
253
+
254
+ full_name = resolve_full_queue_name(queue_name)
255
+ sanitized = QueueNameValidator.sanitize!(full_name)
256
+
257
+ synchronized do
258
+ with_raw_connection do |conn|
259
+ if has_msg_id
260
+ msg_id_present?(conn, sanitized, msg_id.to_i)
261
+ else
262
+ uniqueness_key_present?(conn, sanitized, uniqueness_key)
263
+ end
264
+ end
265
+ end
266
+ rescue ActiveRecord::StatementInvalid => e
267
+ raise unless undefined_table_error?(e)
268
+
269
+ nil
270
+ rescue StandardError => e
271
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
272
+
273
+ nil
274
+ end
275
+
224
276
  def purge_archive(queue_name, older_than:, batch_size: 1000)
225
277
  full_name = config.queue_name(queue_name)
226
278
  sanitized = QueueNameValidator.sanitize!(full_name)
@@ -266,6 +318,42 @@ module Pgbus
266
318
 
267
319
  private
268
320
 
321
+ # Accept either a logical name ("default") or an already-prefixed
322
+ # physical name ("pgbus_default") and return the physical name.
323
+ # Coerces symbols to strings so callers can pass either form.
324
+ def resolve_full_queue_name(queue_name)
325
+ name = queue_name.to_s
326
+ prefix = "#{config.queue_prefix}_"
327
+ name.start_with?(prefix) ? name : config.queue_name(name)
328
+ end
329
+
330
+ def msg_id_present?(conn, sanitized, msg_id)
331
+ result = conn.exec_params(
332
+ "SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 LIMIT 1",
333
+ [msg_id]
334
+ )
335
+ result.ntuples.positive?
336
+ end
337
+
338
+ def uniqueness_key_present?(conn, sanitized, uniqueness_key)
339
+ result = conn.exec_params(
340
+ "SELECT 1 FROM pgmq.q_#{sanitized} " \
341
+ "WHERE message::jsonb ->> 'pgbus_uniqueness_key' = $1 LIMIT 1",
342
+ [uniqueness_key]
343
+ )
344
+ result.ntuples.positive?
345
+ end
346
+
347
+ # Detect "relation does not exist" via the underlying PG error type.
348
+ # Falls back to message matching only if PG::UndefinedTable is undefined
349
+ # (very old pg gem) — never relies on locale-sensitive text.
350
+ def undefined_table_error?(error)
351
+ cause = error.respond_to?(:cause) ? error.cause : nil
352
+ return true if defined?(PG::UndefinedTable) && cause.is_a?(PG::UndefinedTable)
353
+
354
+ false
355
+ end
356
+
269
357
  def collect_configured_queues
270
358
  queues = Set.new
271
359
  queues << config.default_queue
@@ -352,7 +440,7 @@ module Pgbus
352
440
  @queues_created.compute_if_absent(full_name) do
353
441
  synchronized do
354
442
  @pgmq.create(full_name)
355
- @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
356
444
  end
357
445
  true
358
446
  end
@@ -0,0 +1,190 @@
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
+ capsules = split_capsules(@input).map { |segment| parse_capsule(segment) }
66
+ validate_no_duplicate_queues_across_capsules!(capsules)
67
+ capsules
68
+ end
69
+
70
+ private
71
+
72
+ def validate_input_type!
73
+ return if @input.is_a?(String)
74
+
75
+ raise ParseError,
76
+ "expected String, got #{@input.class} (#{@input.inspect})"
77
+ end
78
+
79
+ def validate_input_not_empty!
80
+ return if @input.strip != ""
81
+
82
+ raise ParseError, "empty capsule string — must declare at least one queue"
83
+ end
84
+
85
+ # Splits on `;` and trims whitespace, dropping a trailing empty segment
86
+ # so `"a; b;"` is treated the same as `"a; b"`. Leading-empty segments
87
+ # ("; a") are kept and rejected by parse_capsule with a clearer error.
88
+ def split_capsules(string)
89
+ segments = string.strip.split(";").map(&:strip)
90
+ segments.pop if segments.last == "" # trailing semicolon
91
+ segments
92
+ end
93
+
94
+ def parse_capsule(segment)
95
+ if segment == ""
96
+ raise ParseError,
97
+ "empty capsule in #{@input.inspect} — leading or doubled semicolons are not allowed"
98
+ end
99
+
100
+ queues_part, threads_part = split_on_threads(segment)
101
+ queues = parse_queue_list(queues_part, segment)
102
+ threads = parse_thread_count(threads_part, segment)
103
+ validate_no_duplicates_within_capsule!(queues, segment)
104
+
105
+ { queues: queues, threads: threads }
106
+ end
107
+
108
+ # Splits a capsule segment on the LAST `:` so queue names containing
109
+ # underscores or digits are unaffected. Returns [queues_part, threads_part]
110
+ # where threads_part is nil when no `:` is present.
111
+ def split_on_threads(segment)
112
+ idx = segment.rindex(":")
113
+ return [segment, nil] unless idx
114
+
115
+ [segment[0...idx].strip, segment[(idx + 1)..].strip]
116
+ end
117
+
118
+ def parse_queue_list(queues_part, original_segment)
119
+ if queues_part.empty?
120
+ raise ParseError,
121
+ "expected queue name before ':' in #{original_segment.inspect}"
122
+ end
123
+
124
+ queues = queues_part.split(",").map(&:strip)
125
+ queues.each { |q| validate_queue_name!(q, original_segment) }
126
+ queues
127
+ end
128
+
129
+ def validate_queue_name!(name, original_segment)
130
+ return if name.match?(QUEUE_NAME_PATTERN)
131
+
132
+ raise ParseError,
133
+ "invalid character in queue name #{name.inspect} (in #{original_segment.inspect}) — " \
134
+ "queue names must match /[a-zA-Z0-9_]+\\*?/"
135
+ end
136
+
137
+ def parse_thread_count(threads_part, original_segment)
138
+ return DEFAULT_THREADS if threads_part.nil?
139
+
140
+ if threads_part.empty?
141
+ raise ParseError,
142
+ "expected thread count after ':' in #{original_segment.inspect}"
143
+ end
144
+
145
+ unless threads_part.match?(THREAD_COUNT_PATTERN)
146
+ raise ParseError,
147
+ "invalid thread count #{threads_part.inspect} in #{original_segment.inspect} — " \
148
+ "must be a positive integer"
149
+ end
150
+
151
+ n = threads_part.to_i
152
+ if n.zero?
153
+ raise ParseError,
154
+ "thread count must be a positive integer, got 0 in #{original_segment.inspect}"
155
+ end
156
+
157
+ n
158
+ end
159
+
160
+ def validate_no_duplicates_within_capsule!(queues, original_segment)
161
+ seen = {}
162
+ queues.each do |q|
163
+ if seen[q]
164
+ raise ParseError,
165
+ "queue #{q.inspect} listed twice in capsule #{original_segment.inspect} — " \
166
+ "duplicate queues within a capsule are not allowed"
167
+ end
168
+ seen[q] = true
169
+ end
170
+ end
171
+
172
+ def validate_no_duplicate_queues_across_capsules!(capsules)
173
+ return if capsules.size < 2
174
+
175
+ seen = {}
176
+ capsules.each_with_index do |capsule, idx|
177
+ capsule[:queues].each do |q|
178
+ if seen[q]
179
+ label = (q == WILDCARD ? "wildcard '*'" : "queue #{q.inspect}")
180
+ raise ParseError,
181
+ "#{label} appears in two capsules (positions #{seen[q]} and #{idx + 1}) " \
182
+ "in #{@input.inspect} — each queue can only be assigned to one capsule"
183
+ end
184
+ seen[q] = idx + 1
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end