pgbus 0.1.4 → 0.1.5
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/app/controllers/pgbus/outbox_controller.rb +10 -0
- data/app/controllers/pgbus/queues_controller.rb +10 -0
- data/app/helpers/pgbus/application_helper.rb +6 -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 +1 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +7 -1
- 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 +4 -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/templates/add_outbox.rb.erb +25 -0
- data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
- data/lib/pgbus/active_job/adapter.rb +6 -5
- data/lib/pgbus/active_job/executor.rb +22 -5
- data/lib/pgbus/circuit_breaker.rb +112 -0
- data/lib/pgbus/client.rb +140 -49
- data/lib/pgbus/configuration.rb +49 -1
- data/lib/pgbus/dedup_cache.rb +76 -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/dispatcher.rb +46 -0
- data/lib/pgbus/process/heartbeat.rb +3 -1
- data/lib/pgbus/process/lifecycle.rb +111 -0
- data/lib/pgbus/process/supervisor.rb +40 -5
- data/lib/pgbus/process/worker.rb +84 -18
- data/lib/pgbus/rate_counter.rb +81 -0
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +87 -2
- data/lib/pgbus.rb +8 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7594e67d8f35115e8a8498a64c766a37bd2705f62ee0414471ac08370596d51
|
|
4
|
+
data.tar.gz: 31048f0243cf7eddf24e49a79fee753390051637b1173534891d7155c42fc734
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2351357593c783a226b0dccda08f278c2ddf7ab40f2d727bf7c3d5366e3a24aa3ae79670f0a09d867f1f774350e6677e17960d9fd90b46d4be94f7bf4085ba96
|
|
7
|
+
data.tar.gz: 2e427fc91be9934d37ec6bc21a0e1ffec5be034fdc31ead152c3c86006805778ee3ed9e5490df112c659e2f9f7363333b35889f1dc9374cf1bb0f3069ade65c4
|
|
@@ -17,5 +17,15 @@ module Pgbus
|
|
|
17
17
|
data_source.purge_queue(params[:name])
|
|
18
18
|
redirect_to queue_path(name: params[:name]), notice: "Queue purged."
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
def pause
|
|
22
|
+
data_source.pause_queue(params[:name], reason: params[:reason])
|
|
23
|
+
redirect_to queue_path(name: params[:name]), notice: "Queue paused."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resume
|
|
27
|
+
data_source.resume_queue(params[:name])
|
|
28
|
+
redirect_to queue_path(name: params[:name]), notice: "Queue resumed."
|
|
29
|
+
end
|
|
20
30
|
end
|
|
21
31
|
end
|
|
@@ -45,6 +45,12 @@ module Pgbus
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def pgbus_paused_badge(paused)
|
|
49
|
+
return unless paused
|
|
50
|
+
|
|
51
|
+
tag.span("Paused", class: "inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800")
|
|
52
|
+
end
|
|
53
|
+
|
|
48
54
|
def pgbus_parse_message(message)
|
|
49
55
|
return {} unless message
|
|
50
56
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class OutboxEntry < Pgbus::ApplicationRecord
|
|
5
|
+
self.table_name = "pgbus_outbox_entries"
|
|
6
|
+
|
|
7
|
+
scope :unpublished, -> { where(published_at: nil) }
|
|
8
|
+
scope :published_before, ->(time) { where(published_at: ...time) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class QueueState < Pgbus::ApplicationRecord
|
|
5
|
+
self.table_name = "pgbus_queue_states"
|
|
6
|
+
|
|
7
|
+
scope :paused, -> { where(paused: true) }
|
|
8
|
+
|
|
9
|
+
def self.paused?(queue_name)
|
|
10
|
+
where(queue_name: queue_name, paused: true).exists?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.pause!(queue_name, reason: nil)
|
|
14
|
+
record = find_or_initialize_by(queue_name: queue_name)
|
|
15
|
+
record.update!(paused: true, paused_reason: reason, paused_at: Time.current, circuit_breaker_resume_at: nil)
|
|
16
|
+
record
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.resume!(queue_name)
|
|
20
|
+
record = find_by(queue_name: queue_name)
|
|
21
|
+
return unless record
|
|
22
|
+
|
|
23
|
+
record.update!(
|
|
24
|
+
paused: false,
|
|
25
|
+
paused_reason: nil,
|
|
26
|
+
paused_at: nil,
|
|
27
|
+
circuit_breaker_trip_count: 0,
|
|
28
|
+
circuit_breaker_resume_at: nil
|
|
29
|
+
)
|
|
30
|
+
record
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<turbo-frame id="dashboard-stats" data-auto-refresh src="<%= pgbus.root_path(frame: 'stats') %>">
|
|
2
|
-
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-
|
|
2
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6 mb-8">
|
|
3
3
|
<div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
|
|
4
4
|
<p class="text-sm font-medium text-gray-500">Queues</p>
|
|
5
5
|
<p class="mt-1 text-3xl font-semibold text-gray-900"><%= @stats[:total_queues] %></p>
|
|
@@ -32,5 +32,11 @@
|
|
|
32
32
|
<%= @stats[:failed_count] %> / <%= @stats[:dlq_depth] %>
|
|
33
33
|
</p>
|
|
34
34
|
</div>
|
|
35
|
+
|
|
36
|
+
<div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
|
|
37
|
+
<p class="text-sm font-medium text-gray-500">Throughput</p>
|
|
38
|
+
<p class="mt-1 text-3xl font-semibold text-gray-900"><%= @stats[:throughput_rate] %></p>
|
|
39
|
+
<p class="text-xs text-gray-400">msgs/s</p>
|
|
40
|
+
</div>
|
|
35
41
|
</div>
|
|
36
42
|
</turbo-frame>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<div class="mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold text-gray-900">Outbox</h1>
|
|
3
|
+
<p class="mt-1 text-sm text-gray-500">Transactional outbox entries pending publication to PGMQ</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="mb-6 grid grid-cols-3 gap-4">
|
|
7
|
+
<div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
|
|
8
|
+
<dt class="text-xs font-medium uppercase text-gray-500">Unpublished</dt>
|
|
9
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900"><%= pgbus_number(@stats[:unpublished]) %></dd>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
|
|
12
|
+
<dt class="text-xs font-medium uppercase text-gray-500">Total</dt>
|
|
13
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900"><%= pgbus_number(@stats[:total]) %></dd>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
|
|
16
|
+
<dt class="text-xs font-medium uppercase text-gray-500">Oldest Unpublished</dt>
|
|
17
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900"><%= @stats[:oldest_unpublished_age] ? "#{@stats[:oldest_unpublished_age]}s" : "—" %></dd>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="overflow-hidden rounded-lg bg-white shadow ring-1 ring-gray-200">
|
|
22
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
23
|
+
<thead class="bg-gray-50">
|
|
24
|
+
<tr>
|
|
25
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">ID</th>
|
|
26
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Queue / Topic</th>
|
|
27
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Payload</th>
|
|
28
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Priority</th>
|
|
29
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Status</th>
|
|
30
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Created</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody class="divide-y divide-gray-100">
|
|
34
|
+
<% @entries.each do |entry| %>
|
|
35
|
+
<tr class="hover:bg-gray-50">
|
|
36
|
+
<td class="px-4 py-3 text-sm font-mono text-gray-700"><%= entry.id %></td>
|
|
37
|
+
<td class="px-4 py-3 text-sm text-gray-700"><%= entry.routing_key || entry.queue_name %></td>
|
|
38
|
+
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate"><%= pgbus_json_preview(entry.payload) %></td>
|
|
39
|
+
<td class="px-4 py-3 text-sm text-right text-gray-500"><%= entry.priority %></td>
|
|
40
|
+
<td class="px-4 py-3 text-sm text-right">
|
|
41
|
+
<% if entry.published_at %>
|
|
42
|
+
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Published</span>
|
|
43
|
+
<% else %>
|
|
44
|
+
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">Pending</span>
|
|
45
|
+
<% end %>
|
|
46
|
+
</td>
|
|
47
|
+
<td class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_time_ago(entry.created_at) %></td>
|
|
48
|
+
</tr>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% if @entries.empty? %>
|
|
51
|
+
<tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400">No outbox entries</td></tr>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
@@ -18,13 +18,27 @@
|
|
|
18
18
|
<td class="px-4 py-3 text-sm">
|
|
19
19
|
<%= link_to q[:name], pgbus.queue_path(name: q[:name]), class: "font-medium text-indigo-600 hover:text-indigo-500", data: { turbo_frame: "_top" } %>
|
|
20
20
|
<%= pgbus_queue_badge(q[:name]) %>
|
|
21
|
+
<% if q[:paused] %>
|
|
22
|
+
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">Paused</span>
|
|
23
|
+
<% end %>
|
|
21
24
|
</td>
|
|
22
25
|
<td class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_length]) %></td>
|
|
23
26
|
<td class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_visible_length]) %></td>
|
|
24
27
|
<td class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_msg_age_sec] || "—" %></td>
|
|
25
28
|
<td class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:newest_msg_age_sec] || "—" %></td>
|
|
26
29
|
<td class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_number(q[:total_messages]) %></td>
|
|
27
|
-
<td class="px-4 py-3 text-sm text-right">
|
|
30
|
+
<td class="px-4 py-3 text-sm text-right space-x-2">
|
|
31
|
+
<% if q[:paused] %>
|
|
32
|
+
<%= button_to "Resume", pgbus.resume_queue_path(name: q[:name]),
|
|
33
|
+
method: :post,
|
|
34
|
+
class: "text-xs text-green-600 hover:text-green-800 font-medium",
|
|
35
|
+
data: { turbo_frame: "_top" } %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<%= button_to "Pause", pgbus.pause_queue_path(name: q[:name]),
|
|
38
|
+
method: :post,
|
|
39
|
+
class: "text-xs text-yellow-600 hover:text-yellow-800 font-medium",
|
|
40
|
+
data: { turbo_confirm: "Pause processing for #{q[:name]}?", turbo_frame: "_top" } %>
|
|
41
|
+
<% end %>
|
|
28
42
|
<%= button_to "Purge", pgbus.purge_queue_path(name: q[:name]),
|
|
29
43
|
method: :post,
|
|
30
44
|
class: "text-xs text-red-600 hover:text-red-800 font-medium",
|
data/config/routes.rb
CHANGED
|
@@ -6,6 +6,8 @@ Pgbus::Engine.routes.draw do
|
|
|
6
6
|
resources :queues, only: %i[index show], param: :name do
|
|
7
7
|
member do
|
|
8
8
|
post :purge
|
|
9
|
+
post :pause
|
|
10
|
+
post :resume
|
|
9
11
|
end
|
|
10
12
|
end
|
|
11
13
|
|
|
@@ -46,6 +48,8 @@ Pgbus::Engine.routes.draw do
|
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
|
|
51
|
+
resources :outbox, only: [:index], controller: "outbox"
|
|
52
|
+
|
|
49
53
|
namespace :api do
|
|
50
54
|
get :stats, to: "stats#show"
|
|
51
55
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -38,18 +38,19 @@ module Pgbus
|
|
|
38
38
|
def enqueue_with_concurrency(active_job, queue, payload_hash, delay: 0)
|
|
39
39
|
key = Concurrency.extract_key(payload_hash)
|
|
40
40
|
concurrency = concurrency_config(active_job)
|
|
41
|
+
priority = active_job.try(:priority)
|
|
41
42
|
|
|
42
43
|
if key && concurrency
|
|
43
44
|
result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
|
|
44
45
|
|
|
45
46
|
if result == :acquired
|
|
46
|
-
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
47
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
47
48
|
active_job.provider_job_id = msg_id
|
|
48
49
|
else
|
|
49
|
-
handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
50
|
+
handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
|
|
50
51
|
end
|
|
51
52
|
else
|
|
52
|
-
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay)
|
|
53
|
+
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
53
54
|
active_job.provider_job_id = msg_id
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -63,14 +64,14 @@ module Pgbus
|
|
|
63
64
|
active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
|
|
64
65
|
end
|
|
65
66
|
|
|
66
|
-
def handle_conflict(concurrency, active_job, key, queue, payload_hash)
|
|
67
|
+
def handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: nil)
|
|
67
68
|
case concurrency[:on_conflict]
|
|
68
69
|
when :block
|
|
69
70
|
Concurrency::BlockedExecution.insert(
|
|
70
71
|
concurrency_key: key,
|
|
71
72
|
queue_name: queue,
|
|
72
73
|
payload: payload_hash,
|
|
73
|
-
priority:
|
|
74
|
+
priority: priority || Pgbus.configuration.default_priority,
|
|
74
75
|
duration: concurrency[:duration]
|
|
75
76
|
)
|
|
76
77
|
when :discard
|
|
@@ -10,12 +10,12 @@ 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
14
|
payload = JSON.parse(message.message)
|
|
15
15
|
read_count = message.read_ct.to_i
|
|
16
16
|
|
|
17
17
|
if read_count > config.max_retries
|
|
18
|
-
handle_dead_letter(message, queue_name, payload)
|
|
18
|
+
handle_dead_letter(message, queue_name, payload, source_queue: source_queue)
|
|
19
19
|
signal_concurrency(payload)
|
|
20
20
|
signal_batch_discarded(payload)
|
|
21
21
|
return :dead_lettered
|
|
@@ -28,7 +28,7 @@ module Pgbus
|
|
|
28
28
|
Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
|
|
29
29
|
job = ::ActiveJob::Base.deserialize(payload)
|
|
30
30
|
execute_job(job)
|
|
31
|
-
|
|
31
|
+
archive_from(queue_name, message.msg_id.to_i, source_queue: source_queue)
|
|
32
32
|
job_succeeded = true
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -109,12 +109,29 @@ module Pgbus
|
|
|
109
109
|
Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
def
|
|
112
|
+
def archive_from(queue_name, msg_id, source_queue: nil)
|
|
113
|
+
if source_queue
|
|
114
|
+
client.archive_from_queue(source_queue, msg_id)
|
|
115
|
+
else
|
|
116
|
+
client.archive_message(queue_name, msg_id)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle_dead_letter(message, queue_name, payload, source_queue: nil)
|
|
113
121
|
Pgbus.logger.warn do
|
|
114
122
|
job_class = payload["job_class"] || "unknown"
|
|
115
123
|
"[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
|
|
116
124
|
end
|
|
117
|
-
|
|
125
|
+
if source_queue
|
|
126
|
+
client.ensure_dead_letter_queue(queue_name)
|
|
127
|
+
dlq_name = config.dead_letter_queue_name(queue_name)
|
|
128
|
+
client.transaction do |txn|
|
|
129
|
+
txn.produce(dlq_name, message.message, headers: message.headers)
|
|
130
|
+
txn.delete(source_queue, message.msg_id.to_i)
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
client.move_to_dead_letter(queue_name, message)
|
|
134
|
+
end
|
|
118
135
|
end
|
|
119
136
|
end
|
|
120
137
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class CircuitBreaker
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(config: Pgbus.configuration)
|
|
10
|
+
@config = config
|
|
11
|
+
@failure_counts = Concurrent::Map.new
|
|
12
|
+
@pause_cache = Concurrent::Map.new
|
|
13
|
+
@pause_cache_ttl = 30 # seconds
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record_success(queue_name)
|
|
17
|
+
@failure_counts.delete(queue_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_failure(queue_name)
|
|
21
|
+
return unless config.circuit_breaker_enabled
|
|
22
|
+
|
|
23
|
+
count = @failure_counts.compute(queue_name) { |val| (val || 0) + 1 }
|
|
24
|
+
|
|
25
|
+
return unless count >= config.circuit_breaker_threshold
|
|
26
|
+
|
|
27
|
+
trip!(queue_name, count)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def paused?(queue_name)
|
|
31
|
+
cached = @pause_cache[queue_name]
|
|
32
|
+
return cached[:paused] if cached && (Time.now - cached[:checked_at]) < @pause_cache_ttl
|
|
33
|
+
|
|
34
|
+
paused = check_paused(queue_name)
|
|
35
|
+
@pause_cache[queue_name] = { paused: paused, checked_at: Time.now }
|
|
36
|
+
paused
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pause!(queue_name, reason: nil)
|
|
40
|
+
QueueState.pause!(queue_name, reason: reason)
|
|
41
|
+
invalidate_cache(queue_name)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resume!(queue_name)
|
|
45
|
+
QueueState.resume!(queue_name)
|
|
46
|
+
@failure_counts.delete(queue_name)
|
|
47
|
+
invalidate_cache(queue_name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def invalidate_cache(queue_name = nil)
|
|
51
|
+
if queue_name
|
|
52
|
+
@pause_cache.delete(queue_name)
|
|
53
|
+
else
|
|
54
|
+
@pause_cache.clear
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def trip!(queue_name, failure_count)
|
|
61
|
+
trip_count = current_trip_count(queue_name) + 1
|
|
62
|
+
backoff = calculate_backoff(trip_count)
|
|
63
|
+
resume_at = Time.current + backoff
|
|
64
|
+
|
|
65
|
+
Pgbus.logger.warn do
|
|
66
|
+
"[Pgbus] Circuit breaker tripped for #{queue_name}: #{failure_count} consecutive failures, " \
|
|
67
|
+
"backoff #{backoff}s (trip ##{trip_count})"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
QueueState.find_or_initialize_by(queue_name: queue_name).update!(
|
|
71
|
+
paused: true,
|
|
72
|
+
paused_reason: "circuit_breaker: #{failure_count} consecutive failures",
|
|
73
|
+
paused_at: Time.current,
|
|
74
|
+
circuit_breaker_trip_count: trip_count,
|
|
75
|
+
circuit_breaker_resume_at: resume_at
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@failure_counts.delete(queue_name)
|
|
79
|
+
invalidate_cache(queue_name)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
Pgbus.logger.error { "[Pgbus] Circuit breaker trip failed for #{queue_name}: #{e.message}" }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_paused(queue_name)
|
|
85
|
+
state = QueueState.find_by(queue_name: queue_name)
|
|
86
|
+
return false unless state&.paused?
|
|
87
|
+
|
|
88
|
+
# Auto-resume if circuit breaker backoff has expired
|
|
89
|
+
if state.circuit_breaker_resume_at && Time.current >= state.circuit_breaker_resume_at
|
|
90
|
+
QueueState.resume!(queue_name)
|
|
91
|
+
Pgbus.logger.info { "[Pgbus] Circuit breaker auto-resumed #{queue_name}" }
|
|
92
|
+
return false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
true
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Pgbus.logger.warn { "[Pgbus] Circuit breaker pause check failed for #{queue_name}: #{e.message}" }
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def current_trip_count(queue_name)
|
|
102
|
+
QueueState.find_by(queue_name: queue_name)&.circuit_breaker_trip_count || 0
|
|
103
|
+
rescue StandardError
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def calculate_backoff(trip_count)
|
|
108
|
+
backoff = config.circuit_breaker_base_backoff * (2**(trip_count - 1))
|
|
109
|
+
[backoff, config.circuit_breaker_max_backoff].min
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|