pgbus 0.0.1 → 0.1.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 +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 +0 -3
- data/lib/pgbus/active_job/executor.rb +27 -12
- data/lib/pgbus/batch.rb +60 -69
- data/lib/pgbus/cli.rb +11 -16
- data/lib/pgbus/client.rb +25 -7
- 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 +33 -0
- data/lib/pgbus/engine.rb +19 -1
- data/lib/pgbus/event_bus/handler.rb +4 -14
- 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 +8 -9
- data/lib/pgbus/process/dispatcher.rb +26 -24
- data/lib/pgbus/process/heartbeat.rb +15 -23
- data/lib/pgbus/process/signal_handler.rb +23 -1
- data/lib/pgbus/process/supervisor.rb +51 -2
- data/lib/pgbus/process/worker.rb +37 -9
- data/lib/pgbus/recurring/already_recorded.rb +7 -0
- data/lib/pgbus/recurring/command_job.rb +16 -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 +10 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +187 -22
- 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
|
|
@@ -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
|
+
job_succeeded = true
|
|
32
|
+
client.archive_message(queue_name, message.msg_id.to_i)
|
|
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,15 @@ 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 AFTER archive, in an ensure block so they
|
|
45
|
+
# fire even if archive_message raises. This prevents the semaphore slot
|
|
46
|
+
# from being stuck until expiry when the archive DB call fails after the
|
|
47
|
+
# job has already completed successfully.
|
|
48
|
+
if job_succeeded
|
|
49
|
+
signal_concurrency(payload)
|
|
50
|
+
signal_batch_completed(payload)
|
|
51
|
+
end
|
|
37
52
|
end
|
|
38
53
|
|
|
39
54
|
private
|
|
@@ -67,12 +82,12 @@ module Pgbus
|
|
|
67
82
|
key = Concurrency.extract_key(payload)
|
|
68
83
|
return unless key
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
# Atomic permit handoff: try to promote a blocked job first.
|
|
86
|
+
# promote_next wraps delete + enqueue in a transaction so neither is lost.
|
|
87
|
+
# If promoted, the slot stays occupied (no release needed).
|
|
88
|
+
# Only release the semaphore if there's nothing to promote.
|
|
89
|
+
promoted = Concurrency::BlockedExecution.promote_next(key, client: client)
|
|
90
|
+
Concurrency::Semaphore.release(key) unless promoted
|
|
76
91
|
rescue StandardError => e
|
|
77
92
|
Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
|
|
78
93
|
end
|
data/lib/pgbus/batch.rb
CHANGED
|
@@ -29,117 +29,108 @@ 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)
|
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,8 +6,18 @@ 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,
|
|
@@ -42,25 +52,33 @@ module Pgbus
|
|
|
42
52
|
def send_message(queue_name, payload, headers: nil, delay: 0)
|
|
43
53
|
full_name = config.queue_name(queue_name)
|
|
44
54
|
ensure_queue(queue_name)
|
|
45
|
-
|
|
55
|
+
Instrumentation.instrument("pgbus.client.send_message", queue: full_name) do
|
|
56
|
+
@pgmq.produce(full_name, serialize(payload), headers: headers && serialize(headers), delay: delay)
|
|
57
|
+
end
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def send_batch(queue_name, payloads, headers: nil, delay: 0)
|
|
49
61
|
full_name = config.queue_name(queue_name)
|
|
50
62
|
ensure_queue(queue_name)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
|
|
64
|
+
serialized = payloads.map { |p| serialize(p) }
|
|
65
|
+
serialized_headers = headers&.map { |h| serialize(h) }
|
|
66
|
+
@pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay)
|
|
67
|
+
end
|
|
54
68
|
end
|
|
55
69
|
|
|
56
70
|
def read_message(queue_name, vt: nil)
|
|
57
71
|
full_name = config.queue_name(queue_name)
|
|
58
|
-
|
|
72
|
+
Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
|
|
73
|
+
@pgmq.read(full_name, vt: vt || config.visibility_timeout)
|
|
74
|
+
end
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
def read_batch(queue_name, qty:, vt: nil)
|
|
62
78
|
full_name = config.queue_name(queue_name)
|
|
63
|
-
|
|
79
|
+
Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
|
|
80
|
+
@pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty)
|
|
81
|
+
end
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
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
|
|
@@ -8,57 +8,29 @@ module Pgbus
|
|
|
8
8
|
# Returns :acquired if a slot was available, :blocked if the limit is reached.
|
|
9
9
|
def acquire(key, max_value, duration)
|
|
10
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
|
|
11
|
+
Pgbus::Semaphore.acquire!(key, max_value, expires_at)
|
|
24
12
|
end
|
|
25
13
|
|
|
26
14
|
# Release one slot in the semaphore. Called after a job completes.
|
|
27
15
|
def release(key)
|
|
28
|
-
|
|
29
|
-
UPDATE pgbus_semaphores
|
|
30
|
-
SET value = GREATEST(value - 1, 0)
|
|
31
|
-
WHERE key = $1
|
|
32
|
-
SQL
|
|
16
|
+
Pgbus::Semaphore.where(key: key).update_all("value = GREATEST(value - 1, 0)")
|
|
33
17
|
end
|
|
34
18
|
|
|
35
19
|
# Delete semaphores that have expired (safety net for crashed workers).
|
|
36
|
-
# Returns
|
|
20
|
+
# Returns an array of hashes with expired keys.
|
|
21
|
+
# Uses DELETE ... RETURNING for atomicity (no race between pluck and delete).
|
|
37
22
|
def expire_stale
|
|
38
|
-
result =
|
|
39
|
-
DELETE FROM pgbus_semaphores
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
result.to_a
|
|
23
|
+
result = Pgbus::Semaphore.connection.exec_query(
|
|
24
|
+
"DELETE FROM pgbus_semaphores WHERE expires_at < $1 RETURNING key",
|
|
25
|
+
"Pgbus Semaphore Expire",
|
|
26
|
+
[Time.now.utc]
|
|
27
|
+
)
|
|
28
|
+
result.rows.map { |row| { "key" => row[0] } }
|
|
45
29
|
end
|
|
46
30
|
|
|
47
31
|
# Check current value for a key. Useful for testing/monitoring.
|
|
48
32
|
def current_value(key)
|
|
49
|
-
|
|
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)
|
|
33
|
+
Pgbus::Semaphore.where(key: key).pick(:value)
|
|
62
34
|
end
|
|
63
35
|
end
|
|
64
36
|
end
|
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -22,13 +22,15 @@ module Pgbus
|
|
|
22
22
|
def limits_concurrency(to:, key: nil, duration: 15 * 60, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
|
|
23
23
|
raise ArgumentError, "to: must be a positive integer" unless to.is_a?(Integer) && to.positive?
|
|
24
24
|
raise ArgumentError, "on_conflict must be :block, :discard, or :raise" unless %i[block discard raise].include?(on_conflict)
|
|
25
|
+
raise ArgumentError, "duration must be a positive number" unless duration.is_a?(Numeric) && duration.positive?
|
|
26
|
+
raise ArgumentError, "key must be callable (Proc or lambda)" if key && !key.respond_to?(:call)
|
|
25
27
|
|
|
26
28
|
@pgbus_concurrency = {
|
|
27
29
|
limit: to,
|
|
28
30
|
key: key || ->(*) { name },
|
|
29
31
|
duration: duration,
|
|
30
32
|
on_conflict: on_conflict
|
|
31
|
-
}
|
|
33
|
+
}.freeze
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def pgbus_concurrency
|
|
@@ -45,7 +47,13 @@ module Pgbus
|
|
|
45
47
|
config = active_job.class.pgbus_concurrency
|
|
46
48
|
return nil unless config
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
args = active_job.arguments
|
|
51
|
+
last = args.last
|
|
52
|
+
if last.is_a?(Hash) && last.each_key.all?(Symbol)
|
|
53
|
+
config[:key].call(*args[...-1], **last)
|
|
54
|
+
else
|
|
55
|
+
config[:key].call(*args)
|
|
56
|
+
end
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
# Inject the resolved concurrency key into the job's serialized payload.
|