pgbus 0.0.1 → 0.1.2
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 +37 -3
- data/Rakefile +98 -1
- data/app/controllers/pgbus/application_controller.rb +8 -0
- data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
- data/app/helpers/pgbus/application_helper.rb +41 -0
- data/app/models/pgbus/application_record.rb +7 -0
- data/app/models/pgbus/batch_entry.rb +31 -0
- data/app/models/pgbus/blocked_execution.rb +40 -0
- data/app/models/pgbus/process_entry.rb +9 -0
- data/app/models/pgbus/processed_event.rb +9 -0
- data/app/models/pgbus/recurring_execution.rb +33 -0
- data/app/models/pgbus/recurring_task.rb +42 -0
- data/app/models/pgbus/semaphore.rb +29 -0
- data/app/views/layouts/pgbus/application.html.erb +1 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
- data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
- data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
- data/config/routes.rb +7 -0
- data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
- data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
- data/lib/generators/pgbus/install_generator.rb +76 -2
- data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
- data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
- data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
- data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
- data/lib/pgbus/active_job/adapter.rb +3 -6
- data/lib/pgbus/active_job/executor.rb +26 -12
- data/lib/pgbus/batch.rb +65 -72
- data/lib/pgbus/cli.rb +11 -16
- data/lib/pgbus/client.rb +32 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
- data/lib/pgbus/concurrency/semaphore.rb +11 -39
- data/lib/pgbus/concurrency.rb +10 -2
- data/lib/pgbus/configuration.rb +48 -0
- data/lib/pgbus/engine.rb +19 -1
- data/lib/pgbus/event_bus/handler.rb +10 -23
- data/lib/pgbus/instrumentation.rb +29 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
- data/lib/pgbus/pgmq_schema.rb +159 -0
- data/lib/pgbus/process/consumer.rb +17 -9
- data/lib/pgbus/process/dispatcher.rb +33 -41
- data/lib/pgbus/process/heartbeat.rb +15 -23
- data/lib/pgbus/process/signal_handler.rb +23 -1
- data/lib/pgbus/process/supervisor.rb +79 -2
- data/lib/pgbus/process/worker.rb +42 -13
- data/lib/pgbus/recurring/already_recorded.rb +7 -0
- data/lib/pgbus/recurring/command_job.rb +28 -0
- data/lib/pgbus/recurring/config_loader.rb +35 -0
- data/lib/pgbus/recurring/schedule.rb +102 -0
- data/lib/pgbus/recurring/scheduler.rb +102 -0
- data/lib/pgbus/recurring/task.rb +111 -0
- data/lib/pgbus/serializer.rb +16 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +217 -36
- data/lib/pgbus.rb +8 -0
- data/lib/tasks/pgbus_pgmq.rake +62 -0
- metadata +51 -24
- data/.bun-version +0 -1
- data/.claude/commands/architect.md +0 -100
- data/.claude/commands/github-review-comments.md +0 -237
- data/.claude/commands/lfg.md +0 -271
- data/.claude/commands/review-pr.md +0 -69
- data/.claude/commands/security.md +0 -122
- data/.claude/commands/tdd.md +0 -148
- data/.claude/rules/agents.md +0 -49
- data/.claude/rules/coding-style.md +0 -91
- data/.claude/rules/git-workflow.md +0 -56
- data/.claude/rules/performance.md +0 -73
- data/.claude/rules/testing.md +0 -67
- data/CLAUDE.md +0 -80
- data/CODE_OF_CONDUCT.md +0 -10
- data/bun.lock +0 -18
- data/docs/README.md +0 -28
- data/docs/switch_from_good_job.md +0 -279
- data/docs/switch_from_sidekiq.md +0 -226
- data/docs/switch_from_solid_queue.md +0 -247
- data/package.json +0 -9
- data/sig/pgbus.rbs +0 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class UpgradePgmqToV<%= target_version_slug.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
# Drop existing PGMQ functions and types so they can be re-created
|
|
4
|
+
# at the new version. Tables (pgmq.meta, queue tables) are preserved.
|
|
5
|
+
execute Pgbus::PgmqSchema.drop_pgmq_functions_sql
|
|
6
|
+
|
|
7
|
+
# Re-create all PGMQ functions and types at the new version.
|
|
8
|
+
# This uses the vendored SQL which doesn't require the pgmq extension.
|
|
9
|
+
execute Pgbus::PgmqSchema.install_sql("<%= target_version %>")
|
|
10
|
+
|
|
11
|
+
# Record the upgrade
|
|
12
|
+
execute <<~SQL
|
|
13
|
+
CREATE TABLE IF NOT EXISTS pgbus_pgmq_schema_versions (
|
|
14
|
+
id SERIAL PRIMARY KEY,
|
|
15
|
+
version VARCHAR NOT NULL,
|
|
16
|
+
installed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
|
17
|
+
install_method VARCHAR NOT NULL DEFAULT 'embedded'
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
INSERT INTO pgbus_pgmq_schema_versions (version, install_method)
|
|
21
|
+
VALUES ('<%= target_version %>', 'upgrade');
|
|
22
|
+
SQL
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def down
|
|
26
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
27
|
+
"PGMQ schema downgrade is not supported. " \
|
|
28
|
+
"Restore from a database backup if needed."
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradePgmqGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Upgrade PGMQ schema to the latest vendored version"
|
|
14
|
+
|
|
15
|
+
class_option :database,
|
|
16
|
+
type: :string,
|
|
17
|
+
default: nil,
|
|
18
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
19
|
+
|
|
20
|
+
def create_migration
|
|
21
|
+
if separate_database?
|
|
22
|
+
migration_template "upgrade_pgmq.rb.erb",
|
|
23
|
+
"db/pgbus_migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
|
|
24
|
+
else
|
|
25
|
+
migration_template "upgrade_pgmq.rb.erb",
|
|
26
|
+
"db/migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_upgrade
|
|
31
|
+
say ""
|
|
32
|
+
say "PGMQ upgrade migration created!", :green
|
|
33
|
+
say " Target version: #{target_version}", :yellow
|
|
34
|
+
say ""
|
|
35
|
+
say "Next steps:"
|
|
36
|
+
say " 1. Review the migration in db/#{separate_database? ? "pgbus_migrate" : "migrate"}/"
|
|
37
|
+
say " 2. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
38
|
+
say ""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def target_version
|
|
48
|
+
Pgbus::PgmqSchema.latest_version
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def target_version_slug
|
|
52
|
+
target_version.tr(".", "_")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def separate_database?
|
|
56
|
+
options[:database].present?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -7,7 +7,7 @@ module Pgbus
|
|
|
7
7
|
class Adapter
|
|
8
8
|
def enqueue(active_job)
|
|
9
9
|
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
10
|
-
payload_hash =
|
|
10
|
+
payload_hash = Serializer.serialize_job_hash(active_job)
|
|
11
11
|
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
12
12
|
payload_hash = inject_batch_metadata(payload_hash)
|
|
13
13
|
|
|
@@ -16,7 +16,7 @@ module Pgbus
|
|
|
16
16
|
|
|
17
17
|
def enqueue_at(active_job, timestamp)
|
|
18
18
|
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
19
|
-
payload_hash =
|
|
19
|
+
payload_hash = Serializer.serialize_job_hash(active_job)
|
|
20
20
|
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
21
21
|
payload_hash = inject_batch_metadata(payload_hash)
|
|
22
22
|
delay = [(timestamp - Time.now.to_f).ceil, 0].max
|
|
@@ -88,7 +88,7 @@ module Pgbus
|
|
|
88
88
|
def enqueue_immediate(queue, jobs)
|
|
89
89
|
return if jobs.empty?
|
|
90
90
|
|
|
91
|
-
payloads = jobs.map { |j|
|
|
91
|
+
payloads = jobs.map { |j| Serializer.serialize_job_hash(j) }
|
|
92
92
|
msg_ids = Pgbus.client.send_batch(queue, payloads)
|
|
93
93
|
|
|
94
94
|
unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
|
|
@@ -104,6 +104,3 @@ module Pgbus
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
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)
|
|
@@ -21,12 +21,18 @@ module Pgbus
|
|
|
21
21
|
return :dead_lettered
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
job_class = payload["job_class"]
|
|
25
|
+
|
|
26
|
+
job_succeeded = false
|
|
27
|
+
|
|
28
|
+
Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
|
|
29
|
+
job = ::ActiveJob::Base.deserialize(payload)
|
|
30
|
+
execute_job(job)
|
|
31
|
+
client.archive_message(queue_name, message.msg_id.to_i)
|
|
32
|
+
job_succeeded = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
|
|
30
36
|
:success
|
|
31
37
|
rescue StandardError => e
|
|
32
38
|
handle_failure(message, queue_name, e)
|
|
@@ -34,6 +40,14 @@ module Pgbus
|
|
|
34
40
|
# Don't signal concurrency on transient failure — the job will be retried.
|
|
35
41
|
# Semaphore is released only on success or dead-lettering.
|
|
36
42
|
:failed
|
|
43
|
+
ensure
|
|
44
|
+
# Signal concurrency and batch only when the job was archived successfully.
|
|
45
|
+
# job_succeeded is set AFTER archive_message, so if archive fails the
|
|
46
|
+
# semaphore slot stays held until VT expires and the job is retried.
|
|
47
|
+
if job_succeeded
|
|
48
|
+
signal_concurrency(payload)
|
|
49
|
+
signal_batch_completed(payload)
|
|
50
|
+
end
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
private
|
|
@@ -67,12 +81,12 @@ module Pgbus
|
|
|
67
81
|
key = Concurrency.extract_key(payload)
|
|
68
82
|
return unless key
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
# Atomic permit handoff: try to promote a blocked job first.
|
|
85
|
+
# promote_next wraps delete + enqueue in a transaction so neither is lost.
|
|
86
|
+
# If promoted, the slot stays occupied (no release needed).
|
|
87
|
+
# Only release the semaphore if there's nothing to promote.
|
|
88
|
+
promoted = Concurrency::BlockedExecution.promote_next(key, client: client)
|
|
89
|
+
Concurrency::Semaphore.release(key) unless promoted
|
|
76
90
|
rescue StandardError => e
|
|
77
91
|
Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
|
|
78
92
|
end
|
data/lib/pgbus/batch.rb
CHANGED
|
@@ -29,124 +29,117 @@ module Pgbus
|
|
|
29
29
|
self
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Record a completed job. Returns the batch
|
|
32
|
+
# Record a completed job. Returns the batch row after update.
|
|
33
33
|
def self.job_completed(batch_id)
|
|
34
34
|
update_counter(batch_id, "completed_jobs")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# Record a discarded (dead-lettered) job. Returns the batch
|
|
37
|
+
# Record a discarded (dead-lettered) job. Returns the batch row after update.
|
|
38
38
|
def self.job_discarded(batch_id)
|
|
39
39
|
update_counter(batch_id, "discarded_jobs")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# Find a batch record by ID. Returns a hash or nil.
|
|
43
43
|
def self.find(batch_id)
|
|
44
|
-
|
|
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
|
|
44
|
+
BatchEntry.find_by(batch_id: batch_id)&.attributes
|
|
52
45
|
end
|
|
53
46
|
|
|
54
47
|
# Delete finished batches older than the given threshold.
|
|
55
48
|
def self.cleanup(older_than:)
|
|
56
|
-
|
|
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
|
|
49
|
+
BatchEntry.stale(before: older_than).delete_all
|
|
64
50
|
end
|
|
65
51
|
|
|
66
52
|
private
|
|
67
53
|
|
|
68
54
|
def create_record
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"Pgbus Batch Create",
|
|
78
|
-
[batch_id, description, on_finish&.name, on_success&.name, on_discard&.name, JSON.generate(properties)]
|
|
55
|
+
BatchEntry.create!(
|
|
56
|
+
batch_id: batch_id,
|
|
57
|
+
description: description,
|
|
58
|
+
on_finish_class: on_finish&.name,
|
|
59
|
+
on_success_class: on_success&.name,
|
|
60
|
+
on_discard_class: on_discard&.name,
|
|
61
|
+
properties: JSON.generate(properties),
|
|
62
|
+
status: "pending"
|
|
79
63
|
)
|
|
80
64
|
end
|
|
81
65
|
|
|
82
66
|
def count_jobs(&)
|
|
67
|
+
previous_batch_id = Thread.current[:pgbus_batch_id]
|
|
68
|
+
previous_count = Thread.current[:pgbus_batch_job_count]
|
|
69
|
+
|
|
83
70
|
Thread.current[:pgbus_batch_id] = batch_id
|
|
84
|
-
|
|
71
|
+
Thread.current[:pgbus_batch_job_count] = 0
|
|
85
72
|
|
|
86
73
|
yield
|
|
87
74
|
|
|
88
75
|
@job_count = Thread.current[:pgbus_batch_job_count] || 0
|
|
89
76
|
ensure
|
|
90
|
-
Thread.current[:pgbus_batch_id] =
|
|
91
|
-
Thread.current[:pgbus_batch_job_count] =
|
|
77
|
+
Thread.current[:pgbus_batch_id] = previous_batch_id
|
|
78
|
+
Thread.current[:pgbus_batch_job_count] = previous_count
|
|
92
79
|
end
|
|
93
80
|
|
|
94
81
|
def update_total
|
|
95
|
-
|
|
82
|
+
if @job_count.zero?
|
|
83
|
+
# Finish empty batches immediately — no jobs to signal completion
|
|
84
|
+
BatchEntry.where(batch_id: batch_id).update_all(
|
|
85
|
+
total_jobs: 0,
|
|
86
|
+
status: "finished",
|
|
87
|
+
finished_at: Time.current
|
|
88
|
+
)
|
|
89
|
+
fire_empty_batch_callbacks
|
|
90
|
+
else
|
|
91
|
+
BatchEntry.where(batch_id: batch_id).update_all(total_jobs: @job_count, status: "processing")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
96
94
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
95
|
+
def fire_empty_batch_callbacks
|
|
96
|
+
record = BatchEntry.find_by(batch_id: batch_id)
|
|
97
|
+
return unless record
|
|
98
|
+
|
|
99
|
+
properties = parse_properties(record.properties)
|
|
100
|
+
self.class.send(:enqueue_callback, record.on_finish_class, properties) if record.on_finish_class
|
|
101
|
+
self.class.send(:enqueue_callback, record.on_success_class, properties) if record.on_success_class
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_properties(props)
|
|
105
|
+
JSON.parse(props.presence || "{}")
|
|
106
|
+
rescue JSON::ParserError => e
|
|
107
|
+
Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
|
|
108
|
+
{}
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
class << self
|
|
105
112
|
private
|
|
106
113
|
|
|
107
114
|
def update_counter(batch_id, column)
|
|
108
|
-
|
|
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
|
-
)
|
|
115
|
+
result = BatchEntry.increment_counter!(batch_id, column)
|
|
116
|
+
return nil unless result
|
|
128
117
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
fire_callbacks(row) if row["status"] == "finished"
|
|
133
|
-
row
|
|
118
|
+
fire_callbacks(result[:record]) if result[:just_finished]
|
|
119
|
+
result
|
|
134
120
|
end
|
|
135
121
|
|
|
136
|
-
def fire_callbacks(
|
|
137
|
-
properties =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
122
|
+
def fire_callbacks(record)
|
|
123
|
+
properties = begin
|
|
124
|
+
JSON.parse(record.properties.presence || "{}")
|
|
125
|
+
rescue JSON::ParserError => e
|
|
126
|
+
Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
|
|
127
|
+
{}
|
|
128
|
+
end
|
|
129
|
+
all_succeeded = record.discarded_jobs.zero?
|
|
130
|
+
|
|
131
|
+
enqueue_callback(record.on_finish_class, properties) if record.on_finish_class
|
|
132
|
+
enqueue_callback(record.on_success_class, properties) if record.on_success_class && all_succeeded
|
|
133
|
+
enqueue_callback(record.on_discard_class, properties) if record.on_discard_class && !all_succeeded
|
|
143
134
|
end
|
|
144
135
|
|
|
145
136
|
def enqueue_callback(class_name, properties)
|
|
146
|
-
job_class = class_name.
|
|
137
|
+
job_class = class_name.safe_constantize
|
|
138
|
+
unless job_class && job_class < ::ActiveJob::Base
|
|
139
|
+
Pgbus.logger.error { "[Pgbus] Batch callback class invalid or not an ActiveJob: #{class_name}" }
|
|
140
|
+
return
|
|
141
|
+
end
|
|
147
142
|
job_class.perform_later(properties)
|
|
148
|
-
rescue NameError => e
|
|
149
|
-
Pgbus.logger.error { "[Pgbus] Batch callback class not found: #{class_name}: #{e.message}" }
|
|
150
143
|
end
|
|
151
144
|
end
|
|
152
145
|
end
|
data/lib/pgbus/cli.rb
CHANGED
|
@@ -33,24 +33,19 @@ module Pgbus
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def show_status
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"SELECT kind, hostname, pid, metadata, last_heartbeat_at FROM pgbus_processes ORDER BY kind, created_at"
|
|
39
|
-
)
|
|
36
|
+
processes = ProcessEntry.order(:kind, :created_at)
|
|
37
|
+
.select(:kind, :hostname, :pid, :metadata, :last_heartbeat_at)
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
if processes.none?
|
|
40
|
+
puts "No Pgbus processes running."
|
|
41
|
+
return
|
|
42
|
+
end
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
52
|
-
else
|
|
53
|
-
puts "ActiveRecord not available. Run from a Rails context."
|
|
44
|
+
puts "KIND HOST PID HEARTBEAT METADATA"
|
|
45
|
+
puts "-" * 100
|
|
46
|
+
processes.each do |p|
|
|
47
|
+
puts format("%-12s %-20s %-8s %-30s %s",
|
|
48
|
+
p.kind, p.hostname, p.pid, p.last_heartbeat_at, p.metadata)
|
|
54
49
|
end
|
|
55
50
|
end
|
|
56
51
|
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -6,61 +6,78 @@ module Pgbus
|
|
|
6
6
|
class Client
|
|
7
7
|
attr_reader :pgmq, :config
|
|
8
8
|
|
|
9
|
+
PGMQ_REQUIRE_MUTEX = Mutex.new
|
|
10
|
+
private_constant :PGMQ_REQUIRE_MUTEX
|
|
11
|
+
|
|
9
12
|
def initialize(config = Pgbus.configuration)
|
|
10
|
-
|
|
13
|
+
# Define the PGMQ module before requiring the gem so that Zeitwerk's
|
|
14
|
+
# eager_load (called inside pgmq.rb) can resolve the constant.
|
|
15
|
+
# Without this, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError because
|
|
16
|
+
# eager_load runs const_get(:Client) on PGMQ before the module is defined.
|
|
17
|
+
PGMQ_REQUIRE_MUTEX.synchronize do
|
|
18
|
+
Object.const_set(:PGMQ, Module.new) unless defined?(::PGMQ)
|
|
19
|
+
require "pgmq"
|
|
20
|
+
end
|
|
11
21
|
@config = config
|
|
12
22
|
@pgmq = PGMQ::Client.new(
|
|
13
23
|
config.connection_options,
|
|
14
24
|
pool_size: config.pool_size,
|
|
15
25
|
pool_timeout: config.pool_timeout
|
|
16
26
|
)
|
|
17
|
-
@queues_created =
|
|
18
|
-
@mutex = Mutex.new
|
|
27
|
+
@queues_created = Concurrent::Map.new
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
def ensure_queue(name)
|
|
22
31
|
full_name = config.queue_name(name)
|
|
23
|
-
@
|
|
24
|
-
return if @queues_created[full_name]
|
|
32
|
+
return if @queues_created[full_name]
|
|
25
33
|
|
|
34
|
+
@queues_created.compute_if_absent(full_name) do
|
|
26
35
|
@pgmq.create(full_name)
|
|
27
36
|
@pgmq.enable_notify_insert(full_name, throttle_interval_ms: config.notify_throttle_ms) if config.listen_notify
|
|
28
|
-
|
|
37
|
+
true
|
|
29
38
|
end
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
def ensure_dead_letter_queue(name)
|
|
33
42
|
dlq_name = config.dead_letter_queue_name(name)
|
|
34
|
-
@
|
|
35
|
-
return if @queues_created[dlq_name]
|
|
43
|
+
return if @queues_created[dlq_name]
|
|
36
44
|
|
|
45
|
+
@queues_created.compute_if_absent(dlq_name) do
|
|
37
46
|
@pgmq.create(dlq_name)
|
|
38
|
-
|
|
47
|
+
true
|
|
39
48
|
end
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def send_message(queue_name, payload, headers: nil, delay: 0)
|
|
43
52
|
full_name = config.queue_name(queue_name)
|
|
44
53
|
ensure_queue(queue_name)
|
|
45
|
-
|
|
54
|
+
Instrumentation.instrument("pgbus.client.send_message", queue: full_name) do
|
|
55
|
+
@pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
|
|
56
|
+
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
59
|
def send_batch(queue_name, payloads, headers: nil, delay: 0)
|
|
49
60
|
full_name = config.queue_name(queue_name)
|
|
50
61
|
ensure_queue(queue_name)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
|
|
63
|
+
serialized = payloads.map { |p| serialize(p) }
|
|
64
|
+
serialized_headers = headers&.map { |h| serialize(h) }
|
|
65
|
+
@pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
|
|
66
|
+
end
|
|
54
67
|
end
|
|
55
68
|
|
|
56
69
|
def read_message(queue_name, vt: nil)
|
|
57
70
|
full_name = config.queue_name(queue_name)
|
|
58
|
-
|
|
71
|
+
Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
|
|
72
|
+
@pgmq.read(full_name, vt: vt || config.visibility_timeout)
|
|
73
|
+
end
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
def read_batch(queue_name, qty:, vt: nil)
|
|
62
77
|
full_name = config.queue_name(queue_name)
|
|
63
|
-
|
|
78
|
+
Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
|
|
79
|
+
@pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
|
|
80
|
+
end
|
|
64
81
|
end
|
|
65
82
|
|
|
66
83
|
def read_with_poll(queue_name, qty:, vt: nil, max_poll_seconds: 5, poll_interval_ms: 100)
|
|
@@ -8,65 +8,60 @@ module Pgbus
|
|
|
8
8
|
class << self
|
|
9
9
|
# Insert a blocked execution for a job that hit the concurrency limit.
|
|
10
10
|
def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
Pgbus::BlockedExecution.create!(
|
|
12
|
+
concurrency_key: concurrency_key,
|
|
13
|
+
queue_name: queue_name,
|
|
14
|
+
payload: JSON.generate(payload),
|
|
15
|
+
priority: priority,
|
|
16
|
+
expires_at: Time.now.utc + duration
|
|
17
|
+
)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Release the next blocked execution for a given concurrency key.
|
|
21
21
|
# Returns the released row (queue_name, payload) or nil if none.
|
|
22
22
|
def release_next(concurrency_key)
|
|
23
|
-
|
|
23
|
+
Pgbus::BlockedExecution.release_next!(concurrency_key)
|
|
24
|
+
end
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
RETURNING queue_name, payload
|
|
35
|
-
SQL
|
|
26
|
+
# Atomically promote the next blocked execution: delete the row and enqueue
|
|
27
|
+
# the job in a single transaction. Returns true if a job was promoted, false
|
|
28
|
+
# otherwise. This avoids losing a blocked row if enqueue fails.
|
|
29
|
+
def promote_next(concurrency_key, client:, delay: 0)
|
|
30
|
+
released = nil
|
|
31
|
+
Pgbus::BlockedExecution.transaction do
|
|
32
|
+
released = release_next(concurrency_key)
|
|
33
|
+
raise ActiveRecord::Rollback unless released
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
actual_delay = resolve_delay(released[:payload], delay)
|
|
36
|
+
client.send_message(released[:queue_name], released[:payload], delay: actual_delay)
|
|
37
|
+
end
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
!!released
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Pgbus.logger.warn { "[Pgbus] Promote blocked execution failed for #{concurrency_key}: #{e.message}" }
|
|
42
|
+
false
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
# Delete blocked executions that have expired.
|
|
44
46
|
# Returns the count of deleted rows.
|
|
45
47
|
def expire_stale
|
|
46
|
-
|
|
47
|
-
DELETE FROM pgbus_blocked_executions
|
|
48
|
-
WHERE expires_at < $1
|
|
49
|
-
RETURNING id
|
|
50
|
-
SQL
|
|
51
|
-
|
|
52
|
-
result.to_a.size
|
|
48
|
+
Pgbus::BlockedExecution.expired(Time.now.utc).delete_all
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
# Count blocked executions for a given key. Useful for testing/monitoring.
|
|
56
52
|
def count_for(concurrency_key)
|
|
57
|
-
|
|
58
|
-
SELECT COUNT(*) AS cnt FROM pgbus_blocked_executions WHERE concurrency_key = $1
|
|
59
|
-
SQL
|
|
60
|
-
|
|
61
|
-
result.first&.fetch("cnt", 0).to_i
|
|
53
|
+
Pgbus::BlockedExecution.where(concurrency_key: concurrency_key).count
|
|
62
54
|
end
|
|
63
55
|
|
|
64
56
|
private
|
|
65
57
|
|
|
66
|
-
def
|
|
67
|
-
|
|
58
|
+
def resolve_delay(payload, default_delay)
|
|
59
|
+
scheduled_at = payload["scheduled_at"]
|
|
60
|
+
return default_delay unless scheduled_at
|
|
68
61
|
|
|
69
|
-
|
|
62
|
+
[Time.parse(scheduled_at).to_f - Time.now.to_f, 0].max.ceil
|
|
63
|
+
rescue StandardError
|
|
64
|
+
default_delay
|
|
70
65
|
end
|
|
71
66
|
end
|
|
72
67
|
end
|