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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/outbox_controller.rb +10 -0
  3. data/app/controllers/pgbus/queues_controller.rb +10 -0
  4. data/app/helpers/pgbus/application_helper.rb +6 -0
  5. data/app/models/pgbus/outbox_entry.rb +10 -0
  6. data/app/models/pgbus/queue_state.rb +33 -0
  7. data/app/views/layouts/pgbus/application.html.erb +1 -0
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +7 -1
  9. data/app/views/pgbus/outbox/index.html.erb +55 -0
  10. data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
  11. data/config/routes.rb +4 -0
  12. data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
  13. data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
  14. data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
  15. data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
  16. data/lib/pgbus/active_job/adapter.rb +6 -5
  17. data/lib/pgbus/active_job/executor.rb +22 -5
  18. data/lib/pgbus/circuit_breaker.rb +112 -0
  19. data/lib/pgbus/client.rb +140 -49
  20. data/lib/pgbus/configuration.rb +49 -1
  21. data/lib/pgbus/dedup_cache.rb +76 -0
  22. data/lib/pgbus/event_bus/handler.rb +13 -2
  23. data/lib/pgbus/outbox/poller.rb +117 -0
  24. data/lib/pgbus/outbox.rb +30 -0
  25. data/lib/pgbus/process/dispatcher.rb +46 -0
  26. data/lib/pgbus/process/heartbeat.rb +3 -1
  27. data/lib/pgbus/process/lifecycle.rb +111 -0
  28. data/lib/pgbus/process/supervisor.rb +40 -5
  29. data/lib/pgbus/process/worker.rb +84 -18
  30. data/lib/pgbus/rate_counter.rb +81 -0
  31. data/lib/pgbus/recurring/schedule.rb +1 -1
  32. data/lib/pgbus/version.rb +1 -1
  33. data/lib/pgbus/web/data_source.rb +87 -2
  34. data/lib/pgbus.rb +8 -0
  35. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b8f740d2e7c3b561a9591c94d08aef98b22566a3f56517c3055e4fab2c76f98
4
- data.tar.gz: a6ee24b79ade13317966dee815a49c1d03b2b290dd7635751fa070216fee7d0b
3
+ metadata.gz: f7594e67d8f35115e8a8498a64c766a37bd2705f62ee0414471ac08370596d51
4
+ data.tar.gz: 31048f0243cf7eddf24e49a79fee753390051637b1173534891d7155c42fc734
5
5
  SHA512:
6
- metadata.gz: 4df75c29bc7aa63b4a37b6425f9adf333d0986225ee99d65b33a125fd12c23f03a4000b5a38a610c39c46608622cb4dce5998864dff16942aa223d0671f006bf
7
- data.tar.gz: a8da2aafd4259b0a73502201aad5542ca31a8d7299e9b84e6e62f3413d62582febd947e1b748024fd09975d1e9b8d2efd16601b278367d6a69feb6e8456ad6d0
6
+ metadata.gz: 2351357593c783a226b0dccda08f278c2ddf7ab40f2d727bf7c3d5366e3a24aa3ae79670f0a09d867f1f774350e6677e17960d9fd90b46d4be94f7bf4085ba96
7
+ data.tar.gz: 2e427fc91be9934d37ec6bc21a0e1ffec5be034fdc31ead152c3c86006805778ee3ed9e5490df112c659e2f9f7363333b35889f1dc9374cf1bb0f3069ade65c4
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class OutboxController < ApplicationController
5
+ def index
6
+ @stats = data_source.outbox_stats
7
+ @entries = data_source.outbox_entries(page: page_param, per_page: per_page)
8
+ end
9
+ end
10
+ end
@@ -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
@@ -46,6 +46,7 @@
46
46
  <%= pgbus_nav_link "Processes", pgbus.processes_path %>
47
47
  <%= pgbus_nav_link "Events", pgbus.events_path %>
48
48
  <%= pgbus_nav_link "DLQ", pgbus.dead_letter_index_path %>
49
+ <%= pgbus_nav_link "Outbox", pgbus.outbox_index_path %>
49
50
  </div>
50
51
  </div>
51
52
  </div>
@@ -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-5 mb-8">
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: active_job.try(:priority) || 0,
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
- client.archive_message(queue_name, message.msg_id.to_i)
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 handle_dead_letter(message, queue_name, payload)
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
- client.move_to_dead_letter(queue_name, message)
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