pgbus 0.0.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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.bun-version +1 -0
  3. data/.claude/commands/architect.md +100 -0
  4. data/.claude/commands/github-review-comments.md +237 -0
  5. data/.claude/commands/lfg.md +271 -0
  6. data/.claude/commands/review-pr.md +69 -0
  7. data/.claude/commands/security.md +122 -0
  8. data/.claude/commands/tdd.md +148 -0
  9. data/.claude/rules/agents.md +49 -0
  10. data/.claude/rules/coding-style.md +91 -0
  11. data/.claude/rules/git-workflow.md +56 -0
  12. data/.claude/rules/performance.md +73 -0
  13. data/.claude/rules/testing.md +67 -0
  14. data/CHANGELOG.md +5 -0
  15. data/CLAUDE.md +80 -0
  16. data/CODE_OF_CONDUCT.md +10 -0
  17. data/LICENSE.txt +21 -0
  18. data/README.md +417 -0
  19. data/Rakefile +14 -0
  20. data/app/controllers/pgbus/api/stats_controller.rb +11 -0
  21. data/app/controllers/pgbus/application_controller.rb +35 -0
  22. data/app/controllers/pgbus/dashboard_controller.rb +27 -0
  23. data/app/controllers/pgbus/dead_letter_controller.rb +50 -0
  24. data/app/controllers/pgbus/events_controller.rb +23 -0
  25. data/app/controllers/pgbus/jobs_controller.rb +48 -0
  26. data/app/controllers/pgbus/processes_controller.rb +10 -0
  27. data/app/controllers/pgbus/queues_controller.rb +21 -0
  28. data/app/helpers/pgbus/application_helper.rb +69 -0
  29. data/app/views/layouts/pgbus/application.html.erb +76 -0
  30. data/app/views/pgbus/dashboard/_processes_table.html.erb +30 -0
  31. data/app/views/pgbus/dashboard/_queues_table.html.erb +39 -0
  32. data/app/views/pgbus/dashboard/_recent_failures.html.erb +33 -0
  33. data/app/views/pgbus/dashboard/_stats_cards.html.erb +28 -0
  34. data/app/views/pgbus/dashboard/show.html.erb +10 -0
  35. data/app/views/pgbus/dead_letter/_messages_table.html.erb +40 -0
  36. data/app/views/pgbus/dead_letter/index.html.erb +15 -0
  37. data/app/views/pgbus/dead_letter/show.html.erb +52 -0
  38. data/app/views/pgbus/events/index.html.erb +57 -0
  39. data/app/views/pgbus/events/show.html.erb +28 -0
  40. data/app/views/pgbus/jobs/_enqueued_table.html.erb +34 -0
  41. data/app/views/pgbus/jobs/_failed_table.html.erb +45 -0
  42. data/app/views/pgbus/jobs/index.html.erb +16 -0
  43. data/app/views/pgbus/jobs/show.html.erb +57 -0
  44. data/app/views/pgbus/processes/_processes_table.html.erb +37 -0
  45. data/app/views/pgbus/processes/index.html.erb +3 -0
  46. data/app/views/pgbus/queues/_queues_list.html.erb +41 -0
  47. data/app/views/pgbus/queues/index.html.erb +3 -0
  48. data/app/views/pgbus/queues/show.html.erb +49 -0
  49. data/bun.lock +18 -0
  50. data/config/routes.rb +45 -0
  51. data/docs/README.md +28 -0
  52. data/docs/switch_from_good_job.md +279 -0
  53. data/docs/switch_from_sidekiq.md +226 -0
  54. data/docs/switch_from_solid_queue.md +247 -0
  55. data/exe/pgbus +7 -0
  56. data/lib/generators/pgbus/install_generator.rb +56 -0
  57. data/lib/generators/pgbus/templates/migration.rb.erb +114 -0
  58. data/lib/generators/pgbus/templates/pgbus.yml.erb +74 -0
  59. data/lib/generators/pgbus/templates/pgbus_binstub.erb +7 -0
  60. data/lib/pgbus/active_job/adapter.rb +109 -0
  61. data/lib/pgbus/active_job/executor.rb +107 -0
  62. data/lib/pgbus/batch.rb +153 -0
  63. data/lib/pgbus/cli.rb +84 -0
  64. data/lib/pgbus/client.rb +162 -0
  65. data/lib/pgbus/concurrency/blocked_execution.rb +74 -0
  66. data/lib/pgbus/concurrency/semaphore.rb +66 -0
  67. data/lib/pgbus/concurrency.rb +65 -0
  68. data/lib/pgbus/config_loader.rb +27 -0
  69. data/lib/pgbus/configuration.rb +99 -0
  70. data/lib/pgbus/engine.rb +31 -0
  71. data/lib/pgbus/event.rb +31 -0
  72. data/lib/pgbus/event_bus/handler.rb +76 -0
  73. data/lib/pgbus/event_bus/publisher.rb +42 -0
  74. data/lib/pgbus/event_bus/registry.rb +54 -0
  75. data/lib/pgbus/event_bus/subscriber.rb +30 -0
  76. data/lib/pgbus/process/consumer.rb +113 -0
  77. data/lib/pgbus/process/dispatcher.rb +154 -0
  78. data/lib/pgbus/process/heartbeat.rb +71 -0
  79. data/lib/pgbus/process/signal_handler.rb +49 -0
  80. data/lib/pgbus/process/supervisor.rb +198 -0
  81. data/lib/pgbus/process/worker.rb +153 -0
  82. data/lib/pgbus/serializer.rb +43 -0
  83. data/lib/pgbus/version.rb +5 -0
  84. data/lib/pgbus/web/authentication.rb +24 -0
  85. data/lib/pgbus/web/data_source.rb +406 -0
  86. data/lib/pgbus.rb +49 -0
  87. data/package.json +9 -0
  88. data/sig/pgbus.rbs +4 -0
  89. metadata +198 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module Concurrency
7
+ module BlockedExecution
8
+ class << self
9
+ # Insert a blocked execution for a job that hit the concurrency limit.
10
+ def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
11
+ expires_at = Time.now.utc + duration
12
+
13
+ execute(<<~SQL, "Pgbus Blocked Insert", [concurrency_key, queue_name, JSON.generate(payload), priority, expires_at])
14
+ INSERT INTO pgbus_blocked_executions
15
+ (concurrency_key, queue_name, payload, priority, expires_at)
16
+ VALUES ($1, $2, $3, $4, $5)
17
+ SQL
18
+ end
19
+
20
+ # Release the next blocked execution for a given concurrency key.
21
+ # Returns the released row (queue_name, payload) or nil if none.
22
+ def release_next(concurrency_key)
23
+ return nil unless defined?(ActiveRecord::Base)
24
+
25
+ result = execute(<<~SQL, "Pgbus Blocked Release", [concurrency_key])
26
+ DELETE FROM pgbus_blocked_executions
27
+ WHERE id = (
28
+ SELECT id FROM pgbus_blocked_executions
29
+ WHERE concurrency_key = $1
30
+ ORDER BY priority ASC, created_at ASC
31
+ LIMIT 1
32
+ FOR UPDATE SKIP LOCKED
33
+ )
34
+ RETURNING queue_name, payload
35
+ SQL
36
+
37
+ row = result.first
38
+ return nil unless row
39
+
40
+ { queue_name: row["queue_name"], payload: JSON.parse(row["payload"]) }
41
+ end
42
+
43
+ # Delete blocked executions that have expired.
44
+ # Returns the count of deleted rows.
45
+ def expire_stale
46
+ result = execute(<<~SQL, "Pgbus Blocked Expire", [Time.now.utc])
47
+ DELETE FROM pgbus_blocked_executions
48
+ WHERE expires_at < $1
49
+ RETURNING id
50
+ SQL
51
+
52
+ result.to_a.size
53
+ end
54
+
55
+ # Count blocked executions for a given key. Useful for testing/monitoring.
56
+ def count_for(concurrency_key)
57
+ result = execute(<<~SQL, "Pgbus Blocked Count", [concurrency_key])
58
+ SELECT COUNT(*) AS cnt FROM pgbus_blocked_executions WHERE concurrency_key = $1
59
+ SQL
60
+
61
+ result.first&.fetch("cnt", 0).to_i
62
+ end
63
+
64
+ private
65
+
66
+ def execute(sql, name, binds)
67
+ return [] unless defined?(ActiveRecord::Base)
68
+
69
+ ActiveRecord::Base.connection.exec_query(sql, name, binds)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Concurrency
5
+ module Semaphore
6
+ class << self
7
+ # Attempt to acquire a slot in the semaphore for the given key.
8
+ # Returns :acquired if a slot was available, :blocked if the limit is reached.
9
+ def acquire(key, max_value, duration)
10
+ expires_at = Time.now.utc + duration
11
+
12
+ result = execute(<<~SQL, "Pgbus Semaphore Acquire", [key, max_value, expires_at])
13
+ INSERT INTO pgbus_semaphores (key, value, max_value, expires_at)
14
+ VALUES ($1, 1, $2, $3)
15
+ ON CONFLICT (key) DO UPDATE
16
+ SET value = pgbus_semaphores.value + 1,
17
+ max_value = EXCLUDED.max_value,
18
+ expires_at = GREATEST(pgbus_semaphores.expires_at, EXCLUDED.expires_at)
19
+ WHERE pgbus_semaphores.value < pgbus_semaphores.max_value
20
+ RETURNING value
21
+ SQL
22
+
23
+ result.any? ? :acquired : :blocked
24
+ end
25
+
26
+ # Release one slot in the semaphore. Called after a job completes.
27
+ def release(key)
28
+ execute(<<~SQL, "Pgbus Semaphore Release", [key])
29
+ UPDATE pgbus_semaphores
30
+ SET value = GREATEST(value - 1, 0)
31
+ WHERE key = $1
32
+ SQL
33
+ end
34
+
35
+ # Delete semaphores that have expired (safety net for crashed workers).
36
+ # Returns the number of deleted rows.
37
+ def expire_stale
38
+ result = execute(<<~SQL, "Pgbus Semaphore Expire", [Time.now.utc])
39
+ DELETE FROM pgbus_semaphores
40
+ WHERE expires_at < $1 OR value <= 0
41
+ RETURNING key
42
+ SQL
43
+
44
+ result.to_a
45
+ end
46
+
47
+ # Check current value for a key. Useful for testing/monitoring.
48
+ def current_value(key)
49
+ result = execute(<<~SQL, "Pgbus Semaphore Value", [key])
50
+ SELECT value FROM pgbus_semaphores WHERE key = $1
51
+ SQL
52
+
53
+ result.first&.fetch("value", nil)&.to_i
54
+ end
55
+
56
+ private
57
+
58
+ def execute(sql, name, binds)
59
+ return [] unless defined?(ActiveRecord::Base)
60
+
61
+ ActiveRecord::Base.connection.exec_query(sql, name, binds)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Pgbus
6
+ module Concurrency
7
+ extend ActiveSupport::Concern
8
+
9
+ METADATA_KEY = "pgbus_concurrency_key"
10
+
11
+ class_methods do
12
+ # Limit concurrent execution of jobs with the same key.
13
+ #
14
+ # limits_concurrency to: 1, key: ->(order) { "ProcessOrder-#{order.id}" }
15
+ # limits_concurrency to: 3, key: ->(user_id) { "ImportUser-#{user_id}" }, on_conflict: :discard
16
+ #
17
+ # Options:
18
+ # to: Maximum concurrent jobs for the same key (required)
19
+ # key: Proc receiving job arguments, returns a string key. Default: job class name.
20
+ # duration: Safety expiry for semaphore (default: 15 minutes)
21
+ # on_conflict: What to do when limit is reached — :block, :discard, or :raise (default: :block)
22
+ def limits_concurrency(to:, key: nil, duration: 15 * 60, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
23
+ raise ArgumentError, "to: must be a positive integer" unless to.is_a?(Integer) && to.positive?
24
+ raise ArgumentError, "on_conflict must be :block, :discard, or :raise" unless %i[block discard raise].include?(on_conflict)
25
+
26
+ @pgbus_concurrency = {
27
+ limit: to,
28
+ key: key || ->(*) { name },
29
+ duration: duration,
30
+ on_conflict: on_conflict
31
+ }
32
+ end
33
+
34
+ def pgbus_concurrency
35
+ @pgbus_concurrency
36
+ end
37
+ end
38
+
39
+ class << self
40
+ # Resolve the concurrency key for a given job instance.
41
+ # Returns nil if the job class has no concurrency config.
42
+ def resolve_key(active_job)
43
+ return nil unless active_job.class.respond_to?(:pgbus_concurrency)
44
+
45
+ config = active_job.class.pgbus_concurrency
46
+ return nil unless config
47
+
48
+ config[:key].call(*active_job.arguments)
49
+ end
50
+
51
+ # Inject the resolved concurrency key into the job's serialized payload.
52
+ def inject_metadata(active_job, payload_hash)
53
+ key = resolve_key(active_job)
54
+ return payload_hash unless key
55
+
56
+ payload_hash.merge(METADATA_KEY => key)
57
+ end
58
+
59
+ # Extract the concurrency key from a deserialized payload.
60
+ def extract_key(payload)
61
+ payload[METADATA_KEY]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+
6
+ module Pgbus
7
+ module ConfigLoader
8
+ module_function
9
+
10
+ def load(path, env: nil)
11
+ env ||= defined?(Rails) ? Rails.env : ENV.fetch("PGBUS_ENV", "development")
12
+ raw = File.read(path)
13
+ parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
14
+ config_hash = parsed.fetch(env, parsed)
15
+ apply(config_hash)
16
+ end
17
+
18
+ def apply(hash)
19
+ config = Pgbus.configuration
20
+ hash.each do |key, value|
21
+ setter = :"#{key}="
22
+ config.public_send(setter, value) if config.respond_to?(setter)
23
+ end
24
+ config
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Pgbus
6
+ class Configuration
7
+ # Connection settings
8
+ attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
9
+
10
+ # Queue settings
11
+ attr_accessor :default_queue, :queue_prefix
12
+
13
+ # Worker settings
14
+ attr_accessor :workers, :polling_interval, :visibility_timeout
15
+
16
+ # Worker recycling
17
+ attr_accessor :max_jobs_per_worker, :max_memory_mb, :max_worker_lifetime
18
+
19
+ # Dispatcher settings
20
+ attr_accessor :dispatch_interval
21
+
22
+ # Dead letter queue
23
+ attr_accessor :max_retries, :dead_letter_queue_suffix
24
+
25
+ # Event bus
26
+ attr_accessor :idempotency_ttl
27
+
28
+ # Logging
29
+ attr_accessor :logger
30
+
31
+ # LISTEN/NOTIFY
32
+ attr_accessor :listen_notify, :notify_throttle_ms
33
+
34
+ # Event consumers
35
+ attr_accessor :event_consumers
36
+
37
+ # Web dashboard
38
+ attr_accessor :web_auth, :web_refresh_interval, :web_per_page, :web_live_updates, :web_data_source
39
+
40
+ def initialize
41
+ @database_url = nil
42
+ @connection_params = nil
43
+ @pool_size = 5
44
+ @pool_timeout = 5
45
+
46
+ @default_queue = "default"
47
+ @queue_prefix = "pgbus"
48
+
49
+ @workers = [{ queues: %w[default], threads: 5 }]
50
+ @polling_interval = 0.1
51
+ @visibility_timeout = 30
52
+
53
+ @max_jobs_per_worker = nil
54
+ @max_memory_mb = nil
55
+ @max_worker_lifetime = nil
56
+
57
+ @dispatch_interval = 1.0
58
+
59
+ @max_retries = 5
60
+ @dead_letter_queue_suffix = "_dlq"
61
+
62
+ @idempotency_ttl = 7 * 24 * 3600 # 7 days
63
+
64
+ @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
65
+
66
+ @listen_notify = true
67
+ @notify_throttle_ms = 250
68
+
69
+ @event_consumers = nil
70
+
71
+ @web_auth = nil
72
+ @web_refresh_interval = 5000
73
+ @web_per_page = 25
74
+ @web_live_updates = true
75
+ @web_data_source = nil
76
+ end
77
+
78
+ def queue_name(name)
79
+ "#{queue_prefix}_#{name}"
80
+ end
81
+
82
+ def dead_letter_queue_name(name)
83
+ "#{queue_name(name)}#{dead_letter_queue_suffix}"
84
+ end
85
+
86
+ def connection_options
87
+ if database_url
88
+ database_url
89
+ elsif connection_params
90
+ connection_params
91
+ elsif defined?(ActiveRecord::Base)
92
+ -> { ActiveRecord::Base.connection.raw_connection }
93
+ else
94
+ raise ConfigurationError, "No database connection configured. " \
95
+ "Set Pgbus.configuration.database_url, connection_params, or use with Rails."
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Pgbus
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Pgbus
8
+
9
+ initializer "pgbus.configure" do |app|
10
+ config_path = app.root.join("config", "pgbus.yml")
11
+ Pgbus::ConfigLoader.load(config_path) if config_path.exist?
12
+ end
13
+
14
+ initializer "pgbus.active_job" do
15
+ ActiveSupport.on_load(:active_job) do
16
+ require "pgbus/active_job/adapter"
17
+ end
18
+ end
19
+
20
+ initializer "pgbus.logger" do
21
+ ActiveSupport.on_load(:after_initialize) do
22
+ Pgbus.configuration.logger ||= Rails.logger
23
+ end
24
+ end
25
+
26
+ initializer "pgbus.web" do
27
+ require "pgbus/web/authentication"
28
+ require "pgbus/web/data_source"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Pgbus
6
+ class Event
7
+ attr_reader :event_id, :payload, :published_at, :routing_key, :headers
8
+
9
+ def initialize(event_id:, payload:, published_at: nil, routing_key: nil, headers: nil)
10
+ @event_id = event_id
11
+ @payload = payload
12
+ @published_at = published_at || Time.now.utc
13
+ @routing_key = routing_key
14
+ @headers = headers || {}
15
+ end
16
+
17
+ def [](key)
18
+ payload.is_a?(Hash) ? payload[key.to_s] : nil
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ "event_id" => event_id,
24
+ "payload" => payload,
25
+ "published_at" => published_at.iso8601(6),
26
+ "routing_key" => routing_key,
27
+ "headers" => headers
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module EventBus
5
+ class Handler
6
+ class << self
7
+ def idempotent!
8
+ @idempotent = true
9
+ end
10
+
11
+ def idempotent?
12
+ @idempotent == true
13
+ end
14
+ end
15
+
16
+ def process(message)
17
+ raw = JSON.parse(message.message)
18
+ event = build_event(raw)
19
+
20
+ if self.class.idempotent?
21
+ return :skipped if already_processed?(event.event_id)
22
+
23
+ mark_processed!(event.event_id)
24
+ end
25
+
26
+ handle(event)
27
+ instrument("pgbus.event_processed", event_id: event.event_id, handler: self.class.name)
28
+ :handled
29
+ end
30
+
31
+ def handle(event)
32
+ raise NotImplementedError, "#{self.class.name} must implement #handle(event)"
33
+ end
34
+
35
+ private
36
+
37
+ def build_event(raw)
38
+ payload = raw["payload"]
39
+ payload = GlobalID::Locator.locate(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
40
+
41
+ Event.new(
42
+ event_id: raw["event_id"],
43
+ payload: payload,
44
+ published_at: raw["published_at"] ? Time.parse(raw["published_at"]) : nil
45
+ )
46
+ end
47
+
48
+ def instrument(event_name, payload = {})
49
+ return unless defined?(ActiveSupport::Notifications)
50
+
51
+ ActiveSupport::Notifications.instrument(event_name, payload)
52
+ end
53
+
54
+ def already_processed?(event_id)
55
+ return false unless defined?(ActiveRecord::Base)
56
+
57
+ ActiveRecord::Base.connection.select_value(
58
+ "SELECT 1 FROM pgbus_processed_events WHERE event_id = $1 AND handler_class = $2",
59
+ "Pgbus Idempotency Check",
60
+ [event_id, self.class.name]
61
+ )
62
+ end
63
+
64
+ def mark_processed!(event_id)
65
+ return unless defined?(ActiveRecord::Base)
66
+
67
+ ActiveRecord::Base.connection.exec_insert(
68
+ "INSERT INTO pgbus_processed_events (event_id, handler_class, processed_at) " \
69
+ "VALUES ($1, $2, $3) ON CONFLICT (event_id, handler_class) DO NOTHING",
70
+ "Pgbus Idempotency Mark",
71
+ [event_id, self.class.name, Time.now.utc]
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module EventBus
5
+ module Publisher
6
+ module_function
7
+
8
+ def publish(routing_key, payload, headers: nil, delay: 0)
9
+ event_data = build_event_data(payload)
10
+
11
+ if Pgbus.client.pgmq.respond_to?(:produce_topic)
12
+ Pgbus.client.publish_to_topic(routing_key, event_data, headers: headers, delay: delay)
13
+ else
14
+ # Fallback: send directly to queues matching the routing key
15
+ Pgbus.client.send_message(routing_key, event_data, headers: headers, delay: delay)
16
+ end
17
+ end
18
+
19
+ def publish_later(routing_key, payload, delay:, headers: nil)
20
+ publish(routing_key, payload, headers: headers, delay: delay)
21
+ end
22
+
23
+ def build_event_data(payload)
24
+ event_id = SecureRandom.uuid
25
+
26
+ serialized_payload = if payload.respond_to?(:to_global_id)
27
+ { "_global_id" => payload.to_global_id.to_s }
28
+ elsif payload.is_a?(Hash)
29
+ payload
30
+ else
31
+ { "value" => payload }
32
+ end
33
+
34
+ {
35
+ "event_id" => event_id,
36
+ "payload" => serialized_payload,
37
+ "published_at" => Time.now.utc.iso8601(6)
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Pgbus
6
+ module EventBus
7
+ class Registry
8
+ include Singleton
9
+
10
+ attr_reader :subscribers
11
+
12
+ def initialize
13
+ @subscribers = []
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def subscribe(pattern, handler_class, queue_name: nil)
18
+ subscriber = Subscriber.new(
19
+ pattern: pattern,
20
+ handler_class: handler_class,
21
+ queue_name: queue_name
22
+ )
23
+
24
+ @mutex.synchronize do
25
+ @subscribers << subscriber
26
+ end
27
+
28
+ subscriber
29
+ end
30
+
31
+ def setup_all!
32
+ @subscribers.each(&:setup!)
33
+ end
34
+
35
+ def handlers_for(routing_key)
36
+ @subscribers.select { |s| matches?(s.pattern, routing_key) }
37
+ end
38
+
39
+ def clear!
40
+ @mutex.synchronize { @subscribers.clear }
41
+ end
42
+
43
+ private
44
+
45
+ def matches?(pattern, routing_key)
46
+ regex = pattern
47
+ .gsub(".", "\\.")
48
+ .gsub("*", "[^.]+")
49
+ .gsub("#", ".*")
50
+ routing_key.match?(/\A#{regex}\z/)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module EventBus
5
+ class Subscriber
6
+ attr_reader :pattern, :handler_class, :queue_name
7
+
8
+ def initialize(pattern:, handler_class:, queue_name: nil)
9
+ @pattern = pattern
10
+ @handler_class = handler_class
11
+ @queue_name = queue_name || derive_queue_name
12
+ end
13
+
14
+ def setup!
15
+ Pgbus.client.ensure_queue(queue_name)
16
+ Pgbus.client.bind_topic(pattern, queue_name)
17
+ end
18
+
19
+ private
20
+
21
+ def derive_queue_name
22
+ handler_class.name
23
+ .gsub("::", "_")
24
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
25
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
26
+ .downcase
27
+ end
28
+ end
29
+ end
30
+ end