ears 0.20.0 → 0.21.1

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: cfbd6ab51a763dae5a9e93db48acb059af700fa88eee4b3f854e912b9f6d19be
4
- data.tar.gz: aa328f03ba25e0e7afabdc30bfb9a2212b5072ed6a50ba10a1df9396197ada61
3
+ metadata.gz: 3ffa13eeabb39addd2d5acb749c6edd32515770b357f7ef6cd3f944cc6aae182
4
+ data.tar.gz: 4aa87462141bd49cbc616dfff68e7d5b5bf24274b7a6c0430ca02182e15259c5
5
5
  SHA512:
6
- metadata.gz: 140f6f39b0f3099eb0a00a26a0de7bbbff66335a6bad2cd71d3b8e26f1810301ff16de00675e580beb71de959b8a80d80a4a76309556e4f22019dd37effd717f
7
- data.tar.gz: 36053b2d7203bb37e2fd527d86c69922f3cc5a4e99c7723f0b73c7f92a24b9df439baf61aaaa72209857b48bab1a17ebfbd5775633ecefaa270bb911757f28c7
6
+ metadata.gz: 45a3dffcf2f868f753fc985657bc24909c1bba97f1e633eb012ed4185c231d50edc98093f529b336f1e743dabe374a5ff6e662116d72f74ad5c166c0d81146ff
7
+ data.tar.gz: 020e285268b69f96269a45e70abdb2287f48ff4346eac060a3dd6c4cebc8c27248f49c045a1c0a0139ea5c8bfc6aafb959634a6c62f945328c9fb1056d71e875
data/.tool-versions CHANGED
@@ -1,2 +1,2 @@
1
- ruby 3.2.8
2
- nodejs 24.1.0
1
+ ruby 3.2.9
2
+ nodejs 24.4.1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1 (2025-09-09)
4
+
5
+ - Add testing abstractions: `Ears::Testing::TestHelper`, `Ears::Testing::MessageCapture`, and `Ears::Testing::PublisherMock`
6
+
7
+ ## 0.21.0 (2025-09-08)
8
+
9
+ - Introduce Ears::Publisher with thread-safe channel pooling
10
+ - Introduce configurable logger
11
+
3
12
  ## 0.20.0 (2025-06-02)
4
13
 
5
14
  - Drop support for Ruby 3.1
data/Gemfile.lock CHANGED
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ears (0.20.0)
4
+ ears (0.21.1)
5
5
  bunny (>= 2.22.0)
6
+ connection_pool (~> 2.4)
6
7
  multi_json
7
8
 
8
9
  GEM
@@ -13,14 +14,15 @@ GEM
13
14
  bunny (2.24.0)
14
15
  amq-protocol (~> 2.3)
15
16
  sorted_set (~> 1, >= 1.0.2)
17
+ connection_pool (2.5.3)
16
18
  diff-lcs (1.6.2)
17
19
  docile (1.4.1)
18
- json (2.12.2)
20
+ json (2.13.2)
19
21
  language_server-protocol (3.17.0.5)
20
22
  lint_roller (1.1.0)
21
- multi_json (1.15.0)
23
+ multi_json (1.17.0)
22
24
  parallel (1.27.0)
23
- parser (3.3.8.0)
25
+ parser (3.3.9.0)
24
26
  ast (~> 2.4.1)
25
27
  racc
26
28
  prettier_print (1.2.1)
@@ -34,7 +36,7 @@ GEM
34
36
  rspec-core (~> 3.13.0)
35
37
  rspec-expectations (~> 3.13.0)
36
38
  rspec-mocks (~> 3.13.0)
37
- rspec-core (3.13.4)
39
+ rspec-core (3.13.5)
38
40
  rspec-support (~> 3.13.0)
39
41
  rspec-expectations (3.13.5)
40
42
  diff-lcs (>= 1.2.0, < 2.0)
@@ -43,7 +45,7 @@ GEM
43
45
  diff-lcs (>= 1.2.0, < 2.0)
44
46
  rspec-support (~> 3.13.0)
45
47
  rspec-support (3.13.4)
46
- rubocop (1.75.8)
48
+ rubocop (1.79.0)
47
49
  json (~> 2.3)
48
50
  language_server-protocol (~> 3.17.0.2)
49
51
  lint_roller (~> 1.1.0)
@@ -51,10 +53,11 @@ GEM
51
53
  parser (>= 3.3.0.2)
52
54
  rainbow (>= 2.2.2, < 4.0)
53
55
  regexp_parser (>= 2.9.3, < 3.0)
54
- rubocop-ast (>= 1.44.0, < 2.0)
56
+ rubocop-ast (>= 1.46.0, < 2.0)
55
57
  ruby-progressbar (~> 1.7)
58
+ tsort (>= 0.2.0)
56
59
  unicode-display_width (>= 2.4.0, < 4.0)
57
- rubocop-ast (1.44.1)
60
+ rubocop-ast (1.46.0)
58
61
  parser (>= 3.3.7.2)
59
62
  prism (~> 1.4)
60
63
  rubocop-rake (0.7.1)
@@ -69,13 +72,14 @@ GEM
69
72
  docile (~> 1.1)
70
73
  simplecov-html (~> 0.11)
71
74
  simplecov_json_formatter (~> 0.1)
72
- simplecov-html (0.13.1)
75
+ simplecov-html (0.13.2)
73
76
  simplecov_json_formatter (0.1.4)
74
77
  sorted_set (1.0.3)
75
78
  rbtree
76
79
  set (~> 1.0)
77
- syntax_tree (6.2.0)
80
+ syntax_tree (6.3.0)
78
81
  prettier_print (>= 1.2.0)
82
+ tsort (0.2.0)
79
83
  unicode-display_width (3.1.4)
80
84
  unicode-emoji (~> 4.0, >= 4.0.4)
81
85
  unicode-emoji (4.0.4)
@@ -106,15 +110,16 @@ CHECKSUMS
106
110
  amq-protocol (2.3.4) sha256=98be5b9244e28dc66acc8351a254dbf45d996c5a0b7d49ab3ff8b72b0d2e6308
107
111
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
108
112
  bunny (2.24.0) sha256=072fe4ae98eaa9c95a17e4d166204f710bba8a9a7070b73a8c3b023f439d1682
113
+ connection_pool (2.5.3) sha256=cfd74a82b9b094d1ce30c4f1a346da23ee19dc8a062a16a85f58eab1ced4305b
109
114
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
110
115
  docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
111
- ears (0.20.0)
112
- json (2.12.2) sha256=ba94a48ad265605c8fa9a50a5892f3ba6a02661aa010f638211f3cb36f44abf4
116
+ ears (0.21.1)
117
+ json (2.13.2) sha256=02e1f118d434c6b230a64ffa5c8dee07e3ec96244335c392eaed39e1199dbb68
113
118
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
114
119
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
115
- multi_json (1.15.0) sha256=1fd04138b6e4a90017e8d1b804c039031399866ff3fbabb7822aea367c78615d
120
+ multi_json (1.17.0) sha256=76581f6c96aebf2e85f8a8b9854829e0988f335e8671cd1a56a1036eb75e4a1b
116
121
  parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
117
- parser (3.3.8.0) sha256=2476364142b307fa5a1b1ece44f260728be23858a9c71078e956131a75453c45
122
+ parser (3.3.9.0) sha256=94d6929354b1a6e3e1f89d79d4d302cc8f5aa814431a6c9c7e0623335d7687f2
118
123
  prettier_print (1.2.1) sha256=a72838b5f23facff21f90a5423cdcdda19e4271092b41f4ea7f50b83929e6ff9
119
124
  prism (1.4.0) sha256=dc0e3e00e93160213dc2a65519d9002a4a1e7b962db57d444cf1a71565bb703e
120
125
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
@@ -123,24 +128,25 @@ CHECKSUMS
123
128
  rbtree (0.4.6) sha256=14eea4469b24fd2472542e5f3eb105d6344c8ccf36f0b56d55fdcfeb4e0f10fc
124
129
  regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61
125
130
  rspec (3.13.1) sha256=b9f9a58fa915b8d94a1d6b3195fe6dd28c4c34836a6097015142c4a9ace72140
126
- rspec-core (3.13.4) sha256=f9da156b7b775c82610a7b580624df51a55102f8c8e4a103b98f5d7a9fa23958
131
+ rspec-core (3.13.5) sha256=ab3f682897c6131c67f9a17cfee5022a597f283aebe654d329a565f9937a4fa3
127
132
  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
128
133
  rspec-mocks (3.13.5) sha256=e4338a6f285ada9fe56f5893f5457783af8194f5d08884d17a87321d5195ea81
129
134
  rspec-support (3.13.4) sha256=184b1814f6a968102b57df631892c7f1990a91c9a3b9e80ef892a0fc2a71a3f7
130
- rubocop (1.75.8) sha256=c80ab4286c5dcfc49d7ad1787cdba5569b63b58c96ee7afde4ec47a9c8a85be9
131
- rubocop-ast (1.44.1) sha256=e3cc04203b2ef04f6d6cf5f85fe6d643f442b18cc3b23e3ada0ce5b6521b8e92
135
+ rubocop (1.79.0) sha256=c709e83b16f9fced295d83d190a3a5bbcc46c419d8f9b85f259b99ba6faf5bbe
136
+ rubocop-ast (1.46.0) sha256=0da7f6ad5b98614f89b74f11873c191059c823eae07d6ffd40a42a3338f2232b
132
137
  rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d
133
138
  rubocop-rspec (3.6.0) sha256=c0e4205871776727e54dee9cc91af5fd74578001551ba40e1fe1a1ab4b404479
134
139
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
135
140
  set (1.1.2) sha256=ca33a60d202e788041d94a5d4c12315b1639875576f1a266f3a10913646d8ef1
136
141
  simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
137
- simplecov-html (0.13.1) sha256=5dab0b7ee612e60e9887ad57693832fdf4695b4c0c859eaea5f95c18791ef10b
142
+ simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
138
143
  simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
139
144
  sorted_set (1.0.3) sha256=4f2b8bee6e8c59cbd296228c0f1f81679357177a8b6859dcc2a99e86cce6372f
140
- syntax_tree (6.2.0) sha256=a50a01c246601af3c258edbb6b12e44373d17966ab3bebd1f7224b3b994a343d
145
+ syntax_tree (6.3.0) sha256=56e25a9692c798ec94c5442fe94c5e94af76bef91edc8bb02052cbdecf35f13d
146
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
141
147
  unicode-display_width (3.1.4) sha256=8caf2af1c0f2f07ec89ef9e18c7d88c2790e217c482bfc78aaa65eadd5415ac1
142
148
  unicode-emoji (4.0.4) sha256=2c2c4ef7f353e5809497126285a50b23056cc6e61b64433764a35eff6c36532a
143
149
  yard (0.9.37) sha256=a6e910399e78e613f80ba9add9ba7c394b1a935f083cccbef82903a3d2a26992
144
150
 
145
151
  BUNDLED WITH
146
- 2.6.9
152
+ 2.7.1
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ears
2
2
 
3
- `Ears` is a small, simple library for writing RabbitMQ consumers.
3
+ `Ears` is a small, simple library for writing RabbitMQ consumers and publishers.
4
4
 
5
5
  [![CodeQL](https://github.com/ivx/ears/actions/workflows/codeql.yml/badge.svg)](https://github.com/ivx/ears/actions/workflows/codeql.yml)
6
6
 
@@ -22,7 +22,182 @@ Or install it yourself as:
22
22
 
23
23
  ## Usage
24
24
 
25
- ### Basic usage
25
+ ### Publishing Messages
26
+
27
+ `Ears` provides a thread-safe publisher for sending messages to RabbitMQ exchanges with automatic retry and connection recovery capabilities.
28
+
29
+ #### Basic Publisher Usage
30
+
31
+ To publish messages, create an `Ears::Publisher` instance and call `publish`:
32
+
33
+ ```ruby
34
+ require 'ears'
35
+
36
+ # Configure Ears (same configuration is shared by consumers and publishers)
37
+ Ears.configure do |config|
38
+ config.rabbitmq_url = 'amqp://user:password@myrmq:5672'
39
+ config.connection_name = 'My Publisher'
40
+ end
41
+
42
+ # Create a publisher for a topic exchange
43
+ publisher = Ears::Publisher.new('my_exchange', :topic, durable: true)
44
+
45
+ # Publish a message
46
+ data = { user_id: 123, action: 'login', timestamp: Time.now.iso8601 }
47
+ publisher.publish(data, routing_key: 'user.login')
48
+ ```
49
+
50
+ #### Exchange Types and Options
51
+
52
+ Publishers support all RabbitMQ exchange types:
53
+
54
+ ```ruby
55
+ # Topic exchange (default)
56
+ topic_publisher = Ears::Publisher.new('events', :topic)
57
+
58
+ # Direct exchange
59
+ direct_publisher = Ears::Publisher.new('commands', :direct)
60
+
61
+ # Fanout exchange
62
+ fanout_publisher = Ears::Publisher.new('broadcasts', :fanout)
63
+
64
+ # Headers exchange
65
+ headers_publisher = Ears::Publisher.new('complex_routing', :headers)
66
+
67
+ # Custom exchange options
68
+ publisher =
69
+ Ears::Publisher.new(
70
+ 'my_exchange',
71
+ :topic,
72
+ durable: true,
73
+ auto_delete: false,
74
+ arguments: {
75
+ 'x-message-ttl' => 60_000,
76
+ },
77
+ )
78
+ ```
79
+
80
+ #### Message Options
81
+
82
+ The `publish` method accepts various message options:
83
+
84
+ ```ruby
85
+ publisher.publish(
86
+ { message: 'Hello World' },
87
+ routing_key: 'greeting.hello',
88
+ persistent: true, # Persist message to disk (default: true)
89
+ headers: {
90
+ version: '1.0',
91
+ }, # Custom headers
92
+ timestamp: Time.now.to_i, # Message timestamp (default: current time)
93
+ message_id: SecureRandom.uuid, # Unique message identifier
94
+ correlation_id: 'abc-123', # Correlation ID for request/response patterns
95
+ reply_to: 'response_queue', # Queue for responses
96
+ expiration: '60000', # Message TTL in milliseconds
97
+ priority: 5, # Message priority (0-9)
98
+ type: 'user_event', # Message type
99
+ app_id: 'my_application', # Application identifier
100
+ user_id: 'system', # User identifier (verified by RabbitMQ)
101
+ )
102
+ ```
103
+
104
+ #### Thread-Safe Publishing
105
+
106
+ Publishers use a connection pool for thread-safe operation, making them suitable for concurrent use:
107
+
108
+ ```ruby
109
+ # Single publisher can be safely used across multiple threads
110
+ publisher = Ears::Publisher.new('events', :topic)
111
+
112
+ # Example with multiple threads
113
+ threads =
114
+ 10.times.map do |i|
115
+ Thread.new do
116
+ 100.times do |j|
117
+ publisher.publish({ thread: i, message: j }, routing_key: "thread.#{i}")
118
+ end
119
+ end
120
+ end
121
+
122
+ threads.each(&:join)
123
+ ```
124
+
125
+ #### Publisher Configuration
126
+
127
+ Publisher behavior can be fine-tuned through configuration options:
128
+
129
+ ```ruby
130
+ Ears.configure do |config|
131
+ # Connection settings
132
+ config.rabbitmq_url = 'amqp://user:password@myrmq:5672'
133
+ config.connection_name = 'My Application'
134
+
135
+ # Publisher-specific settings
136
+ config.publisher_pool_size = 32 # Channel pool size (default: 32)
137
+ config.publisher_pool_timeout = 2 # Pool checkout timeout in seconds (default: 2)
138
+
139
+ # Connection retry settings
140
+ config.publisher_connection_attempts = 30 # Connection retry attempts (default: 30)
141
+ config.publisher_connection_base_delay = 1 # Base delay between connection attempts (default: 1s)
142
+ config.publisher_connection_backoff_factor = 1.5 # Connection backoff multiplier (default: 1.5)
143
+
144
+ # Publish retry settings
145
+ config.publisher_max_retries = 3 # Max publish retry attempts (default: 3)
146
+ config.publisher_retry_base_delay = 0.1 # Base delay between publish retries (default: 0.1s)
147
+ config.publisher_retry_backoff_factor = 2 # Publish retry backoff multiplier (default: 2)
148
+ end
149
+ ```
150
+
151
+ #### Fault Tolerance and Recovery
152
+
153
+ Publishers automatically handle connection failures and provide several recovery mechanisms:
154
+
155
+ ##### Automatic Retry with Exponential Backoff
156
+
157
+ ```ruby
158
+ # Publishers automatically retry failed operations
159
+ publisher = Ears::Publisher.new('events', :topic)
160
+
161
+ # This will automatically retry with exponential backoff if the connection fails
162
+ publisher.publish({ event: 'user_signup' }, routing_key: 'user.signup')
163
+ ```
164
+
165
+ ##### Manual Recovery
166
+
167
+ If you need to manually reset the connection pool (e.g., after detecting connection issues):
168
+
169
+ ```ruby
170
+ publisher = Ears::Publisher.new('events', :topic)
171
+
172
+ # Reset the channel pool to force new connections
173
+ publisher.reset!
174
+
175
+ # Subsequent publishes will use fresh channels
176
+ publisher.publish({ event: 'recovery_test' }, routing_key: 'system.recovery')
177
+ ```
178
+
179
+ ##### Error Handling
180
+
181
+ Publishers raise specific exceptions that you can handle:
182
+
183
+ ```ruby
184
+ require 'ears'
185
+
186
+ publisher = Ears::Publisher.new('events', :topic)
187
+
188
+ begin
189
+ publisher.publish({ data: 'test' }, routing_key: 'test.message')
190
+ rescue Ears::PublisherRetryHandler::PublishError => e
191
+ # Handle publish failures (after all retries exhausted)
192
+ logger.error "Failed to publish message: #{e.message}"
193
+ # Consider queuing message for later retry or alerting
194
+ rescue => e
195
+ # Handle other unexpected errors
196
+ logger.error "Unexpected error: #{e.message}"
197
+ end
198
+ ```
199
+
200
+ ### Basic consumer usage
26
201
 
27
202
  First, you should configure `Ears`.
28
203
 
@@ -34,6 +209,16 @@ Ears.configure do |config|
34
209
  config.connection_name = 'My Consumer'
35
210
  config.recover_from_connection_close = false # optional configuration, defaults to true if not set
36
211
  config.recovery_attempts = 3 # optional configuration, defaults to 10, Bunny::Session would have been nil
212
+
213
+ # Publisher configuration (optional)
214
+ config.publisher_pool_size = 32 # Thread pool size for publishers (default: 32)
215
+ config.publisher_pool_timeout = 2 # Timeout for pool checkout in seconds (default: 2)
216
+ config.publisher_connection_attempts = 30 # Connection retry attempts (default: 30)
217
+ config.publisher_connection_base_delay = 1 # Base delay between connection attempts in seconds (default: 1)
218
+ config.publisher_connection_backoff_factor = 1.5 # Connection retry backoff multiplier (default: 1.5)
219
+ config.publisher_max_retries = 3 # Max publish retry attempts (default: 3)
220
+ config.publisher_retry_base_delay = 0.1 # Base delay between publish retries in seconds (default: 0.1)
221
+ config.publisher_retry_backoff_factor = 2 # Publish retry backoff multiplier (default: 2)
37
222
  end
38
223
  ```
39
224
 
@@ -285,6 +470,193 @@ Ears.stop!
285
470
 
286
471
  It will close and reset the current Bunny connection, leading to all consumers being shut down. Also, it will reset the channel.
287
472
 
473
+ ### Complete Example: Consumer and Publisher
474
+
475
+ Here's a complete example showing both consumer and publisher usage:
476
+
477
+ ```ruby
478
+ require 'ears'
479
+
480
+ # Shared configuration
481
+ Ears.configure do |config|
482
+ config.rabbitmq_url = 'amqp://guest:guest@localhost:5672'
483
+ config.connection_name = 'Order Processing Service'
484
+ config.publisher_pool_size = 16
485
+ end
486
+
487
+ # Consumer that processes orders and publishes events
488
+ class OrderProcessor < Ears::Consumer
489
+ configure(
490
+ queue: 'orders',
491
+ exchange: 'ecommerce',
492
+ routing_keys: %w[order.created order.updated],
493
+ retry_queue: true,
494
+ error_queue: true,
495
+ )
496
+
497
+ def initialize
498
+ super
499
+ @event_publisher = Ears::Publisher.new('events', :topic, durable: true)
500
+ end
501
+
502
+ def work(delivery_info, metadata, payload)
503
+ order = JSON.parse(payload)
504
+
505
+ # Process the order
506
+ process_order(order)
507
+
508
+ # Publish success event
509
+ @event_publisher.publish(
510
+ {
511
+ order_id: order['id'],
512
+ status: 'processed',
513
+ processed_at: Time.now.iso8601,
514
+ },
515
+ routing_key: 'order.processed',
516
+ )
517
+
518
+ ack
519
+ rescue => error
520
+ # Publish error event
521
+ @event_publisher.publish(
522
+ {
523
+ order_id: order&.dig('id'),
524
+ error: error.message,
525
+ failed_at: Time.now.iso8601,
526
+ },
527
+ routing_key: 'order.failed',
528
+ )
529
+
530
+ reject # Send to error queue
531
+ end
532
+
533
+ private
534
+
535
+ def process_order(order)
536
+ # Order processing logic here
537
+ sleep(0.1) # Simulate processing time
538
+ end
539
+ end
540
+
541
+ # Setup and run
542
+ Ears.setup { Ears.setup_consumers(OrderProcessor) }
543
+
544
+ begin
545
+ Ears.run!
546
+ ensure
547
+ # Cleanup code here
548
+ end
549
+ ```
550
+
551
+ ## Testing
552
+
553
+ Ears provides testing helpers to easily test your message publishing without connecting to RabbitMQ.
554
+
555
+ ### Basic Setup
556
+
557
+ Include the test helper in your RSpec tests and mock the exchanges you want to test:
558
+
559
+ ```ruby
560
+ require 'ears/testing'
561
+
562
+ RSpec.describe MyService do
563
+ include Ears::Testing::TestHelper
564
+
565
+ before do
566
+ # Mock exchanges that your code will publish to
567
+ mock_ears('events', 'notifications')
568
+ end
569
+
570
+ after do
571
+ # Clean up mocks and captured messages
572
+ ears_reset!
573
+ end
574
+ end
575
+ ```
576
+
577
+ ### Capturing and Inspecting Messages
578
+
579
+ Use the helper methods to inspect published messages:
580
+
581
+ ```ruby
582
+ it 'publishes user creation event' do
583
+ service = UserService.new
584
+ service.create_user(name: 'John', email: 'john@example.com')
585
+
586
+ # Get all messages published to 'events' exchange
587
+ messages = published_messages('events')
588
+ expect(messages.size).to eq(1)
589
+
590
+ # Inspect the message
591
+ message = messages.first
592
+ expect(message.routing_key).to eq('user.created')
593
+ expect(message.data).to include(name: 'John')
594
+ expect(message.options[:headers]).to include(version: '1.0')
595
+ end
596
+ ```
597
+
598
+ ### Available Helper Methods
599
+
600
+ - `published_messages(exchange_name = nil)` - Get messages for a specific exchange or all messages
601
+ - `last_published_message(exchange_name = nil)` - Get the most recent message
602
+ - `clear_published_messages` - Clear captured messages during a test
603
+
604
+ ### Message Properties
605
+
606
+ Each captured message has the following properties:
607
+
608
+ - `exchange_name` - Name of the exchange
609
+ - `routing_key` - Message routing key
610
+ - `data` - The message payload
611
+ - `options` - Publishing options (headers, persistent, etc.)
612
+ - `timestamp` - When the message was captured
613
+ - `thread_id` - Thread that published the message
614
+
615
+ ### Error Handling
616
+
617
+ By default, publishing to unmocked exchanges raises an error:
618
+
619
+ ```ruby
620
+ it 'raises error for unmocked exchanges' do
621
+ publisher = Ears::Publisher.new('unmocked_exchange')
622
+
623
+ expect {
624
+ publisher.publish({ data: 'test' }, routing_key: 'test')
625
+ }.to raise_error(Ears::Testing::UnmockedExchangeError)
626
+ end
627
+ ```
628
+
629
+ ### Complete Example
630
+
631
+ ```ruby
632
+ require 'ears/testing'
633
+
634
+ RSpec.describe OrderProcessor do
635
+ include Ears::Testing::TestHelper
636
+
637
+ before { mock_ears('events', 'notifications') }
638
+ after { ears_reset! }
639
+
640
+ it 'publishes events when processing order' do
641
+ processor = OrderProcessor.new
642
+ order = { id: 123, items: ['item1'], total: 99.99 }
643
+
644
+ processor.process(order)
645
+
646
+ # Check event was published
647
+ events = published_messages('events')
648
+ expect(events.size).to eq(1)
649
+ expect(events.first.routing_key).to eq('order.processed')
650
+ expect(events.first.data[:order_id]).to eq(123)
651
+
652
+ # Check notification was sent
653
+ notifications = published_messages('notifications')
654
+ expect(notifications.size).to eq(1)
655
+ expect(notifications.first.routing_key).to eq('email.order_confirmation')
656
+ end
657
+ end
658
+ ```
659
+
288
660
  ## Documentation
289
661
 
290
662
  If you need more in-depth information, look at [our API documentation](https://www.rubydoc.info/gems/ears).
data/ears.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = 'A gem for building RabbitMQ consumers.'
11
11
  spec.homepage = 'https://github.com/ivx/ears'
12
12
  spec.license = 'MIT'
13
- spec.required_ruby_version = Gem::Requirement.new('>= 3.2.8')
13
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.2.9')
14
14
 
15
15
  spec.metadata['allowed_push_host'] = 'https://rubygems.org'
16
16
 
@@ -33,5 +33,6 @@ Gem::Specification.new do |spec|
33
33
  spec.require_paths = ['lib']
34
34
 
35
35
  spec.add_dependency 'bunny', '>= 2.22.0'
36
+ spec.add_dependency 'connection_pool', '~> 2.4'
36
37
  spec.add_dependency 'multi_json'
37
38
  end
@@ -1,4 +1,5 @@
1
1
  require 'ears/errors'
2
+ require 'logger'
2
3
 
3
4
  module Ears
4
5
  # The class representing the global {Ears} configuration.
@@ -8,6 +9,14 @@ module Ears
8
9
 
9
10
  DEFAULT_RABBITMQ_URL = 'amqp://guest:guest@localhost:5672'
10
11
  DEFAULT_RECOVERY_ATTEMPTS = 10
12
+ DEFAULT_PUBLISHER_POOL_SIZE = 32
13
+ DEFAULT_PUBLISHER_POOL_TIMEOUT = 2
14
+ DEFAULT_PUBLISHER_CONNECTION_ATTEMPTS = 30
15
+ DEFAULT_PUBLISHER_CONNECTION_BASE_DELAY = 1
16
+ DEFAULT_PUBLISHER_CONNECTION_BACKOFF_FACTOR = 1.5
17
+ DEFAULT_PUBLISHER_MAX_RETRIES = 3
18
+ DEFAULT_PUBLISHER_RETRY_BASE_DELAY = 0.1
19
+ DEFAULT_PUBLISHER_RETRY_BACKOFF_FACTOR = 2
11
20
 
12
21
  # @return [String] the connection string for RabbitMQ.
13
22
  attr_accessor :rabbitmq_url
@@ -21,9 +30,46 @@ module Ears
21
30
  # @return [Integer] max number of recovery attempts, nil means forever
22
31
  attr_accessor :recovery_attempts
23
32
 
33
+ # @return [Integer] the size of the publisher channel pool
34
+ attr_accessor :publisher_pool_size
35
+
36
+ # @return [Integer] the timeout in seconds for acquiring a channel from the publisher pool
37
+ attr_accessor :publisher_pool_timeout
38
+
39
+ # @return [Integer] the number of connection attempts for the publisher
40
+ attr_accessor :publisher_connection_attempts
41
+
42
+ # @return [Float] the base delay in seconds between connection attempts
43
+ attr_accessor :publisher_connection_base_delay
44
+
45
+ # @return [Float] the backoff factor for exponential connection delays
46
+ attr_accessor :publisher_connection_backoff_factor
47
+
48
+ # @return [Integer] the maximum number of retries for failed publish attempts
49
+ attr_accessor :publisher_max_retries
50
+
51
+ # @return [Float] the base delay in seconds between retry attempts
52
+ attr_accessor :publisher_retry_base_delay
53
+
54
+ # @return [Float] the backoff factor for exponential retry delays
55
+ attr_accessor :publisher_retry_backoff_factor
56
+
57
+ # @return [Logger] the logger instance for Ears operations
58
+ attr_accessor :logger
59
+
24
60
  def initialize
25
61
  @rabbitmq_url = DEFAULT_RABBITMQ_URL
26
62
  @recovery_attempts = DEFAULT_RECOVERY_ATTEMPTS
63
+ @publisher_pool_size = DEFAULT_PUBLISHER_POOL_SIZE
64
+ @publisher_pool_timeout = DEFAULT_PUBLISHER_POOL_TIMEOUT
65
+ @publisher_connection_attempts = DEFAULT_PUBLISHER_CONNECTION_ATTEMPTS
66
+ @publisher_connection_base_delay = DEFAULT_PUBLISHER_CONNECTION_BASE_DELAY
67
+ @publisher_connection_backoff_factor =
68
+ DEFAULT_PUBLISHER_CONNECTION_BACKOFF_FACTOR
69
+ @publisher_max_retries = DEFAULT_PUBLISHER_MAX_RETRIES
70
+ @publisher_retry_base_delay = DEFAULT_PUBLISHER_RETRY_BASE_DELAY
71
+ @publisher_retry_backoff_factor = DEFAULT_PUBLISHER_RETRY_BACKOFF_FACTOR
72
+ @logger = Logger.new(IO::NULL)
27
73
  end
28
74
 
29
75
  # @return [Proc] that is passed to Bunny’s recovery_attempts_exhausted block. Nil if recovery_attempts is nil.
@@ -0,0 +1,108 @@
1
+ require 'bunny'
2
+ require 'ears/publisher_channel_pool'
3
+ require 'ears/publisher_retry_handler'
4
+
5
+ module Ears
6
+ # Publisher for sending messages to RabbitMQ exchanges.
7
+ #
8
+ # Uses a connection pool for thread-safe publishing with configurable pool size.
9
+ # This provides better performance and thread safety compared to using per-thread channels.
10
+ class Publisher
11
+ # Creates a new publisher for the specified exchange.
12
+ #
13
+ # @param [String] exchange_name The name of the exchange to publish to.
14
+ # @param [Symbol] exchange_type The type of the exchange (:direct, :fanout, :topic or :headers).
15
+ # @param [Hash] exchange_options The options for the exchange. These are passed on to +Bunny::Exchange.new+.
16
+ def initialize(exchange_name, exchange_type = :topic, exchange_options = {})
17
+ @exchange_name = exchange_name
18
+ @exchange_type = exchange_type
19
+ @exchange_options = { durable: true }.merge(exchange_options)
20
+ @config = Ears.configuration
21
+ @logger = Ears.configuration.logger
22
+ end
23
+
24
+ # Publishes a JSON message to the configured exchange.
25
+ #
26
+ # @param [Hash, Array, Object] data The data to serialize as JSON and publish.
27
+ # @param [String] routing_key The routing key for the message.
28
+ #
29
+ # @option opts [String] :routing_key Routing key
30
+ # @option opts [Boolean] :persistent Should the message be persisted to disk?
31
+ # @option opts [Boolean] :mandatory Should the message be returned if it cannot be routed to any queue?
32
+ # @option opts [Integer] :timestamp A timestamp associated with this message
33
+ # @option opts [Integer] :expiration Expiration time after which the message will be deleted
34
+ # @option opts [String] :type Message type, e.g. what type of event or command this message represents. Can be any string
35
+ # @option opts [String] :reply_to Queue name other apps should send the response to
36
+ # @option opts [String] :content_type Message content type (e.g. application/json)
37
+ # @option opts [String] :content_encoding Message content encoding (e.g. gzip)
38
+ # @option opts [String] :correlation_id Message correlated to this one, e.g. what request this message is a reply for
39
+ # @option opts [Integer] :priority Message priority, 0 to 9. Not used by RabbitMQ, only applications
40
+ # @option opts [String] :message_id Any message identifier
41
+ # @option opts [String] :user_id Optional user ID. Verified by RabbitMQ against the actual connection username
42
+ # @option opts [String] :app_id Optional application ID
43
+ #
44
+ # @raise [PublishError] if publishing fails
45
+ # @return [void]
46
+ def publish(data, routing_key:, **options)
47
+ publish_options = default_publish_options.merge(options)
48
+
49
+ retry_handler.run do
50
+ publish_with_channel(data:, routing_key:, publish_options:)
51
+ end
52
+ end
53
+
54
+ # Resets the channel pool, forcing new channels to be created.
55
+ # This can be useful for connection recovery scenarios.
56
+ #
57
+ # @return [void]
58
+ def reset!
59
+ PublisherChannelPool.reset!
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :exchange_name,
65
+ :exchange_type,
66
+ :exchange_options,
67
+ :config,
68
+ :logger
69
+
70
+ 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
75
+
76
+ PublisherChannelPool.with_channel do |channel|
77
+ exchange = create_exchange(channel)
78
+ exchange.publish(
79
+ data,
80
+ { routing_key: routing_key }.merge(publish_options),
81
+ )
82
+ end
83
+ end
84
+
85
+ def create_exchange(channel)
86
+ Bunny::Exchange.new(
87
+ channel,
88
+ exchange_type,
89
+ exchange_name,
90
+ exchange_options,
91
+ )
92
+ end
93
+
94
+ def default_publish_options
95
+ {
96
+ persistent: true,
97
+ timestamp: Time.now.to_i,
98
+ headers: {
99
+ },
100
+ content_type: 'application/json',
101
+ }
102
+ end
103
+
104
+ def retry_handler
105
+ @retry_handler ||= PublisherRetryHandler.new(config, logger)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,55 @@
1
+ require 'connection_pool'
2
+
3
+ module Ears
4
+ # Channel pool management for publishers.
5
+ # Provides thread-safe channel pooling separate from consumer channels.
6
+ class PublisherChannelPool
7
+ class << self
8
+ # Executes the given block with a channel from the pool.
9
+ #
10
+ # @yieldparam [Bunny::Channel] channel The channel to use for publishing
11
+ # @return [Object] The result of the block
12
+ def with_channel(&)
13
+ channel_pool.with(&)
14
+ end
15
+
16
+ # Resets the channel pool, forcing new channels to be created.
17
+ # This is useful for connection recovery scenarios.
18
+ #
19
+ # @return [void]
20
+ def reset!
21
+ pool = @channel_pool
22
+ @channel_pool = nil
23
+ @creator_pid = nil
24
+
25
+ pool&.shutdown(&:close)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def channel_pool
32
+ # Recreate lazily after a fork
33
+ reset! if @creator_pid && @creator_pid != Process.pid
34
+
35
+ return @channel_pool if @channel_pool
36
+
37
+ init_mutex.synchronize do
38
+ @channel_pool ||=
39
+ begin
40
+ @creator_pid = Process.pid
41
+
42
+ ConnectionPool.new(
43
+ size: Ears.configuration.publisher_pool_size,
44
+ timeout: Ears.configuration.publisher_pool_timeout,
45
+ ) { Ears.connection.create_channel }
46
+ end
47
+ end
48
+ end
49
+
50
+ def init_mutex
51
+ @init_mutex ||= Mutex.new
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,104 @@
1
+ require 'bunny'
2
+
3
+ module Ears
4
+ # A handler for retries and connection recovery when publishing messages.
5
+ class PublisherRetryHandler
6
+ # Exception for publishing to a stale/closed channel
7
+ class PublishToStaleChannelError < StandardError
8
+ end
9
+
10
+ # Connection errors that should trigger retries
11
+ CONNECTION_ERRORS = [
12
+ PublishToStaleChannelError,
13
+ Bunny::ChannelAlreadyClosed,
14
+ Bunny::ConnectionClosedError,
15
+ Bunny::ConnectionForced,
16
+ Bunny::NetworkFailure,
17
+ Bunny::TCPConnectionFailed,
18
+ IOError,
19
+ Timeout::Error,
20
+ ].freeze
21
+
22
+ def initialize(config, logger)
23
+ @config = config
24
+ @logger = logger
25
+ end
26
+
27
+ def run(&block)
28
+ attempt = 1
29
+ begin
30
+ block.call
31
+ rescue *CONNECTION_ERRORS => e
32
+ attempt = handle_connection_error(e, attempt) # rubocop:disable Lint/UselessAssignment
33
+ retry
34
+ rescue StandardError => e
35
+ attempt = handle_standard_error(e, attempt)
36
+ retry
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :config, :logger
43
+
44
+ def handle_connection_error(error, attempt)
45
+ raise(error) unless retry?(error, attempt)
46
+
47
+ wait_for_connection(error)
48
+ logger.info('Resetting channel pool after connection recovery')
49
+ PublisherChannelPool.reset!
50
+
51
+ attempt + 1
52
+ end
53
+
54
+ def handle_standard_error(error, attempt)
55
+ raise(error) unless retry?(error, attempt)
56
+
57
+ sleep(retry_backoff_delay(attempt)) if attempt > 1
58
+ attempt + 1
59
+ end
60
+
61
+ def retry?(error, attempt)
62
+ logger.info(
63
+ "Trying to recover from publish error. Attempt #{attempt}: #{error.class}: #{error.message}",
64
+ )
65
+
66
+ if attempt > config.publisher_max_retries
67
+ logger.warn(
68
+ "Connection attempts exhausted, giving up: #{error.class}: #{error.message}",
69
+ )
70
+ return false
71
+ end
72
+
73
+ true
74
+ end
75
+
76
+ def wait_for_connection(original_error)
77
+ connection_attempt = 0
78
+ logger.info('Trying to reconnect after connection error')
79
+ while !Ears.connection.open?
80
+ logger.info(
81
+ "Connection still closed, attempt #{connection_attempt + 1}",
82
+ )
83
+ connection_attempt += 1
84
+
85
+ if connection_attempt > config.publisher_connection_attempts
86
+ logger.error('Connection attempts exhausted, giving up')
87
+ raise original_error
88
+ end
89
+
90
+ sleep(connection_backoff_delay(connection_attempt))
91
+ end
92
+ end
93
+
94
+ def retry_backoff_delay(attempt)
95
+ config.publisher_retry_base_delay *
96
+ (config.publisher_retry_backoff_factor**(attempt - 1))
97
+ end
98
+
99
+ def connection_backoff_delay(attempt)
100
+ config.publisher_connection_base_delay *
101
+ (config.publisher_connection_backoff_factor**(attempt - 1))
102
+ end
103
+ end
104
+ end
@@ -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,102 @@
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
+ end
45
+ end
46
+
47
+ def setup_exchange_declare(channel)
48
+ allow(channel).to receive(:exchange_declare) do |name, type, options|
49
+ create_or_get_mock_exchange(name, type, options)
50
+ end
51
+ end
52
+
53
+ def setup_register_exchange(channel)
54
+ allow(channel).to receive(:register_exchange)
55
+ end
56
+
57
+ def setup_basic_publish(channel)
58
+ allow(channel).to receive(
59
+ :basic_publish,
60
+ ) do |data, exchange, routing_key, options|
61
+ if exchange_names.include?(exchange)
62
+ capture_message(exchange, data, routing_key, options)
63
+ elsif strict_mocking?
64
+ raise_unmocked_exchange_error(exchange)
65
+ end
66
+ end
67
+ end
68
+
69
+ def create_or_get_mock_exchange(name, type, _options)
70
+ mock_exchanges[name] ||= create_mock_exchange(name, type)
71
+ end
72
+
73
+ def create_mock_exchange(name, type)
74
+ exchange = instance_double(Bunny::Exchange, name: name, type: type)
75
+
76
+ setup_exchange_publish(exchange, name)
77
+
78
+ exchange
79
+ end
80
+
81
+ def setup_exchange_publish(exchange, name)
82
+ allow(exchange).to receive(:publish) do |data, routing_options|
83
+ routing_key = routing_options[:routing_key]
84
+ capture_message(name, data, routing_key, routing_options)
85
+ end
86
+ end
87
+
88
+ def capture_message(exchange_name, data, routing_key, options)
89
+ message_capture.add_message(exchange_name, data, routing_key, options)
90
+ end
91
+
92
+ def strict_mocking?
93
+ Ears::Testing.configuration.strict_exchange_mocking
94
+ end
95
+
96
+ def raise_unmocked_exchange_error(exchange)
97
+ raise UnmockedExchangeError,
98
+ "Exchange '#{exchange}' has not been mocked. Add mock_ears('#{exchange}') to your test setup."
99
+ end
100
+ end
101
+ end
102
+ 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.20.0'
2
+ VERSION = '0.21.1'
3
3
  end
data/lib/ears.rb CHANGED
@@ -2,6 +2,8 @@ require 'bunny'
2
2
  require 'ears/configuration'
3
3
  require 'ears/consumer'
4
4
  require 'ears/middleware'
5
+ require 'ears/publisher'
6
+ require 'ears/publisher_channel_pool'
5
7
  require 'ears/setup'
6
8
  require 'ears/version'
7
9
 
data/package-lock.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "devDependencies": {
9
9
  "@invisionag/prettier-config": "^2.1.3",
10
10
  "@prettier/plugin-ruby": "^4.0.4",
11
- "prettier": "^3.5.3"
11
+ "prettier": "^3.6.2"
12
12
  }
13
13
  },
14
14
  "node_modules/@invisionag/prettier-config": {
@@ -30,9 +30,9 @@
30
30
  }
31
31
  },
32
32
  "node_modules/prettier": {
33
- "version": "3.5.3",
34
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
35
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
33
+ "version": "3.6.2",
34
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
35
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
36
36
  "dev": true,
37
37
  "license": "MIT",
38
38
  "bin": {
@@ -62,9 +62,9 @@
62
62
  "requires": {}
63
63
  },
64
64
  "prettier": {
65
- "version": "3.5.3",
66
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
67
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
65
+ "version": "3.6.2",
66
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
67
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
68
68
  "dev": true
69
69
  }
70
70
  }
data/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "devDependencies": {
10
10
  "@invisionag/prettier-config": "^2.1.3",
11
11
  "@prettier/plugin-ruby": "^4.0.4",
12
- "prettier": "^3.5.3"
12
+ "prettier": "^3.6.2"
13
13
  },
14
14
  "prettier": "@invisionag/prettier-config/ruby"
15
15
  }
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.20.0
4
+ version: 0.21.1
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-06-02 00:00:00.000000000 Z
11
+ date: 2025-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 2.22.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: connection_pool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.4'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: multi_json
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -73,7 +87,14 @@ files:
73
87
  - lib/ears/middlewares/appsignal.rb
74
88
  - lib/ears/middlewares/json.rb
75
89
  - lib/ears/middlewares/max_retries.rb
90
+ - lib/ears/publisher.rb
91
+ - lib/ears/publisher_channel_pool.rb
92
+ - lib/ears/publisher_retry_handler.rb
76
93
  - lib/ears/setup.rb
94
+ - lib/ears/testing.rb
95
+ - lib/ears/testing/message_capture.rb
96
+ - lib/ears/testing/publisher_mock.rb
97
+ - lib/ears/testing/test_helper.rb
77
98
  - lib/ears/version.rb
78
99
  - package-lock.json
79
100
  - package.json
@@ -94,7 +115,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
94
115
  requirements:
95
116
  - - ">="
96
117
  - !ruby/object:Gem::Version
97
- version: 3.2.8
118
+ version: 3.2.9
98
119
  required_rubygems_version: !ruby/object:Gem::Requirement
99
120
  requirements:
100
121
  - - ">="