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.
- checksums.yaml +4 -4
- data/README.md +360 -331
- data/app/controllers/pgbus/dead_letter_controller.rb +3 -7
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +1 -2
- data/lib/generators/pgbus/templates/pgbus.yml.erb +5 -3
- data/lib/generators/pgbus/update_generator.rb +75 -0
- data/lib/pgbus/circuit_breaker.rb +17 -3
- data/lib/pgbus/cli.rb +95 -3
- data/lib/pgbus/client.rb +12 -3
- data/lib/pgbus/configuration/capsule_dsl.rb +190 -0
- data/lib/pgbus/configuration.rb +305 -25
- data/lib/pgbus/generators/config_converter.rb +323 -0
- data/lib/pgbus/process/dispatcher.rb +8 -6
- data/lib/pgbus/process/supervisor.rb +11 -16
- data/lib/pgbus/process/worker.rb +1 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +6 -6
- data/lib/pgbus.rb +13 -1
- metadata +4 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
<%
|
|
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:
|
|
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 >=
|
|
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 =
|
|
109
|
-
[backoff,
|
|
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
|
|
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.
|
|
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:
|
|
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,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
|