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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c45dd364f341b6819b7901f583e058dbe761b375210e4162f19faf75917e3043
4
- data.tar.gz: ed5ee189a3ff3d7fe0610deada3b2daba3726a2647d7762c58735e0771c9e0eb
3
+ metadata.gz: 8d07ecda9d90b73fa2ab8d9a34bf775b305bba9349dd5d49c99b587ea5b614bc
4
+ data.tar.gz: af072bf9e30ead0928db95896380384c49d8a49a59994864693aea80dd249bda
5
5
  SHA512:
6
- metadata.gz: e28c032dc7b4f2cba37bd709c4a45030bcedd90274b86a1499d55dd4f4e255769e580983023602a42267b8728068a7eb11f7fdcbc8d12bd3efd83f35a50f241b
7
- data.tar.gz: dc6d8d5d2e4feebbf7d940c173f53700549b5364c0e15df3f6afac1d72ecc6b1222127d85649016e9c6590e3c184eb9365da95c424a21a98be683862f4a24e67
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] Executor phase=perform msg_id=#{msg_id} job=#{job_class}" }
65
+ Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
68
66
  execute_job(job)
69
- Pgbus.logger.debug { "[Pgbus] Executor phase=archive msg_id=#{msg_id} job=#{job_class}" }
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
@@ -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",
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.7.1"
4
+ VERSION = "0.7.3"
5
5
  end
@@ -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.1
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