shared_broker 1.5.0 → 1.8.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: 7fef9571ebd7987fe360d47b8a4374206808487afc3b641b19cb19c6dbfaf85f
4
- data.tar.gz: 98fc3d5712273e6d426ac380e5ebd3ff4c9cd6319a1eb00b333ea25cc977cb0f
3
+ metadata.gz: 972f349828dcd6839a7e95c7e5d285603fcbec36c7ef52c4953e85ee67038f34
4
+ data.tar.gz: 19a1b4d987ea960bb1f2ab362bb3acc1afea685e6d4f019dfac14f5ec9b7c24c
5
5
  SHA512:
6
- metadata.gz: b6d52305cbfe8cd4e72d9e5bcbd84cc8bbda1c5c738a43e2727586e82f88bbe01d9e060d31ee28443c0b73172b4206ba33fd8640be394e6fba6b9b190f808a44
7
- data.tar.gz: fdf993c33a31f8364aa613123159aebbeeae8ebfdd4f5548d6f1eb7210b318e0cc81f660e7ab28d4bc998868386c63086fbcc793d0cfe3ade14ffae82f8afd58
6
+ metadata.gz: 4d86395b12e3f37e71a5014ba8b6196436f4d28ed06cdc1227363a20be5e62943edf1280819d68cbafee1bf249aae89414a28da50abfbacefaaffb3e4dfcabe8
7
+ data.tar.gz: aef9fe8f2f60ccb9f3812470f98ddacccc75b2699d75e3f286525636fbdb5e278900e5befad76432c172c734cfc8de0058d7d00dcf349545f9106627edc3c669
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.8.0] - 2026-06-21
9
+
10
+ ### Added
11
+ - **Resilience & Advanced Processing**:
12
+ - **Batch Publishing**: Added `publish_batch` method in `SharedBroker::Client` and adapter classes, verifying schemas and encrypting payloads individually before transmission.
13
+ - **Deduplication / Idempotency**: Created `SharedBroker::Middlewares::Idempotency` to deduplicate consumed messages by tracking their `correlation_id` against a memory or cache-based store (such as `Rails.cache`).
14
+ - **DLQ Redrive**: Created `SharedBroker::DLQ::Redriver` utility along with `redrive_dlq` on memory, Redis, and RabbitMQ adapters to pull failed messages from DLQs back into active queues.
15
+
16
+ ## [1.7.0] - 2026-06-18
17
+
18
+ ### Added
19
+ - **Automatic Payload Compression**: Decoupled payload compression mechanism for publishing and subscribing.
20
+ - `SharedBroker::Compressor` supporting standard `:gzip` and `:deflate` algorithms.
21
+ - Configurable global `compression_algorithm` and `compression_threshold` limits to prevent compressing small payloads.
22
+ - Automatic versioning of encrypted and compressed payloads via the `_compression` tag.
23
+
24
+ ## [1.6.0] - 2026-06-16
25
+
26
+ ### Added
27
+ - **Encryption Key Rotation & Granularity**: Introduced flexible topic-based encryption keys and multi-version key rotation.
28
+ - `SharedBroker::KeyProvider::Registry` to register multiple keys by key ID and route active keys based on topic glob patterns.
29
+ - `SharedBroker::KeyProvider::Static` for seamless backward compatibility with single global encryption keys.
30
+ - Automatic versioning of encrypted payloads with `_key_id` metadata, enabling decrypting historical messages using rotated keys.
31
+
8
32
  ## [1.5.0] - 2026-06-10
9
33
 
10
34
  ### Added
data/README.md CHANGED
@@ -160,6 +160,59 @@ HYBRID_BROKER = SharedBroker::Client.new(
160
160
  )
161
161
  ```
162
162
 
163
+ #### E. Encryption Key Rotation & Granularity
164
+
165
+ If your system requires encrypting payloads with different keys based on the topic (e.g., highly sensitive financial data vs. general notifications) or rotating keys without breaking the decryption of historical messages in queues, you can configure a Key Provider Registry:
166
+
167
+ ```ruby
168
+ # 1. Initialize Registry with a map of historical/current keys and active key mappings
169
+ key_registry = SharedBroker::KeyProvider::Registry.new(
170
+ keys: {
171
+ "v1" => "a" * 32, # historical key
172
+ "v2" => "b" * 32, # current general key
173
+ "finance_key_1" => "c" * 32 # current finance key
174
+ },
175
+ active_keys: {
176
+ # Topic-specific key mapping using glob patterns
177
+ "payment.*" => "finance_key_1",
178
+ # Fallback key mapping for all other topics
179
+ "*" => "v2"
180
+ }
181
+ )
182
+
183
+ # 2. Register key provider globally or pass it to Client initialize
184
+ SharedBroker.key_provider = key_registry
185
+
186
+ # Alternatively, pass it directly to the client
187
+ SPOT_BROKER = SharedBroker::Client.new(
188
+ adapter: BROKER_ADAPTER,
189
+ key_provider: key_registry
190
+ )
191
+ ```
192
+
193
+ With a key provider registry configured:
194
+ - **Publishing**: Payloads are encrypted using the active key matching the topic pattern, and a `_key_id` metadata tag is automatically appended to the envelope.
195
+ - **Subscribing**: The gem automatically reads the `_key_id` from the payload envelope and decrypts it using the correct historical key version.
196
+ - **Fallback**: If no `_key_id` is present on a received message (e.g., legacy message), it falls back to the key associated with the topic pattern.
197
+
198
+ #### F. Automatic Payload Compression
199
+
200
+ To optimize network bandwidth and storage costs, `SharedBroker` can automatically compress large payloads before encrypting them. You can configure compression globally:
201
+
202
+ ```ruby
203
+ # Enable compression using either :gzip or :deflate (nil by default)
204
+ SharedBroker.compression_algorithm = :gzip
205
+
206
+ # Set the threshold in bytes. Payloads smaller than this will NOT be compressed.
207
+ # This avoids the overhead of compression for tiny messages.
208
+ SharedBroker.compression_threshold = 1024 # 1 KB
209
+ ```
210
+
211
+ When compression is active:
212
+ - **Publishing**: If the payload size exceeds the threshold, it is compressed, marked with the `_compression` tag in the metadata envelope, and then encrypted.
213
+ - **Subscribing**: The consumer checks for the `_compression` tag, decrypts the payload, and automatically decompresses it before passing it to your subscriber block.
214
+ - **Compatibility**: If a consumer receives a message that has no `_compression` tag, it will bypass decompression and decrypt normally.
215
+
163
216
  ---
164
217
 
165
218
  ## Usage
@@ -199,6 +252,49 @@ SPOT_BROKER.subscribe(
199
252
  end
200
253
  ```
201
254
 
255
+
256
+ ### Publishing Events in Batch
257
+ Send multiple events at once:
258
+
259
+ ```ruby
260
+ events = [
261
+ { id: 1, email: "alice@example.com" },
262
+ { id: 2, email: "bob@example.com" }
263
+ ]
264
+
265
+ # Validates and encrypts all payloads, then publishes them in a batch
266
+ SPOT_BROKER.publish_batch("user.created", events)
267
+ ```
268
+
269
+ ### Subscriber Idempotency Middleware
270
+ Configure deduplication of messages using the Idempotency Middleware. It skips already processed messages by checking their `correlation_id` (uses an internal in-memory store by default, but accepts duck-typed stores like `Rails.cache`):
271
+
272
+ ```ruby
273
+ # Initialize with custom store and cache expiration (default 3600 seconds)
274
+ idempotency = SharedBroker::Middlewares::Idempotency.new(
275
+ store: Rails.cache,
276
+ expires_in: 86400 # 1 day
277
+ )
278
+
279
+ SPOT_BROKER = SharedBroker::Client.new(
280
+ adapter: BROKER_ADAPTER,
281
+ middlewares: [idempotency]
282
+ )
283
+ ```
284
+
285
+ ### DLQ Redrive Utility
286
+ Move failed messages back to the original queue for reprocessing after bug fixes:
287
+
288
+ ```ruby
289
+ # Redrives up to 50 messages from DLQ list/queue to original topic
290
+ SharedBroker::DLQ::Redriver.redrive(
291
+ SPOT_BROKER,
292
+ "my_consumption_queue.dlq",
293
+ "user.created",
294
+ limit: 50
295
+ )
296
+ ```
297
+
202
298
  ---
203
299
 
204
300
  ## Running Gem Tests
@@ -1,15 +1,29 @@
1
- # frozen_string_literal: true
2
-
3
- module SharedBroker
4
- module Adapters
5
- class Base
6
- def publish(topic, message, correlation_id: nil)
7
- raise NotImplementedError, "#{self.class.name} must implement #publish"
8
- end
9
-
10
- def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
11
- raise NotImplementedError, "#{self.class.name} must implement #subscribe"
12
- end
13
- end
14
- end
15
- end
1
+ # frozen_string_literal: true
2
+
3
+ module SharedBroker
4
+ module Adapters
5
+ class Base
6
+ def publish(topic, message, correlation_id: nil)
7
+ raise NotImplementedError, "#{self.class.name} must implement #publish"
8
+ end
9
+
10
+ def publish_batch(topic, messages, correlation_id: nil)
11
+ unless messages.is_a?(Array)
12
+ raise ArgumentError, "Expected messages to be an Array, got #{messages.class} with value #{messages.inspect}"
13
+ end
14
+
15
+ messages.each do |message|
16
+ publish(topic, message, correlation_id: correlation_id)
17
+ end
18
+ end
19
+
20
+ def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
21
+ raise NotImplementedError, "#{self.class.name} must implement #subscribe"
22
+ end
23
+
24
+ def redrive_dlq(dlq_name, original_topic, limit: nil)
25
+ raise NotImplementedError, "#{self.class.name} must implement #redrive_dlq"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,51 +1,70 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
- require_relative "base"
5
-
6
- module SharedBroker
7
- module Adapters
8
- class InMemory < Base
9
- def initialize
10
- @storage = Hash.new { |h, k| h[k] = [] }
11
- @subscribers = Hash.new { |h, k| h[k] = [] }
12
- end
13
-
14
- def publish(topic, message, correlation_id: nil)
15
- msg_with_metadata = message.merge(_correlation_id: correlation_id)
16
- @storage[topic] << msg_with_metadata
17
-
18
- @subscribers[topic].each do |sub|
19
- attempts = 0
20
- begin
21
- sub[:block].call(msg_with_metadata)
22
- rescue => e
23
- attempts += 1
24
- if attempts <= sub[:max_retries]
25
- # Sleep briefly or not at all in memory to keep tests fast
26
- sleep(0.001 * sub[:backoff_base]**attempts)
27
- retry
28
- else
29
- dlq_topic = "#{sub[:queue_name]}.dlq"
30
- dlq_msg = msg_with_metadata.merge(
31
- _x_original_routing_key: topic,
32
- _x_failed_at: Time.now.utc.iso8601,
33
- _x_exception_class: e.class.name,
34
- _x_exception_message: e.message
35
- )
36
- @storage[dlq_topic] << dlq_msg
37
- end
38
- end
39
- end
40
- end
41
-
42
- def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
43
- @subscribers[topic] << { queue_name: queue_name, max_retries: max_retries, backoff_base: backoff_base, block: block }
44
- end
45
-
46
- def published_messages(topic)
47
- @storage[topic]
48
- end
49
- end
50
- end
51
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "base"
5
+
6
+ module SharedBroker
7
+ module Adapters
8
+ class InMemory < Base
9
+ def initialize
10
+ @storage = Hash.new { |h, k| h[k] = [] }
11
+ @subscribers = Hash.new { |h, k| h[k] = [] }
12
+ end
13
+
14
+ def publish(topic, message, correlation_id: nil)
15
+ msg_with_metadata = message.merge(_correlation_id: correlation_id)
16
+ @storage[topic] << msg_with_metadata
17
+
18
+ @subscribers[topic].each do |sub|
19
+ attempts = 0
20
+ begin
21
+ sub[:block].call(msg_with_metadata)
22
+ rescue SharedBroker::ShutdownError => e
23
+ raise e
24
+ rescue => e
25
+ attempts += 1
26
+ if attempts <= sub[:max_retries]
27
+ # Sleep briefly or not at all in memory to keep tests fast
28
+ sleep(0.001 * sub[:backoff_base]**attempts)
29
+ retry
30
+ else
31
+ dlq_topic = "#{sub[:queue_name]}.dlq"
32
+ dlq_msg = msg_with_metadata.merge(
33
+ _x_original_routing_key: topic,
34
+ _x_failed_at: Time.now.utc.iso8601,
35
+ _x_exception_class: e.class.name,
36
+ _x_exception_message: e.message
37
+ )
38
+ @storage[dlq_topic] << dlq_msg
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
45
+ @subscribers[topic] << { queue_name: queue_name, max_retries: max_retries, backoff_base: backoff_base, block: block }
46
+ end
47
+
48
+ def published_messages(topic)
49
+ @storage[topic]
50
+ end
51
+
52
+ def redrive_dlq(dlq_name, original_topic, limit: nil)
53
+ dlq_messages = @storage[dlq_name]
54
+ return if dlq_messages.empty?
55
+
56
+ to_redrive = limit ? dlq_messages.first(limit) : dlq_messages.dup
57
+ to_redrive.each do |msg|
58
+ cleaned_msg = msg.dup
59
+ cleaned_msg.delete(:_x_original_routing_key)
60
+ cleaned_msg.delete(:_x_failed_at)
61
+ cleaned_msg.delete(:_x_exception_class)
62
+ cleaned_msg.delete(:_x_exception_message)
63
+
64
+ publish(original_topic, cleaned_msg, correlation_id: cleaned_msg[:_correlation_id])
65
+ @storage[dlq_name].delete(msg)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -41,6 +41,8 @@ module SharedBroker
41
41
  attempts = 0
42
42
  begin
43
43
  block.call(data)
44
+ rescue SharedBroker::ShutdownError
45
+ consumer.stop
44
46
  rescue => e
45
47
  attempts += 1
46
48
  if attempts <= max_retries
@@ -1,84 +1,104 @@
1
- # frozen_string_literal: true
2
-
3
- require "bunny"
4
- require "json"
5
- require "time"
6
- require_relative "base"
7
-
8
- module SharedBroker
9
- module Adapters
10
- class RabbitMQ < Base
11
- EXCHANGE_NAME = "shared_broker_events"
12
-
13
- def initialize(amqp_url:)
14
- @connection = Bunny.new(amqp_url)
15
- @connection.start
16
- @channel = @connection.create_channel
17
- @exchange = @channel.topic(EXCHANGE_NAME, durable: true)
18
- end
19
-
20
- def publish(topic, message, correlation_id: nil)
21
- unless message.is_a?(Hash)
22
- raise ArgumentError, "Message must be a Hash, got #{message.class} with value #{message.inspect}"
23
- end
24
-
25
- options = { routing_key: topic }
26
- options[:correlation_id] = correlation_id if correlation_id
27
- @exchange.publish(message.to_json, options)
28
- end
29
-
30
- def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
31
- queue = @channel.queue(queue_name, durable: true)
32
- queue.bind(@exchange, routing_key: topic)
33
-
34
- queue.subscribe(manual_ack: true) do |delivery_info, metadata, payload|
35
- data = JSON.parse(payload, symbolize_names: true)
36
- if metadata.respond_to?(:correlation_id) && metadata.correlation_id
37
- data[:_correlation_id] = metadata.correlation_id
38
- end
39
-
40
- attempts = 0
41
- begin
42
- block.call(data)
43
- @channel.acknowledge(delivery_info.delivery_tag, false)
44
- rescue => e
45
- attempts += 1
46
- if attempts <= max_retries
47
- sleep(backoff_base**attempts)
48
- retry
49
- else
50
- publish_to_dlq(queue_name, payload, delivery_info, metadata, e)
51
- @channel.acknowledge(delivery_info.delivery_tag, false)
52
- end
53
- end
54
- end
55
- end
56
-
57
- def close
58
- @channel.close if @channel
59
- @connection.close if @connection
60
- end
61
-
62
- private
63
-
64
- def publish_to_dlq(queue_name, payload, delivery_info, metadata, exception)
65
- dlq_name = "#{queue_name}.dlq"
66
- dlq_queue = @channel.queue(dlq_name, durable: true)
67
-
68
- headers = {
69
- x_original_routing_key: delivery_info.routing_key,
70
- x_failed_at: Time.now.utc.iso8601,
71
- x_exception_class: exception.class.name,
72
- x_exception_message: exception.message
73
- }
74
-
75
- @channel.default_exchange.publish(
76
- payload,
77
- routing_key: dlq_queue.name,
78
- correlation_id: metadata.correlation_id,
79
- headers: headers
80
- )
81
- end
82
- end
83
- end
84
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "bunny"
4
+ require "json"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module SharedBroker
9
+ module Adapters
10
+ class RabbitMQ < Base
11
+ EXCHANGE_NAME = "shared_broker_events"
12
+
13
+ def initialize(amqp_url:)
14
+ @connection = Bunny.new(amqp_url)
15
+ @connection.start
16
+ @channel = @connection.create_channel
17
+ @exchange = @channel.topic(EXCHANGE_NAME, durable: true)
18
+ end
19
+
20
+ def publish(topic, message, correlation_id: nil)
21
+ unless message.is_a?(Hash)
22
+ raise ArgumentError, "Message must be a Hash, got #{message.class} with value #{message.inspect}"
23
+ end
24
+
25
+ options = { routing_key: topic }
26
+ options[:correlation_id] = correlation_id if correlation_id
27
+ @exchange.publish(message.to_json, options)
28
+ end
29
+
30
+ def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
31
+ queue = @channel.queue(queue_name, durable: true)
32
+ queue.bind(@exchange, routing_key: topic)
33
+
34
+ queue.subscribe(manual_ack: true) do |delivery_info, metadata, payload|
35
+ data = JSON.parse(payload, symbolize_names: true)
36
+ if metadata.respond_to?(:correlation_id) && metadata.correlation_id
37
+ data[:_correlation_id] = metadata.correlation_id
38
+ end
39
+
40
+ attempts = 0
41
+ begin
42
+ block.call(data)
43
+ @channel.acknowledge(delivery_info.delivery_tag, false)
44
+ rescue SharedBroker::ShutdownError
45
+ # Do not acknowledge or send to DLQ. Let RabbitMQ re-queue the message.
46
+ rescue => e
47
+ attempts += 1
48
+ if attempts <= max_retries
49
+ sleep(backoff_base**attempts)
50
+ retry
51
+ else
52
+ publish_to_dlq(queue_name, payload, delivery_info, metadata, e)
53
+ @channel.acknowledge(delivery_info.delivery_tag, false)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def redrive_dlq(dlq_name, original_topic, limit: nil)
60
+ dlq_queue = @channel.queue(dlq_name, durable: true)
61
+ count = 0
62
+
63
+ loop do
64
+ break if limit && count >= limit
65
+ delivery_info, metadata, payload = dlq_queue.pop(manual_ack: true)
66
+ break unless delivery_info
67
+
68
+ options = { routing_key: original_topic }
69
+ options[:correlation_id] = metadata.correlation_id if metadata&.correlation_id
70
+ @exchange.publish(payload, options)
71
+
72
+ @channel.acknowledge(delivery_info.delivery_tag, false)
73
+ count += 1
74
+ end
75
+ end
76
+
77
+ def close
78
+ @channel.close if @channel
79
+ @connection.close if @connection
80
+ end
81
+
82
+ private
83
+
84
+ def publish_to_dlq(queue_name, payload, delivery_info, metadata, exception)
85
+ dlq_name = "#{queue_name}.dlq"
86
+ dlq_queue = @channel.queue(dlq_name, durable: true)
87
+
88
+ headers = {
89
+ x_original_routing_key: delivery_info.routing_key,
90
+ x_failed_at: Time.now.utc.iso8601,
91
+ x_exception_class: exception.class.name,
92
+ x_exception_message: exception.message
93
+ }
94
+
95
+ @channel.default_exchange.publish(
96
+ payload,
97
+ routing_key: dlq_queue.name,
98
+ correlation_id: metadata.correlation_id,
99
+ headers: headers
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,64 +1,80 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require "json"
5
- require "time"
6
-
7
- module SharedBroker
8
- module Adapters
9
- class Redis < Base
10
- def initialize(redis_url:)
11
- begin
12
- require "redis"
13
- rescue LoadError
14
- raise unless defined?(::Redis)
15
- end
16
- @redis = ::Redis.new(url: redis_url)
17
- end
18
-
19
- def publish(topic, message, correlation_id: nil)
20
- unless message.is_a?(Hash)
21
- raise ArgumentError, "Expected message to be a Hash, got #{message.class} with value #{message.inspect}"
22
- end
23
-
24
- payload = message.merge(_correlation_id: correlation_id)
25
- @redis.publish(topic, payload.to_json)
26
- end
27
-
28
- def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
29
- Thread.new do
30
- @redis.subscribe(topic) do |on|
31
- on.message do |_channel, msg_json|
32
- data = JSON.parse(msg_json, symbolize_names: true)
33
- attempts = 0
34
- begin
35
- block.call(data)
36
- rescue => e
37
- attempts += 1
38
- if attempts <= max_retries
39
- sleep(backoff_base**attempts)
40
- retry
41
- else
42
- publish_to_dlq(topic, queue_name, msg_json, e)
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
49
-
50
- private
51
-
52
- def publish_to_dlq(topic, queue_name, payload_json, exception)
53
- dlq_key = "dlq:#{topic}:#{queue_name}"
54
- dlq_payload = {
55
- payload: JSON.parse(payload_json, symbolize_names: true),
56
- x_failed_at: Time.now.utc.iso8601,
57
- x_exception_class: exception.class.name,
58
- x_exception_message: exception.message
59
- }
60
- @redis.rpush(dlq_key, dlq_payload.to_json)
61
- end
62
- end
63
- end
64
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "json"
5
+ require "time"
6
+
7
+ module SharedBroker
8
+ module Adapters
9
+ class Redis < Base
10
+ def initialize(redis_url:)
11
+ begin
12
+ require "redis"
13
+ rescue LoadError
14
+ raise unless defined?(::Redis)
15
+ end
16
+ @redis = ::Redis.new(url: redis_url)
17
+ end
18
+
19
+ def publish(topic, message, correlation_id: nil)
20
+ unless message.is_a?(Hash)
21
+ raise ArgumentError, "Expected message to be a Hash, got #{message.class} with value #{message.inspect}"
22
+ end
23
+
24
+ payload = message.merge(_correlation_id: correlation_id)
25
+ @redis.publish(topic, payload.to_json)
26
+ end
27
+
28
+ def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, &block)
29
+ Thread.new do
30
+ @redis.subscribe(topic) do |on|
31
+ on.message do |_channel, msg_json|
32
+ data = JSON.parse(msg_json, symbolize_names: true)
33
+ attempts = 0
34
+ begin
35
+ block.call(data)
36
+ rescue SharedBroker::ShutdownError
37
+ @redis.unsubscribe
38
+ rescue => e
39
+ attempts += 1
40
+ if attempts <= max_retries
41
+ sleep(backoff_base**attempts)
42
+ retry
43
+ else
44
+ publish_to_dlq(topic, queue_name, msg_json, e)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def redrive_dlq(dlq_name, original_topic, limit: nil)
53
+ count = 0
54
+ loop do
55
+ break if limit && count >= limit
56
+ raw_json = @redis.lpop(dlq_name)
57
+ break unless raw_json
58
+
59
+ dlq_data = JSON.parse(raw_json, symbolize_names: true)
60
+ original_payload = dlq_data[:payload]
61
+ publish(original_topic, original_payload, correlation_id: original_payload[:_correlation_id])
62
+ count += 1
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def publish_to_dlq(topic, queue_name, payload_json, exception)
69
+ dlq_key = "dlq:#{topic}:#{queue_name}"
70
+ dlq_payload = {
71
+ payload: JSON.parse(payload_json, symbolize_names: true),
72
+ x_failed_at: Time.now.utc.iso8601,
73
+ x_exception_class: exception.class.name,
74
+ x_exception_message: exception.message
75
+ }
76
+ @redis.rpush(dlq_key, dlq_payload.to_json)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,69 +1,114 @@
1
- # frozen_string_literal: true
2
-
3
- require "openssl"
4
- require "base64"
5
- require "json"
6
-
7
- module SharedBroker
8
- module Cipher
9
- class DecryptionError < StandardError; end
10
-
11
- ALGORITHM = "aes-256-gcm"
12
-
13
- def self.encrypt(payload_hash, key)
14
- return payload_hash unless key
15
-
16
- unless payload_hash.is_a?(Hash)
17
- raise ArgumentError, "Expected payload_hash to be a Hash, got #{payload_hash.class} with value #{payload_hash.inspect}"
18
- end
19
-
20
- # We don't want to encrypt correlation ID or special metadata keys if we want the broker to read them,
21
- # but we want to encrypt the actual payload data.
22
- # Let's extract metadata starting with underscore and encrypt the rest.
23
- metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
24
- data_to_encrypt = payload_hash.reject { |k, _| k.to_s.start_with?("_") }
25
-
26
- cipher = OpenSSL::Cipher.new(ALGORITHM)
27
- cipher.encrypt
28
- cipher.key = key
29
- iv = cipher.random_iv
30
-
31
- encrypted_data = cipher.update(data_to_encrypt.to_json) + cipher.final
32
- auth_tag = cipher.auth_tag
33
-
34
- metadata.merge(
35
- _encrypted: true,
36
- _iv: Base64.strict_encode64(iv),
37
- _auth_tag: Base64.strict_encode64(auth_tag),
38
- _data: Base64.strict_encode64(encrypted_data)
39
- )
40
- end
41
-
42
- def self.decrypt(payload_hash, key)
43
- return payload_hash unless payload_hash.is_a?(Hash) && payload_hash[:_encrypted]
44
- return payload_hash unless key
45
-
46
- cipher = OpenSSL::Cipher.new(ALGORITHM)
47
- cipher.decrypt
48
- cipher.key = key
49
- cipher.iv = Base64.strict_decode64(payload_hash[:_iv])
50
- cipher.auth_tag = Base64.strict_decode64(payload_hash[:_auth_tag])
51
-
52
- encrypted_bytes = Base64.strict_decode64(payload_hash[:_data])
53
- decrypted_json = cipher.update(encrypted_bytes) + cipher.final
54
-
55
- decrypted_data = JSON.parse(decrypted_json, symbolize_names: true)
56
-
57
- # Merge back the unencrypted metadata
58
- metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
59
- metadata.delete(:_encrypted)
60
- metadata.delete(:_iv)
61
- metadata.delete(:_auth_tag)
62
- metadata.delete(:_data)
63
-
64
- decrypted_data.merge(metadata)
65
- rescue => e
66
- raise DecryptionError, "Failed to decrypt payload. Error: #{e.message}. Offending payload: #{payload_hash.inspect}"
67
- end
68
- end
69
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module SharedBroker
8
+ module Cipher
9
+ class DecryptionError < StandardError; end
10
+
11
+ ALGORITHM = "aes-256-gcm"
12
+
13
+ def self.encrypt(payload_hash, key_provider_or_key, topic: nil)
14
+ return payload_hash unless key_provider_or_key
15
+
16
+ unless payload_hash.is_a?(Hash)
17
+ raise ArgumentError, "Expected payload_hash to be a Hash, got #{payload_hash.class} with value #{payload_hash.inspect}"
18
+ end
19
+
20
+ key, key_id = resolve_encryption_key(key_provider_or_key, topic)
21
+ return payload_hash unless key
22
+
23
+ metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
24
+ data_to_encrypt = payload_hash.reject { |k, _| k.to_s.start_with?("_") }
25
+ json_data = data_to_encrypt.to_json
26
+
27
+ compression_alg = nil
28
+ if SharedBroker.compression_algorithm && json_data.bytesize > SharedBroker.compression_threshold
29
+ compression_alg = SharedBroker.compression_algorithm
30
+ json_data = SharedBroker::Compressor.compress(json_data, compression_alg)
31
+ end
32
+
33
+ cipher = OpenSSL::Cipher.new(ALGORITHM)
34
+ cipher.encrypt
35
+ cipher.key = key
36
+ iv = cipher.random_iv
37
+
38
+ encrypted_data = cipher.update(json_data) + cipher.final
39
+ auth_tag = cipher.auth_tag
40
+
41
+ envelope = {
42
+ _encrypted: true,
43
+ _iv: Base64.strict_encode64(iv),
44
+ _auth_tag: Base64.strict_encode64(auth_tag),
45
+ _data: Base64.strict_encode64(encrypted_data)
46
+ }
47
+ envelope[:_key_id] = key_id.to_s if key_id
48
+ envelope[:_compression] = compression_alg.to_s if compression_alg
49
+
50
+ metadata.merge(envelope)
51
+ end
52
+
53
+ def self.decrypt(payload_hash, key_provider_or_key, topic: nil)
54
+ return payload_hash unless payload_hash.is_a?(Hash) && payload_hash[:_encrypted]
55
+ return payload_hash unless key_provider_or_key
56
+
57
+ key = resolve_decryption_key(payload_hash, key_provider_or_key, topic)
58
+ return payload_hash unless key
59
+
60
+ cipher = OpenSSL::Cipher.new(ALGORITHM)
61
+ cipher.decrypt
62
+ cipher.key = key
63
+ cipher.iv = Base64.strict_decode64(payload_hash[:_iv])
64
+ cipher.auth_tag = Base64.strict_decode64(payload_hash[:_auth_tag])
65
+
66
+ encrypted_bytes = Base64.strict_decode64(payload_hash[:_data])
67
+ decrypted_raw = cipher.update(encrypted_bytes) + cipher.final
68
+
69
+ compression_alg = payload_hash[:_compression]
70
+ decrypted_json = if compression_alg
71
+ SharedBroker::Compressor.decompress(decrypted_raw, compression_alg)
72
+ else
73
+ decrypted_raw
74
+ end
75
+
76
+ decrypted_data = JSON.parse(decrypted_json, symbolize_names: true)
77
+
78
+ metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
79
+ clean_envelope_metadata!(metadata)
80
+
81
+ decrypted_data.merge(metadata)
82
+ rescue => e
83
+ raise DecryptionError, "Failed to decrypt payload. Error: #{e.message}. Offending payload: #{payload_hash.inspect}"
84
+ end
85
+
86
+ private
87
+
88
+ def self.resolve_encryption_key(provider_or_key, topic)
89
+ if provider_or_key.respond_to?(:key_for)
90
+ [provider_or_key.key_for(topic), provider_or_key.active_key_id_for(topic)]
91
+ else
92
+ [provider_or_key, nil]
93
+ end
94
+ end
95
+
96
+ def self.resolve_decryption_key(payload, provider_or_key, topic)
97
+ if provider_or_key.respond_to?(:key_for_id)
98
+ key_id = payload[:_key_id]
99
+ key_id ? provider_or_key.key_for_id(key_id) : provider_or_key.key_for(topic)
100
+ else
101
+ provider_or_key
102
+ end
103
+ end
104
+
105
+ def self.clean_envelope_metadata!(metadata)
106
+ metadata.delete(:_encrypted)
107
+ metadata.delete(:_iv)
108
+ metadata.delete(:_auth_tag)
109
+ metadata.delete(:_data)
110
+ metadata.delete(:_key_id)
111
+ metadata.delete(:_compression)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module SharedBroker
6
+ module Compressor
7
+ class CompressionError < StandardError; end
8
+ class DecompressionError < StandardError; end
9
+
10
+ SUPPORTED_ALGORITHMS = %w[gzip deflate].freeze
11
+
12
+ def self.compress(data, algorithm)
13
+ validate_algorithm!(algorithm)
14
+
15
+ case algorithm.to_s
16
+ when "gzip"
17
+ Zlib.gzip(data)
18
+ when "deflate"
19
+ Zlib::Deflate.deflate(data)
20
+ end
21
+ rescue => e
22
+ raise CompressionError, "Failed to compress data using #{algorithm.inspect}. Error: #{e.message}"
23
+ end
24
+
25
+ def self.decompress(data, algorithm)
26
+ validate_algorithm!(algorithm)
27
+
28
+ case algorithm.to_s
29
+ when "gzip"
30
+ Zlib.gunzip(data)
31
+ when "deflate"
32
+ Zlib::Inflate.inflate(data)
33
+ end
34
+ rescue => e
35
+ raise DecompressionError, "Failed to decompress data using #{algorithm.inspect}. Error: #{e.message}"
36
+ end
37
+
38
+ def self.validate_algorithm!(algorithm)
39
+ return if SUPPORTED_ALGORITHMS.include?(algorithm.to_s)
40
+
41
+ raise ArgumentError, "Unsupported compression algorithm: #{algorithm.inspect}. Expected one of: #{SUPPORTED_ALGORITHMS.inspect}"
42
+ end
43
+ private_class_method :validate_algorithm!
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedBroker
4
+ module DLQ
5
+ class Redriver
6
+ def self.redrive(client, dlq_name, original_topic, limit: nil)
7
+ adapter = client.send(:resolve_adapter, original_topic)
8
+ adapter.redrive_dlq(dlq_name, original_topic, limit: limit)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedBroker
4
+ module KeyProvider
5
+ class KeyNotFoundError < StandardError; end
6
+
7
+ # Static key provider for backward compatibility
8
+ class Static
9
+ def initialize(key)
10
+ raise ArgumentError, "Expected key to be a 32-byte String, got #{key.inspect}" unless key.is_a?(String)
11
+ @key = key
12
+ end
13
+
14
+ def key_for(_topic)
15
+ @key
16
+ end
17
+
18
+ def key_for_id(_key_id)
19
+ @key
20
+ end
21
+
22
+ def active_key_id_for(_topic)
23
+ nil
24
+ end
25
+ end
26
+
27
+ # Flexible registry for multiple keys and topic-based routing patterns
28
+ class Registry
29
+ def initialize(keys: {}, active_keys: {})
30
+ validate_inputs!(keys, active_keys)
31
+ @keys = keys.transform_keys(&:to_s)
32
+ @active_keys = active_keys.transform_keys(&:to_s)
33
+ end
34
+
35
+ def key_for(topic)
36
+ key_id = active_key_id_for(topic)
37
+ key_for_id(key_id)
38
+ end
39
+
40
+ def key_for_id(key_id)
41
+ return nil if key_id.nil?
42
+
43
+ key = @keys[key_id.to_s]
44
+ return key if key
45
+
46
+ raise KeyNotFoundError, "Key ID #{key_id.inspect} not found in registered keys. Available keys: #{@keys.keys.inspect}"
47
+ end
48
+
49
+ def active_key_id_for(topic)
50
+ topic_str = topic.to_s
51
+ return @active_keys[topic_str] if @active_keys.key?(topic_str)
52
+
53
+ pattern = find_matching_pattern(topic_str)
54
+ return @active_keys[pattern] if pattern
55
+
56
+ fallback_key_id
57
+ end
58
+
59
+ private
60
+
61
+ def validate_inputs!(keys, active_keys)
62
+ unless keys.is_a?(Hash) && active_keys.is_a?(Hash)
63
+ raise ArgumentError, "Expected keys and active_keys to be Hashes, got keys: #{keys.inspect}, active_keys: #{active_keys.inspect}"
64
+ end
65
+ end
66
+
67
+ def find_matching_pattern(topic_str)
68
+ @active_keys.keys.find do |pattern|
69
+ pattern != "*" && File.fnmatch?(pattern, topic_str)
70
+ end
71
+ end
72
+
73
+ def fallback_key_id
74
+ @active_keys["*"] || raise(KeyNotFoundError, "No active key configuration found for fallback '*' pattern")
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedBroker
4
+ module Middlewares
5
+ class Idempotency
6
+ class MemoryStore
7
+ def initialize
8
+ @store = {}
9
+ end
10
+
11
+ def exists?(key)
12
+ prune
13
+ @store.key?(key)
14
+ end
15
+
16
+ def write(key, value, expires_in: 3600)
17
+ @store[key] = Time.now + expires_in
18
+ end
19
+
20
+ def prune
21
+ now = Time.now
22
+ @store.delete_if { |_, expiry| expiry < now }
23
+ end
24
+ end
25
+
26
+ def initialize(store: nil, expires_in: 3600)
27
+ @store = store || MemoryStore.new
28
+ @expires_in = expires_in
29
+ end
30
+
31
+ def call(topic, message, metadata)
32
+ return yield unless metadata[:operation] == :subscribe
33
+
34
+ correlation_id = metadata[:correlation_id] || message[:_correlation_id]
35
+ return yield unless correlation_id
36
+
37
+ key = "shared_broker:idempotency:#{topic}:#{correlation_id}"
38
+ return if duplicate?(key)
39
+
40
+ mark_processed(key)
41
+ yield
42
+ end
43
+
44
+ private
45
+
46
+ def duplicate?(key)
47
+ if @store.respond_to?(:exist?)
48
+ @store.exist?(key)
49
+ elsif @store.respond_to?(:exists?)
50
+ @store.exists?(key)
51
+ else
52
+ false
53
+ end
54
+ end
55
+
56
+ def mark_processed(key)
57
+ @store.write(key, true, expires_in: @expires_in)
58
+ rescue ArgumentError
59
+ @store.write(key, @expires_in)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -29,19 +29,42 @@ module SharedBroker
29
29
 
30
30
  def clear_cache
31
31
  @cache.clear
32
+ if SharedBroker.cache_store.respond_to?(:clear)
33
+ SharedBroker.cache_store.clear
34
+ end
32
35
  end
33
36
 
34
37
  private
35
38
 
36
39
  def fetch_schema(topic)
37
- cached = @cache[topic.to_s]
38
- return cached[:schema] if cached && cached[:expires_at] > Time.now
40
+ cache_key = "shared_broker:schema:#{topic}"
41
+ cached_schema = read_cache(cache_key)
42
+ return cached_schema if cached_schema
39
43
 
40
44
  schema = download_schema(topic)
41
- @cache[topic.to_s] = { schema: schema, expires_at: Time.now + @cache_ttl } if schema
45
+ write_cache(cache_key, schema) if schema
42
46
  schema
43
47
  end
44
48
 
49
+ def read_cache(key)
50
+ if SharedBroker.cache_store
51
+ return SharedBroker.cache_store.read(key)
52
+ end
53
+
54
+ cached = @cache[key]
55
+ return cached[:schema] if cached && cached[:expires_at] > Time.now
56
+ nil
57
+ end
58
+
59
+ def write_cache(key, value)
60
+ if SharedBroker.cache_store
61
+ SharedBroker.cache_store.write(key, value, expires_in: @cache_ttl)
62
+ return
63
+ end
64
+
65
+ @cache[key] = { schema: value, expires_at: Time.now + @cache_ttl }
66
+ end
67
+
45
68
  def download_schema(topic)
46
69
  uri = URI("#{@base_url}/schemas/#{topic}.json")
47
70
  request = Net::HTTP::Get.new(uri)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharedBroker
4
- VERSION = "1.5.0"
4
+ VERSION = "1.8.0"
5
5
  end
data/lib/shared_broker.rb CHANGED
@@ -8,10 +8,14 @@ require_relative "shared_broker/schema_registry/providers/local"
8
8
  require_relative "shared_broker/schema_registry/providers/http"
9
9
  require_relative "shared_broker/validation"
10
10
  require_relative "shared_broker/cipher"
11
+ require_relative "shared_broker/key_provider"
12
+ require_relative "shared_broker/compressor"
11
13
  require_relative "shared_broker/concurrency/semaphore"
12
14
  require_relative "shared_broker/concurrency/limiter"
13
15
  require_relative "shared_broker/middleware_pipeline"
14
16
  require_relative "shared_broker/middlewares/open_telemetry_propagation"
17
+ require_relative "shared_broker/middlewares/idempotency"
18
+ require_relative "shared_broker/dlq/redriver"
15
19
  require_relative "shared_broker/adapters/base"
16
20
  require_relative "shared_broker/adapters/in_memory"
17
21
  require_relative "shared_broker/adapters/rabbit_mq"
@@ -19,28 +23,73 @@ require_relative "shared_broker/adapters/kafka"
19
23
  require_relative "shared_broker/adapters/redis"
20
24
 
21
25
  module SharedBroker
26
+ ShutdownError = Class.new(StandardError)
27
+
22
28
  class << self
23
- attr_accessor :encryption_key
29
+ attr_accessor :encryption_key, :key_provider, :compression_algorithm, :compression_threshold, :cache_store, :shutdown_requested, :registered_clients
24
30
  end
25
31
 
26
32
  # Default key for development/test if not set
27
33
  @encryption_key = ENV.fetch("SHARED_BROKER_ENCRYPTION_KEY") { "a" * 32 }
34
+ @key_provider = nil
35
+ @compression_algorithm = nil
36
+ @compression_threshold = 1024
37
+ @cache_store = nil
38
+ @shutdown_requested = false
39
+ @registered_clients = []
40
+ @registered_clients_mutex = Mutex.new
41
+
42
+ def self.register_client(client)
43
+ @registered_clients_mutex.synchronize do
44
+ @registered_clients << client
45
+ end
46
+ end
47
+
48
+ def self.shutdown!(timeout: 10)
49
+ @shutdown_requested = true
50
+
51
+ threads = @registered_clients_mutex.synchronize do
52
+ @registered_clients.flat_map(&:active_threads)
53
+ end
54
+
55
+ threads.each { |t| t.join(timeout) }
56
+ end
57
+
58
+ def self.reset_shutdown!
59
+ @shutdown_requested = false
60
+ end
28
61
 
29
62
  class Client
30
63
  attr_reader :circuit_breaker, :middleware_pipeline, :adapters, :routing
31
64
 
32
- def initialize(adapter: nil, adapters: nil, routing: nil, circuit_breaker: nil, middlewares: nil)
65
+ def initialize(adapter: nil, adapters: nil, routing: nil, circuit_breaker: nil, middlewares: nil, key_provider: nil)
33
66
  setup_adapters(adapter: adapter, adapters: adapters, routing: routing)
34
67
  @circuit_breaker = circuit_breaker || CircuitBreaker.new
35
68
  resolved_middlewares = middlewares || [SharedBroker::Middlewares::OpenTelemetryPropagation.new]
36
69
  @middleware_pipeline = MiddlewarePipeline.new(resolved_middlewares)
70
+ @key_provider = key_provider
71
+ @running_threads = []
72
+ @running_threads_mutex = Mutex.new
73
+ SharedBroker.register_client(self)
74
+ end
75
+
76
+ def register_thread(thread)
77
+ @running_threads_mutex.synchronize do
78
+ @running_threads << thread
79
+ end
80
+ end
81
+
82
+ def active_threads
83
+ @running_threads_mutex.synchronize do
84
+ @running_threads.select(&:alive?)
85
+ end
37
86
  end
38
87
 
39
88
  def publish(topic, message, correlation_id: nil)
40
89
  metadata = { correlation_id: correlation_id, operation: :publish }
41
90
  @middleware_pipeline.execute(topic, message, metadata) do
42
91
  SharedBroker::Validation.validate!(topic, message)
43
- encrypted_msg = SharedBroker::Cipher.encrypt(message, SharedBroker.encryption_key)
92
+ encrypted_msg = SharedBroker::Cipher.encrypt(message, active_key_provider, topic: topic)
44
93
 
45
94
  @circuit_breaker.run do
46
95
  resolve_adapter(topic).publish(topic, encrypted_msg, correlation_id: correlation_id)
@@ -48,6 +97,24 @@ module SharedBroker
48
97
  end
49
98
  end
50
99
 
100
+ def publish_batch(topic, messages, correlation_id: nil)
101
+ unless messages.is_a?(Array)
102
+ raise ArgumentError, "Expected messages to be an Array, got #{messages.class} with value #{messages.inspect}"
103
+ end
104
+
105
+ processed_messages = messages.map do |message|
106
+ SharedBroker::Validation.validate!(topic, message)
107
+ SharedBroker::Cipher.encrypt(message, active_key_provider, topic: topic)
108
+ end
109
+
110
+ metadata = { correlation_id: correlation_id, operation: :publish_batch }
111
+ @middleware_pipeline.execute(topic, processed_messages, metadata) do
112
+ @circuit_breaker.run do
113
+ resolve_adapter(topic).publish_batch(topic, processed_messages, correlation_id: correlation_id)
114
+ end
115
+ end
116
+ end
117
+
51
118
  def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, max_concurrency: nil, backpressure_check: nil, backpressure_backoff: 1.0, &block)
52
119
  limiter = SharedBroker::Concurrency::Limiter.new(
53
120
  max_concurrency: max_concurrency,
@@ -55,9 +122,13 @@ module SharedBroker
55
122
  backpressure_backoff: backpressure_backoff
56
123
  )
57
124
 
58
- resolve_adapter(topic).subscribe(topic, queue_name, max_retries: max_retries, backoff_base: backoff_base) do |raw_message|
125
+ res = resolve_adapter(topic).subscribe(topic, queue_name, max_retries: max_retries, backoff_base: backoff_base) do |raw_message|
126
+ raise SharedBroker::ShutdownError, "Shutdown requested" if SharedBroker.shutdown_requested
127
+
59
128
  limiter.run do
60
- decrypted_msg = SharedBroker::Cipher.decrypt(raw_message, SharedBroker.encryption_key)
129
+ raise SharedBroker::ShutdownError, "Shutdown requested" if SharedBroker.shutdown_requested
130
+
131
+ decrypted_msg = SharedBroker::Cipher.decrypt(raw_message, active_key_provider, topic: topic)
61
132
  SharedBroker::Validation.validate!(topic, decrypted_msg)
62
133
 
63
134
  metadata = { correlation_id: decrypted_msg[:_correlation_id], operation: :subscribe, queue_name: queue_name }
@@ -66,10 +137,17 @@ module SharedBroker
66
137
  end
67
138
  end
68
139
  end
140
+
141
+ register_thread(res) if res.is_a?(Thread)
142
+ res
69
143
  end
70
144
 
71
145
  private
72
146
 
147
+ def active_key_provider
148
+ @key_provider || SharedBroker.key_provider || SharedBroker::KeyProvider::Static.new(SharedBroker.encryption_key)
149
+ end
150
+
73
151
  def setup_adapters(adapter: nil, adapters: nil, routing: nil)
74
152
  if adapter || (adapters.nil? && routing.nil?)
75
153
  validate_single_adapter!(adapter)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shared_broker
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wesley Lima
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-10 00:00:00.000000000 Z
11
+ date: 2026-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -155,9 +155,13 @@ files:
155
155
  - lib/shared_broker/adapters/redis.rb
156
156
  - lib/shared_broker/cipher.rb
157
157
  - lib/shared_broker/circuit_breaker.rb
158
+ - lib/shared_broker/compressor.rb
158
159
  - lib/shared_broker/concurrency/limiter.rb
159
160
  - lib/shared_broker/concurrency/semaphore.rb
161
+ - lib/shared_broker/dlq/redriver.rb
162
+ - lib/shared_broker/key_provider.rb
160
163
  - lib/shared_broker/middleware_pipeline.rb
164
+ - lib/shared_broker/middlewares/idempotency.rb
161
165
  - lib/shared_broker/middlewares/open_telemetry_propagation.rb
162
166
  - lib/shared_broker/schema_registry.rb
163
167
  - lib/shared_broker/schema_registry/providers/http.rb