shared_broker 1.6.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: 24d5ac30bc2bef8c5fd99edbcb8e7e986fa8005fde7cfdb39bfb4f3c6010efa8
4
- data.tar.gz: d66d983238edb492bddf145c48866ce83a55b88bf6806472910bdafdbbd418ec
3
+ metadata.gz: 972f349828dcd6839a7e95c7e5d285603fcbec36c7ef52c4953e85ee67038f34
4
+ data.tar.gz: 19a1b4d987ea960bb1f2ab362bb3acc1afea685e6d4f019dfac14f5ec9b7c24c
5
5
  SHA512:
6
- metadata.gz: c25d32bc67d7c28427c533b1f3e23b048a7513740f0e8a51e067f5f5d861b761a3189b7d84d06b977400b21bf022c003c50d4515e46cfdc6cee064d36acbdb38
7
- data.tar.gz: 1ab4165d6750a4f1d28f3ce67c070d58135d10c793b4eac944ba257b8b01f3dcd0665e36018aa0c32b27076439297e6e8ee0b789f5b943fa2820ef9472d2f09f
6
+ metadata.gz: 4d86395b12e3f37e71a5014ba8b6196436f4d28ed06cdc1227363a20be5e62943edf1280819d68cbafee1bf249aae89414a28da50abfbacefaaffb3e4dfcabe8
7
+ data.tar.gz: aef9fe8f2f60ccb9f3812470f98ddacccc75b2699d75e3f286525636fbdb5e278900e5befad76432c172c734cfc8de0058d7d00dcf349545f9106627edc3c669
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+
8
24
  ## [1.6.0] - 2026-06-16
9
25
 
10
26
  ### Added
data/README.md CHANGED
@@ -195,6 +195,24 @@ With a key provider registry configured:
195
195
  - **Subscribing**: The gem automatically reads the `_key_id` from the payload envelope and decrypts it using the correct historical key version.
196
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
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
+
198
216
  ---
199
217
 
200
218
  ## Usage
@@ -234,6 +252,49 @@ SPOT_BROKER.subscribe(
234
252
  end
235
253
  ```
236
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
+
237
298
  ---
238
299
 
239
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
@@ -22,13 +22,20 @@ module SharedBroker
22
22
 
23
23
  metadata = payload_hash.select { |k, _| k.to_s.start_with?("_") }
24
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
25
32
 
26
33
  cipher = OpenSSL::Cipher.new(ALGORITHM)
27
34
  cipher.encrypt
28
35
  cipher.key = key
29
36
  iv = cipher.random_iv
30
37
 
31
- encrypted_data = cipher.update(data_to_encrypt.to_json) + cipher.final
38
+ encrypted_data = cipher.update(json_data) + cipher.final
32
39
  auth_tag = cipher.auth_tag
33
40
 
34
41
  envelope = {
@@ -38,6 +45,7 @@ module SharedBroker
38
45
  _data: Base64.strict_encode64(encrypted_data)
39
46
  }
40
47
  envelope[:_key_id] = key_id.to_s if key_id
48
+ envelope[:_compression] = compression_alg.to_s if compression_alg
41
49
 
42
50
  metadata.merge(envelope)
43
51
  end
@@ -56,7 +64,14 @@ module SharedBroker
56
64
  cipher.auth_tag = Base64.strict_decode64(payload_hash[:_auth_tag])
57
65
 
58
66
  encrypted_bytes = Base64.strict_decode64(payload_hash[:_data])
59
- decrypted_json = cipher.update(encrypted_bytes) + cipher.final
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
60
75
 
61
76
  decrypted_data = JSON.parse(decrypted_json, symbolize_names: true)
62
77
 
@@ -93,6 +108,7 @@ module SharedBroker
93
108
  metadata.delete(:_auth_tag)
94
109
  metadata.delete(:_data)
95
110
  metadata.delete(:_key_id)
111
+ metadata.delete(:_compression)
96
112
  end
97
113
  end
98
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,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.6.0"
4
+ VERSION = "1.8.0"
5
5
  end
data/lib/shared_broker.rb CHANGED
@@ -9,10 +9,13 @@ require_relative "shared_broker/schema_registry/providers/http"
9
9
  require_relative "shared_broker/validation"
10
10
  require_relative "shared_broker/cipher"
11
11
  require_relative "shared_broker/key_provider"
12
+ require_relative "shared_broker/compressor"
12
13
  require_relative "shared_broker/concurrency/semaphore"
13
14
  require_relative "shared_broker/concurrency/limiter"
14
15
  require_relative "shared_broker/middleware_pipeline"
15
16
  require_relative "shared_broker/middlewares/open_telemetry_propagation"
17
+ require_relative "shared_broker/middlewares/idempotency"
18
+ require_relative "shared_broker/dlq/redriver"
16
19
  require_relative "shared_broker/adapters/base"
17
20
  require_relative "shared_broker/adapters/in_memory"
18
21
  require_relative "shared_broker/adapters/rabbit_mq"
@@ -20,13 +23,41 @@ require_relative "shared_broker/adapters/kafka"
20
23
  require_relative "shared_broker/adapters/redis"
21
24
 
22
25
  module SharedBroker
26
+ ShutdownError = Class.new(StandardError)
27
+
23
28
  class << self
24
- attr_accessor :encryption_key, :key_provider
29
+ attr_accessor :encryption_key, :key_provider, :compression_algorithm, :compression_threshold, :cache_store, :shutdown_requested, :registered_clients
25
30
  end
26
31
 
27
32
  # Default key for development/test if not set
28
33
  @encryption_key = ENV.fetch("SHARED_BROKER_ENCRYPTION_KEY") { "a" * 32 }
29
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
30
61
 
31
62
  class Client
32
63
  attr_reader :circuit_breaker, :middleware_pipeline, :adapters, :routing
@@ -37,6 +68,21 @@ module SharedBroker
37
68
  resolved_middlewares = middlewares || [SharedBroker::Middlewares::OpenTelemetryPropagation.new]
38
69
  @middleware_pipeline = MiddlewarePipeline.new(resolved_middlewares)
39
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
40
86
  end
41
87
 
42
88
  def publish(topic, message, correlation_id: nil)
@@ -51,6 +97,24 @@ module SharedBroker
51
97
  end
52
98
  end
53
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
+
54
118
  def subscribe(topic, queue_name, max_retries: 3, backoff_base: 2, max_concurrency: nil, backpressure_check: nil, backpressure_backoff: 1.0, &block)
55
119
  limiter = SharedBroker::Concurrency::Limiter.new(
56
120
  max_concurrency: max_concurrency,
@@ -58,8 +122,12 @@ module SharedBroker
58
122
  backpressure_backoff: backpressure_backoff
59
123
  )
60
124
 
61
- 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
+
62
128
  limiter.run do
129
+ raise SharedBroker::ShutdownError, "Shutdown requested" if SharedBroker.shutdown_requested
130
+
63
131
  decrypted_msg = SharedBroker::Cipher.decrypt(raw_message, active_key_provider, topic: topic)
64
132
  SharedBroker::Validation.validate!(topic, decrypted_msg)
65
133
 
@@ -69,6 +137,9 @@ module SharedBroker
69
137
  end
70
138
  end
71
139
  end
140
+
141
+ register_thread(res) if res.is_a?(Thread)
142
+ res
72
143
  end
73
144
 
74
145
  private
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.6.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-16 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,10 +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
160
162
  - lib/shared_broker/key_provider.rb
161
163
  - lib/shared_broker/middleware_pipeline.rb
164
+ - lib/shared_broker/middlewares/idempotency.rb
162
165
  - lib/shared_broker/middlewares/open_telemetry_propagation.rb
163
166
  - lib/shared_broker/schema_registry.rb
164
167
  - lib/shared_broker/schema_registry/providers/http.rb