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.
- checksums.yaml +4 -4
- data/README.md +236 -0
- data/app/controllers/pgbus/events_controller.rb +98 -2
- data/app/views/pgbus/events/_pending_table.html.erb +148 -0
- data/app/views/pgbus/events/index.html.erb +21 -1
- data/config/locales/da.yml +109 -0
- data/config/locales/de.yml +109 -0
- data/config/locales/en.yml +47 -0
- data/config/locales/es.yml +109 -0
- data/config/locales/fi.yml +109 -0
- data/config/locales/fr.yml +109 -0
- data/config/locales/it.yml +109 -0
- data/config/locales/ja.yml +109 -0
- data/config/locales/nb.yml +109 -0
- data/config/locales/nl.yml +109 -0
- data/config/locales/pt.yml +109 -0
- data/config/locales/sv.yml +109 -0
- data/config/routes.rb +7 -0
- data/lib/pgbus/configuration.rb +2 -1
- data/lib/pgbus/event_bus/publisher.rb +28 -3
- data/lib/pgbus/testing/assertions.rb +59 -0
- data/lib/pgbus/testing/minitest.rb +32 -0
- data/lib/pgbus/testing/rspec.rb +76 -0
- data/lib/pgbus/testing.rb +123 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +147 -2
- data/lib/pgbus/web/stream_app.rb +7 -0
- data/lib/pgbus.rb +1 -0
- metadata +6 -1
|
@@ -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
|
@@ -707,10 +707,155 @@ module Pgbus
|
|
|
707
707
|
[]
|
|
708
708
|
end
|
|
709
709
|
|
|
710
|
-
#
|
|
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
|
-
{
|
|
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}" }
|
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -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.
|
|
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
|