ears 0.20.0 → 0.21.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: cfbd6ab51a763dae5a9e93db48acb059af700fa88eee4b3f854e912b9f6d19be
4
- data.tar.gz: aa328f03ba25e0e7afabdc30bfb9a2212b5072ed6a50ba10a1df9396197ada61
3
+ metadata.gz: a7786b24328603a3d9b231e06400194e69ab38b00de05ec0b0ee5424f0ec3a7b
4
+ data.tar.gz: 386cedcd1ed08364678da108e1c44e4142d5ba915478476b2d95b14afec3eff3
5
5
  SHA512:
6
- metadata.gz: 140f6f39b0f3099eb0a00a26a0de7bbbff66335a6bad2cd71d3b8e26f1810301ff16de00675e580beb71de959b8a80d80a4a76309556e4f22019dd37effd717f
7
- data.tar.gz: 36053b2d7203bb37e2fd527d86c69922f3cc5a4e99c7723f0b73c7f92a24b9df439baf61aaaa72209857b48bab1a17ebfbd5775633ecefaa270bb911757f28c7
6
+ metadata.gz: 841d1b043e68ebbcf850fab367e770e145cf3519d15e33f613db18c41e8df72d2c33d0337b50ba3c18d866894defe32d4584230d78447192eb2ac71b6dea134f
7
+ data.tar.gz: 7a2bb015f0f4175499c07609cc0ab923af8ecd2157df7a6a57c5c1233c68cfc2d5813ac2452c571551efb83658bfbb5642cf4373bb0efde0a6e9cd88a0530b31
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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 (2025-09-08)
4
+
5
+ - Introduce Ears::Publisher with thread-safe channel pooling
6
+ - Introduce configurable logger
7
+
3
8
  ## 0.20.0 (2025-06-02)
4
9
 
5
10
  - 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.0)
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.0)
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,84 @@ 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
+
288
551
  ## Documentation
289
552
 
290
553
  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
data/lib/ears/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Ears
2
- VERSION = '0.20.0'
2
+ VERSION = '0.21.0'
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.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-06-02 00:00:00.000000000 Z
11
+ date: 2025-09-08 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,6 +87,9 @@ 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
77
94
  - lib/ears/version.rb
78
95
  - package-lock.json
@@ -94,7 +111,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
94
111
  requirements:
95
112
  - - ">="
96
113
  - !ruby/object:Gem::Version
97
- version: 3.2.8
114
+ version: 3.2.9
98
115
  required_rubygems_version: !ruby/object:Gem::Requirement
99
116
  requirements:
100
117
  - - ">="