bunny-publisher 0.1.6 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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