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.
- checksums.yaml +4 -4
- data/README.md +360 -331
- data/app/controllers/pgbus/dead_letter_controller.rb +3 -7
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +28 -1
- data/app/views/layouts/pgbus/application.html.erb +58 -12
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +3 -5
- data/app/views/pgbus/insights/show.html.erb +6 -6
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +2 -3
- 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 +91 -3
- data/lib/pgbus/configuration/capsule_dsl.rb +190 -0
- data/lib/pgbus/configuration.rb +305 -25
- data/lib/pgbus/failed_event_recorder.rb +15 -2
- data/lib/pgbus/generators/config_converter.rb +323 -0
- data/lib/pgbus/process/dispatcher.rb +42 -20
- data/lib/pgbus/process/supervisor.rb +11 -16
- data/lib/pgbus/process/worker.rb +20 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +50 -15
- data/lib/pgbus.rb +13 -1
- metadata +4 -1
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
|
|
|
@@ -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:
|
|
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
|