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 +4 -4
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +2 -2
- data/README.md +160 -0
- data/lib/ears/configuration.rb +17 -1
- data/lib/ears/errors.rb +12 -0
- data/lib/ears/publisher.rb +60 -5
- data/lib/ears/publisher_channel_pool.rb +65 -18
- data/lib/ears/publisher_confirmation_handler.rb +91 -0
- data/lib/ears/publisher_retry_handler.rb +6 -0
- data/lib/ears/testing/message_capture.rb +89 -0
- data/lib/ears/testing/publisher_mock.rb +111 -0
- data/lib/ears/testing/test_helper.rb +50 -0
- data/lib/ears/testing.rb +37 -0
- data/lib/ears/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d9d5a827ad0e368b8243e4e03b00695cd84cd57dbcbb09d4249e97aa84c41f08
|
4
|
+
data.tar.gz: ead5ad7a108d114400bb3819188db589e9d5841bd178efcc9333b4702e91de85
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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).
|
data/lib/ears/configuration.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/ears/publisher.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
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
|
-
|
22
|
-
|
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
|
-
|
33
|
+
std_pool&.shutdown(&:close)
|
34
|
+
cnf_pool&.shutdown(&:close)
|
26
35
|
nil
|
27
36
|
end
|
28
37
|
|
29
|
-
|
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
|
-
|
32
|
-
|
33
|
-
|
46
|
+
cnf_pool&.shutdown(&:close)
|
47
|
+
nil
|
48
|
+
end
|
34
49
|
|
35
|
-
|
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
|
-
|
57
|
+
# Double-check in case another thread was waiting
|
58
|
+
@standard_pool ||=
|
39
59
|
begin
|
40
|
-
@creator_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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
data/lib/ears/testing.rb
ADDED
@@ -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
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.
|
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-
|
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
|