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
|
+
# Pgbus configuration
|
|
2
|
+
# https://github.com/mhenrixon/pgbus
|
|
3
|
+
|
|
4
|
+
default: &default
|
|
5
|
+
# Queue prefix for all PGMQ queues
|
|
6
|
+
queue_prefix: pgbus
|
|
7
|
+
|
|
8
|
+
# Default queue name (without prefix)
|
|
9
|
+
default_queue: default
|
|
10
|
+
|
|
11
|
+
# Connection pool for PGMQ client
|
|
12
|
+
pool_size: 5
|
|
13
|
+
pool_timeout: 5
|
|
14
|
+
|
|
15
|
+
# Use PostgreSQL LISTEN/NOTIFY for instant job wake-up
|
|
16
|
+
listen_notify: true
|
|
17
|
+
notify_throttle_ms: 250
|
|
18
|
+
|
|
19
|
+
# Visibility timeout in seconds (how long a message is invisible after being read)
|
|
20
|
+
visibility_timeout: 30
|
|
21
|
+
|
|
22
|
+
# Dead letter queue
|
|
23
|
+
max_retries: 5
|
|
24
|
+
|
|
25
|
+
# Idempotency deduplication TTL (seconds)
|
|
26
|
+
idempotency_ttl: 604800 # 7 days
|
|
27
|
+
|
|
28
|
+
# Worker definitions
|
|
29
|
+
workers:
|
|
30
|
+
- queues:
|
|
31
|
+
- critical
|
|
32
|
+
- default
|
|
33
|
+
threads: 5
|
|
34
|
+
- queues:
|
|
35
|
+
- low
|
|
36
|
+
threads: 2
|
|
37
|
+
|
|
38
|
+
# Worker recycling (prevents memory bloat)
|
|
39
|
+
max_jobs_per_worker: 10000
|
|
40
|
+
max_memory_mb: 512
|
|
41
|
+
# max_worker_lifetime: 3600 # seconds
|
|
42
|
+
|
|
43
|
+
# Dispatcher settings (maintenance tasks)
|
|
44
|
+
dispatch_interval: 1.0
|
|
45
|
+
|
|
46
|
+
# Event consumers (uncomment to enable event bus)
|
|
47
|
+
# event_consumers:
|
|
48
|
+
# - topics:
|
|
49
|
+
# - "orders.#"
|
|
50
|
+
# threads: 3
|
|
51
|
+
# - topics:
|
|
52
|
+
# - "notifications.#"
|
|
53
|
+
# threads: 1
|
|
54
|
+
|
|
55
|
+
development:
|
|
56
|
+
<<: *default
|
|
57
|
+
workers:
|
|
58
|
+
- queues:
|
|
59
|
+
- default
|
|
60
|
+
threads: 2
|
|
61
|
+
max_jobs_per_worker: ~
|
|
62
|
+
max_memory_mb: ~
|
|
63
|
+
|
|
64
|
+
test:
|
|
65
|
+
<<: *default
|
|
66
|
+
workers:
|
|
67
|
+
- queues:
|
|
68
|
+
- default
|
|
69
|
+
threads: 1
|
|
70
|
+
polling_interval: 0.01
|
|
71
|
+
visibility_timeout: 5
|
|
72
|
+
|
|
73
|
+
production:
|
|
74
|
+
<<: *default
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module ActiveJob
|
|
7
|
+
class Adapter
|
|
8
|
+
def enqueue(active_job)
|
|
9
|
+
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
10
|
+
payload_hash = JSON.parse(Serializer.serialize_job(active_job))
|
|
11
|
+
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
12
|
+
payload_hash = inject_batch_metadata(payload_hash)
|
|
13
|
+
|
|
14
|
+
enqueue_with_concurrency(active_job, queue, payload_hash)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def enqueue_at(active_job, timestamp)
|
|
18
|
+
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
19
|
+
payload_hash = JSON.parse(Serializer.serialize_job(active_job))
|
|
20
|
+
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
21
|
+
payload_hash = inject_batch_metadata(payload_hash)
|
|
22
|
+
delay = [(timestamp - Time.now.to_f).ceil, 0].max
|
|
23
|
+
|
|
24
|
+
enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enqueue_all(active_jobs)
|
|
28
|
+
active_jobs.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
|
|
29
|
+
enqueue_immediate(queue, jobs.reject { |j| j.scheduled_at && j.scheduled_at > Time.now })
|
|
30
|
+
jobs.select { |j| j.scheduled_at && j.scheduled_at > Time.now }.each { |j| enqueue_at(j, j.scheduled_at.to_f) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
active_jobs.count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def enqueue_with_concurrency(active_job, queue, payload_hash, delay: 0)
|
|
39
|
+
key = Concurrency.extract_key(payload_hash)
|
|
40
|
+
concurrency = concurrency_config(active_job)
|
|
41
|
+
|
|
42
|
+
if key && concurrency
|
|
43
|
+
result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
|
|
44
|
+
|
|
45
|
+
if result == :acquired
|
|
46
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
47
|
+
active_job.provider_job_id = msg_id
|
|
48
|
+
else
|
|
49
|
+
handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
53
|
+
active_job.provider_job_id = msg_id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
active_job
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def concurrency_config(active_job)
|
|
60
|
+
active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
64
|
+
case concurrency[:on_conflict]
|
|
65
|
+
when :block
|
|
66
|
+
Concurrency::BlockedExecution.insert(
|
|
67
|
+
concurrency_key: key,
|
|
68
|
+
queue_name: queue,
|
|
69
|
+
payload: payload_hash,
|
|
70
|
+
priority: active_job.try(:priority) || 0,
|
|
71
|
+
duration: concurrency[:duration]
|
|
72
|
+
)
|
|
73
|
+
when :discard
|
|
74
|
+
Pgbus.logger.info { "[Pgbus] Discarding job #{active_job.class.name}: concurrency limit for #{key}" }
|
|
75
|
+
when :raise
|
|
76
|
+
raise ConcurrencyLimitExceeded, "Concurrency limit reached for key: #{key}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inject_batch_metadata(payload_hash)
|
|
81
|
+
batch_id = Thread.current[:pgbus_batch_id]
|
|
82
|
+
return payload_hash unless batch_id
|
|
83
|
+
|
|
84
|
+
Thread.current[:pgbus_batch_job_count] = (Thread.current[:pgbus_batch_job_count] || 0) + 1
|
|
85
|
+
payload_hash.merge(Batch::METADATA_KEY => batch_id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def enqueue_immediate(queue, jobs)
|
|
89
|
+
return if jobs.empty?
|
|
90
|
+
|
|
91
|
+
payloads = jobs.map { |j| JSON.parse(Serializer.serialize_job(j)) }
|
|
92
|
+
msg_ids = Pgbus.client.send_batch(queue, payloads)
|
|
93
|
+
|
|
94
|
+
unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
|
|
95
|
+
raise "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
jobs.zip(msg_ids).each { |job, id| job.provider_job_id = id }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def enqueue_after_transaction_commit?
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Register the adapter with ActiveJob (register method added in Rails 7.2+)
|
|
109
|
+
ActiveJob::QueueAdapters.register(:pgbus, Pgbus::ActiveJob::Adapter) if ActiveJob::QueueAdapters.respond_to?(:register)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module ActiveJob
|
|
5
|
+
class Executor
|
|
6
|
+
attr_reader :client, :config
|
|
7
|
+
|
|
8
|
+
def initialize(client: Pgbus.client, config: Pgbus.configuration)
|
|
9
|
+
@client = client
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute(message, queue_name)
|
|
14
|
+
payload = JSON.parse(message.message)
|
|
15
|
+
read_count = message.read_ct.to_i
|
|
16
|
+
|
|
17
|
+
if read_count > config.max_retries
|
|
18
|
+
handle_dead_letter(message, queue_name, payload)
|
|
19
|
+
signal_concurrency(payload)
|
|
20
|
+
signal_batch_discarded(payload)
|
|
21
|
+
return :dead_lettered
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
job = ::ActiveJob::Base.deserialize(payload)
|
|
25
|
+
execute_job(job)
|
|
26
|
+
client.archive_message(queue_name, message.msg_id.to_i)
|
|
27
|
+
signal_concurrency(payload)
|
|
28
|
+
signal_batch_completed(payload)
|
|
29
|
+
instrument("pgbus.job_completed", queue: queue_name, job_class: payload["job_class"])
|
|
30
|
+
:success
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
handle_failure(message, queue_name, e)
|
|
33
|
+
instrument("pgbus.job_failed", queue: queue_name, job_class: payload&.dig("job_class"), error: e.class.name)
|
|
34
|
+
# Don't signal concurrency on transient failure — the job will be retried.
|
|
35
|
+
# Semaphore is released only on success or dead-lettering.
|
|
36
|
+
:failed
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def execute_job(job)
|
|
42
|
+
if defined?(Rails) && Rails.application
|
|
43
|
+
Rails.application.executor.wrap { job.perform_now }
|
|
44
|
+
else
|
|
45
|
+
job.perform_now
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle_failure(_message, _queue_name, error)
|
|
50
|
+
Pgbus.logger.error { "[Pgbus] Job failed: #{error.class}: #{error.message}" }
|
|
51
|
+
Pgbus.logger.debug { error.backtrace&.join("\n") }
|
|
52
|
+
|
|
53
|
+
# Message visibility timeout will expire and it becomes available again.
|
|
54
|
+
# read_ct tracks delivery attempts — when it exceeds max_retries,
|
|
55
|
+
# the next read will route to DLQ.
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def instrument(event_name, payload = {})
|
|
59
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
60
|
+
|
|
61
|
+
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
Pgbus.logger.debug { "[Pgbus] Notification failure #{event_name}: #{e.class}: #{e.message}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def signal_concurrency(payload)
|
|
67
|
+
key = Concurrency.extract_key(payload)
|
|
68
|
+
return unless key
|
|
69
|
+
|
|
70
|
+
Concurrency::Semaphore.release(key)
|
|
71
|
+
|
|
72
|
+
released = Concurrency::BlockedExecution.release_next(key)
|
|
73
|
+
return unless released
|
|
74
|
+
|
|
75
|
+
client.send_message(released[:queue_name], released[:payload])
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def signal_batch_completed(payload)
|
|
81
|
+
batch_id = payload[Batch::METADATA_KEY]
|
|
82
|
+
return unless batch_id
|
|
83
|
+
|
|
84
|
+
Batch.job_completed(batch_id)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Pgbus.logger.warn { "[Pgbus] Batch completion signal failed: #{e.message}" }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def signal_batch_discarded(payload)
|
|
90
|
+
batch_id = payload[Batch::METADATA_KEY]
|
|
91
|
+
return unless batch_id
|
|
92
|
+
|
|
93
|
+
Batch.job_discarded(batch_id)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_dead_letter(message, queue_name, payload)
|
|
99
|
+
Pgbus.logger.warn do
|
|
100
|
+
job_class = payload["job_class"] || "unknown"
|
|
101
|
+
"[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
|
|
102
|
+
end
|
|
103
|
+
client.move_to_dead_letter(queue_name, message)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/pgbus/batch.rb
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
class Batch
|
|
8
|
+
METADATA_KEY = "pgbus_batch_id"
|
|
9
|
+
|
|
10
|
+
attr_reader :batch_id, :properties, :description,
|
|
11
|
+
:on_finish, :on_success, :on_discard
|
|
12
|
+
|
|
13
|
+
def initialize(on_finish: nil, on_success: nil, on_discard: nil, description: nil, properties: {})
|
|
14
|
+
@batch_id = SecureRandom.uuid
|
|
15
|
+
@on_finish = on_finish
|
|
16
|
+
@on_success = on_success
|
|
17
|
+
@on_discard = on_discard
|
|
18
|
+
@description = description
|
|
19
|
+
@properties = properties
|
|
20
|
+
@job_count = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Enqueue a group of jobs as a batch.
|
|
24
|
+
# Jobs enqueued inside the block are tracked as part of this batch.
|
|
25
|
+
def enqueue(&)
|
|
26
|
+
create_record
|
|
27
|
+
count_jobs(&)
|
|
28
|
+
update_total
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Record a completed job. Returns the batch status after update.
|
|
33
|
+
def self.job_completed(batch_id)
|
|
34
|
+
update_counter(batch_id, "completed_jobs")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Record a discarded (dead-lettered) job. Returns the batch status after update.
|
|
38
|
+
def self.job_discarded(batch_id)
|
|
39
|
+
update_counter(batch_id, "discarded_jobs")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find a batch record by ID. Returns a hash or nil.
|
|
43
|
+
def self.find(batch_id)
|
|
44
|
+
return nil unless defined?(ActiveRecord::Base)
|
|
45
|
+
|
|
46
|
+
result = ActiveRecord::Base.connection.exec_query(
|
|
47
|
+
"SELECT * FROM pgbus_batches WHERE batch_id = $1",
|
|
48
|
+
"Pgbus Batch Find",
|
|
49
|
+
[batch_id]
|
|
50
|
+
)
|
|
51
|
+
result.first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Delete finished batches older than the given threshold.
|
|
55
|
+
def self.cleanup(older_than:)
|
|
56
|
+
return 0 unless defined?(ActiveRecord::Base)
|
|
57
|
+
|
|
58
|
+
result = ActiveRecord::Base.connection.exec_query(
|
|
59
|
+
"DELETE FROM pgbus_batches WHERE status = 'finished' AND finished_at < $1 RETURNING id",
|
|
60
|
+
"Pgbus Batch Cleanup",
|
|
61
|
+
[older_than]
|
|
62
|
+
)
|
|
63
|
+
result.to_a.size
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def create_record
|
|
69
|
+
return unless defined?(ActiveRecord::Base)
|
|
70
|
+
|
|
71
|
+
ActiveRecord::Base.connection.exec_query(
|
|
72
|
+
<<~SQL,
|
|
73
|
+
INSERT INTO pgbus_batches
|
|
74
|
+
(batch_id, description, on_finish_class, on_success_class, on_discard_class, properties, status)
|
|
75
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
|
|
76
|
+
SQL
|
|
77
|
+
"Pgbus Batch Create",
|
|
78
|
+
[batch_id, description, on_finish&.name, on_success&.name, on_discard&.name, JSON.generate(properties)]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def count_jobs(&)
|
|
83
|
+
Thread.current[:pgbus_batch_id] = batch_id
|
|
84
|
+
@job_count = 0
|
|
85
|
+
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
@job_count = Thread.current[:pgbus_batch_job_count] || 0
|
|
89
|
+
ensure
|
|
90
|
+
Thread.current[:pgbus_batch_id] = nil
|
|
91
|
+
Thread.current[:pgbus_batch_job_count] = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def update_total
|
|
95
|
+
return unless defined?(ActiveRecord::Base)
|
|
96
|
+
|
|
97
|
+
ActiveRecord::Base.connection.exec_query(
|
|
98
|
+
"UPDATE pgbus_batches SET total_jobs = $1, status = 'processing' WHERE batch_id = $2",
|
|
99
|
+
"Pgbus Batch Update Total",
|
|
100
|
+
[@job_count, batch_id]
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def update_counter(batch_id, column)
|
|
108
|
+
return nil unless defined?(ActiveRecord::Base)
|
|
109
|
+
|
|
110
|
+
result = ActiveRecord::Base.connection.exec_query(
|
|
111
|
+
<<~SQL,
|
|
112
|
+
UPDATE pgbus_batches
|
|
113
|
+
SET #{column} = #{column} + 1,
|
|
114
|
+
status = CASE
|
|
115
|
+
WHEN completed_jobs + discarded_jobs + 1 = total_jobs THEN 'finished'
|
|
116
|
+
ELSE status
|
|
117
|
+
END,
|
|
118
|
+
finished_at = CASE
|
|
119
|
+
WHEN completed_jobs + discarded_jobs + 1 = total_jobs THEN NOW()
|
|
120
|
+
ELSE finished_at
|
|
121
|
+
END
|
|
122
|
+
WHERE batch_id = $1
|
|
123
|
+
RETURNING status, total_jobs, completed_jobs, discarded_jobs, on_finish_class, on_success_class, on_discard_class, properties
|
|
124
|
+
SQL
|
|
125
|
+
"Pgbus Batch Counter",
|
|
126
|
+
[batch_id]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
row = result.first
|
|
130
|
+
return nil unless row
|
|
131
|
+
|
|
132
|
+
fire_callbacks(row) if row["status"] == "finished"
|
|
133
|
+
row
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fire_callbacks(row)
|
|
137
|
+
properties = JSON.parse(row["properties"] || "{}")
|
|
138
|
+
all_succeeded = row["discarded_jobs"].to_i.zero?
|
|
139
|
+
|
|
140
|
+
enqueue_callback(row["on_finish_class"], properties) if row["on_finish_class"]
|
|
141
|
+
enqueue_callback(row["on_success_class"], properties) if row["on_success_class"] && all_succeeded
|
|
142
|
+
enqueue_callback(row["on_discard_class"], properties) if row["on_discard_class"] && !all_succeeded
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def enqueue_callback(class_name, properties)
|
|
146
|
+
job_class = class_name.constantize
|
|
147
|
+
job_class.perform_later(properties)
|
|
148
|
+
rescue NameError => e
|
|
149
|
+
Pgbus.logger.error { "[Pgbus] Batch callback class not found: #{class_name}: #{e.message}" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/pgbus/cli.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module CLI
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def start(args)
|
|
8
|
+
command = args.first || "help"
|
|
9
|
+
|
|
10
|
+
case command
|
|
11
|
+
when "start"
|
|
12
|
+
start_supervisor
|
|
13
|
+
when "status"
|
|
14
|
+
show_status
|
|
15
|
+
when "queues"
|
|
16
|
+
list_queues
|
|
17
|
+
when "version"
|
|
18
|
+
puts "pgbus #{Pgbus::VERSION}"
|
|
19
|
+
when "help", "--help", "-h"
|
|
20
|
+
print_help
|
|
21
|
+
else
|
|
22
|
+
puts "Unknown command: #{command}"
|
|
23
|
+
print_help
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start_supervisor
|
|
29
|
+
Pgbus.logger.info { "[Pgbus] Starting Pgbus #{Pgbus::VERSION}..." }
|
|
30
|
+
|
|
31
|
+
supervisor = Process::Supervisor.new
|
|
32
|
+
supervisor.run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def show_status
|
|
36
|
+
if defined?(ActiveRecord::Base)
|
|
37
|
+
processes = ActiveRecord::Base.connection.execute(
|
|
38
|
+
"SELECT kind, hostname, pid, metadata, last_heartbeat_at FROM pgbus_processes ORDER BY kind, created_at"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if processes.none?
|
|
42
|
+
puts "No Pgbus processes running."
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts "KIND HOST PID HEARTBEAT METADATA"
|
|
47
|
+
puts "-" * 100
|
|
48
|
+
processes.each do |p|
|
|
49
|
+
puts format("%-12s %-20s %-8s %-30s %s",
|
|
50
|
+
p["kind"], p["hostname"], p["pid"], p["last_heartbeat_at"], p["metadata"])
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
puts "ActiveRecord not available. Run from a Rails context."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def list_queues
|
|
58
|
+
Pgbus.client.list_queues
|
|
59
|
+
metrics = Pgbus.client.metrics
|
|
60
|
+
|
|
61
|
+
puts "QUEUE DEPTH VISIBLE OLDEST (s) TOTAL "
|
|
62
|
+
puts "-" * 95
|
|
63
|
+
|
|
64
|
+
Array(metrics).each do |m|
|
|
65
|
+
puts format("%-40s %-10s %-10s %-15s %-15s",
|
|
66
|
+
m.queue_name, m.queue_length, m.queue_visible_length,
|
|
67
|
+
m.oldest_msg_age_sec || "-", m.total_messages)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print_help
|
|
72
|
+
puts <<~HELP
|
|
73
|
+
Usage: pgbus <command>
|
|
74
|
+
|
|
75
|
+
Commands:
|
|
76
|
+
start Start the Pgbus supervisor (workers + dispatcher)
|
|
77
|
+
status Show running Pgbus processes
|
|
78
|
+
queues List queues with metrics
|
|
79
|
+
version Show version
|
|
80
|
+
help Show this help
|
|
81
|
+
HELP
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/pgbus/client.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Client
|
|
7
|
+
attr_reader :pgmq, :config
|
|
8
|
+
|
|
9
|
+
def initialize(config = Pgbus.configuration)
|
|
10
|
+
require "pgmq-ruby"
|
|
11
|
+
@config = config
|
|
12
|
+
@pgmq = PGMQ::Client.new(
|
|
13
|
+
config.connection_options,
|
|
14
|
+
pool_size: config.pool_size,
|
|
15
|
+
pool_timeout: config.pool_timeout
|
|
16
|
+
)
|
|
17
|
+
@queues_created = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ensure_queue(name)
|
|
22
|
+
full_name = config.queue_name(name)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
return if @queues_created[full_name]
|
|
25
|
+
|
|
26
|
+
@pgmq.create(full_name)
|
|
27
|
+
@pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
|
|
28
|
+
@queues_created[full_name] = true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_dead_letter_queue(name)
|
|
33
|
+
dlq_name = config.dead_letter_queue_name(name)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
return if @queues_created[dlq_name]
|
|
36
|
+
|
|
37
|
+
@pgmq.create(dlq_name)
|
|
38
|
+
@queues_created[dlq_name] = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def send_message(queue_name, payload, headers: nil, delay: 0)
|
|
43
|
+
full_name = config.queue_name(queue_name)
|
|
44
|
+
ensure_queue(queue_name)
|
|
45
|
+
@pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def send_batch(queue_name, payloads, headers: nil, delay: 0)
|
|
49
|
+
full_name = config.queue_name(queue_name)
|
|
50
|
+
ensure_queue(queue_name)
|
|
51
|
+
serialized = payloads.map { |p| serialize(p) }
|
|
52
|
+
serialized_headers = headers&.map { |h| serialize(h) }
|
|
53
|
+
@pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_message(queue_name, vt: nil)
|
|
57
|
+
full_name = config.queue_name(queue_name)
|
|
58
|
+
@pgmq.read(full_name, vt: vt || config.visibility_timeout)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_batch(queue_name, qty:, vt: nil)
|
|
62
|
+
full_name = config.queue_name(queue_name)
|
|
63
|
+
@pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
|
|
67
|
+
full_name = config.queue_name(queue_name)
|
|
68
|
+
@pgmq.read_with_poll(
|
|
69
|
+
full_name,
|
|
70
|
+
vt: vt || config.visibility_timeout,
|
|
71
|
+
qty: qty,
|
|
72
|
+
max_poll_seconds: max_poll_seconds,
|
|
73
|
+
poll_interval_ms: poll_interval_ms
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete_message(queue_name, msg_id)
|
|
78
|
+
full_name = config.queue_name(queue_name)
|
|
79
|
+
@pgmq.delete(full_name, msg_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def archive_message(queue_name, msg_id)
|
|
83
|
+
full_name = config.queue_name(queue_name)
|
|
84
|
+
@pgmq.archive(full_name, msg_id)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extend_visibility(queue_name, msg_id, vt:)
|
|
88
|
+
full_name = config.queue_name(queue_name)
|
|
89
|
+
@pgmq.set_vt(full_name, msg_id, vt: vt)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def set_visibility_timeout(queue_name, msg_id, vt:)
|
|
93
|
+
@pgmq.set_vt(queue_name, msg_id, vt: vt)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_from_queue(queue_name, msg_id)
|
|
97
|
+
@pgmq.delete(queue_name, msg_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def transaction(&)
|
|
101
|
+
@pgmq.transaction(&)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def move_to_dead_letter(queue_name, message)
|
|
105
|
+
ensure_dead_letter_queue(queue_name)
|
|
106
|
+
dlq_name = config.dead_letter_queue_name(queue_name)
|
|
107
|
+
full_queue = config.queue_name(queue_name)
|
|
108
|
+
|
|
109
|
+
@pgmq.transaction do |txn|
|
|
110
|
+
txn.produce(dlq_name, message.message, headers: message.headers)
|
|
111
|
+
txn.delete(full_queue, message.msg_id.to_i)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def metrics(queue_name = nil)
|
|
116
|
+
if queue_name
|
|
117
|
+
@pgmq.metrics(config.queue_name(queue_name))
|
|
118
|
+
else
|
|
119
|
+
@pgmq.metrics_all
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def list_queues
|
|
124
|
+
@pgmq.list_queues
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def purge_queue(queue_name)
|
|
128
|
+
@pgmq.purge_queue(config.queue_name(queue_name))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Topic routing
|
|
132
|
+
def bind_topic(pattern, queue_name)
|
|
133
|
+
full_name = config.queue_name(queue_name)
|
|
134
|
+
ensure_queue(queue_name)
|
|
135
|
+
@pgmq.bind_topic(pattern, full_name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def publish_to_topic(routing_key, payload, headers: nil, delay: 0)
|
|
139
|
+
@pgmq.produce_topic(
|
|
140
|
+
routing_key,
|
|
141
|
+
serialize(payload),
|
|
142
|
+
headers: headers && serialize(headers),
|
|
143
|
+
delay: delay
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def close
|
|
148
|
+
@pgmq.close
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def serialize(data)
|
|
154
|
+
case data
|
|
155
|
+
when String
|
|
156
|
+
data
|
|
157
|
+
else
|
|
158
|
+
JSON.generate(data)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|