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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +39 -0
- data/.rubocop.yml +11 -5
- data/CHANGELOG.md +9 -1
- data/Gemfile +6 -1
- data/Gemfile.lock +68 -36
- data/README.md +105 -44
- data/bin/console +3 -6
- data/bin/warren +6 -0
- data/lefthook.yml +53 -0
- data/lib/sanger_warren.rb +8 -0
- data/lib/warren.rb +49 -4
- data/lib/warren/app.rb +9 -0
- data/lib/warren/app/cli.rb +34 -0
- data/lib/warren/app/config.rb +100 -0
- data/lib/warren/app/consumer.rb +53 -0
- data/lib/warren/app/consumer_add.rb +122 -0
- data/lib/warren/app/consumer_start.rb +25 -0
- data/lib/warren/app/exchange_config.rb +138 -0
- data/lib/warren/app/templates/subscriber.tt +32 -0
- data/lib/warren/callback.rb +2 -7
- data/lib/warren/client.rb +111 -0
- data/lib/warren/config/consumers.rb +101 -0
- data/lib/warren/den.rb +77 -0
- data/lib/warren/exceptions.rb +15 -0
- data/lib/warren/fox.rb +161 -0
- data/lib/warren/framework_adaptor/rails_adaptor.rb +83 -0
- data/lib/warren/handler/base.rb +20 -0
- data/lib/warren/handler/broadcast.rb +30 -16
- data/lib/warren/handler/log.rb +42 -10
- data/lib/warren/handler/test.rb +102 -14
- data/lib/warren/helpers/state_machine.rb +55 -0
- data/lib/warren/log_tagger.rb +58 -0
- data/lib/warren/message.rb +5 -5
- data/lib/warren/message/short.rb +41 -4
- data/lib/warren/railtie.rb +12 -0
- data/lib/warren/subscriber/base.rb +123 -0
- data/lib/warren/subscription.rb +71 -0
- data/lib/warren/version.rb +2 -1
- data/sanger-warren.gemspec +5 -4
- metadata +48 -8
- data/.travis.yml +0 -6
@@ -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
|
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
|
-
|
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
|
35
|
+
def default_exchange
|
32
36
|
raise StandardError, 'No exchange configured' if @exchange_name.nil?
|
33
37
|
|
34
|
-
@
|
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
|
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
|
-
# @
|
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(
|
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
|
-
|
111
|
-
routing_key_template: @routing_key_template)
|
125
|
+
new_channel
|
112
126
|
end
|
113
127
|
end
|
114
128
|
|
data/lib/warren/handler/log.rb
CHANGED
@@ -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 {
|
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
|
-
|
37
|
-
|
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
|
83
|
+
yield new_channel
|
52
84
|
end
|
53
85
|
|
54
86
|
#
|