sanger_warren 0.1.0 → 0.2.0.rc1

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.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Exceptions used by the warren gem
5
+ module Exceptions
6
+ # raise {Warren::Exceptions::TemporaryIssue} in a {Warren::Subscriber} to
7
+ # nack the message, requeuing it, and sending the consumers into sleep
8
+ # mode until the issue resolves itself.
9
+ TemporaryIssue = Class.new(StandardError)
10
+
11
+ # {Warren::Exceptions::Exceptions::MultipleAcknowledgements} is raised if a message
12
+ # is acknowledged, or rejected (nacked) multiple times.
13
+ MultipleAcknowledgements = Class.new(StandardError)
14
+ end
15
+ end
data/lib/warren/fox.rb ADDED
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'bunny'
5
+ require 'warren'
6
+ require 'warren/helpers/state_machine'
7
+ require 'warren/subscriber/base'
8
+ require 'warren/log_tagger'
9
+ require 'warren/framework_adaptor/rails_adaptor'
10
+
11
+ module Warren
12
+ # A fox is a rabbitMQ consumer. It handles subscription to the queue
13
+ # and passing message on to the registered Subscriber
14
+ class Fox
15
+ # A little cute fox emoji to easily flag output from the consumers
16
+ FOX = '🦊'
17
+
18
+ extend Forwardable
19
+ extend Warren::Helpers::StateMachine
20
+ # Maximum wait time between database retries: 5 minutes
21
+ MAX_RECONNECT_DELAY = 60 * 5
22
+
23
+ attr_reader :state, :subscription, :consumer_tag
24
+
25
+ #
26
+ # Creates a fox, a RabbitMQ consumer.
27
+ # Subscribes to the queues defined in `subscription`
28
+ # and passes messages on to the subscriber
29
+ #
30
+ # @param name [String] The name of the consumer
31
+ # @param subscription [Warren::Subscription] Describes the queue to subscribe to
32
+ # @param adaptor [#recovered?,#handle,#env] An adaptor to handle framework specifics
33
+ #
34
+ def initialize(name:, subscription:, adaptor:, subscribed_class:)
35
+ @consumer_tag = "#{adaptor.env}_#{name}_#{Process.pid}"
36
+ @subscription = subscription
37
+ @logger = Warren::LogTagger.new(logger: adaptor.logger, tag: "#{FOX} #{@consumer_tag}")
38
+ @adaptor = adaptor
39
+ @subscribed_class = subscribed_class
40
+ @state = :initialized
41
+ end
42
+
43
+ states :stopping, :stopped, :paused, :starting, :started, :running
44
+ def_delegators :@logger, :warn, :info, :error, :debug
45
+
46
+ #
47
+ # Starts up the fox, automatically registering the configured queues and bindings
48
+ # before subscribing to the queue.
49
+ #
50
+ # @return [Void]
51
+ #
52
+ def run!
53
+ starting!
54
+ subscription.activate! # Set up the queues
55
+ running! # Transition to running state
56
+ subscribe! # Subscribe to the queue
57
+
58
+ info { 'Started consumer' }
59
+ end
60
+
61
+ #
62
+ # Stop the consumer and unsubscribes from the queue. Blocks until fully unsubscribed.
63
+ #
64
+ # @return [Void]
65
+ #
66
+ def stop!
67
+ info { 'Stopping consumer' }
68
+ stopping!
69
+ unsubscribe!
70
+ info { 'Stopped consumer' }
71
+ stopped!
72
+ end
73
+
74
+ #
75
+ # Temporarily unsubscribes the consumer, and schedules an attempted recovery.
76
+ # Recovery is triggered by the {#attempt_recovery} method which gets called
77
+ # periodically by {Warren::Client}
78
+ #
79
+ # @return [Void]
80
+ #
81
+ def pause!
82
+ return unless running?
83
+
84
+ unsubscribe!
85
+ @recovery_attempts = 0
86
+ @recover_at = Time.now
87
+ paused!
88
+ end
89
+
90
+ # If the fox is paused, and a recovery attempt is scheduled, will prompt
91
+ # the framework adaptor to attempt to recover. (Such as reconnecting to the
92
+ # database). If this operation is successful will resubscribe to the queue,
93
+ # otherwise a further recovery attempt will be scheduled. Successive recovery
94
+ # attempts will be gradually further apart, up to the MAX_RECONNECT_DELAY
95
+ # of 5 minutes.
96
+ def attempt_recovery
97
+ return unless paused? && recovery_due?
98
+
99
+ warn { "Attempting recovery: #{@recovery_attempts}" }
100
+ if recovered?
101
+ running!
102
+ subscribe!
103
+ else
104
+ @recovery_attempts += 1
105
+ @recover_at = Time.now + delay_for_attempt
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ # Our consumer operates in another thread. It is non blocking.
112
+ def subscribe!
113
+ raise StandardError, 'Consumer already exists' unless @consumer.nil?
114
+
115
+ @consumer = @subscription.subscribe(@consumer_tag) do |delivery_info, properties, payload|
116
+ process(delivery_info, properties, payload)
117
+ end
118
+ end
119
+
120
+ # Cancels the consumer and unregisters it
121
+ def unsubscribe!
122
+ info { 'Unsubscribing' }
123
+ @consumer&.cancel
124
+ @consumer = nil
125
+ info { 'Unsubscribed' }
126
+ end
127
+
128
+ def delay_for_attempt
129
+ [2**@recovery_attempts, MAX_RECONNECT_DELAY].min
130
+ end
131
+
132
+ def recovery_due?
133
+ Time.now > @recover_at
134
+ end
135
+
136
+ def recovered?
137
+ @adaptor.recovered?
138
+ end
139
+
140
+ def process(delivery_info, properties, payload)
141
+ log_message(payload) do
142
+ message = @subscribed_class.new(self, delivery_info, properties, payload)
143
+ @adaptor.handle { message._process_ }
144
+ rescue Warren::Exceptions::TemporaryIssue => e
145
+ warn { "Temporary Issue: #{e.message}" }
146
+ pause!
147
+ message.requeue(e)
148
+ rescue StandardError => e
149
+ message.dead_letter(e)
150
+ end
151
+ end
152
+
153
+ def log_message(payload)
154
+ debug { 'Started message process' }
155
+ debug { payload }
156
+ yield
157
+ ensure
158
+ debug { 'Finished message process' }
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ module FrameworkAdaptor
5
+ # The RailsAdaptor provides error handling and application
6
+ # loading for Rails applications
7
+ class RailsAdaptor
8
+ # Matches errors associated with database connection loss.
9
+ # To understand exactly how this works, we need to go under the hood of
10
+ # `rescue`.
11
+ # When an exception is raised in Ruby, the interpreter begins unwinding
12
+ # the stack, looking for `rescue` statements. For each one it
13
+ # finds it performs the check `ExceptionClass === raised_exception`,
14
+ # and if this returns true, it enters the rescue block, otherwise it
15
+ # continues unwinding the stack.
16
+ # Under normal circumstances Class#=== returns true for instances of that
17
+ # class. Here we override that behaviour and explicitly check for a
18
+ # database connection instead. This ensures that regardless of what
19
+ # exception gets thrown if we loose access to the database, we correctly
20
+ # handle the message
21
+ class ConnectionMissing
22
+ def self.===(_)
23
+ # We used to inspect the exception, and try and check it against a list
24
+ # of errors that might indicate connectivity issues. But this list
25
+ # just grew and grew over time. So instead we just explicitly check
26
+ # the outcome
27
+ !ActiveRecord::Base.connection.active?
28
+ rescue StandardError => _e
29
+ # Unfortunately ActiveRecord::Base.connection.active? can throw an
30
+ # exception if it is unable to connect, and furthermore the class
31
+ # depends on the adapter used.
32
+ true
33
+ end
34
+ end
35
+
36
+ def recovered?
37
+ ActiveRecord::Base.connection.reconnect!
38
+ true
39
+ rescue StandardError
40
+ false
41
+ end
42
+
43
+ def handle
44
+ with_connection do
45
+ yield
46
+ rescue ConnectionMissing => e
47
+ raise Warren::Exceptions::TemporaryIssue, e.message
48
+ end
49
+ end
50
+
51
+ def with_connection
52
+ begin
53
+ ActiveRecord::Base.connection
54
+ rescue StandardError => e
55
+ raise Warren::Exceptions::TemporaryIssue, e.message
56
+ end
57
+
58
+ yield
59
+ ensure
60
+ ActiveRecord::Base.clear_active_connections!
61
+ end
62
+
63
+ def env
64
+ Rails.env
65
+ end
66
+
67
+ def logger
68
+ Rails.logger
69
+ end
70
+
71
+ def load_application
72
+ $stdout.puts 'Loading application...'
73
+ require './config/environment'
74
+ Warren.load_configuration
75
+ $stdout.puts 'Loaded!'
76
+ rescue LoadError
77
+ # Need to work out an elegant way to handle non-rails
78
+ # apps
79
+ $stdout.puts 'Could not auto-load application'
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ module Handler
5
+ # Base class
6
+ class Base
7
+ #
8
+ # Provide API compatibility with the RabbitMQ versions
9
+ # Do nothing in this case
10
+ #
11
+ def connect; end
12
+
13
+ #
14
+ # Provide API compatibility with the RabbitMQ versions
15
+ # Do nothing in this case
16
+ #
17
+ def disconnect; end
18
+ end
19
+ end
20
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bunny'
4
+ require 'forwardable'
5
+ require 'connection_pool'
6
+ require_relative 'base'
4
7
 
5
8
  module Warren
6
9
  module Handler
@@ -8,9 +11,14 @@ module Warren
8
11
  # Class Warren::Broadcast provides a connection pool of
9
12
  # threadsafe RabbitMQ channels for broadcasting messages
10
13
  #
11
- class Broadcast
12
- # Wraps a {Bunny::Channel}
14
+ class Broadcast < Warren::Handler::Base
15
+ # Wraps a Bunny::Channel
16
+ # @see https://rubydoc.info/gems/bunny/Bunny/Channel
13
17
  class Channel
18
+ extend Forwardable
19
+
20
+ def_delegators :@bun_channel, :close, :exchange, :queue, :prefetch, :ack, :nack
21
+
14
22
  def initialize(bun_channel, routing_key_template:, exchange: nil)
15
23
  @bun_channel = bun_channel
16
24
  @exchange_name = exchange
@@ -18,26 +26,23 @@ module Warren
18
26
  end
19
27
 
20
28
  def <<(message)
21
- exchange.publish(message.payload, routing_key: key_for(message))
29
+ default_exchange.publish(message.payload, routing_key: key_for(message))
22
30
  self
23
31
  end
24
32
 
25
- def close
26
- @bun_channel.close
27
- end
28
-
29
33
  private
30
34
 
31
- def exchange
35
+ def default_exchange
32
36
  raise StandardError, 'No exchange configured' if @exchange_name.nil?
33
37
 
34
- @exchange ||= @bun_channel.topic(@exchange_name, auto_delete: false, durable: true)
38
+ @default_exchange ||= exchange(@exchange_name, auto_delete: false, durable: true, type: :topic)
35
39
  end
36
40
 
37
41
  def key_for(message)
38
42
  @routing_key_template % message.routing_key
39
43
  end
40
44
  end
45
+
41
46
  #
42
47
  # Creates a warren but does not connect.
43
48
  #
@@ -47,6 +52,7 @@ module Warren
47
52
  # @param [String,nil] routing_key_prefix The prefix to pass before the routing key.
48
53
  # Can be used to ensure environments remain distinct.
49
54
  def initialize(exchange:, routing_key_prefix:, server: {}, pool_size: 14)
55
+ super()
50
56
  @server = server
51
57
  @exchange_name = exchange
52
58
  @pool_size = pool_size
@@ -74,12 +80,12 @@ module Warren
74
80
  end
75
81
 
76
82
  #
77
- # Yields an exchange which gets returned to the pool on block closure
78
- #
83
+ # Yields an {Warren::Handler::Broadcast::Channel} which gets returned to the pool on block closure
79
84
  #
80
85
  # @return [void]
81
86
  #
82
- # @yieldreturn [Warren::Broadcast::Channel] A rabbitMQ channel that sends messages to the configured exchange
87
+ # @yieldparam [Warren::Handler::Broadcast::Channel] A rabbitMQ channel that sends messages to the configured
88
+ # exchange
83
89
  def with_channel(&block)
84
90
  connection_pool.with(&block)
85
91
  end
@@ -90,7 +96,7 @@ module Warren
90
96
  #
91
97
  # @param [Warren::Message] message The message to broadcast. Must respond to #routing_key and #payload
92
98
  #
93
- # @return [Warren::Broadcast] Returns itself to allow chaining. But you're
99
+ # @return [Warren::Handler::Broadcast] Returns itself to allow chaining. But you're
94
100
  # probably better off using #with_channel
95
101
  # in that case
96
102
  #
@@ -99,16 +105,24 @@ module Warren
99
105
  self
100
106
  end
101
107
 
108
+ def new_channel
109
+ Channel.new(session.create_channel(nil, 1), exchange: @exchange_name,
110
+ routing_key_template: @routing_key_template)
111
+ end
112
+
102
113
  private
103
114
 
115
+ def server_connection
116
+ ENV.fetch('WARREN_CONNECTION_URI', @server)
117
+ end
118
+
104
119
  def session
105
- @session ||= Bunny.new(@server)
120
+ @session ||= Bunny.new(server_connection)
106
121
  end
107
122
 
108
123
  def connection_pool
109
124
  @connection_pool ||= start_session && ConnectionPool.new(size: @pool_size, timeout: 5) do
110
- Channel.new(session.create_channel, exchange: @exchange_name,
111
- routing_key_template: @routing_key_template)
125
+ new_channel
112
126
  end
113
127
  end
114
128
 
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'base'
4
+
3
5
  module Warren
4
6
  module Handler
5
7
  # Class Warren::Log provides a dummy RabbitMQ
6
8
  # connection pool for use during development
7
- class Log
8
- # Mimics a {Bunny::Channel} but instead passes out to a logger
9
+ class Log < Warren::Handler::Base
10
+ # Mimics a {Broadcast::Channel} but instead passes out to a logger
9
11
  class Channel
10
12
  def initialize(logger, routing_key_template: '%s')
11
13
  @logger = logger
@@ -18,6 +20,19 @@ module Warren
18
20
  self
19
21
  end
20
22
 
23
+ def exchange(name, options)
24
+ @logger.debug "Declared exchange: #{name}, #{options.inspect}"
25
+ Exchange.new(name, options)
26
+ end
27
+
28
+ def queue(name, options)
29
+ @logger.debug "Declared queue: #{name}, #{options.inspect}"
30
+ Queue.new(@logger, name)
31
+ end
32
+
33
+ # NOOP - Provided for API compatibility
34
+ def prefetch(number); end
35
+
21
36
  private
22
37
 
23
38
  def key_for(message)
@@ -25,20 +40,37 @@ module Warren
25
40
  end
26
41
  end
27
42
 
43
+ Exchange = Struct.new(:name, :options)
44
+
45
+ # Queue class to provide extended logging in development mode
46
+ class Queue
47
+ def initialize(logger, name)
48
+ @logger = logger
49
+ @name = name
50
+ end
51
+
52
+ def bind(exchange, options)
53
+ @logger.debug "Bound queue #{@name}: #{exchange}, #{options.inspect}"
54
+ end
55
+
56
+ def subscribe(options)
57
+ @logger.debug "Subscribed to queue #{@name}: #{options.inspect}"
58
+ @logger.warn 'This is a Warren::Handler::Log no messages will be processed'
59
+ nil
60
+ end
61
+ end
62
+
28
63
  attr_reader :logger
29
64
 
30
65
  def initialize(logger:, routing_key_prefix: nil)
66
+ super()
31
67
  @logger = logger
32
68
  @routing_key_template = Handler.routing_key_template(routing_key_prefix)
33
69
  end
34
70
 
35
- #
36
- # Provide API compatibility with the RabbitMQ versions
37
- # Do nothing in this case
38
- #
39
- def connect; end
40
-
41
- def disconnect; end
71
+ def new_channel
72
+ Channel.new(@logger, routing_key_template: @routing_key_template)
73
+ end
42
74
 
43
75
  #
44
76
  # Yields a Warren::Log::Channel
@@ -48,7 +80,7 @@ module Warren
48
80
  #
49
81
  # @yieldreturn [Warren::Log::Channel] A rabbitMQ channel that logs messaged to the test warren
50
82
  def with_channel
51
- yield Channel.new(@logger, routing_key_template: @routing_key_template)
83
+ yield new_channel
52
84
  end
53
85
 
54
86
  #