pgbus 0.7.2 → 0.7.4

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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pgbus"
4
+
5
+ module Pgbus
6
+ # Test helpers for Pgbus EventBus. Opt-in via explicit require — never
7
+ # autoloaded by Zeitwerk so this code never leaks into production.
8
+ #
9
+ # require "pgbus/testing" # core only
10
+ # require "pgbus/testing/rspec" # RSpec matchers + auto-config
11
+ # require "pgbus/testing/minitest" # Minitest assertions
12
+ #
13
+ # Three modes:
14
+ # :fake — capture published events in an in-memory store (default for tests)
15
+ # :inline — capture AND immediately dispatch to matching handlers
16
+ # :disabled — pass through to the real publisher (production behavior)
17
+ module Testing
18
+ MODES = %i[fake inline disabled].freeze
19
+ MODE_KEY = :__pgbus_test_mode
20
+
21
+ # Thread-safe in-memory store for events captured in fake/inline mode.
22
+ class EventStore
23
+ def initialize
24
+ @mutex = Mutex.new
25
+ @events = []
26
+ end
27
+
28
+ def push_event(event)
29
+ @mutex.synchronize { @events << event }
30
+ end
31
+
32
+ def events(routing_key: nil)
33
+ @mutex.synchronize do
34
+ result = @events.dup
35
+ result = result.select { |e| e.routing_key == routing_key } if routing_key
36
+ result
37
+ end
38
+ end
39
+
40
+ def size
41
+ @mutex.synchronize { @events.size }
42
+ end
43
+
44
+ def clear!
45
+ @mutex.synchronize { @events.clear }
46
+ end
47
+
48
+ # Dispatch all stored events to their matching handlers, then clear.
49
+ # Events are removed one at a time after successful dispatch so that
50
+ # a handler exception leaves unprocessed events in the store.
51
+ def drain!
52
+ loop do
53
+ event = @mutex.synchronize { @events.first }
54
+ break unless event
55
+
56
+ Pgbus::EventBus::Registry.instance.handlers_for(event.routing_key).each do |subscriber|
57
+ subscriber.handler_class.new.handle(event)
58
+ end
59
+
60
+ @mutex.synchronize { @events.shift }
61
+ end
62
+ end
63
+ end
64
+
65
+ # Eagerly initialize the store so concurrent access never races on creation.
66
+ @store = EventStore.new
67
+
68
+ class << self
69
+ def mode!(mode, &block)
70
+ raise ArgumentError, "Unknown mode: #{mode}. Valid modes: #{MODES.join(", ")}" unless MODES.include?(mode)
71
+
72
+ sync_streams_test_mode!(mode)
73
+
74
+ unless block
75
+ Thread.main[MODE_KEY] = mode
76
+ return
77
+ end
78
+
79
+ old = Thread.current[MODE_KEY]
80
+ Thread.current[MODE_KEY] = mode
81
+ yield
82
+ ensure
83
+ if block
84
+ Thread.current[MODE_KEY] = old
85
+ sync_streams_test_mode!(old || :disabled)
86
+ end
87
+ end
88
+
89
+ def mode
90
+ Thread.current[MODE_KEY] || Thread.main[MODE_KEY] || :disabled
91
+ end
92
+
93
+ def fake!(&) = mode!(:fake, &)
94
+ def inline!(&) = mode!(:inline, &)
95
+ def disabled!(&) = mode!(:disabled, &)
96
+
97
+ def fake? = mode == :fake
98
+ def inline? = mode == :inline
99
+ def disabled? = mode == :disabled
100
+
101
+ attr_reader :store
102
+
103
+ private
104
+
105
+ # When entering fake/inline mode, enable streams_test_mode to prevent
106
+ # rack.hijack from spawning background threads that acquire DB
107
+ # connections outside the test transaction (see issue #133). When
108
+ # returning to :disabled mode, restore the previous setting.
109
+ def sync_streams_test_mode!(mode)
110
+ return unless defined?(Pgbus.configuration)
111
+
112
+ if mode == :disabled
113
+ Pgbus.configuration.streams_test_mode = false
114
+ Pgbus::Web::Streamer.reset! if defined?(Pgbus::Web::Streamer)
115
+ else
116
+ Pgbus.configuration.streams_test_mode = true
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ require_relative "testing/assertions"
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.7.2"
4
+ VERSION = "0.7.4"
5
5
  end
@@ -707,10 +707,155 @@ module Pgbus
707
707
  []
708
708
  end
709
709
 
710
- # Subscriber registry
710
+ # Pending events — messages sitting in handler queues that haven't been processed.
711
+ # Identifies handler queues via the subscriber registry and queries them
712
+ # for unprocessed messages. Subscriber queue names are logical
713
+ # (e.g. "task_completion_handler"), while `pgmq.meta.queue_name` stores
714
+ # physical names (e.g. "pgbus_task_completion_handler"), so we normalize
715
+ # through `config.queue_name` before intersecting.
716
+ def pending_events(page: 1, per_page: 25)
717
+ handler_queues = handler_queue_physical_names
718
+ return [] if handler_queues.empty?
719
+
720
+ existing = connection.select_values(
721
+ "SELECT queue_name FROM pgmq.meta ORDER BY queue_name", "Pgbus Queue Names"
722
+ )
723
+ target_queues = handler_queues & existing
724
+ return [] if target_queues.empty?
725
+
726
+ offset = (page - 1) * per_page
727
+ paginated_queue_messages(target_queues, per_page, offset)
728
+ rescue StandardError => e
729
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching pending events: #{e.message}" }
730
+ []
731
+ end
732
+
733
+ # Physical queue names for all registered subscribers. Used for both
734
+ # pending_events lookup and server-side validation of target queues
735
+ # in reroute_event.
736
+ def handler_queue_physical_names
737
+ registered_subscribers.map { |s| s[:physical_queue_name] }.uniq
738
+ end
739
+
740
+ # Find the handler class registered for a given physical queue name.
741
+ # Returns nil if no subscriber matches — used to reject forged handler
742
+ # values in mark_event_handled / reroute_event.
743
+ def handler_class_for_queue(physical_queue_name)
744
+ sub = registered_subscribers.find { |s| s[:physical_queue_name] == physical_queue_name }
745
+ sub && sub[:handler_class]
746
+ end
747
+
748
+ # Discard (archive) an event message from a handler queue.
749
+ def discard_event(queue_name, msg_id)
750
+ release_lock_for_message(queue_name, msg_id)
751
+ @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
752
+ true
753
+ rescue StandardError => e
754
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding event #{msg_id}: #{e.message}" }
755
+ false
756
+ end
757
+
758
+ # Mark an event as handled: archive the queue message and insert a
759
+ # ProcessedEvent record so it won't be reprocessed on replay.
760
+ #
761
+ # The insert is performed BEFORE archive. If the archive step fails
762
+ # afterwards the operator can retry — replay protection is already in
763
+ # place and the idempotency dedup will cause the handler to skip the
764
+ # event even if it is eventually re-read from the queue. Doing it the
765
+ # other way around would risk losing the message without recording the
766
+ # marker.
767
+ def mark_event_handled(queue_name, msg_id, handler_class)
768
+ detail = job_detail(queue_name, msg_id)
769
+ return false unless detail
770
+
771
+ raw = JSON.parse(detail[:message])
772
+ event_id = raw["event_id"]
773
+ return false unless event_id
774
+
775
+ ProcessedEvent.insert(
776
+ { event_id: event_id, handler_class: handler_class, processed_at: Time.now.utc },
777
+ unique_by: %i[event_id handler_class]
778
+ )
779
+ # Release the uniqueness lock while we still hold the payload in
780
+ # memory — otherwise the message is archived but the lock row stays
781
+ # behind, blocking later publishes with the same key. Mirrors
782
+ # discard_event.
783
+ release_lock_for_payload(detail[:message])
784
+ @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
785
+ true
786
+ rescue StandardError => e
787
+ Pgbus.logger.debug { "[Pgbus::Web] Error marking event #{msg_id} handled: #{e.message}" }
788
+ false
789
+ end
790
+
791
+ # Edit the payload of a stuck event: delete old message and re-enqueue
792
+ # with the corrected payload in the same queue. The produce + delete
793
+ # are wrapped in a PGMQ transaction so the message can't be lost if
794
+ # either half fails (same pattern as retry_dlq_message).
795
+ def edit_event_payload(queue_name, msg_id, new_payload_json)
796
+ begin
797
+ parsed = JSON.parse(new_payload_json)
798
+ rescue JSON::ParserError
799
+ return false
800
+ end
801
+
802
+ detail = job_detail(queue_name, msg_id)
803
+ return false unless detail
804
+
805
+ @client.transaction do |txn|
806
+ txn.produce(queue_name, parsed.to_json, headers: detail[:headers])
807
+ txn.delete(queue_name, msg_id.to_i)
808
+ end
809
+ true
810
+ rescue StandardError => e
811
+ Pgbus.logger.debug { "[Pgbus::Web] Error editing event #{msg_id}: #{e.message}" }
812
+ false
813
+ end
814
+
815
+ # Reroute an event from one handler queue to another. Wrapped in a
816
+ # PGMQ transaction so produce on the target and delete on the source
817
+ # are atomic.
818
+ def reroute_event(source_queue, msg_id, target_queue)
819
+ detail = job_detail(source_queue, msg_id)
820
+ return false unless detail
821
+
822
+ @client.transaction do |txn|
823
+ txn.produce(target_queue, detail[:message], headers: detail[:headers])
824
+ txn.delete(source_queue, msg_id.to_i)
825
+ end
826
+ true
827
+ rescue StandardError => e
828
+ Pgbus.logger.debug { "[Pgbus::Web] Error rerouting event #{msg_id}: #{e.message}" }
829
+ false
830
+ end
831
+
832
+ # Bulk discard selected events from handler queues.
833
+ def discard_selected_events(selections)
834
+ return 0 if selections.empty?
835
+
836
+ count = 0
837
+ selections.each do |sel|
838
+ discard_event(sel[:queue_name], sel[:msg_id]) && count += 1
839
+ rescue StandardError => e
840
+ Pgbus.logger.debug { "[Pgbus::Web] Error in bulk discard for #{sel[:msg_id]}: #{e.message}" }
841
+ next
842
+ end
843
+ count
844
+ end
845
+
846
+ # Subscriber registry. `queue_name` is the logical name the subscriber
847
+ # registered with; `physical_queue_name` is what the queue is actually
848
+ # called in `pgmq.meta` (e.g. logical "task_completion_handler" ->
849
+ # physical "pgbus_task_completion_handler"). The dashboard needs the
850
+ # physical name to match against pending messages / target queues.
711
851
  def registered_subscribers
712
852
  EventBus::Registry.instance.subscribers.map do |s|
713
- { pattern: s.pattern, handler_class: s.handler_class.name, queue_name: s.queue_name }
853
+ {
854
+ pattern: s.pattern,
855
+ handler_class: s.handler_class.name,
856
+ queue_name: s.queue_name,
857
+ physical_queue_name: @client.config.queue_name(s.queue_name)
858
+ }
714
859
  end
715
860
  rescue StandardError => e
716
861
  Pgbus.logger.debug { "[Pgbus::Web] Error fetching subscribers: #{e.message}" }
@@ -62,6 +62,8 @@ module Pgbus
62
62
  cursor = parse_cursor(env, request)
63
63
  return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
64
64
 
65
+ return test_mode_stub if config.streams_test_mode
66
+
65
67
  return over_capacity if streamer.registry.size >= config.streams_max_connections
66
68
 
67
69
  if config.streams_falcon_streaming_body
@@ -210,6 +212,11 @@ module Pgbus
210
212
  def server_error
211
213
  [500, { "content-type" => "text/plain" }, ["pgbus: internal error"]]
212
214
  end
215
+
216
+ def test_mode_stub
217
+ body = ": pgbus test mode — connection accepted, no polling\n\n"
218
+ [200, sse_headers, [body]]
219
+ end
213
220
  end
214
221
  end
215
222
  end
data/lib/pgbus.rb CHANGED
@@ -39,6 +39,7 @@ module Pgbus
39
39
  )
40
40
  loader.ignore("#{__dir__}/generators")
41
41
  loader.ignore("#{__dir__}/active_job")
42
+ loader.ignore("#{__dir__}/pgbus/testing")
42
43
  # lib/puma/plugin/pgbus_streams.rb is a Puma plugin — it's required
43
44
  # explicitly by the user from config/puma.rb via `plugin :pgbus_streams`.
44
45
  # Without this ignore, Zeitwerk scans lib/puma/ under the pgbus loader
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -170,6 +170,7 @@ files:
170
170
  - app/views/pgbus/dead_letter/_messages_table.html.erb
171
171
  - app/views/pgbus/dead_letter/index.html.erb
172
172
  - app/views/pgbus/dead_letter/show.html.erb
173
+ - app/views/pgbus/events/_pending_table.html.erb
173
174
  - app/views/pgbus/events/index.html.erb
174
175
  - app/views/pgbus/events/show.html.erb
175
176
  - app/views/pgbus/insights/show.html.erb
@@ -308,6 +309,10 @@ files:
308
309
  - lib/pgbus/streams/turbo_broadcastable.rb
309
310
  - lib/pgbus/streams/turbo_stream_override.rb
310
311
  - lib/pgbus/streams/watermark_cache_middleware.rb
312
+ - lib/pgbus/testing.rb
313
+ - lib/pgbus/testing/assertions.rb
314
+ - lib/pgbus/testing/minitest.rb
315
+ - lib/pgbus/testing/rspec.rb
311
316
  - lib/pgbus/uniqueness.rb
312
317
  - lib/pgbus/version.rb
313
318
  - lib/pgbus/web/authentication.rb