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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +96 -0
- data/lib/shared_broker/adapters/base.rb +29 -15
- data/lib/shared_broker/adapters/in_memory.rb +70 -51
- data/lib/shared_broker/adapters/kafka.rb +2 -0
- data/lib/shared_broker/adapters/rabbit_mq.rb +104 -84
- data/lib/shared_broker/adapters/redis.rb +80 -64
- data/lib/shared_broker/cipher.rb +114 -69
- data/lib/shared_broker/compressor.rb +45 -0
- data/lib/shared_broker/dlq/redriver.rb +12 -0
- data/lib/shared_broker/key_provider.rb +78 -0
- data/lib/shared_broker/middlewares/idempotency.rb +63 -0
- data/lib/shared_broker/schema_registry/providers/http.rb +26 -3
- data/lib/shared_broker/version.rb +1 -1
- data/lib/shared_broker.rb +83 -5
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 972f349828dcd6839a7e95c7e5d285603fcbec36c7ef52c4953e85ee67038f34
|
|
4
|
+
data.tar.gz: 19a1b4d987ea960bb1f2ab362bb3acc1afea685e6d4f019dfac14f5ec9b7c24c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
@@ -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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
data/lib/shared_broker/cipher.rb
CHANGED
|
@@ -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,
|
|
14
|
-
return payload_hash unless
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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)
|
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,
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|