pgbus 0.7.1 → 0.7.3
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/lib/pgbus/active_job/executor.rb +11 -11
- data/lib/pgbus/configuration.rb +8 -1
- data/lib/pgbus/event_bus/publisher.rb +23 -0
- data/lib/pgbus/failed_event_recorder.rb +12 -0
- data/lib/pgbus/process/worker.rb +16 -0
- 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/stream_app.rb +7 -0
- data/lib/pgbus.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d07ecda9d90b73fa2ab8d9a34bf775b305bba9349dd5d49c99b587ea5b614bc
|
|
4
|
+
data.tar.gz: af072bf9e30ead0928db95896380384c49d8a49a59994864693aea80dd249bda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49eccf16ff1d0dcdec75f111bda474b343f3cf154b14569d19138f1b666457d71482037e390f4faa7136e21ead14b32a8a9408d18256843992e83476424a333c
|
|
7
|
+
data.tar.gz: 0d78309574c68a90b59b276b7648ac17cd8c74f3dbedebbd3d6cabe70a062a4d879cac19da8b2ab6ac78584f671ec7eda1db37fa459ecd761b81f0cd5c81ded0
|
|
@@ -19,7 +19,11 @@ module Pgbus
|
|
|
19
19
|
|
|
20
20
|
def execute(message, queue_name, source_queue: nil)
|
|
21
21
|
execution_start = monotonic_now
|
|
22
|
+
tag = "msg_id=#{message.msg_id} queue=#{queue_name} read_ct=#{message.read_ct}"
|
|
23
|
+
Pgbus.logger.debug { "[Pgbus::Executor] start #{tag}" }
|
|
24
|
+
|
|
22
25
|
payload = JSON.parse(message.message)
|
|
26
|
+
job_class = payload["job_class"]
|
|
23
27
|
read_count = message.read_ct.to_i
|
|
24
28
|
|
|
25
29
|
if read_count > config.max_retries
|
|
@@ -29,10 +33,9 @@ module Pgbus
|
|
|
29
33
|
signal_batch_discarded(payload)
|
|
30
34
|
Uniqueness.release_lock(Uniqueness.extract_key(payload))
|
|
31
35
|
record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
|
|
36
|
+
Pgbus.logger.debug { "[Pgbus::Executor] dead_lettered #{tag} job_class=#{job_class}" }
|
|
32
37
|
return :dead_lettered
|
|
33
38
|
end
|
|
34
|
-
|
|
35
|
-
job_class = payload["job_class"]
|
|
36
39
|
uniqueness_key = Uniqueness.extract_key(payload)
|
|
37
40
|
uniqueness_strategy = Uniqueness.extract_strategy(payload)
|
|
38
41
|
|
|
@@ -53,28 +56,24 @@ module Pgbus
|
|
|
53
56
|
end
|
|
54
57
|
end
|
|
55
58
|
|
|
59
|
+
Pgbus.logger.debug { "[Pgbus::Executor] deserialized #{tag} job_class=#{job_class}" }
|
|
56
60
|
job_succeeded = false
|
|
57
61
|
|
|
58
|
-
# Debug-level phase markers. Silent at INFO+, but invaluable when a
|
|
59
|
-
# fiber interrupt or connection issue loses control flow between phases
|
|
60
|
-
# (issue #126). Each line identifies msg_id + phase so the gap is
|
|
61
|
-
# visible in logs: "deserialized" without "archived" means the job
|
|
62
|
-
# ran but its message was never archived.
|
|
63
62
|
msg_id = message.msg_id.to_i
|
|
64
63
|
Instrumentation.instrument("pgbus.executor.execute", queue: queue_name, job_class: job_class) do
|
|
65
|
-
Pgbus.logger.debug { "[Pgbus] Executor phase=deserialize msg_id=#{msg_id} job=#{job_class}" }
|
|
66
64
|
job = ::ActiveJob::Base.deserialize(payload)
|
|
67
|
-
Pgbus.logger.debug { "[Pgbus]
|
|
65
|
+
Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
|
|
68
66
|
execute_job(job)
|
|
69
|
-
Pgbus.logger.debug { "[Pgbus]
|
|
67
|
+
Pgbus.logger.debug { "[Pgbus::Executor] perform_returned #{tag} job_class=#{job_class}" }
|
|
70
68
|
archive_from(queue_name, msg_id, source_queue: source_queue)
|
|
69
|
+
Pgbus.logger.debug { "[Pgbus::Executor] archived #{tag} job_class=#{job_class}" }
|
|
71
70
|
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id)
|
|
72
71
|
job_succeeded = true
|
|
73
|
-
Pgbus.logger.debug { "[Pgbus] Executor phase=succeeded msg_id=#{msg_id} job=#{job_class}" }
|
|
74
72
|
end
|
|
75
73
|
|
|
76
74
|
instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
|
|
77
75
|
record_stat(payload, queue_name, "success", execution_start, message: message)
|
|
76
|
+
Pgbus.logger.debug { "[Pgbus::Executor] done #{tag} job_class=#{job_class}" }
|
|
78
77
|
:success
|
|
79
78
|
rescue *FATAL_EXCEPTIONS
|
|
80
79
|
# Process-fatal: propagate so the supervisor/OS can react.
|
|
@@ -88,6 +87,7 @@ module Pgbus
|
|
|
88
87
|
handle_failure(message, queue_name, e, payload: payload)
|
|
89
88
|
instrument("pgbus.job_failed", queue: queue_name, job_class: payload&.dig("job_class"), error: e.class.name)
|
|
90
89
|
record_stat(payload, queue_name, "failed", execution_start, message: message)
|
|
90
|
+
Pgbus.logger.debug { "[Pgbus::Executor] failed #{tag} job_class=#{payload&.dig("job_class")} error=#{e.class}" }
|
|
91
91
|
# Don't signal concurrency on transient failure — the job will be retried.
|
|
92
92
|
# Semaphore is released only on success or dead-lettering.
|
|
93
93
|
:failed
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -85,6 +85,10 @@ module Pgbus
|
|
|
85
85
|
# Requires a matching entry in config/database.yml under the "pgbus" key.
|
|
86
86
|
attr_accessor :connects_to
|
|
87
87
|
|
|
88
|
+
# Zombie message detection — logs a warning when a message is redelivered
|
|
89
|
+
# (read_ct > 1) without any prior failure recorded in pgbus_failed_events.
|
|
90
|
+
attr_accessor :zombie_detection
|
|
91
|
+
|
|
88
92
|
# Job stats
|
|
89
93
|
attr_accessor :stats_enabled
|
|
90
94
|
attr_reader :stats_retention # rubocop:disable Style/AccessorGrouping
|
|
@@ -99,7 +103,7 @@ module Pgbus
|
|
|
99
103
|
:streams_default_retention, :streams_retention, :streams_heartbeat_interval,
|
|
100
104
|
:streams_max_connections, :streams_idle_timeout, :streams_listen_health_check_ms,
|
|
101
105
|
:streams_write_deadline_ms, :streams_falcon_streaming_body,
|
|
102
|
-
:streams_stats_enabled
|
|
106
|
+
:streams_stats_enabled, :streams_test_mode
|
|
103
107
|
|
|
104
108
|
def initialize
|
|
105
109
|
@database_url = nil
|
|
@@ -160,6 +164,8 @@ module Pgbus
|
|
|
160
164
|
@skip_recurring = false
|
|
161
165
|
@recurring_execution_retention = 7 * 24 * 3600 # 7 days
|
|
162
166
|
|
|
167
|
+
@zombie_detection = true
|
|
168
|
+
|
|
163
169
|
@stats_enabled = true
|
|
164
170
|
@stats_retention = 30 * 24 * 3600 # 30 days
|
|
165
171
|
|
|
@@ -203,6 +209,7 @@ module Pgbus
|
|
|
203
209
|
# gates pgbus_job_stats recording) on purpose — operators
|
|
204
210
|
# usually want job stats on and stream stats off, or vice versa.
|
|
205
211
|
@streams_stats_enabled = false
|
|
212
|
+
@streams_test_mode = false
|
|
206
213
|
end
|
|
207
214
|
|
|
208
215
|
def queue_name(name)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
module EventBus
|
|
5
7
|
module Publisher
|
|
@@ -7,6 +9,27 @@ module Pgbus
|
|
|
7
9
|
|
|
8
10
|
def publish(routing_key, payload, headers: nil, delay: 0)
|
|
9
11
|
event_data = build_event_data(payload)
|
|
12
|
+
|
|
13
|
+
if defined?(Pgbus::Testing) && !Pgbus::Testing.disabled?
|
|
14
|
+
event = Pgbus::Event.new(
|
|
15
|
+
event_id: event_data["event_id"],
|
|
16
|
+
payload: event_data["payload"],
|
|
17
|
+
published_at: event_data["published_at"] ? Time.parse(event_data["published_at"]) : nil,
|
|
18
|
+
routing_key: routing_key,
|
|
19
|
+
headers: headers
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Pgbus::Testing.store.push_event(event)
|
|
23
|
+
|
|
24
|
+
if Pgbus::Testing.inline? && delay.to_i <= 0
|
|
25
|
+
Pgbus::EventBus::Registry.instance.handlers_for(routing_key).each do |subscriber|
|
|
26
|
+
subscriber.handler_class.new.handle(event)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return event_data
|
|
31
|
+
end
|
|
32
|
+
|
|
10
33
|
Pgbus.client.publish_to_topic(routing_key, event_data, headers: headers, delay: delay)
|
|
11
34
|
end
|
|
12
35
|
|
|
@@ -35,6 +35,18 @@ module Pgbus
|
|
|
35
35
|
ErrorReporter.report(e, { action: "record_failed_event", queue: queue_name, msg_id: msg_id })
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def exists?(queue_name:, msg_id:)
|
|
39
|
+
result = connection.select_value(
|
|
40
|
+
"SELECT 1 FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
|
|
41
|
+
"FailedEvent Exists",
|
|
42
|
+
[queue_name, msg_id.to_i]
|
|
43
|
+
)
|
|
44
|
+
!result.nil?
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Pgbus.logger.debug { "[Pgbus] FailedEvent exists? check failed: #{e.class}: #{e.message}" }
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
def clear!(queue_name:, msg_id:)
|
|
39
51
|
connection.exec_delete(
|
|
40
52
|
"DELETE FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2",
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -126,6 +126,7 @@ module Pgbus
|
|
|
126
126
|
|
|
127
127
|
@rate_counter.increment(:dequeued, tagged_messages.size)
|
|
128
128
|
tagged_messages.each do |queue_name, message, source_queue|
|
|
129
|
+
detect_zombie(queue_name, message)
|
|
129
130
|
@in_flight.increment
|
|
130
131
|
@pool.post { process_message(message, queue_name, source_queue: source_queue) }
|
|
131
132
|
end
|
|
@@ -285,6 +286,21 @@ module Pgbus
|
|
|
285
286
|
Pgbus.logger.error { "[Pgbus] Queue table missing: #{error.message}" }
|
|
286
287
|
end
|
|
287
288
|
|
|
289
|
+
def detect_zombie(queue_name, message)
|
|
290
|
+
return unless config.zombie_detection
|
|
291
|
+
return unless message.read_ct.to_i > 1
|
|
292
|
+
|
|
293
|
+
return if FailedEventRecorder.exists?(queue_name: queue_name, msg_id: message.msg_id.to_i)
|
|
294
|
+
|
|
295
|
+
Pgbus.logger.warn do
|
|
296
|
+
"[Pgbus] Zombie message redelivered: queue=#{queue_name} msg_id=#{message.msg_id} " \
|
|
297
|
+
"read_ct=#{message.read_ct} — previous read did not record a failure. " \
|
|
298
|
+
"The worker may have crashed mid-execute or the executor silently dropped the job."
|
|
299
|
+
end
|
|
300
|
+
rescue StandardError => e
|
|
301
|
+
Pgbus.logger.debug { "[Pgbus] Zombie detection failed: #{e.class}: #{e.message}" }
|
|
302
|
+
end
|
|
303
|
+
|
|
288
304
|
def check_recycle
|
|
289
305
|
return unless @lifecycle.running? && recycle_needed?
|
|
290
306
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Testing
|
|
5
|
+
# Framework-agnostic assertion helpers. Included by both RSpec and Minitest
|
|
6
|
+
# integrations. Can also be included directly in any test class.
|
|
7
|
+
#
|
|
8
|
+
# include Pgbus::Testing::Assertions
|
|
9
|
+
#
|
|
10
|
+
# assert_pgbus_published(count: 1, routing_key: "orders.created") do
|
|
11
|
+
# Order.create!(...)
|
|
12
|
+
# end
|
|
13
|
+
module Assertions
|
|
14
|
+
def pgbus_published_events(routing_key: nil)
|
|
15
|
+
Pgbus::Testing.store.events(routing_key: routing_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def assert_pgbus_published(count:, routing_key: nil)
|
|
19
|
+
before = pgbus_published_events(routing_key: routing_key).size
|
|
20
|
+
yield
|
|
21
|
+
after = pgbus_published_events(routing_key: routing_key).size
|
|
22
|
+
actual = after - before
|
|
23
|
+
|
|
24
|
+
return if actual == count
|
|
25
|
+
|
|
26
|
+
suffix = routing_key ? " matching #{routing_key.inspect}" : ""
|
|
27
|
+
raise_assertion("Expected #{count} event(s) published#{suffix}, got #{actual}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assert_no_pgbus_published(routing_key: nil)
|
|
31
|
+
before = pgbus_published_events(routing_key: routing_key).size
|
|
32
|
+
yield
|
|
33
|
+
after = pgbus_published_events(routing_key: routing_key).size
|
|
34
|
+
actual = after - before
|
|
35
|
+
|
|
36
|
+
return if actual.zero?
|
|
37
|
+
|
|
38
|
+
suffix = routing_key ? " matching #{routing_key.inspect}" : ""
|
|
39
|
+
raise_assertion("Expected no events published#{suffix}, got #{actual}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Execute the block, capturing events, then dispatch all captured events
|
|
43
|
+
# to their registered handlers.
|
|
44
|
+
def perform_published_events
|
|
45
|
+
yield
|
|
46
|
+
Pgbus::Testing.store.drain!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def raise_assertion(message)
|
|
52
|
+
# Use Minitest::Assertion if available, otherwise a generic RuntimeError
|
|
53
|
+
raise Minitest::Assertion, message if defined?(Minitest::Assertion)
|
|
54
|
+
|
|
55
|
+
raise message
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../testing"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Testing
|
|
7
|
+
# Minitest integration for Pgbus test helpers.
|
|
8
|
+
#
|
|
9
|
+
# Include in your test_helper.rb:
|
|
10
|
+
#
|
|
11
|
+
# require "pgbus/testing/minitest"
|
|
12
|
+
#
|
|
13
|
+
# class ActiveSupport::TestCase
|
|
14
|
+
# include Pgbus::Testing::MinitestHelpers
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# This provides:
|
|
18
|
+
# - Automatic fake mode + store clearing per test
|
|
19
|
+
# - assert_pgbus_published / assert_no_pgbus_published
|
|
20
|
+
# - perform_published_events
|
|
21
|
+
# - pgbus_published_events
|
|
22
|
+
module MinitestHelpers
|
|
23
|
+
include Pgbus::Testing::Assertions
|
|
24
|
+
|
|
25
|
+
def before_setup
|
|
26
|
+
Pgbus::Testing.fake!
|
|
27
|
+
Pgbus::Testing.store.clear!
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../testing"
|
|
4
|
+
|
|
5
|
+
RSpec::Matchers.define :have_published_event do |expected_routing_key|
|
|
6
|
+
supports_block_expectations
|
|
7
|
+
|
|
8
|
+
chain(:with_payload) { |payload| @expected_payload = payload }
|
|
9
|
+
chain(:with_headers) { |headers| @expected_headers = headers }
|
|
10
|
+
chain(:exactly) { |count| @expected_count = count }
|
|
11
|
+
|
|
12
|
+
match do |block|
|
|
13
|
+
@before = Pgbus::Testing.store.events(routing_key: expected_routing_key).dup
|
|
14
|
+
block.call
|
|
15
|
+
@after = Pgbus::Testing.store.events(routing_key: expected_routing_key)
|
|
16
|
+
@new_events = @after - @before
|
|
17
|
+
|
|
18
|
+
return false if @new_events.empty?
|
|
19
|
+
|
|
20
|
+
if @expected_payload
|
|
21
|
+
@new_events = @new_events.select { |e| values_match?(@expected_payload, e.payload) }
|
|
22
|
+
return false if @new_events.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if @expected_headers
|
|
26
|
+
@new_events = @new_events.select { |e| values_match?(@expected_headers, e.headers) }
|
|
27
|
+
return false if @new_events.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return false if @expected_count && @new_events.size != @expected_count
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
match_when_negated do |block|
|
|
36
|
+
@before = Pgbus::Testing.store.events(routing_key: expected_routing_key).dup
|
|
37
|
+
block.call
|
|
38
|
+
@after = Pgbus::Testing.store.events(routing_key: expected_routing_key)
|
|
39
|
+
@new_events = @after - @before
|
|
40
|
+
|
|
41
|
+
@new_events = @new_events.select { |e| values_match?(@expected_payload, e.payload) } if @expected_payload
|
|
42
|
+
|
|
43
|
+
@new_events = @new_events.select { |e| values_match?(@expected_headers, e.headers) } if @expected_headers
|
|
44
|
+
|
|
45
|
+
if @expected_count
|
|
46
|
+
@new_events.size != @expected_count
|
|
47
|
+
else
|
|
48
|
+
@new_events.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
failure_message do
|
|
53
|
+
parts = ["expected block to publish a #{expected_routing_key.inspect} event"]
|
|
54
|
+
parts << "with payload #{@expected_payload.inspect}" if @expected_payload
|
|
55
|
+
parts << "with headers #{@expected_headers.inspect}" if @expected_headers
|
|
56
|
+
parts << "exactly #{@expected_count} time(s)" if @expected_count
|
|
57
|
+
|
|
58
|
+
published = @new_events&.size || 0
|
|
59
|
+
parts << "but #{published} matching event(s) were published"
|
|
60
|
+
|
|
61
|
+
if published.positive? && @expected_payload
|
|
62
|
+
actual_payloads = @after.map(&:payload)
|
|
63
|
+
parts << "actual payloads: #{actual_payloads.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
parts.join(", ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
failure_message_when_negated do
|
|
70
|
+
"expected block not to publish a #{expected_routing_key.inspect} event, but #{@new_events.size} were published"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
RSpec.configure do |config|
|
|
75
|
+
config.include Pgbus::Testing::Assertions
|
|
76
|
+
end
|
|
@@ -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
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -308,6 +308,10 @@ files:
|
|
|
308
308
|
- lib/pgbus/streams/turbo_broadcastable.rb
|
|
309
309
|
- lib/pgbus/streams/turbo_stream_override.rb
|
|
310
310
|
- lib/pgbus/streams/watermark_cache_middleware.rb
|
|
311
|
+
- lib/pgbus/testing.rb
|
|
312
|
+
- lib/pgbus/testing/assertions.rb
|
|
313
|
+
- lib/pgbus/testing/minitest.rb
|
|
314
|
+
- lib/pgbus/testing/rspec.rb
|
|
311
315
|
- lib/pgbus/uniqueness.rb
|
|
312
316
|
- lib/pgbus/version.rb
|
|
313
317
|
- lib/pgbus/web/authentication.rb
|