ears 0.21.0 → 0.22.0

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: a7786b24328603a3d9b231e06400194e69ab38b00de05ec0b0ee5424f0ec3a7b
4
- data.tar.gz: 386cedcd1ed08364678da108e1c44e4142d5ba915478476b2d95b14afec3eff3
3
+ metadata.gz: d9d5a827ad0e368b8243e4e03b00695cd84cd57dbcbb09d4249e97aa84c41f08
4
+ data.tar.gz: ead5ad7a108d114400bb3819188db589e9d5841bd178efcc9333b4702e91de85
5
5
  SHA512:
6
- metadata.gz: 841d1b043e68ebbcf850fab367e770e145cf3519d15e33f613db18c41e8df72d2c33d0337b50ba3c18d866894defe32d4584230d78447192eb2ac71b6dea134f
7
- data.tar.gz: 7a2bb015f0f4175499c07609cc0ab923af8ecd2157df7a6a57c5c1233c68cfc2d5813ac2452c571551efb83658bfbb5642cf4373bb0efde0a6e9cd88a0530b31
6
+ metadata.gz: 50a91737737c9344011c0cd44970bb9cbe1aa8b5cc3d904ba1b4301e6076affaea338cbe68af1c4456f380b23d94b81b4f032fc26eda94c81b7b4b14b9777b43
7
+ data.tar.gz: 2b0644c8648c8bfc1f83349cb1f7342b302fe8b90ef67f76a9ec1013fed1bb0286fcc28c86267a904b281130a0015c20adf64c258a5c052caefcb9c6ff97ca43
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 (2025-09-11)
4
+
5
+ - Support publisher confirms in `Ears::Publisher`
6
+
7
+ ## 0.21.1 (2025-09-09)
8
+
9
+ - Add testing abstractions: `Ears::Testing::TestHelper`, `Ears::Testing::MessageCapture`, and `Ears::Testing::PublisherMock`
10
+
3
11
  ## 0.21.0 (2025-09-08)
4
12
 
5
13
  - Introduce Ears::Publisher with thread-safe channel pooling
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ears (0.21.0)
4
+ ears (0.22.0)
5
5
  bunny (>= 2.22.0)
6
6
  connection_pool (~> 2.4)
7
7
  multi_json
@@ -113,7 +113,7 @@ CHECKSUMS
113
113
  connection_pool (2.5.3) sha256=cfd74a82b9b094d1ce30c4f1a346da23ee19dc8a062a16a85f58eab1ced4305b
114
114
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
115
115
  docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
116
- ears (0.21.0)
116
+ ears (0.22.0)
117
117
  json (2.13.2) sha256=02e1f118d434c6b230a64ffa5c8dee07e3ec96244335c392eaed39e1199dbb68
118
118
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
119
119
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/README.md CHANGED
@@ -197,6 +197,57 @@ rescue => e
197
197
  end
198
198
  ```
199
199
 
200
+ ### Publisher Confirms
201
+
202
+ #### Basic Usage
203
+
204
+ For guaranteed message delivery, use `publish_with_confirmation` which waits for broker acknowledgment:
205
+
206
+ ```ruby
207
+ publisher = Ears::Publisher.new('events', :topic)
208
+
209
+ # Publish with confirmation - blocks until broker acknowledges
210
+ publisher.publish_with_confirmation(
211
+ { user_id: 123, action: 'payment_processed' },
212
+ routing_key: 'payment.processed',
213
+ )
214
+ ```
215
+
216
+ #### Configuration
217
+
218
+ Publisher confirms use a separate channel pool with configurable settings:
219
+
220
+ ```ruby
221
+ Ears.configure do |config|
222
+ # Confirms-specific channel pool size (default: 32)
223
+ config.publisher_confirms_pool_size = 32
224
+
225
+ # Timeout for waiting for confirms in seconds (default: 5.0)
226
+ config.publisher_confirms_timeout = 5.0
227
+
228
+ # Cleanup timeout after confirmation failure (default: 1.0)
229
+ config.publisher_confirms_cleanup_timeout = 1.0
230
+ end
231
+ ```
232
+
233
+ #### Error Handling
234
+
235
+ Publisher confirms raise specific exceptions that are NOT automatically retried:
236
+
237
+ ```ruby
238
+ begin
239
+ publisher.publish_with_confirmation(data, routing_key: 'important.event')
240
+ rescue Ears::PublishConfirmationTimeout => e
241
+ # Message may or may not have reached broker
242
+ logger.error "Confirmation timed out: #{e.message}"
243
+ rescue Ears::PublishNacked => e
244
+ # Broker explicitly rejected the message
245
+ logger.error "Message was nacked: #{e.message}"
246
+ end
247
+ ```
248
+
249
+ **Note:** Unlike regular publishing, confirmation errors are not retried to avoid message duplication.
250
+
200
251
  ### Basic consumer usage
201
252
 
202
253
  First, you should configure `Ears`.
@@ -548,6 +599,115 @@ ensure
548
599
  end
549
600
  ```
550
601
 
602
+ ## Testing
603
+
604
+ Ears provides testing helpers to easily test your message publishing without connecting to RabbitMQ.
605
+
606
+ ### Basic Setup
607
+
608
+ Include the test helper in your RSpec tests and mock the exchanges you want to test:
609
+
610
+ ```ruby
611
+ require 'ears/testing'
612
+
613
+ RSpec.describe MyService do
614
+ include Ears::Testing::TestHelper
615
+
616
+ before do
617
+ # Mock exchanges that your code will publish to
618
+ mock_ears('events', 'notifications')
619
+ end
620
+
621
+ after do
622
+ # Clean up mocks and captured messages
623
+ ears_reset!
624
+ end
625
+ end
626
+ ```
627
+
628
+ ### Capturing and Inspecting Messages
629
+
630
+ Use the helper methods to inspect published messages:
631
+
632
+ ```ruby
633
+ it 'publishes user creation event' do
634
+ service = UserService.new
635
+ service.create_user(name: 'John', email: 'john@example.com')
636
+
637
+ # Get all messages published to 'events' exchange
638
+ messages = published_messages('events')
639
+ expect(messages.size).to eq(1)
640
+
641
+ # Inspect the message
642
+ message = messages.first
643
+ expect(message.routing_key).to eq('user.created')
644
+ expect(message.data).to include(name: 'John')
645
+ expect(message.options[:headers]).to include(version: '1.0')
646
+ end
647
+ ```
648
+
649
+ ### Available Helper Methods
650
+
651
+ - `published_messages(exchange_name = nil)` - Get messages for a specific exchange or all messages
652
+ - `last_published_message(exchange_name = nil)` - Get the most recent message
653
+ - `clear_published_messages` - Clear captured messages during a test
654
+
655
+ ### Message Properties
656
+
657
+ Each captured message has the following properties:
658
+
659
+ - `exchange_name` - Name of the exchange
660
+ - `routing_key` - Message routing key
661
+ - `data` - The message payload
662
+ - `options` - Publishing options (headers, persistent, etc.)
663
+ - `timestamp` - When the message was captured
664
+ - `thread_id` - Thread that published the message
665
+
666
+ ### Error Handling
667
+
668
+ By default, publishing to unmocked exchanges raises an error:
669
+
670
+ ```ruby
671
+ it 'raises error for unmocked exchanges' do
672
+ publisher = Ears::Publisher.new('unmocked_exchange')
673
+
674
+ expect {
675
+ publisher.publish({ data: 'test' }, routing_key: 'test')
676
+ }.to raise_error(Ears::Testing::UnmockedExchangeError)
677
+ end
678
+ ```
679
+
680
+ ### Complete Example
681
+
682
+ ```ruby
683
+ require 'ears/testing'
684
+
685
+ RSpec.describe OrderProcessor do
686
+ include Ears::Testing::TestHelper
687
+
688
+ before { mock_ears('events', 'notifications') }
689
+ after { ears_reset! }
690
+
691
+ it 'publishes events when processing order' do
692
+ processor = OrderProcessor.new
693
+ order = { id: 123, items: ['item1'], total: 99.99 }
694
+
695
+ processor.process(order)
696
+
697
+ # Check event was published
698
+ events = published_messages('events')
699
+ expect(events.size).to eq(1)
700
+ expect(events.first.routing_key).to eq('order.processed')
701
+ expect(events.first.data[:order_id]).to eq(123)
702
+
703
+ # Check notification was sent
704
+ notifications = published_messages('notifications')
705
+ expect(notifications.size).to eq(1)
706
+ expect(notifications.first.routing_key).to eq('email.order_confirmation')
707
+ end
708
+ end
709
+ ```
710
+
551
711
  ## Documentation
552
712
 
553
713
  If you need more in-depth information, look at [our API documentation](https://www.rubydoc.info/gems/ears).
@@ -17,6 +17,9 @@ module Ears
17
17
  DEFAULT_PUBLISHER_MAX_RETRIES = 3
18
18
  DEFAULT_PUBLISHER_RETRY_BASE_DELAY = 0.1
19
19
  DEFAULT_PUBLISHER_RETRY_BACKOFF_FACTOR = 2
20
+ DEFAULT_PUBLISHER_CONFIRMS_POOL_SIZE = 32
21
+ DEFAULT_PUBLISHER_CONFIRMS_TIMEOUT = 5.0
22
+ DEFAULT_PUBLISHER_CONFIRMS_CLEANUP_TIMEOUT = 1.0
20
23
 
21
24
  # @return [String] the connection string for RabbitMQ.
22
25
  attr_accessor :rabbitmq_url
@@ -57,7 +60,16 @@ module Ears
57
60
  # @return [Logger] the logger instance for Ears operations
58
61
  attr_accessor :logger
59
62
 
60
- def initialize
63
+ # @return [Integer] the size of the publisher confirms channel pool
64
+ attr_accessor :publisher_confirms_pool_size
65
+
66
+ # @return [Float] the timeout in seconds for waiting for publisher confirms
67
+ attr_accessor :publisher_confirms_timeout
68
+
69
+ # @return [Float] the timeout in seconds for cleanup operations after confirmation timeout
70
+ attr_accessor :publisher_confirms_cleanup_timeout
71
+
72
+ def initialize # rubocop:disable Metrics/MethodLength
61
73
  @rabbitmq_url = DEFAULT_RABBITMQ_URL
62
74
  @recovery_attempts = DEFAULT_RECOVERY_ATTEMPTS
63
75
  @publisher_pool_size = DEFAULT_PUBLISHER_POOL_SIZE
@@ -69,6 +81,10 @@ module Ears
69
81
  @publisher_max_retries = DEFAULT_PUBLISHER_MAX_RETRIES
70
82
  @publisher_retry_base_delay = DEFAULT_PUBLISHER_RETRY_BASE_DELAY
71
83
  @publisher_retry_backoff_factor = DEFAULT_PUBLISHER_RETRY_BACKOFF_FACTOR
84
+ @publisher_confirms_pool_size = DEFAULT_PUBLISHER_CONFIRMS_POOL_SIZE
85
+ @publisher_confirms_timeout = DEFAULT_PUBLISHER_CONFIRMS_TIMEOUT
86
+ @publisher_confirms_cleanup_timeout =
87
+ DEFAULT_PUBLISHER_CONFIRMS_CLEANUP_TIMEOUT
72
88
  @logger = Logger.new(IO::NULL)
73
89
  end
74
90
 
data/lib/ears/errors.rb CHANGED
@@ -2,4 +2,16 @@ module Ears
2
2
  # Error that is raised when the Bunny recovery attempts are exhausted.
3
3
  class MaxRecoveryAttemptsExhaustedError < StandardError
4
4
  end
5
+
6
+ # Base error class for publisher-related errors.
7
+ class PublishError < StandardError
8
+ end
9
+
10
+ # Error raised when a publisher confirmation times out.
11
+ class PublishConfirmationTimeout < PublishError
12
+ end
13
+
14
+ # Error raised when a message is nacked by the broker.
15
+ class PublishNacked < PublishError
16
+ end
5
17
  end
@@ -1,6 +1,8 @@
1
1
  require 'bunny'
2
2
  require 'ears/publisher_channel_pool'
3
3
  require 'ears/publisher_retry_handler'
4
+ require 'ears/publisher_confirmation_handler'
5
+ require 'ears/errors'
4
6
 
5
7
  module Ears
6
8
  # Publisher for sending messages to RabbitMQ exchanges.
@@ -51,6 +53,50 @@ module Ears
51
53
  end
52
54
  end
53
55
 
56
+ # Publishes a message to the configured exchange with confirmation.
57
+ # Waits for RabbitMQ to confirm the message was received.
58
+ #
59
+ # @param [Hash, Array, Object] data The data to serialize as JSON and publish.
60
+ # @param [String] routing_key The routing key for the message.
61
+ #
62
+ # @option opts [String] :routing_key Routing key
63
+ # @option opts [Boolean] :persistent Should the message be persisted to disk?
64
+ # @option opts [Boolean] :mandatory Should the message be returned if it cannot be routed to any queue?
65
+ # @option opts [Integer] :timestamp A timestamp associated with this message
66
+ # @option opts [Integer] :expiration Expiration time after which the message will be deleted
67
+ # @option opts [String] :type Message type, e.g. what type of event or command this message represents. Can be any string
68
+ # @option opts [String] :reply_to Queue name other apps should send the response to
69
+ # @option opts [String] :content_type Message content type (e.g. application/json)
70
+ # @option opts [String] :content_encoding Message content encoding (e.g. gzip)
71
+ # @option opts [String] :correlation_id Message correlated to this one, e.g. what request this message is a reply for
72
+ # @option opts [Integer] :priority Message priority, 0 to 9. Not used by RabbitMQ, only applications
73
+ # @option opts [String] :message_id Any message identifier
74
+ # @option opts [String] :user_id Optional user ID. Verified by RabbitMQ against the actual connection username
75
+ # @option opts [String] :app_id Optional application ID
76
+ #
77
+ # @raise [PublishConfirmationTimeout] if confirmation times out
78
+ # @raise [PublishNacked] if message is nacked by the broker
79
+ # @return [void]
80
+ def publish_with_confirmation(data, routing_key:, **options)
81
+ publish_options = default_publish_options.merge(options)
82
+
83
+ retry_handler.run do
84
+ validate_connection!
85
+
86
+ PublisherChannelPool.with_channel(confirms: true) do |channel|
87
+ exchange = create_exchange(channel)
88
+
89
+ publisher_confirmation_handler.publish_with_confirmation(
90
+ channel: channel,
91
+ exchange: exchange,
92
+ data: data,
93
+ routing_key: routing_key,
94
+ options: publish_options,
95
+ )
96
+ end
97
+ end
98
+ end
99
+
54
100
  # Resets the channel pool, forcing new channels to be created.
55
101
  # This can be useful for connection recovery scenarios.
56
102
  #
@@ -68,12 +114,9 @@ module Ears
68
114
  :logger
69
115
 
70
116
  def publish_with_channel(data:, routing_key:, publish_options:)
71
- unless Ears.connection.open?
72
- raise PublisherRetryHandler::PublishToStaleChannelError,
73
- 'Connection is not open'
74
- end
117
+ validate_connection!
75
118
 
76
- PublisherChannelPool.with_channel do |channel|
119
+ PublisherChannelPool.with_channel(confirms: false) do |channel|
77
120
  exchange = create_exchange(channel)
78
121
  exchange.publish(
79
122
  data,
@@ -82,6 +125,13 @@ module Ears
82
125
  end
83
126
  end
84
127
 
128
+ def validate_connection!
129
+ unless Ears.connection.open?
130
+ raise PublisherRetryHandler::PublishToStaleChannelError,
131
+ 'Connection is not open'
132
+ end
133
+ end
134
+
85
135
  def create_exchange(channel)
86
136
  Bunny::Exchange.new(
87
137
  channel,
@@ -104,5 +154,10 @@ module Ears
104
154
  def retry_handler
105
155
  @retry_handler ||= PublisherRetryHandler.new(config, logger)
106
156
  end
157
+
158
+ def publisher_confirmation_handler
159
+ @publisher_confirmation_handler ||=
160
+ PublisherConfirmationHandler.new(config: config, logger: logger)
161
+ end
107
162
  end
108
163
  end
@@ -3,50 +3,97 @@ require 'connection_pool'
3
3
  module Ears
4
4
  # Channel pool management for publishers.
5
5
  # Provides thread-safe channel pooling separate from consumer channels.
6
+ # Maintains two separate pools: one for standard publishing and one for confirmed publishing.
6
7
  class PublisherChannelPool
7
8
  class << self
8
- # Executes the given block with a channel from the pool.
9
+ # Executes the given block with a channel from the appropriate pool.
9
10
  #
11
+ # @param [Boolean] confirms Whether to use a channel with publisher confirms enabled
10
12
  # @yieldparam [Bunny::Channel] channel The channel to use for publishing
11
13
  # @return [Object] The result of the block
12
- def with_channel(&)
13
- channel_pool.with(&)
14
+ def with_channel(confirms: false, &)
15
+ # CRITICAL: Preserve fork-safety at the entry point
16
+ reset! if @creator_pid && @creator_pid != Process.pid
17
+
18
+ pool = confirms ? confirms_pool : standard_pool
19
+ pool.with(&)
14
20
  end
15
21
 
16
- # Resets the channel pool, forcing new channels to be created.
22
+ # Resets both channel pools, forcing new channels to be created.
17
23
  # This is useful for connection recovery scenarios.
18
24
  #
19
25
  # @return [void]
20
26
  def reset!
21
- pool = @channel_pool
22
- @channel_pool = nil
27
+ std_pool = @standard_pool
28
+ cnf_pool = @confirms_pool
29
+ @standard_pool = nil
30
+ @confirms_pool = nil
23
31
  @creator_pid = nil
24
32
 
25
- pool&.shutdown(&:close)
33
+ std_pool&.shutdown(&:close)
34
+ cnf_pool&.shutdown(&:close)
26
35
  nil
27
36
  end
28
37
 
29
- private
38
+ # Resets only the confirms channel pool, forcing new confirmed channels to be created.
39
+ # This is useful when confirmation failures occur and channels may have contaminated state.
40
+ #
41
+ # @return [void]
42
+ def reset_confirms_pool!
43
+ cnf_pool = @confirms_pool
44
+ @confirms_pool = nil
30
45
 
31
- def channel_pool
32
- # Recreate lazily after a fork
33
- reset! if @creator_pid && @creator_pid != Process.pid
46
+ cnf_pool&.shutdown(&:close)
47
+ nil
48
+ end
34
49
 
35
- return @channel_pool if @channel_pool
50
+ private
51
+
52
+ def standard_pool
53
+ # CRITICAL: Lazy-init must be thread-safe
54
+ return @standard_pool if @standard_pool
36
55
 
37
56
  init_mutex.synchronize do
38
- @channel_pool ||=
57
+ # Double-check in case another thread was waiting
58
+ @standard_pool ||=
39
59
  begin
40
- @creator_pid = Process.pid
60
+ @creator_pid ||= Process.pid
61
+ create_pool(confirms: false)
62
+ end
63
+ end
64
+ end
65
+
66
+ def confirms_pool
67
+ # CRITICAL: Lazy-init must be thread-safe
68
+ return @confirms_pool if @confirms_pool
41
69
 
42
- ConnectionPool.new(
43
- size: Ears.configuration.publisher_pool_size,
44
- timeout: Ears.configuration.publisher_pool_timeout,
45
- ) { Ears.connection.create_channel }
70
+ init_mutex.synchronize do
71
+ # Double-check in case another thread was waiting
72
+ @confirms_pool ||=
73
+ begin
74
+ @creator_pid ||= Process.pid
75
+ create_pool(confirms: true)
46
76
  end
47
77
  end
48
78
  end
49
79
 
80
+ def create_pool(confirms:)
81
+ ConnectionPool.new(
82
+ size: pool_size_for(confirms),
83
+ timeout: Ears.configuration.publisher_pool_timeout,
84
+ ) do
85
+ channel = Ears.connection.create_channel
86
+ channel.confirm_select if confirms
87
+ channel
88
+ end
89
+ end
90
+
91
+ def pool_size_for(confirms)
92
+ return Ears.configuration.publisher_confirms_pool_size if confirms
93
+
94
+ Ears.configuration.publisher_pool_size
95
+ end
96
+
50
97
  def init_mutex
51
98
  @init_mutex ||= Mutex.new
52
99
  end
@@ -0,0 +1,91 @@
1
+ require 'ears/errors'
2
+
3
+ module Ears
4
+ # Handles publisher confirmations for RabbitMQ messages.
5
+ #
6
+ # This class encapsulates the logic for publishing messages with confirmations,
7
+ # including timeout handling, channel cleanup, and pool coordination.
8
+ class PublisherConfirmationHandler
9
+ # Creates a new confirmation handler.
10
+ #
11
+ # @param [Ears::Configuration] config The Ears configuration object.
12
+ # @param [Logger] logger The logger instance for warnings and errors.
13
+ def initialize(config:, logger:)
14
+ @config = config
15
+ @logger = logger
16
+ end
17
+
18
+ # Publishes a message with confirmation.
19
+ #
20
+ # @param [Bunny::Channel] channel The channel to use for publishing.
21
+ # @param [Bunny::Exchange] exchange The exchange to publish to.
22
+ # @param [Hash, Array, Object] data The data to publish.
23
+ # @param [String] routing_key The routing key for the message.
24
+ # @param [Hash] options The publish options.
25
+ #
26
+ # @raise [PublishConfirmationTimeout] if confirmation times out
27
+ # @raise [PublishNacked] if message is nacked by the broker
28
+ # @return [void]
29
+ def publish_with_confirmation(
30
+ channel:,
31
+ exchange:,
32
+ data:,
33
+ routing_key:,
34
+ options:
35
+ )
36
+ exchange.publish(data, { routing_key: routing_key }.merge(options))
37
+
38
+ timeout = config.publisher_confirms_timeout
39
+ unless wait_for_confirms_with_timeout(channel, timeout)
40
+ handle_confirmation_failure(channel, timeout)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :config, :logger
47
+
48
+ def wait_for_confirms_with_timeout(channel, timeout)
49
+ return channel.wait_for_confirms if timeout.nil?
50
+
51
+ waiter = Thread.new { channel.wait_for_confirms }
52
+
53
+ return waiter.value if waiter.join(timeout)
54
+
55
+ begin
56
+ channel.close if channel.open?
57
+ rescue StandardError => e
58
+ warn("Failed closing channel on timeout: #{e.message}")
59
+ end
60
+
61
+ cleanup_timeout = config.publisher_confirms_cleanup_timeout
62
+ waiter.join(cleanup_timeout) ||
63
+ warn('Confirm waiter did not stop promptly after close')
64
+
65
+ false
66
+ end
67
+
68
+ def handle_confirmation_failure(channel, timeout)
69
+ begin
70
+ channel.close if channel&.open?
71
+ rescue StandardError => e
72
+ warn("Failed closing channel on failed confirmation: #{e.message}")
73
+ end
74
+
75
+ PublisherChannelPool.reset_confirms_pool!
76
+
77
+ if channel.nacked_set&.any?
78
+ warn('Publisher confirmation failed: message was nacked by broker.')
79
+ raise PublishNacked, 'Message was nacked by broker'
80
+ else
81
+ warn("Publisher confirmation failed: timeout after #{timeout}s.")
82
+ raise PublishConfirmationTimeout,
83
+ "Confirmation timeout after #{timeout}s"
84
+ end
85
+ end
86
+
87
+ def warn(message)
88
+ logger.warn(message)
89
+ end
90
+ end
91
+ end
@@ -19,6 +19,10 @@ module Ears
19
19
  Timeout::Error,
20
20
  ].freeze
21
21
 
22
+ # Confirmation errors should NOT be retried - they indicate the message
23
+ # was published but confirmation failed, so retrying could duplicate the message
24
+ NON_RETRYABLE_ERRORS = [PublishConfirmationTimeout, PublishNacked].freeze
25
+
22
26
  def initialize(config, logger)
23
27
  @config = config
24
28
  @logger = logger
@@ -28,6 +32,8 @@ module Ears
28
32
  attempt = 1
29
33
  begin
30
34
  block.call
35
+ rescue *NON_RETRYABLE_ERRORS => e
36
+ raise e
31
37
  rescue *CONNECTION_ERRORS => e
32
38
  attempt = handle_connection_error(e, attempt) # rubocop:disable Lint/UselessAssignment
33
39
  retry
@@ -0,0 +1,89 @@
1
+ module Ears
2
+ module Testing
3
+ class MessageCapture
4
+ Message =
5
+ Struct.new(
6
+ :exchange_name,
7
+ :routing_key,
8
+ :data,
9
+ :options,
10
+ :timestamp,
11
+ :thread_id,
12
+ keyword_init: true,
13
+ )
14
+
15
+ def initialize
16
+ @messages = {}
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def add_message(exchange_name, data, routing_key, options = {})
21
+ @mutex.synchronize do
22
+ @messages[exchange_name] ||= []
23
+
24
+ message =
25
+ Message.new(
26
+ exchange_name: exchange_name,
27
+ routing_key: routing_key,
28
+ data: data,
29
+ options: options,
30
+ timestamp: Time.now,
31
+ thread_id: Thread.current.object_id.to_s,
32
+ )
33
+
34
+ @messages[exchange_name] << message
35
+
36
+ shift_messages(exchange_name)
37
+
38
+ message
39
+ end
40
+ end
41
+
42
+ def messages_for(exchange_name)
43
+ @mutex.synchronize { (@messages[exchange_name] || []).dup }
44
+ end
45
+
46
+ def all_messages
47
+ @mutex.synchronize { @messages.values.flatten }
48
+ end
49
+
50
+ def clear
51
+ @mutex.synchronize { @messages.clear }
52
+ end
53
+
54
+ def count(exchange_name = nil)
55
+ @mutex.synchronize do
56
+ return (@messages[exchange_name] || []).size if exchange_name
57
+
58
+ @messages.values.sum(&:size)
59
+ end
60
+ end
61
+
62
+ def empty?
63
+ @mutex.synchronize do
64
+ @messages.empty? || @messages.values.all?(&:empty?)
65
+ end
66
+ end
67
+
68
+ def find_messages(exchange_name: nil, routing_key: nil, data: nil)
69
+ messages = exchange_name ? messages_for(exchange_name) : all_messages
70
+
71
+ messages.select do |msg|
72
+ next false if routing_key && msg.routing_key != routing_key
73
+ next false if data && msg.data != data
74
+
75
+ true
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def shift_messages(exchange_name)
82
+ max_messages = Ears::Testing.configuration.max_captured_messages
83
+ if @messages[exchange_name].size > max_messages
84
+ @messages[exchange_name].shift
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,111 @@
1
+ require 'rspec/mocks'
2
+
3
+ module Ears
4
+ module Testing
5
+ class UnmockedExchangeError < StandardError
6
+ end
7
+
8
+ class PublisherMock
9
+ include RSpec::Mocks::ExampleMethods
10
+
11
+ def initialize(exchange_names, message_capture)
12
+ @exchange_names = Array(exchange_names)
13
+ @message_capture = message_capture
14
+ @mock_exchanges = {}
15
+ end
16
+
17
+ def setup_mocks
18
+ setup_connection
19
+ setup_channel_pool
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :exchange_names, :message_capture, :mock_exchanges
25
+
26
+ def setup_connection
27
+ mock_connection = instance_double(Bunny::Session, open?: true)
28
+ Ears.instance_variable_set(:@connection, mock_connection)
29
+ end
30
+
31
+ def setup_channel_pool
32
+ mock_channel = create_mock_channel
33
+ allow(Ears::PublisherChannelPool).to receive(:with_channel).and_yield(
34
+ mock_channel,
35
+ )
36
+ mock_channel
37
+ end
38
+
39
+ def create_mock_channel
40
+ instance_double(Bunny::Channel).tap do |channel|
41
+ setup_exchange_declare(channel)
42
+ setup_register_exchange(channel)
43
+ setup_basic_publish(channel)
44
+ setup_publisher_confirms(channel)
45
+ end
46
+ end
47
+
48
+ def setup_exchange_declare(channel)
49
+ allow(channel).to receive(:exchange_declare) do |name, type, options|
50
+ create_or_get_mock_exchange(name, type, options)
51
+ end
52
+ end
53
+
54
+ def setup_register_exchange(channel)
55
+ allow(channel).to receive(:register_exchange)
56
+ end
57
+
58
+ def setup_basic_publish(channel)
59
+ allow(channel).to receive(
60
+ :basic_publish,
61
+ ) do |data, exchange, routing_key, options|
62
+ if exchange_names.include?(exchange)
63
+ capture_message(exchange, data, routing_key, options)
64
+ elsif strict_mocking?
65
+ raise_unmocked_exchange_error(exchange)
66
+ end
67
+ end
68
+ end
69
+
70
+ def setup_publisher_confirms(channel)
71
+ allow(channel).to receive(:confirm_select)
72
+ allow(channel).to receive(:wait_for_confirms).and_return(true)
73
+ allow(channel).to receive(:nacked_set).and_return(Set.new)
74
+ allow(channel).to receive(:open?).and_return(true)
75
+ allow(channel).to receive(:close)
76
+ end
77
+
78
+ def create_or_get_mock_exchange(name, type, _options)
79
+ mock_exchanges[name] ||= create_mock_exchange(name, type)
80
+ end
81
+
82
+ def create_mock_exchange(name, type)
83
+ exchange = instance_double(Bunny::Exchange, name: name, type: type)
84
+
85
+ setup_exchange_publish(exchange, name)
86
+
87
+ exchange
88
+ end
89
+
90
+ def setup_exchange_publish(exchange, name)
91
+ allow(exchange).to receive(:publish) do |data, routing_options|
92
+ routing_key = routing_options[:routing_key]
93
+ capture_message(name, data, routing_key, routing_options)
94
+ end
95
+ end
96
+
97
+ def capture_message(exchange_name, data, routing_key, options)
98
+ message_capture.add_message(exchange_name, data, routing_key, options)
99
+ end
100
+
101
+ def strict_mocking?
102
+ Ears::Testing.configuration.strict_exchange_mocking
103
+ end
104
+
105
+ def raise_unmocked_exchange_error(exchange)
106
+ raise UnmockedExchangeError,
107
+ "Exchange '#{exchange}' has not been mocked. Add mock_ears('#{exchange}') to your test setup."
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,50 @@
1
+ require 'rspec/mocks'
2
+
3
+ module Ears
4
+ module Testing
5
+ module TestHelper
6
+ include RSpec::Mocks::ExampleMethods
7
+
8
+ def mock_ears(*exchange_names)
9
+ Ears::Testing.message_capture ||= MessageCapture.new
10
+
11
+ @original_connection = Ears.instance_variable_get(:@connection)
12
+
13
+ publisher_mock =
14
+ PublisherMock.new(exchange_names, Ears::Testing.message_capture)
15
+ publisher_mock.setup_mocks
16
+ end
17
+
18
+ def ears_reset!
19
+ Ears::Testing.message_capture = nil
20
+
21
+ if instance_variable_defined?(:@original_connection)
22
+ Ears.instance_variable_set(:@connection, @original_connection)
23
+ remove_instance_variable(:@original_connection)
24
+ end
25
+
26
+ if defined?(Ears::PublisherChannelPool)
27
+ Ears::PublisherChannelPool.reset!
28
+ end
29
+ end
30
+
31
+ def published_messages(exchange_name = nil)
32
+ return [] unless Ears::Testing.message_capture
33
+
34
+ if exchange_name
35
+ return Ears::Testing.message_capture.messages_for(exchange_name)
36
+ end
37
+
38
+ Ears::Testing.message_capture.all_messages
39
+ end
40
+
41
+ def last_published_message(exchange_name = nil)
42
+ published_messages(exchange_name).last
43
+ end
44
+
45
+ def clear_published_messages
46
+ Ears::Testing.message_capture&.clear
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ require 'ears'
2
+ require 'ears/testing/test_helper'
3
+ require 'ears/testing/message_capture'
4
+ require 'ears/testing/publisher_mock'
5
+
6
+ module Ears
7
+ module Testing
8
+ class << self
9
+ attr_accessor :message_capture
10
+
11
+ def configure
12
+ yield(configuration) if block_given?
13
+ end
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def reset!
20
+ @message_capture = nil
21
+ @configuration = nil
22
+ end
23
+ end
24
+
25
+ class Configuration
26
+ attr_accessor :max_captured_messages,
27
+ :auto_cleanup,
28
+ :strict_exchange_mocking
29
+
30
+ def initialize
31
+ @max_captured_messages = 1000
32
+ @auto_cleanup = true
33
+ @strict_exchange_mocking = true
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/ears/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Ears
2
- VERSION = '0.21.0'
2
+ VERSION = '0.22.0'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ears
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - InVision AG
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-08 00:00:00.000000000 Z
11
+ date: 2025-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -89,8 +89,13 @@ files:
89
89
  - lib/ears/middlewares/max_retries.rb
90
90
  - lib/ears/publisher.rb
91
91
  - lib/ears/publisher_channel_pool.rb
92
+ - lib/ears/publisher_confirmation_handler.rb
92
93
  - lib/ears/publisher_retry_handler.rb
93
94
  - lib/ears/setup.rb
95
+ - lib/ears/testing.rb
96
+ - lib/ears/testing/message_capture.rb
97
+ - lib/ears/testing/publisher_mock.rb
98
+ - lib/ears/testing/test_helper.rb
94
99
  - lib/ears/version.rb
95
100
  - package-lock.json
96
101
  - package.json