harmoniser 0.13.0 → 0.15.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: 71c030f6ce0d93a7e9bb55a88c3d3cad300f4ab05809944e08442d2bca43cafb
4
- data.tar.gz: da8f3f99f2a9846b35b10a8e986f28e53087a81d2a1a6fd149778e3d82222de1
3
+ metadata.gz: 1d5d92e016161196555365ed7937a455d1e4765c955afa173f59ebde8323a51d
4
+ data.tar.gz: 735e2b70476779ce10ab83ad2382d70b9ac95598dbdf7d251e90c18ffd025203
5
5
  SHA512:
6
- metadata.gz: 0774eab4d7682a72e880e764acdb55e05e6e9d10048bbdb3e74ed010293d6dc8c416d9de9d73c3a6752e3c9d1ace45d70dc212a3cc01fb2a33b77d4a494d56d2
7
- data.tar.gz: 2eebe48b9779d08b0e516d13daa9f94b358f851de3999d4ca0ee600cab89ae48396bdd351ff583bc9be566e44f113f2fe14c0ef59ce3378d5fe1f35d88802c83
6
+ metadata.gz: b5e54c2077c6fe92dfb649e3ae6d1407b97db2ef8226cf7ef9baad6d700dd11bb24246388fe883de4eb0e24f989cafe7c88c7d8e4c645bb8f7a44edca0719d34
7
+ data.tar.gz: 77c5675b22841464d6381e9285b0e1485ab76f0178dd257ec0b8f8463d35b7e5f55a55ab3f9d5333a425bc4de4efac833810a9a2069bb729bb6bdc3b5fe65c89
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.0] - 2026-03-03
4
+
5
+ ### Changed
6
+ - Close all channels tracked by `Harmoniser::Connection` before closing the connection. This ensures consumers are cancelled and channels are closed while the connection is still open, preventing `Bunny::ConnectionClosedError` that could occur when a consumer acknowledges a message concurrently during shutdown
7
+ - Introduce `Harmoniser::Channel#close` and `Harmoniser::Channel#open?` to expose channel lifecycle as part of the public interface
8
+ - Inject logger into `Harmoniser::Channel` for consistent logger usage across the codebase
9
+
10
+ ## [0.14.0] - 2025-11-25
11
+
12
+ ### Added
13
+ - Introduce Mock functionality for testing without RabbitMQ dependency. Usage:
14
+
15
+ ```ruby
16
+ require "harmoniser/mock"
17
+ Harmoniser::Mock.mock!
18
+
19
+ # Your Publisher/Subscriber code works the same but without RabbitMQ
20
+ class TestPublisher
21
+ include Harmoniser::Publisher
22
+ harmoniser_publisher exchange_name: "my_exchange"
23
+ end
24
+
25
+ exchange = TestPublisher.publish("message", routing_key: "test")
26
+ published_messages = exchange.published_messages # Access captured messages
27
+ ```
28
+
29
+ - Add examples for mock publishing and topology under `examples/` folder
30
+ - Create Channel wrapper (`Harmoniser::Channel`) to isolate RabbitMQ dependencies
31
+
32
+ ### Changed
33
+ - Remove explicit dependencies to Bunny classes except for Consumer, enabling better RabbitMQ isolation through a single, well-defined interface
34
+ - Use Channel wrapper everywhere except for subscriptions (which still require actual Channel instance for Bunny::Consumer)
35
+
36
+
3
37
  ## [0.13.0] - 2025-05-19
4
38
 
5
39
  ### Added
@@ -0,0 +1,75 @@
1
+ require "forwardable"
2
+
3
+ module Harmoniser
4
+ class Channel
5
+ extend Forwardable
6
+
7
+ def_delegators :@bunny_channel,
8
+ :exchange,
9
+ :open?,
10
+ :queue,
11
+ :queue_bind
12
+
13
+ attr_reader :bunny_channel
14
+
15
+ def initialize(bunny_channel, logger: Harmoniser.logger)
16
+ @bunny_channel = bunny_channel
17
+ @logger = logger
18
+ after_initialize
19
+ end
20
+
21
+ def close
22
+ @bunny_channel.close
23
+ rescue => e
24
+ @logger.warn("Failed to close channel: exception = `#{e.detailed_message}`")
25
+ false
26
+ end
27
+
28
+ private
29
+
30
+ def after_initialize
31
+ bunny_channel.cancel_consumers_before_closing!
32
+ attach_callbacks
33
+ end
34
+
35
+ def attach_callbacks
36
+ bunny_channel.on_error(&method(:on_error_callback).to_proc)
37
+ bunny_channel.on_uncaught_exception(&method(:on_uncaught_exception_callback).to_proc)
38
+ end
39
+
40
+ def on_error_callback(channel, amq_method)
41
+ attributes = {
42
+ amq_method: amq_method,
43
+ exchanges: channel.exchanges.keys,
44
+ queues: channel.consumers.values.map(&:queue)
45
+ }
46
+
47
+ if amq_method.is_a?(AMQ::Protocol::Channel::Close)
48
+ attributes[:reply_code] = amq_method.reply_code
49
+ attributes[:reply_text] = amq_method.reply_text
50
+ end
51
+
52
+ stringified_attributes = attributes.map { |k, v| "#{k} = `#{v}`" }.join(", ")
53
+ @logger.warn("Default on_error handler executed for channel: #{stringified_attributes}")
54
+ maybe_kill_process(amq_method)
55
+ end
56
+
57
+ def on_uncaught_exception_callback(error, consumer)
58
+ handle_error(error, {description: "Uncaught exception from consumer", arguments: consumer.arguments, channel_id: consumer.channel.id, consumer_tag: consumer.consumer_tag, exclusive: consumer.exclusive, no_ack: consumer.no_ack, queue: consumer.queue})
59
+ end
60
+
61
+ def maybe_kill_process(amq_method)
62
+ Process.kill("USR1", Process.pid) if ack_timed_out?(amq_method) && Harmoniser.server?
63
+ end
64
+
65
+ def ack_timed_out?(amq_method)
66
+ return false unless amq_method.is_a?(AMQ::Protocol::Channel::Close)
67
+
68
+ amq_method.reply_text =~ /delivery acknowledgement on channel \d+ timed out/
69
+ end
70
+
71
+ def handle_error(exception, ctx)
72
+ Harmoniser.configuration.handle_error(exception, ctx)
73
+ end
74
+ end
75
+ end
@@ -1,4 +1,5 @@
1
1
  require "harmoniser/connection"
2
+ require "harmoniser/channel"
2
3
 
3
4
  module Harmoniser
4
5
  module Connectable
@@ -20,52 +21,8 @@ module Harmoniser
20
21
  def create_channel(consumer_pool_size: 1, consumer_pool_shutdown_timeout: 60)
21
22
  connection
22
23
  .create_channel(nil, consumer_pool_size, false, consumer_pool_shutdown_timeout)
23
- .tap do |channel|
24
- channel.cancel_consumers_before_closing!
25
- attach_callbacks(channel)
26
- end
27
- end
28
-
29
- private
30
-
31
- def attach_callbacks(channel)
32
- channel.on_error(&method(:on_error).to_proc)
33
- channel.on_uncaught_exception(&method(:on_uncaught_exception).to_proc)
34
- end
35
-
36
- def on_error(channel, amq_method)
37
- attributes = {
38
- amq_method: amq_method,
39
- exchanges: channel.exchanges.keys,
40
- queues: channel.consumers.values.map(&:queue)
41
- }
42
-
43
- if amq_method.is_a?(AMQ::Protocol::Channel::Close)
44
- attributes[:reply_code] = amq_method.reply_code
45
- attributes[:reply_text] = amq_method.reply_text
46
- end
47
-
48
- stringified_attributes = attributes.map { |k, v| "#{k} = `#{v}`" }.join(", ")
49
- Harmoniser.logger.warn("Default on_error handler executed for channel: #{stringified_attributes}")
50
- maybe_kill_process(amq_method)
51
- end
52
-
53
- def on_uncaught_exception(error, consumer)
54
- handle_error(error, {description: "Uncaught exception from consumer", arguments: consumer.arguments, channel_id: consumer.channel.id, consumer_tag: consumer.consumer_tag, exclusive: consumer.exclusive, no_ack: consumer.no_ack, queue: consumer.queue})
55
- end
56
-
57
- def maybe_kill_process(amq_method)
58
- Process.kill("USR1", Process.pid) if ack_timed_out?(amq_method) && Harmoniser.server?
59
- end
60
-
61
- def ack_timed_out?(amq_method)
62
- return false unless amq_method.is_a?(AMQ::Protocol::Channel::Close)
63
-
64
- amq_method.reply_text =~ /delivery acknowledgement on channel \d+ timed out/
65
- end
66
-
67
- def handle_error(exception, ctx)
68
- Harmoniser.configuration.handle_error(exception, ctx)
24
+ .yield_self { |bunny_channel| Channel.new(bunny_channel) }
25
+ .tap { |channel| connection.register_channel(channel) }
69
26
  end
70
27
  end
71
28
 
@@ -25,11 +25,16 @@ module Harmoniser
25
25
  def initialize(opts, error_handler: ErrorHandler.default, logger: Harmoniser.logger)
26
26
  @error_handler = error_handler
27
27
  @logger = logger
28
+ @channels = []
28
29
  @bunny = Bunny.new(maybe_dynamic_opts(opts)).tap do |bunny|
29
30
  attach_callbacks(bunny)
30
31
  end
31
32
  end
32
33
 
34
+ def register_channel(channel)
35
+ @channels << channel
36
+ end
37
+
33
38
  def to_s
34
39
  "<#{self.class.name}>:#{object_id} #{user}@#{host}:#{port}, connection_name = `#{connection_name}`, vhost = `#{vhost}`"
35
40
  end
@@ -54,6 +59,7 @@ module Harmoniser
54
59
 
55
60
  def close
56
61
  @logger.info("Connection will be closed: connection = `#{self}`")
62
+ close_channels
57
63
  @bunny.close.tap do
58
64
  @logger.info("Connection closed: connection = `#{self}`")
59
65
  end
@@ -64,6 +70,10 @@ module Harmoniser
64
70
 
65
71
  private
66
72
 
73
+ def close_channels
74
+ @channels.each { |channel| channel.close if channel.open? }
75
+ end
76
+
67
77
  def attach_callbacks(bunny)
68
78
  bunny.on_blocked do |blocked|
69
79
  @logger.warn("Connection blocked: connection = `#{self}`, reason = `#{blocked.reason}`")
@@ -12,7 +12,7 @@ module Harmoniser
12
12
  end
13
13
 
14
14
  def channel
15
- @channel ||= Subscriber.create_channel(consumer_pool_size: @configuration.concurrency, consumer_pool_shutdown_timeout: @configuration.timeout)
15
+ @channel ||= Subscriber.create_channel(consumer_pool_size: @configuration.concurrency, consumer_pool_shutdown_timeout: @configuration.timeout).bunny_channel
16
16
  end
17
17
  end
18
18
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harmoniser
4
+ module Mock
5
+ class Channel
6
+ class MockExchange
7
+ attr_reader :name, :type, :opts
8
+
9
+ def initialize(name, opts = {})
10
+ @name = name
11
+ @type = opts[:type]
12
+ @opts = opts.except(:type)
13
+ @published_messages = []
14
+ @return_handler = nil
15
+ end
16
+
17
+ def publish(payload, opts = {})
18
+ @published_messages << {payload: payload, opts: opts}
19
+ true
20
+ end
21
+
22
+ def on_return(&block)
23
+ @return_handler = block
24
+ self
25
+ end
26
+
27
+ def published_messages
28
+ @published_messages.dup
29
+ end
30
+
31
+ def reset!
32
+ @published_messages.clear
33
+ @return_handler = nil
34
+ end
35
+ end
36
+
37
+ class MockQueue
38
+ attr_reader :name, :opts
39
+
40
+ def initialize(name, opts = {})
41
+ @name = name
42
+ @opts = opts
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @exchanges = {}
48
+ @queues = {}
49
+ @bindings = []
50
+ end
51
+
52
+ def exchange(name, opts = {})
53
+ @exchanges[name] ||= MockExchange.new(name, opts)
54
+ end
55
+
56
+ def queue(name, opts = {})
57
+ @queues[name] ||= MockQueue.new(name, opts)
58
+ end
59
+
60
+ def queue_bind(destination_name, exchange_name, opts = {})
61
+ @bindings << {
62
+ destination_name: destination_name,
63
+ exchange_name: exchange_name,
64
+ opts: opts
65
+ }
66
+ true
67
+ end
68
+
69
+ def exchanges
70
+ @exchanges.dup
71
+ end
72
+
73
+ def queues
74
+ @queues.dup
75
+ end
76
+
77
+ def bindings
78
+ @bindings.dup
79
+ end
80
+
81
+ def reset!
82
+ @exchanges.clear
83
+ @queues.clear
84
+ @bindings.clear
85
+ end
86
+
87
+ def bunny_channel
88
+ raise "Cannot access bunny_channel in mock mode. Mock mode is intended for testing only and cannot be used when running Harmoniser as a server process."
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "harmoniser/mock/channel"
4
+
5
+ module Harmoniser
6
+ module Mock
7
+ class Connection
8
+ def initialize(opts = {}, error_handler: nil, logger: nil)
9
+ @opts = opts
10
+ @error_handler = error_handler
11
+ @logger = logger
12
+ @open = false
13
+ end
14
+
15
+ def create_channel(id = nil, consumer_pool_size = 1, consumer_pool_ack = false, consumer_pool_shutdown_timeout = 60)
16
+ Channel.new
17
+ end
18
+
19
+ def open?
20
+ @open
21
+ end
22
+
23
+ def recovering_from_network_failure?
24
+ false
25
+ end
26
+
27
+ def start
28
+ @open = true
29
+ self
30
+ end
31
+
32
+ def close
33
+ @open = false
34
+ true
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "harmoniser/mock/connection"
4
+ require "harmoniser/mock/channel"
5
+ require "harmoniser/connectable"
6
+
7
+ module Harmoniser
8
+ module Mock
9
+ @mocked = false
10
+ @prepended = false
11
+
12
+ class << self
13
+ def mock!
14
+ unless @prepended
15
+ @prepended = true
16
+ Harmoniser::Connectable::ClassMethods.prepend(MockConnectableMethods)
17
+ end
18
+ @mocked = true
19
+ end
20
+
21
+ def disable!
22
+ @mocked = false
23
+ end
24
+
25
+ def mocked?
26
+ @mocked
27
+ end
28
+
29
+ def disabled?
30
+ !@mocked
31
+ end
32
+ end
33
+
34
+ module MockConnectableMethods
35
+ def connection(configuration = Harmoniser.configuration)
36
+ return super unless Harmoniser::Mock.mocked?
37
+
38
+ Harmoniser::Connectable::MUTEX.synchronize do
39
+ @mock_connection ||= Harmoniser::Mock::Connection.new(configuration.connection_opts, error_handler: configuration.error_handler)
40
+ @mock_connection.start unless @mock_connection.open? || @mock_connection.recovering_from_network_failure?
41
+ @mock_connection
42
+ end
43
+ end
44
+
45
+ def connection?
46
+ return super unless Harmoniser::Mock.mocked?
47
+ !!defined?(@mock_connection)
48
+ end
49
+
50
+ def create_channel(consumer_pool_size: 1, consumer_pool_shutdown_timeout: 60)
51
+ return super unless Harmoniser::Mock.mocked?
52
+ connection.create_channel(nil, consumer_pool_size, false, consumer_pool_shutdown_timeout)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -33,12 +33,12 @@ module Harmoniser
33
33
  end
34
34
 
35
35
  def create_exchange
36
- exchange = Bunny::Exchange.new(
37
- Publisher.create_channel,
38
- @harmoniser_exchange_definition.type,
39
- @harmoniser_exchange_definition.name,
40
- @harmoniser_exchange_definition.opts
41
- )
36
+ exchange = Publisher
37
+ .create_channel
38
+ .exchange(
39
+ @harmoniser_exchange_definition.name,
40
+ {type: @harmoniser_exchange_definition.type}.merge(@harmoniser_exchange_definition.opts)
41
+ )
42
42
  handle_return(exchange)
43
43
  exchange
44
44
  end
@@ -41,7 +41,7 @@ module Harmoniser
41
41
  def create_consumer(channel)
42
42
  raise_missing_consumer_definition unless @harmoniser_consumer_definition
43
43
 
44
- ch = channel || Subscriber.create_channel
44
+ ch = channel || Subscriber.create_channel.bunny_channel
45
45
  consumer = Bunny::Consumer.new(
46
46
  ch,
47
47
  @harmoniser_consumer_definition.queue_name,
@@ -5,7 +5,7 @@ module Harmoniser
5
5
  class Topology
6
6
  include Connectable
7
7
 
8
- attr_reader :bindings, :exchanges, :queues
8
+ attr_reader :bindings, :exchanges, :queues, :declared_channel
9
9
 
10
10
  def initialize
11
11
  @bindings = Set.new
@@ -41,25 +41,26 @@ module Harmoniser
41
41
  end
42
42
 
43
43
  def declare
44
- self.class.create_channel.tap do |ch|
44
+ @declared_channel = self.class.create_channel.tap do |ch|
45
45
  declare_exchanges(ch)
46
46
  declare_queues(ch)
47
47
  declare_bindings(ch)
48
- ch.connection.close
49
48
  end
49
+ self.class.connection.close
50
+ self
50
51
  end
51
52
 
52
53
  private
53
54
 
54
55
  def declare_exchanges(channel)
55
56
  exchanges.each do |exchange|
56
- Bunny::Exchange.new(channel, exchange.type, exchange.name, exchange.opts)
57
+ channel.exchange(exchange.name, {type: exchange.type}.merge(exchange.opts))
57
58
  end
58
59
  end
59
60
 
60
61
  def declare_queues(channel)
61
62
  queues.each do |queue|
62
- Bunny::Queue.new(channel, queue.name, queue.opts)
63
+ channel.queue(queue.name, queue.opts)
63
64
  end
64
65
  end
65
66
 
@@ -1,3 +1,3 @@
1
1
  module Harmoniser
2
- VERSION = "0.13.0"
2
+ VERSION = "0.15.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harmoniser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Lloret
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-19 00:00:00.000000000 Z
11
+ date: 2026-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -42,6 +42,7 @@ files:
42
42
  - bin/setup
43
43
  - harmoniser.gemspec
44
44
  - lib/harmoniser.rb
45
+ - lib/harmoniser/channel.rb
45
46
  - lib/harmoniser/cli.rb
46
47
  - lib/harmoniser/configurable.rb
47
48
  - lib/harmoniser/configuration.rb
@@ -54,6 +55,9 @@ files:
54
55
  - lib/harmoniser/launcher/bounded.rb
55
56
  - lib/harmoniser/launcher/unbounded.rb
56
57
  - lib/harmoniser/loggable.rb
58
+ - lib/harmoniser/mock.rb
59
+ - lib/harmoniser/mock/channel.rb
60
+ - lib/harmoniser/mock/connection.rb
57
61
  - lib/harmoniser/options.rb
58
62
  - lib/harmoniser/parser.rb
59
63
  - lib/harmoniser/publisher.rb