pgbus 0.1.4 → 0.1.6
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 +326 -11
- data/app/controllers/pgbus/api/insights_controller.rb +16 -0
- data/app/controllers/pgbus/insights_controller.rb +10 -0
- data/app/controllers/pgbus/locks_controller.rb +9 -0
- data/app/controllers/pgbus/outbox_controller.rb +10 -0
- data/app/controllers/pgbus/queues_controller.rb +10 -0
- data/app/helpers/pgbus/application_helper.rb +34 -0
- data/app/models/pgbus/job_lock.rb +82 -0
- data/app/models/pgbus/job_stat.rb +94 -0
- data/app/models/pgbus/outbox_entry.rb +10 -0
- data/app/models/pgbus/queue_state.rb +33 -0
- data/app/views/layouts/pgbus/application.html.erb +33 -8
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +24 -18
- data/app/views/pgbus/insights/show.html.erb +161 -0
- data/app/views/pgbus/locks/index.html.erb +53 -0
- data/app/views/pgbus/outbox/index.html.erb +55 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
- data/config/routes.rb +7 -0
- data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
- data/lib/generators/pgbus/add_job_stats_generator.rb +52 -0
- data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
- data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
- data/lib/generators/pgbus/add_recurring_generator.rb +1 -1
- data/lib/generators/pgbus/install_generator.rb +1 -1
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
- data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -0
- data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
- data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
- data/lib/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
- data/lib/pgbus/active_job/adapter.rb +64 -9
- data/lib/pgbus/active_job/executor.rb +67 -5
- data/lib/pgbus/circuit_breaker.rb +112 -0
- data/lib/pgbus/client.rb +127 -50
- data/lib/pgbus/configuration.rb +55 -1
- data/lib/pgbus/dedup_cache.rb +76 -0
- data/lib/pgbus/engine.rb +1 -0
- data/lib/pgbus/event_bus/handler.rb +13 -2
- data/lib/pgbus/outbox/poller.rb +117 -0
- data/lib/pgbus/outbox.rb +30 -0
- data/lib/pgbus/process/consumer_priority.rb +64 -0
- data/lib/pgbus/process/dispatcher.rb +75 -0
- data/lib/pgbus/process/heartbeat.rb +3 -1
- data/lib/pgbus/process/lifecycle.rb +111 -0
- data/lib/pgbus/process/queue_lock.rb +87 -0
- data/lib/pgbus/process/supervisor.rb +46 -6
- data/lib/pgbus/process/wake_signal.rb +53 -0
- data/lib/pgbus/process/worker.rb +117 -21
- data/lib/pgbus/queue_factory.rb +62 -0
- data/lib/pgbus/rate_counter.rb +81 -0
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/uniqueness.rb +169 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +136 -2
- data/lib/pgbus.rb +9 -0
- metadata +31 -1
|
@@ -0,0 +1,52 @@
|
|
|
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 AddJobLocksGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Add job locks table for uniqueness guarantees"
|
|
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_file
|
|
21
|
+
if separate_database?
|
|
22
|
+
migration_template "add_job_locks.rb.erb",
|
|
23
|
+
"db/pgbus_migrate/add_pgbus_job_locks.rb"
|
|
24
|
+
else
|
|
25
|
+
migration_template "add_job_locks.rb.erb",
|
|
26
|
+
"db/migrate/add_pgbus_job_locks.rb"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_install
|
|
31
|
+
say ""
|
|
32
|
+
say "Pgbus job locks table installed!", :green
|
|
33
|
+
say ""
|
|
34
|
+
say "Next steps:"
|
|
35
|
+
say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
36
|
+
say " 2. Add `ensures_uniqueness` to your job classes (DSL is auto-included)"
|
|
37
|
+
say " 3. Restart pgbus: bin/pgbus start"
|
|
38
|
+
say ""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def separate_database?
|
|
48
|
+
options[:database].present?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
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 AddJobStatsGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Add job stats table for dashboard insights and performance tracking"
|
|
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_file
|
|
21
|
+
if separate_database?
|
|
22
|
+
migration_template "add_job_stats.rb.erb",
|
|
23
|
+
"db/pgbus_migrate/add_pgbus_job_stats.rb"
|
|
24
|
+
else
|
|
25
|
+
migration_template "add_job_stats.rb.erb",
|
|
26
|
+
"db/migrate/add_pgbus_job_stats.rb"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_install
|
|
31
|
+
say ""
|
|
32
|
+
say "Pgbus job stats table installed!", :green
|
|
33
|
+
say ""
|
|
34
|
+
say "Next steps:"
|
|
35
|
+
say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
36
|
+
say " 2. Stats collection is enabled by default"
|
|
37
|
+
say " 3. View insights at /pgbus/insights"
|
|
38
|
+
say ""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def separate_database?
|
|
48
|
+
options[:database].present?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
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 AddOutboxGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Add outbox table for transactional event publishing"
|
|
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_file
|
|
21
|
+
if separate_database?
|
|
22
|
+
migration_template "add_outbox.rb.erb",
|
|
23
|
+
"db/pgbus_migrate/add_pgbus_outbox.rb"
|
|
24
|
+
else
|
|
25
|
+
migration_template "add_outbox.rb.erb",
|
|
26
|
+
"db/migrate/add_pgbus_outbox.rb"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_install
|
|
31
|
+
say ""
|
|
32
|
+
say "Pgbus outbox installed!", :green
|
|
33
|
+
say ""
|
|
34
|
+
say "Next steps:"
|
|
35
|
+
say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
36
|
+
say " 2. Enable in config: config.outbox_enabled = true"
|
|
37
|
+
say " 3. Restart pgbus: bin/pgbus start"
|
|
38
|
+
say ""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def separate_database?
|
|
48
|
+
options[:database].present?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
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 AddQueueStatesGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Add queue states table for pause/resume and circuit breaker support"
|
|
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_file
|
|
21
|
+
if separate_database?
|
|
22
|
+
migration_template "add_queue_states.rb.erb",
|
|
23
|
+
"db/pgbus_migrate/add_pgbus_queue_states.rb"
|
|
24
|
+
else
|
|
25
|
+
migration_template "add_queue_states.rb.erb",
|
|
26
|
+
"db/migrate/add_pgbus_queue_states.rb"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_install
|
|
31
|
+
say ""
|
|
32
|
+
say "Pgbus queue states table installed!", :green
|
|
33
|
+
say ""
|
|
34
|
+
say "Next steps:"
|
|
35
|
+
say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
36
|
+
say " 2. Restart pgbus: bin/pgbus start"
|
|
37
|
+
say ""
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def migration_version
|
|
43
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def separate_database?
|
|
47
|
+
options[:database].present?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -17,7 +17,7 @@ module Pgbus
|
|
|
17
17
|
default: nil,
|
|
18
18
|
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def create_migration_file
|
|
21
21
|
if separate_database?
|
|
22
22
|
migration_template "add_recurring_tables.rb.erb",
|
|
23
23
|
"db/pgbus_migrate/add_pgbus_recurring_tables.rb"
|
|
@@ -24,7 +24,7 @@ module Pgbus
|
|
|
24
24
|
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus). " \
|
|
25
25
|
"Migrations go to db/pgbus_migrate/ and schema to db/pgbus_schema.rb"
|
|
26
26
|
|
|
27
|
-
def
|
|
27
|
+
def create_migration_file
|
|
28
28
|
if separate_database?
|
|
29
29
|
migration_template "migration.rb.erb",
|
|
30
30
|
"db/pgbus_migrate/create_pgbus_tables.rb"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class AddPgbusJobLocks < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :pgbus_job_locks do |t|
|
|
4
|
+
t.string :lock_key, null: false
|
|
5
|
+
t.string :job_class, null: false
|
|
6
|
+
t.string :job_id
|
|
7
|
+
t.string :state, null: false, default: "queued"
|
|
8
|
+
t.integer :owner_pid
|
|
9
|
+
t.string :owner_hostname
|
|
10
|
+
t.datetime :locked_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
11
|
+
t.datetime :expires_at, null: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :pgbus_job_locks, :lock_key,
|
|
15
|
+
unique: true, name: "idx_pgbus_job_locks_key"
|
|
16
|
+
add_index :pgbus_job_locks, :expires_at,
|
|
17
|
+
name: "idx_pgbus_job_locks_expires"
|
|
18
|
+
add_index :pgbus_job_locks, [:state, :owner_pid],
|
|
19
|
+
name: "idx_pgbus_job_locks_reaper"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class AddPgbusJobStats < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :pgbus_job_stats do |t|
|
|
4
|
+
t.string :job_class, null: false
|
|
5
|
+
t.string :queue_name, null: false
|
|
6
|
+
t.string :status, null: false
|
|
7
|
+
t.integer :duration_ms, null: false, default: 0
|
|
8
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :pgbus_job_stats, :created_at,
|
|
12
|
+
name: "idx_pgbus_job_stats_time"
|
|
13
|
+
add_index :pgbus_job_stats, [:job_class, :created_at],
|
|
14
|
+
name: "idx_pgbus_job_stats_class_time"
|
|
15
|
+
add_index :pgbus_job_stats, [:status, :created_at],
|
|
16
|
+
name: "idx_pgbus_job_stats_status_time"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class AddPgbusOutbox < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :pgbus_outbox_entries do |t|
|
|
4
|
+
t.string :queue_name
|
|
5
|
+
t.string :routing_key
|
|
6
|
+
t.jsonb :payload, null: false
|
|
7
|
+
t.jsonb :headers
|
|
8
|
+
t.integer :priority, default: 0
|
|
9
|
+
t.integer :delay, default: 0
|
|
10
|
+
t.datetime :published_at
|
|
11
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_check_constraint :pgbus_outbox_entries,
|
|
15
|
+
"(queue_name IS NOT NULL) <> (routing_key IS NOT NULL)",
|
|
16
|
+
name: "chk_pgbus_outbox_destination"
|
|
17
|
+
|
|
18
|
+
add_index :pgbus_outbox_entries, :published_at,
|
|
19
|
+
where: "published_at IS NULL",
|
|
20
|
+
name: "idx_pgbus_outbox_unpublished"
|
|
21
|
+
add_index :pgbus_outbox_entries, :published_at,
|
|
22
|
+
where: "published_at IS NOT NULL",
|
|
23
|
+
name: "idx_pgbus_outbox_cleanup"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class AddPgbusQueueStates < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :pgbus_queue_states do |t|
|
|
4
|
+
t.string :queue_name, null: false
|
|
5
|
+
t.boolean :paused, null: false, default: false
|
|
6
|
+
t.string :paused_reason
|
|
7
|
+
t.datetime :paused_at
|
|
8
|
+
t.integer :circuit_breaker_trip_count, default: 0
|
|
9
|
+
t.datetime :circuit_breaker_resume_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :pgbus_queue_states, :queue_name,
|
|
14
|
+
unique: true, name: "idx_pgbus_queue_states_queue_name"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -17,7 +17,7 @@ module Pgbus
|
|
|
17
17
|
default: nil,
|
|
18
18
|
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def create_migration_file
|
|
21
21
|
if separate_database?
|
|
22
22
|
migration_template "upgrade_pgmq.rb.erb",
|
|
23
23
|
"db/pgbus_migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
|
|
@@ -9,8 +9,11 @@ module Pgbus
|
|
|
9
9
|
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
10
10
|
payload_hash = Serializer.serialize_job_hash(active_job)
|
|
11
11
|
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
12
|
+
payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
|
|
12
13
|
payload_hash = inject_batch_metadata(payload_hash)
|
|
13
14
|
|
|
15
|
+
return active_job if uniqueness_rejected?(active_job, payload_hash)
|
|
16
|
+
|
|
14
17
|
enqueue_with_concurrency(active_job, queue, payload_hash)
|
|
15
18
|
end
|
|
16
19
|
|
|
@@ -18,14 +21,27 @@ module Pgbus
|
|
|
18
21
|
queue = active_job.queue_name || Pgbus.configuration.default_queue
|
|
19
22
|
payload_hash = Serializer.serialize_job_hash(active_job)
|
|
20
23
|
payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
|
|
24
|
+
payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
|
|
21
25
|
payload_hash = inject_batch_metadata(payload_hash)
|
|
22
26
|
delay = [(timestamp - Time.now.to_f).ceil, 0].max
|
|
23
27
|
|
|
28
|
+
return active_job if uniqueness_rejected?(active_job, payload_hash)
|
|
29
|
+
|
|
24
30
|
enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def enqueue_all(active_jobs)
|
|
28
|
-
|
|
34
|
+
# Jobs with uniqueness must go through individual enqueue to acquire locks
|
|
35
|
+
unique, bulk = active_jobs.partition { |j| Uniqueness.uniqueness_config(j) }
|
|
36
|
+
unique.each do |j|
|
|
37
|
+
if j.scheduled_at && j.scheduled_at > Time.now
|
|
38
|
+
enqueue_at(j, j.scheduled_at.to_f)
|
|
39
|
+
else
|
|
40
|
+
enqueue(j)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
bulk.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
|
|
29
45
|
enqueue_immediate(queue, jobs.reject { |j| j.scheduled_at && j.scheduled_at > Time.now })
|
|
30
46
|
jobs.select { |j| j.scheduled_at && j.scheduled_at > Time.now }.each { |j| enqueue_at(j, j.scheduled_at.to_f) }
|
|
31
47
|
end
|
|
@@ -38,39 +54,50 @@ module Pgbus
|
|
|
38
54
|
def enqueue_with_concurrency(active_job, queue, payload_hash, delay: 0)
|
|
39
55
|
key = Concurrency.extract_key(payload_hash)
|
|
40
56
|
concurrency = concurrency_config(active_job)
|
|
57
|
+
priority = active_job.try(:priority)
|
|
41
58
|
|
|
42
59
|
if key && concurrency
|
|
43
60
|
result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
|
|
44
61
|
|
|
45
62
|
if result == :acquired
|
|
46
|
-
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
63
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
47
64
|
active_job.provider_job_id = msg_id
|
|
48
65
|
else
|
|
49
|
-
handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
66
|
+
handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
|
|
50
67
|
end
|
|
51
68
|
else
|
|
52
|
-
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
69
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
53
70
|
active_job.provider_job_id = msg_id
|
|
54
71
|
end
|
|
55
72
|
|
|
73
|
+
Thread.current[:pgbus_acquired_uniqueness_key] = nil
|
|
56
74
|
active_job
|
|
57
|
-
rescue
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
# Roll back the uniqueness lock if enqueue failed
|
|
77
|
+
rollback_key = Thread.current[:pgbus_acquired_uniqueness_key]
|
|
78
|
+
if rollback_key
|
|
79
|
+
begin
|
|
80
|
+
Uniqueness.release_lock(rollback_key)
|
|
81
|
+
rescue StandardError => rollback_error
|
|
82
|
+
Pgbus.logger.warn { "[Pgbus] Lock rollback failed: #{rollback_error.message}" }
|
|
83
|
+
end
|
|
84
|
+
Thread.current[:pgbus_acquired_uniqueness_key] = nil
|
|
85
|
+
end
|
|
86
|
+
raise e
|
|
60
87
|
end
|
|
61
88
|
|
|
62
89
|
def concurrency_config(active_job)
|
|
63
90
|
active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
|
|
64
91
|
end
|
|
65
92
|
|
|
66
|
-
def handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
93
|
+
def handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: nil)
|
|
67
94
|
case concurrency[:on_conflict]
|
|
68
95
|
when :block
|
|
69
96
|
Concurrency::BlockedExecution.insert(
|
|
70
97
|
concurrency_key: key,
|
|
71
98
|
queue_name: queue,
|
|
72
99
|
payload: payload_hash,
|
|
73
|
-
priority:
|
|
100
|
+
priority: priority || Pgbus.configuration.default_priority,
|
|
74
101
|
duration: concurrency[:duration]
|
|
75
102
|
)
|
|
76
103
|
when :discard
|
|
@@ -80,6 +107,34 @@ module Pgbus
|
|
|
80
107
|
end
|
|
81
108
|
end
|
|
82
109
|
|
|
110
|
+
def uniqueness_rejected?(active_job, payload_hash)
|
|
111
|
+
uniqueness_key = Uniqueness.extract_key(payload_hash)
|
|
112
|
+
return false unless uniqueness_key
|
|
113
|
+
|
|
114
|
+
result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job)
|
|
115
|
+
|
|
116
|
+
# :no_lock means no enqueue-time lock needed (e.g. :while_executing strategy)
|
|
117
|
+
return false if result == :no_lock
|
|
118
|
+
|
|
119
|
+
# Store the acquired key so we can release it if enqueue fails
|
|
120
|
+
Thread.current[:pgbus_acquired_uniqueness_key] = uniqueness_key if result == :acquired
|
|
121
|
+
return false if result == :acquired
|
|
122
|
+
|
|
123
|
+
config = Uniqueness.uniqueness_config(active_job)
|
|
124
|
+
case config[:on_conflict]
|
|
125
|
+
when :reject
|
|
126
|
+
raise JobNotUnique, "Job #{active_job.class.name} is already locked"
|
|
127
|
+
when :discard
|
|
128
|
+
Pgbus.logger.info { "[Pgbus] Discarding duplicate job #{active_job.class.name}" }
|
|
129
|
+
true
|
|
130
|
+
when :log
|
|
131
|
+
Pgbus.logger.warn { "[Pgbus] Duplicate job #{active_job.class.name} detected" }
|
|
132
|
+
true
|
|
133
|
+
else
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
83
138
|
def inject_batch_metadata(payload_hash)
|
|
84
139
|
batch_id = Thread.current[:pgbus_batch_id]
|
|
85
140
|
return payload_hash unless batch_id
|
|
@@ -10,33 +10,58 @@ module Pgbus
|
|
|
10
10
|
@config = config
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def execute(message, queue_name)
|
|
13
|
+
def execute(message, queue_name, source_queue: nil)
|
|
14
|
+
execution_start = monotonic_now
|
|
14
15
|
payload = JSON.parse(message.message)
|
|
15
16
|
read_count = message.read_ct.to_i
|
|
16
17
|
|
|
17
18
|
if read_count > config.max_retries
|
|
18
|
-
handle_dead_letter(message, queue_name, payload)
|
|
19
|
+
handle_dead_letter(message, queue_name, payload, source_queue: source_queue)
|
|
19
20
|
signal_concurrency(payload)
|
|
20
21
|
signal_batch_discarded(payload)
|
|
22
|
+
Uniqueness.release_lock(Uniqueness.extract_key(payload))
|
|
23
|
+
record_stat(payload, queue_name, "dead_lettered", execution_start)
|
|
21
24
|
return :dead_lettered
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
job_class = payload["job_class"]
|
|
28
|
+
uniqueness_key = Uniqueness.extract_key(payload)
|
|
29
|
+
uniqueness_strategy = Uniqueness.extract_strategy(payload)
|
|
30
|
+
uniqueness_ttl = payload[Uniqueness::TTL_KEY] || Uniqueness::DEFAULT_LOCK_TTL
|
|
31
|
+
|
|
32
|
+
if uniqueness_key
|
|
33
|
+
case uniqueness_strategy
|
|
34
|
+
when :until_executed
|
|
35
|
+
# Transition the queued lock to executing state with our PID.
|
|
36
|
+
# The lock was acquired at enqueue time — now we claim ownership
|
|
37
|
+
# so the reaper can correlate it with our heartbeat.
|
|
38
|
+
Uniqueness.claim_for_execution!(uniqueness_key, ttl: uniqueness_ttl)
|
|
39
|
+
when :while_executing
|
|
40
|
+
# Acquire the lock now. If another worker is already executing
|
|
41
|
+
# this job, skip it — VT will expire and it'll be retried.
|
|
42
|
+
unless Uniqueness.acquire_execution_lock(uniqueness_key, payload)
|
|
43
|
+
Pgbus.logger.info { "[Pgbus] Skipping duplicate execution for #{job_class}" }
|
|
44
|
+
return :skipped
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
25
48
|
|
|
26
49
|
job_succeeded = false
|
|
27
50
|
|
|
28
51
|
Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
|
|
29
52
|
job = ::ActiveJob::Base.deserialize(payload)
|
|
30
53
|
execute_job(job)
|
|
31
|
-
|
|
54
|
+
archive_from(queue_name, message.msg_id.to_i, source_queue: source_queue)
|
|
32
55
|
job_succeeded = true
|
|
33
56
|
end
|
|
34
57
|
|
|
35
58
|
instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
|
|
59
|
+
record_stat(payload, queue_name, "success", execution_start)
|
|
36
60
|
:success
|
|
37
61
|
rescue StandardError => e
|
|
38
62
|
handle_failure(message, queue_name, e)
|
|
39
63
|
instrument("pgbus.job_failed", queue: queue_name, job_class: payload&.dig("job_class"), error: e.class.name)
|
|
64
|
+
record_stat(payload, queue_name, "failed", execution_start)
|
|
40
65
|
# Don't signal concurrency on transient failure — the job will be retried.
|
|
41
66
|
# Semaphore is released only on success or dead-lettering.
|
|
42
67
|
:failed
|
|
@@ -47,6 +72,8 @@ module Pgbus
|
|
|
47
72
|
if job_succeeded
|
|
48
73
|
signal_concurrency(payload)
|
|
49
74
|
signal_batch_completed(payload)
|
|
75
|
+
# Release uniqueness lock on successful completion (both strategies)
|
|
76
|
+
Uniqueness.release_lock(uniqueness_key) if uniqueness_key
|
|
50
77
|
end
|
|
51
78
|
end
|
|
52
79
|
|
|
@@ -60,6 +87,24 @@ module Pgbus
|
|
|
60
87
|
end
|
|
61
88
|
end
|
|
62
89
|
|
|
90
|
+
def monotonic_now
|
|
91
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def record_stat(payload, queue_name, status, start_time)
|
|
95
|
+
return unless config.stats_enabled
|
|
96
|
+
|
|
97
|
+
duration_ms = ((monotonic_now - start_time) * 1000).round
|
|
98
|
+
JobStat.record!(
|
|
99
|
+
job_class: payload&.dig("job_class") || "unknown",
|
|
100
|
+
queue_name: queue_name,
|
|
101
|
+
status: status,
|
|
102
|
+
duration_ms: duration_ms
|
|
103
|
+
)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Pgbus.logger.debug { "[Pgbus] Stat recording failed: #{e.message}" }
|
|
106
|
+
end
|
|
107
|
+
|
|
63
108
|
def handle_failure(_message, _queue_name, error)
|
|
64
109
|
Pgbus.logger.error { "[Pgbus] Job failed: #{error.class}: #{error.message}" }
|
|
65
110
|
Pgbus.logger.debug { error.backtrace&.join("\n") }
|
|
@@ -109,12 +154,29 @@ module Pgbus
|
|
|
109
154
|
Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
|
|
110
155
|
end
|
|
111
156
|
|
|
112
|
-
def
|
|
157
|
+
def archive_from(queue_name, msg_id, source_queue: nil)
|
|
158
|
+
if source_queue
|
|
159
|
+
client.archive_from_queue(source_queue, msg_id)
|
|
160
|
+
else
|
|
161
|
+
client.archive_message(queue_name, msg_id)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_dead_letter(message, queue_name, payload, source_queue: nil)
|
|
113
166
|
Pgbus.logger.warn do
|
|
114
167
|
job_class = payload["job_class"] || "unknown"
|
|
115
168
|
"[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
|
|
116
169
|
end
|
|
117
|
-
|
|
170
|
+
if source_queue
|
|
171
|
+
client.ensure_dead_letter_queue(queue_name)
|
|
172
|
+
dlq_name = config.dead_letter_queue_name(queue_name)
|
|
173
|
+
client.transaction do |txn|
|
|
174
|
+
txn.produce(dlq_name, message.message, headers: message.headers)
|
|
175
|
+
txn.delete(source_queue, message.msg_id.to_i)
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
client.move_to_dead_letter(queue_name, message)
|
|
179
|
+
end
|
|
118
180
|
end
|
|
119
181
|
end
|
|
120
182
|
end
|