bunny-publisher 0.1.6 → 0.2.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: 114f01661b4bf20f7b8e3e0c2a09dba2b969cac13f5b9e1140edd0a8048b8f86
4
- data.tar.gz: e3a18568ee95e4eca7d5093b9ed3326cffe71c1890c235903d9f4de6395b6efd
3
+ metadata.gz: 201692e546c2e4775b7312c3a2bef73caf0ded34abb7bde4f103c9e77500040c
4
+ data.tar.gz: c71d0c82c4ce663253430b8d24b41ec1818460b40d0d3da4d685ebf544bdde2c
5
5
  SHA512:
6
- metadata.gz: 528c55964119b778c6d7573a489a76b0c315002ccdfff3770a34d26439f76e050e8a629012fe76739cefaffad667201ba1a24b9fa409401fb50b07eb4e6972cd
7
- data.tar.gz: 933da9ba2d644c5138b4b091dede2935de7bab125cadd7007d1b4a7c311717ceb94bc417fe5251d77f7f11dbe7c757a2c45201d369044cb8b5fe14db6eea2a7e
6
+ metadata.gz: 1e7216b2416c32fb0cb5930faff903d49a287022fc8d60746b978f8bef944ee57ec7117a7a5ea5d3cc89f3cd510cb5e582966723cedc84f480d8c7f1c91368e2
7
+ data.tar.gz: fd85b2a03fe362ff0b1efe7e7229e552fdcab8c4adecf6186668e9fa7fda14accbbc0f355e3885f28f8a467797ea34458ddb194d4be0d8155d98723708aae7ce
@@ -4,9 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
6
 
7
- ## [Unreleased](https://github.com/veeqo/bunny-publisher/compare/v0.1.6...HEAD)
7
+ ## [Unreleased](https://github.com/veeqo/bunny-publisher/compare/v0.2.0...HEAD)
8
8
 
9
9
 
10
+ ## [0.2.0](https://github.com/veeqo/bunny-publisher/compare/v0.1.6...v0.2.0) - 2020-11-30
11
+
12
+ ### Changed
13
+ - [#9](https://github.com/veeqo/bunny-publisher/pull/9) Rework callbacks & Mandatory publisher
14
+ - [#10](https://github.com/veeqo/bunny-publisher/pull/10) Improve errors handling for cases when connection can be recovered
15
+
10
16
  ## [0.1.6](https://github.com/veeqo/bunny-publisher/compare/v0.1.5...v0.1.6) - 2020-11-24
11
17
 
12
18
  ### Fixed
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+
3
6
  require 'bunny'
4
7
  require 'bunny_publisher/version'
5
8
  require 'bunny_publisher/callbacks'
@@ -6,7 +6,13 @@ module BunnyPublisher
6
6
  class Base
7
7
  include Callbacks
8
8
 
9
- define_callbacks :after_publish, :before_publish, :around_publish
9
+ # A list of errors that can be fixed by a connection recovery
10
+ RETRIABLE_ERRORS = [
11
+ Bunny::ConnectionClosedError,
12
+ Bunny::NetworkFailure,
13
+ Bunny::ConnectionLevelException,
14
+ Timeout::Error # can be raised by Bunny::Channel#with_continuation_timeout
15
+ ].freeze
10
16
 
11
17
  attr_reader :connection, :channel, :exchange
12
18
 
@@ -24,15 +30,19 @@ module BunnyPublisher
24
30
  @connection = publish_connection || connection
25
31
  end
26
32
 
27
- def publish(message, options = {})
33
+ def publish(message, message_options = {})
28
34
  @mutex.synchronize do
29
- ensure_connection!
30
-
31
- run_callback(:before_publish, message, options)
32
- result = run_callback(:around_publish, message, options) { exchange.publish(message, options) }
33
- run_callback(:after_publish, message, options)
34
-
35
- result
35
+ @message = message
36
+ @message_options = message_options
37
+
38
+ run_callbacks(:publish) do
39
+ with_errors_handling do
40
+ ensure_connection!
41
+ exchange.publish(message, message_options.dup) # Bunny modifies message options
42
+ end
43
+ end
44
+ ensure
45
+ @message = @message_options = nil
36
46
  end
37
47
  end
38
48
 
@@ -44,29 +54,68 @@ module BunnyPublisher
44
54
 
45
55
  private
46
56
 
57
+ attr_reader :message, :message_options
58
+
59
+ delegate :logger, to: :connection
60
+
47
61
  def ensure_connection!
48
62
  @connection ||= build_connection
49
63
 
50
- connection.start if connection.status == :not_connected # Lazy connection initialization.
64
+ connection.start if should_start_connection?
51
65
 
52
- wait_until_connection_ready(connection)
66
+ wait_until_connection_ready
53
67
 
54
68
  @channel ||= connection.create_channel
55
69
  @exchange ||= build_exchange
56
70
  end
57
71
 
58
- def wait_until_connection_ready(conn)
59
- Timeout.timeout((conn.heartbeat || 60) * 2) do # 60 seconds is a default Bunny heartbeat
72
+ def reset_exchange!
73
+ ensure_connection!
74
+ @channel = connection.create_channel
75
+ @exchange = build_exchange
76
+ end
77
+
78
+ def wait_until_connection_ready
79
+ Timeout.timeout(recovery_timeout * 2) do
60
80
  loop do
61
- return if conn.status == :open && conn.transport.open?
81
+ return if connection_open? || !connection.automatically_recover?
62
82
 
63
- sleep 0.001
83
+ sleep 0.01
64
84
  end
65
85
  end
66
86
  rescue Timeout::Error
67
87
  # Connection recovery takes too long, let the next interaction fail with error then.
68
88
  end
69
89
 
90
+ def should_start_connection?
91
+ connection.status == :not_connected || # Lazy connection initialization
92
+ connection.closed?
93
+ end
94
+
95
+ def connection_can_recover?
96
+ connection.automatically_recover? && connection.should_retry_recovery?
97
+ end
98
+
99
+ def connection_open?
100
+ # Do not trust Bunny::Session#open? - it uses :connected & :connecting statuses as "open",
101
+ # while connection is not actually ready to work.
102
+ connection.instance_variable_get(:@status_mutex).synchronize do
103
+ connection.status == :open && connection.transport.open?
104
+ end
105
+ end
106
+
107
+ def recovery_timeout
108
+ # 60 seconds is a default heartbeat timeout https://www.rabbitmq.com/heartbeats.html#heartbeats-timeout
109
+ # Recommended timeout is 5-20 https://www.rabbitmq.com/heartbeats.html#false-positives
110
+ heartbeat_timeout = [
111
+ (connection.respond_to?(:heartbeat_timeout) ? connection.heartbeat_timeout : connection.heartbeat) || 60,
112
+ 5
113
+ ].max
114
+
115
+ # Using x2 of heartbeat timeout to get Bunny chance to detect connection failure & try to recover it
116
+ heartbeat_timeout * 2
117
+ end
118
+
70
119
  def build_connection
71
120
  Bunny.new(@options[:amqp] || ENV['RABBITMQ_URL'], @options)
72
121
  end
@@ -76,5 +125,17 @@ module BunnyPublisher
76
125
 
77
126
  channel.exchange(@exchange_name, @exchange_options)
78
127
  end
128
+
129
+ def with_errors_handling
130
+ yield
131
+ rescue Bunny::ChannelAlreadyClosed
132
+ reset_exchange!
133
+ retry
134
+ rescue *RETRIABLE_ERRORS => e
135
+ raise unless connection_can_recover?
136
+
137
+ logger.warn { e.inspect }
138
+ retry
139
+ end
79
140
  end
80
141
  end
@@ -1,41 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support'
4
+ require 'active_support/callbacks'
5
+
3
6
  module BunnyPublisher
4
- # Adds support for callbacks (one per event!)
7
+ # Adds support for callbacks
5
8
  module Callbacks
6
- def self.included(klass)
7
- klass.extend ClassMethods
9
+ extend ActiveSupport::Concern
10
+ include ActiveSupport::Callbacks
11
+
12
+ included do
13
+ define_callbacks :publish
8
14
  end
9
15
 
10
16
  module ClassMethods
11
- def define_callbacks(*events)
12
- events.each do |event|
13
- singleton_class.define_method(event) do |method_or_proc|
14
- callbacks[event] = method_or_proc
15
- end
16
- end
17
+ def before_publish(*filters, &blk)
18
+ set_callback(:publish, :before, *filters, &blk)
17
19
  end
18
20
 
19
- def callbacks
20
- @callbacks ||= {}
21
+ def around_publish(*filters, &blk)
22
+ set_callback(:publish, :around, *filters, &blk)
21
23
  end
22
- end
23
24
 
24
- private
25
-
26
- def run_callback(event, *args, &block)
27
- case (callback = callback_for_event(event))
28
- when nil
29
- yield if block_given?
30
- when Symbol
31
- send(callback, self, *args, &block)
32
- when Proc
33
- callback.call(self, *args, &block)
25
+ def after_publish(*filters, &blk)
26
+ set_callback(:publish, :after, *filters, &blk)
34
27
  end
35
28
  end
36
-
37
- def callback_for_event(event)
38
- self.class.callbacks[event]
39
- end
40
29
  end
41
30
  end
@@ -1,5 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BunnyPublisher
4
- class PublishError < StandardError; end
4
+ class ReturnedMessageError < StandardError; end
5
+
6
+ class CannotCreateQueue < ReturnedMessageError
7
+ def to_s
8
+ [
9
+ 'Can not create queue for re-publishing. Set queue_name, routing_key, '\
10
+ 'or override BunnyPublisher::Mandatory#declare_republish_queue',
11
+ super
12
+ ].join(' ')
13
+ end
14
+ end
5
15
  end
@@ -6,23 +6,44 @@ module BunnyPublisher
6
6
  # Creates queue/binding before re-publishing the same message again.
7
7
  # This publisher DUPLICATES the connection for re-publishing messages!
8
8
  module Mandatory
9
- def self.included(klass)
10
- klass.define_callbacks :on_message_return,
11
- :after_republish,
12
- :before_republish,
13
- :around_republish
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ define_callbacks :republish
13
+ end
14
+
15
+ module ClassMethods
16
+ attr_reader :on_message_return_callback
17
+
18
+ def before_republish(*filters, &blk)
19
+ set_callback(:republish, :before, *filters, &blk)
20
+ end
21
+
22
+ def around_republish(*filters, &blk)
23
+ set_callback(:republish, :around, *filters, &blk)
24
+ end
25
+
26
+ def after_republish(*filters, &blk)
27
+ set_callback(:republish, :after, *filters, &blk)
28
+ end
29
+
30
+ def on_message_return(method_or_proc)
31
+ unless method_or_proc.is_a?(Proc) || method_or_proc.is_a?(Symbol)
32
+ raise ArgumentError, "Method or Proc expected, #{method_or_proc.class} given"
33
+ end
34
+
35
+ @on_message_return_callback = method_or_proc
36
+ end
14
37
  end
15
38
 
16
39
  attr_reader :queue_name, :queue_options
17
40
 
18
- def initialize(republish_connection: nil, queue: nil, queue_options: {}, timeout_at_exit: 5, **options)
41
+ def initialize(queue: nil, queue_options: {}, timeout_at_exit: 5, **options)
19
42
  super(**options)
20
43
 
21
44
  @queue_name = queue
22
45
  @queue_options = queue_options
23
-
24
- @republish_mutex = Mutex.new
25
- @republish_connection = republish_connection
46
+ @returned_messages = ::Queue.new # ruby queue, not Bunny's one
26
47
 
27
48
  at_exit { wait_for_unrouted_messages_processing(timeout: timeout_at_exit) }
28
49
  end
@@ -31,101 +52,100 @@ module BunnyPublisher
31
52
  super(message, options.merge(mandatory: true))
32
53
  end
33
54
 
34
- def close
35
- republish_connection&.close
36
-
37
- super
38
- end
55
+ def declare_republish_queue
56
+ name = queue_name || message_options[:routing_key]
39
57
 
40
- alias stop close
58
+ ensure_can_create_queue!(name)
41
59
 
42
- def declare_republish_queue(return_info, _properties, _message)
43
- republish_channel.queue(queue_name || return_info.routing_key, queue_options)
60
+ channel.queue(name, queue_options)
44
61
  end
45
62
 
46
- def declare_republish_queue_binding(queue, return_info, _properties, _message)
47
- queue.bind(republish_exchange, routing_key: return_info.routing_key)
63
+ def declare_republish_queue_binding(queue)
64
+ routing_key = message_options[:routing_key] || queue_name
65
+
66
+ queue.bind(exchange, routing_key: routing_key)
48
67
  end
49
68
 
50
69
  private
51
70
 
52
- attr_reader :republish_connection, :republish_channel, :republish_exchange
71
+ attr_reader :returned_messages
53
72
 
54
73
  def ensure_connection!
55
74
  super
56
75
 
57
- return if @on_return_set
58
-
59
- # `on_return` is called within a frameset of amqp connection.
60
- # Any interaction within the same connection leads to error. This is why we need extra connection.
61
- # https://github.com/ruby-amqp/bunny/blob/7fb05abf36637557f75a69790be78f9cc1cea807/lib/bunny/session.rb#L683
62
- if callback_for_event(:on_message_return)
63
- exchange.on_return { |*attrs| run_callback(:on_message_return, *attrs) }
64
- else
65
- exchange.on_return { |*attrs| on_message_return(*attrs) }
66
- end
67
-
68
- @on_return_set = true
69
- end
70
-
71
- def ensure_republish_connection!
72
- @republish_connection ||= build_republish_connection
73
- republish_connection.start if republish_connection.status == :not_connected # Lazy connection initialization.
74
-
75
- wait_until_connection_ready(republish_connection)
76
-
77
- @republish_channel ||= republish_connection.create_channel
78
- @republish_exchange ||= clone_exchange_for_republish
76
+ configure_exchange_to_process_returns unless @on_return_set
79
77
  end
80
78
 
81
- def build_republish_connection
82
- Bunny.new(connection.instance_variable_get(:'@opts')) # TODO: find more elegant way to "clone" connection
79
+ def reset_exchange!
80
+ super
81
+ configure_exchange_to_process_returns
83
82
  end
84
83
 
85
- def clone_exchange_for_republish
86
- republish_channel.default_exchange if exchange.name == ''
84
+ def configure_exchange_to_process_returns
85
+ case (callback = self.class.on_message_return_callback)
86
+ when nil
87
+ exchange.on_return { |*attrs| on_message_return(*attrs) }
88
+ when Proc
89
+ exchange.on_return { |*attrs| callback.call(*attrs) }
90
+ when Symbol
91
+ exchange.on_return { |*attrs| send(callback, *attrs) }
92
+ end
87
93
 
88
- republish_channel.exchange exchange.name,
89
- exchange.instance_variable_get(:'@options').merge(type: exchange.type)
94
+ @on_return_set = true
90
95
  end
91
96
 
97
+ # `on_return` is called within a frameset of amqp connection.
98
+ # Any interaction within the same connection leads to error.
99
+ # This is why we process the returned message in a separate thread.
100
+ # https://github.com/ruby-amqp/bunny/blob/7fb05abf36637557f75a69790be78f9cc1cea807/lib/bunny/session.rb#L683
92
101
  def on_message_return(return_info, properties, message)
93
- @unrouted_message_processing = true
102
+ message_options = properties.to_h.merge(routing_key: return_info.routing_key).compact
94
103
 
95
- ensure_message_is_unrouted!(return_info, properties, message)
104
+ if return_info.reply_text == 'NO_ROUTE'
105
+ returned_messages << [message, message_options]
96
106
 
97
- setup_queue_for_republish(return_info, properties, message)
98
-
99
- run_callback(:before_republish, return_info, properties, message)
100
- result = run_callback(:around_republish, return_info, properties, message) do
101
- republish_exchange.publish(message, properties.to_h.merge(routing_key: return_info.routing_key))
107
+ Thread.new { process_returned_message }.tap do |thread|
108
+ thread.abort_on_exception = false
109
+ thread.report_on_exception = true
110
+ end
111
+ else
112
+ # Do not raise error here!
113
+ # The best we can do here is to log to STDERR
114
+ warn 'BunnyPublisher::UnsupportedReplyText: '\
115
+ 'Broker has returned the message with reply_text other than NO_ROUTE '\
116
+ "#{[return_info, properties, message]}"
102
117
  end
103
- run_callback(:after_republish, return_info, properties, message)
104
-
105
- result
106
- ensure
107
- @unrouted_message_processing = false
108
118
  end
109
119
 
110
- def setup_queue_for_republish(return_info, properties, message)
111
- @republish_mutex.synchronize do
112
- ensure_republish_connection!
113
-
114
- queue = declare_republish_queue(return_info, properties, message)
115
-
116
- # default exchange already has bindings with queues
117
- declare_republish_queue_binding(queue, return_info, properties, message) unless republish_exchange.name == ''
118
-
119
- republish_channel.deregister_queue(queue) # we are not going to work with this queue in this channel
120
+ def process_returned_message
121
+ @mutex.synchronize do
122
+ @unrouted_message_processing = true
123
+ @message, @message_options = returned_messages.pop
124
+
125
+ run_callbacks(:republish) do
126
+ with_errors_handling do
127
+ ensure_connection!
128
+ setup_queue_for_republish
129
+ exchange.publish(message, message_options.merge(mandatory: true))
130
+ end
131
+ end
132
+ ensure
133
+ @message = @message_options = nil
134
+ @unrouted_message_processing = false
120
135
  end
121
136
  end
122
137
 
123
- def ensure_message_is_unrouted!(return_info, properties, message)
124
- return if return_info.reply_text == 'NO_ROUTE'
138
+ def setup_queue_for_republish
139
+ queue = declare_republish_queue
140
+
141
+ # default exchange already has bindings with queues, but routing key is required
142
+ if exchange.name == ''
143
+ message_options[:routing_key] = queue.name
144
+ else
145
+ declare_republish_queue_binding(queue)
146
+ end
125
147
 
126
- raise BunnyPublisher::PublishError, message: message,
127
- return_info: return_info,
128
- properties: properties
148
+ channel.deregister_queue(queue) # we are not going to work with this queue in this channel
129
149
  end
130
150
 
131
151
  # TODO: introduce more reliable way to wait for handling of unrouted messages at exit
@@ -134,11 +154,18 @@ module BunnyPublisher
134
154
 
135
155
  return unless @unrouted_message_processing
136
156
 
137
- puts("Waiting up to #{timeout} seconds for unrouted messages handling")
157
+ logger.warn { "Waiting up to #{timeout} seconds for unrouted messages handling" }
138
158
 
139
159
  Timeout.timeout(timeout) { sleep 0.01 while @unrouted_message_processing }
140
160
  rescue Timeout::Error
141
- warn('Some unrouted messages are lost on process exit!')
161
+ logger.warn { 'Some unrouted messages are lost on process exit!' }
162
+ end
163
+
164
+ def ensure_can_create_queue!(name)
165
+ return if name.present?
166
+
167
+ raise BunnyPublisher::CannotCreateQueue, message: message,
168
+ message_options: message_options
142
169
  end
143
170
  end
144
171
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BunnyPublisher
4
- VERSION = '0.1.6'
4
+ VERSION = '0.2.0'
5
5
  end
metadata CHANGED
@@ -1,29 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bunny-publisher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rustam Sharshenov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-24 00:00:00.000000000 Z
11
+ date: 2020-11-30 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bunny
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - "~>"
18
32
  - !ruby/object:Gem::Version
19
- version: '2.10'
33
+ version: '2.17'
20
34
  type: :runtime
21
35
  prerelease: false
22
36
  version_requirements: !ruby/object:Gem::Requirement
23
37
  requirements:
24
38
  - - "~>"
25
39
  - !ruby/object:Gem::Version
26
- version: '2.10'
40
+ version: '2.17'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: appraisal
29
43
  requirement: !ruby/object:Gem::Requirement