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.
- checksums.yaml +7 -0
- data/.bun-version +1 -0
- data/.claude/commands/architect.md +100 -0
- data/.claude/commands/github-review-comments.md +237 -0
- data/.claude/commands/lfg.md +271 -0
- data/.claude/commands/review-pr.md +69 -0
- data/.claude/commands/security.md +122 -0
- data/.claude/commands/tdd.md +148 -0
- data/.claude/rules/agents.md +49 -0
- data/.claude/rules/coding-style.md +91 -0
- data/.claude/rules/git-workflow.md +56 -0
- data/.claude/rules/performance.md +73 -0
- data/.claude/rules/testing.md +67 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +80 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/Rakefile +14 -0
- data/app/controllers/pgbus/api/stats_controller.rb +11 -0
- data/app/controllers/pgbus/application_controller.rb +35 -0
- data/app/controllers/pgbus/dashboard_controller.rb +27 -0
- data/app/controllers/pgbus/dead_letter_controller.rb +50 -0
- data/app/controllers/pgbus/events_controller.rb +23 -0
- data/app/controllers/pgbus/jobs_controller.rb +48 -0
- data/app/controllers/pgbus/processes_controller.rb +10 -0
- data/app/controllers/pgbus/queues_controller.rb +21 -0
- data/app/helpers/pgbus/application_helper.rb +69 -0
- data/app/views/layouts/pgbus/application.html.erb +76 -0
- data/app/views/pgbus/dashboard/_processes_table.html.erb +30 -0
- data/app/views/pgbus/dashboard/_queues_table.html.erb +39 -0
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +33 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +28 -0
- data/app/views/pgbus/dashboard/show.html.erb +10 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +40 -0
- data/app/views/pgbus/dead_letter/index.html.erb +15 -0
- data/app/views/pgbus/dead_letter/show.html.erb +52 -0
- data/app/views/pgbus/events/index.html.erb +57 -0
- data/app/views/pgbus/events/show.html.erb +28 -0
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +34 -0
- data/app/views/pgbus/jobs/_failed_table.html.erb +45 -0
- data/app/views/pgbus/jobs/index.html.erb +16 -0
- data/app/views/pgbus/jobs/show.html.erb +57 -0
- data/app/views/pgbus/processes/_processes_table.html.erb +37 -0
- data/app/views/pgbus/processes/index.html.erb +3 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +41 -0
- data/app/views/pgbus/queues/index.html.erb +3 -0
- data/app/views/pgbus/queues/show.html.erb +49 -0
- data/bun.lock +18 -0
- data/config/routes.rb +45 -0
- data/docs/README.md +28 -0
- data/docs/switch_from_good_job.md +279 -0
- data/docs/switch_from_sidekiq.md +226 -0
- data/docs/switch_from_solid_queue.md +247 -0
- data/exe/pgbus +7 -0
- data/lib/generators/pgbus/install_generator.rb +56 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +114 -0
- data/lib/generators/pgbus/templates/pgbus.yml.erb +74 -0
- data/lib/generators/pgbus/templates/pgbus_binstub.erb +7 -0
- data/lib/pgbus/active_job/adapter.rb +109 -0
- data/lib/pgbus/active_job/executor.rb +107 -0
- data/lib/pgbus/batch.rb +153 -0
- data/lib/pgbus/cli.rb +84 -0
- data/lib/pgbus/client.rb +162 -0
- data/lib/pgbus/concurrency/blocked_execution.rb +74 -0
- data/lib/pgbus/concurrency/semaphore.rb +66 -0
- data/lib/pgbus/concurrency.rb +65 -0
- data/lib/pgbus/config_loader.rb +27 -0
- data/lib/pgbus/configuration.rb +99 -0
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/event.rb +31 -0
- data/lib/pgbus/event_bus/handler.rb +76 -0
- data/lib/pgbus/event_bus/publisher.rb +42 -0
- data/lib/pgbus/event_bus/registry.rb +54 -0
- data/lib/pgbus/event_bus/subscriber.rb +30 -0
- data/lib/pgbus/process/consumer.rb +113 -0
- data/lib/pgbus/process/dispatcher.rb +154 -0
- data/lib/pgbus/process/heartbeat.rb +71 -0
- data/lib/pgbus/process/signal_handler.rb +49 -0
- data/lib/pgbus/process/supervisor.rb +198 -0
- data/lib/pgbus/process/worker.rb +153 -0
- data/lib/pgbus/serializer.rb +43 -0
- data/lib/pgbus/version.rb +5 -0
- data/lib/pgbus/web/authentication.rb +24 -0
- data/lib/pgbus/web/data_source.rb +406 -0
- data/lib/pgbus.rb +49 -0
- data/package.json +9 -0
- data/sig/pgbus.rbs +4 -0
- 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
|
data/lib/pgbus/engine.rb
ADDED
|
@@ -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
|
data/lib/pgbus/event.rb
ADDED
|
@@ -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
|