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,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'exchange_config'
|
4
|
+
require 'warren/client'
|
5
|
+
|
6
|
+
module Warren
|
7
|
+
module App
|
8
|
+
# Handles the initial creation of the configuration object
|
9
|
+
class ConsumerStart
|
10
|
+
def self.invoke(shell, options)
|
11
|
+
new(shell, options).invoke
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(shell, options)
|
15
|
+
@shell = shell
|
16
|
+
@config = Warren::Config::Consumers.new(options[:path])
|
17
|
+
@consumers = options[:consumers]
|
18
|
+
end
|
19
|
+
|
20
|
+
def invoke
|
21
|
+
Warren::Client.new(@config, consumers: @consumers).run
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Warren
|
4
|
+
module App
|
5
|
+
# Generate configuration for the various exchange types
|
6
|
+
class ExchangeConfig
|
7
|
+
EXCHANGE_PROMPT = <<~TYPE
|
8
|
+
Add an exchange binding:
|
9
|
+
(d)irect
|
10
|
+
(f)anout
|
11
|
+
(t)opic
|
12
|
+
(h)eaders
|
13
|
+
(n)one - Stop adding bindings
|
14
|
+
TYPE
|
15
|
+
|
16
|
+
# @return [Array] An array of all binding configurations
|
17
|
+
attr_reader :bindings
|
18
|
+
|
19
|
+
#
|
20
|
+
# Prompts the user to configure multiple queue bindings and returns
|
21
|
+
# the bindings array.
|
22
|
+
#
|
23
|
+
# @param shell [Thor::Shell::Basic] A thor shell object for user communication
|
24
|
+
#
|
25
|
+
# @return [Array<Hash>] A configuration array
|
26
|
+
#
|
27
|
+
def self.ask(shell)
|
28
|
+
ExchangeConfig.new(shell).tap(&:gather_bindings).bindings
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# Extracts the binding configuration from the command line parameters
|
33
|
+
#
|
34
|
+
# @param shell [Array<String>] The binding configuration parameters
|
35
|
+
#
|
36
|
+
# @return [Array<Hash>] A configuration array
|
37
|
+
#
|
38
|
+
def self.parse(shell, bindings)
|
39
|
+
return if bindings.nil?
|
40
|
+
|
41
|
+
ExchangeConfig.new(shell).tap do |config|
|
42
|
+
config.parse(bindings)
|
43
|
+
end.bindings
|
44
|
+
end
|
45
|
+
|
46
|
+
def initialize(shell)
|
47
|
+
@shell = shell
|
48
|
+
@bindings = []
|
49
|
+
end
|
50
|
+
|
51
|
+
def gather_bindings
|
52
|
+
loop do
|
53
|
+
case ask_exchange_type
|
54
|
+
when 'd' then ask_direct_binding
|
55
|
+
when 'f' then ask_fanout_binding
|
56
|
+
when 't' then ask_topic_binding
|
57
|
+
when 'h' then ask_header_binding
|
58
|
+
when 'n' then break
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse(bindings)
|
64
|
+
bindings.each do |binding|
|
65
|
+
add_cli_binding(*binding.split(':'))
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.default_dead_letter(name)
|
70
|
+
new(nil).add_binding('fanout', name, {})
|
71
|
+
end
|
72
|
+
|
73
|
+
def add_binding(type, name, options)
|
74
|
+
@bindings << config(type, name, options)
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def ask_exchange_type
|
80
|
+
@shell.ask(EXCHANGE_PROMPT, limited_to: %w[d f t h n])
|
81
|
+
end
|
82
|
+
|
83
|
+
def ask_exchange
|
84
|
+
@shell.ask 'Specify an exchange: '
|
85
|
+
end
|
86
|
+
|
87
|
+
# This could do with refactoring, but that probably means extracting each exchange
|
88
|
+
# type out into its own class.
|
89
|
+
def add_cli_binding(type, name = nil, routing_keys = nil)
|
90
|
+
case type.downcase
|
91
|
+
when 'direct' then add_binding(type, name, { routing_key: routing_keys })
|
92
|
+
when 'fanout' then add_binding(type, name, {})
|
93
|
+
when 'topic'
|
94
|
+
raise(Thor::Error, "Could not extract routing key from #{binding}") if routing_keys.nil?
|
95
|
+
|
96
|
+
routing_keys.split(',').each { |key| add_binding(type, name, { routing_key: key }) }
|
97
|
+
when 'header' then add_binding(type, name, { arguments: {} })
|
98
|
+
else
|
99
|
+
raise Thor::Error, "Unrecognized exchange type: #{type}"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def ask_direct_binding
|
104
|
+
exchange = ask_exchange
|
105
|
+
routing_key = @shell.ask 'Specify a routing_key: '
|
106
|
+
add_binding('direct', exchange, { routing_key: routing_key })
|
107
|
+
end
|
108
|
+
|
109
|
+
def ask_fanout_binding
|
110
|
+
exchange = ask_exchange
|
111
|
+
add_binding('fanout', exchange, {})
|
112
|
+
end
|
113
|
+
|
114
|
+
def ask_header_binding
|
115
|
+
exchange = ask_exchange
|
116
|
+
@shell.say 'Please manually configure the arguments parameter in the yaml'
|
117
|
+
add_binding('header', exchange, { arguments: {} })
|
118
|
+
end
|
119
|
+
|
120
|
+
def ask_topic_binding
|
121
|
+
exchange = ask_exchange
|
122
|
+
loop do
|
123
|
+
routing_key = @shell.ask 'Specify a routing_key [Leave blank to stop adding]: '
|
124
|
+
break if routing_key == ''
|
125
|
+
|
126
|
+
add_binding('topic', exchange, { routing_key: routing_key })
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def config(type, name, options)
|
131
|
+
{
|
132
|
+
'exchange' => { 'name' => name, 'options' => { type: type, durable: true } },
|
133
|
+
'options' => options
|
134
|
+
}
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Warren powered <%= name %> consumers
|
4
|
+
# <%= desc %>
|
5
|
+
# Takes messages from the <%= queue %> queue
|
6
|
+
#
|
7
|
+
# == Example Message
|
8
|
+
# Add example message here
|
9
|
+
#
|
10
|
+
class <%= subscribed_class %> < Warren::Subscriber::Base
|
11
|
+
# == Handling messages
|
12
|
+
# Message processing is handled in the {#process} method. The following
|
13
|
+
# methods will be useful:
|
14
|
+
#
|
15
|
+
# @!attribute [r] payload
|
16
|
+
# @return [String] the payload of the message
|
17
|
+
# @!attribute [r] delivery_info
|
18
|
+
# @return [Bunny::DeliveryInfo] mostly used internally for nack/acking messages
|
19
|
+
# http://rubybunny.info/articles/queues.html#accessing_message_properties_metadata
|
20
|
+
# @!attribute [r] properties
|
21
|
+
# @return [Bunny::MessageProperties] additional message properties.
|
22
|
+
# http://rubybunny.info/articles/queues.html#accessing_message_properties_metadata
|
23
|
+
|
24
|
+
# Handles message processing. Messages are acknowledged automatically
|
25
|
+
# on return from the method assuming they haven't been handled already.
|
26
|
+
# In the event of an uncaught exception, the message will be dead-lettered.
|
27
|
+
def process
|
28
|
+
# Handle message processing here. Additionally you have the following options:
|
29
|
+
# dead_letter(exception) => Dead Letters the message
|
30
|
+
# requeue(exception) => Sends a nack, which causes the message to be placed back on the queue
|
31
|
+
end
|
32
|
+
end
|
data/lib/warren/callback.rb
CHANGED
@@ -1,17 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative 'message/full'
|
4
|
-
require 'connection_pool'
|
5
|
-
|
6
4
|
require_relative 'callback/broadcast_with_warren'
|
7
5
|
require_relative 'callback/broadcast_associated_with_warren'
|
8
|
-
|
9
|
-
# Module Warren::Callback provides methods to assist with
|
10
|
-
# setting up message broadcast
|
11
|
-
#
|
6
|
+
|
12
7
|
module Warren
|
13
8
|
#
|
14
|
-
# Module Warren::
|
9
|
+
# Module Warren::Callback provides methods to assist with
|
15
10
|
# setting up message broadcast
|
16
11
|
#
|
17
12
|
module Callback
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'warren/den'
|
4
|
+
|
5
|
+
module Warren
|
6
|
+
# Establishes message queue consumers {Warren::Fox} according to the
|
7
|
+
# configuration. Usually generated via the {Warren::App::Consumer} and
|
8
|
+
# triggered via the command line `warren consumer start`
|
9
|
+
class Client
|
10
|
+
# The interrupt updates the client state to 'stopping' and the control loop
|
11
|
+
# handles the actual shutdown. This is as there are limitations on what may
|
12
|
+
# take place during an interrupt. This constant controls the frequency at
|
13
|
+
# while the control loop polls its state.
|
14
|
+
SECONDS_TO_SLEEP = 3
|
15
|
+
|
16
|
+
extend Warren::Helpers::StateMachine
|
17
|
+
extend Forwardable
|
18
|
+
|
19
|
+
states :stopping, :stopped, :paused, :starting, :started, :running
|
20
|
+
|
21
|
+
#
|
22
|
+
# Build a new client object based on the configuration in `config` and the
|
23
|
+
# requested consumers in `consumers`. If `consumers` is nil, all consumers
|
24
|
+
# will be spawned. Consumers are spawned on calling {#run} not at
|
25
|
+
# initialization
|
26
|
+
#
|
27
|
+
# @param config [Warren::Config::Consumers] A consumer configuration object
|
28
|
+
# @param consumers [Array<String>] The names of the consumers to spawn, or
|
29
|
+
# nil to spawn them all
|
30
|
+
#
|
31
|
+
def initialize(config, consumers: nil, adaptor: Warren::FrameworkAdaptor::RailsAdaptor.new)
|
32
|
+
@config = config
|
33
|
+
@consumers = consumers || @config.all_consumers
|
34
|
+
@adaptor = adaptor
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
starting!
|
39
|
+
@adaptor.load_application
|
40
|
+
connect_to_rabbit_mq
|
41
|
+
trap_signals
|
42
|
+
foxes.each(&:run!)
|
43
|
+
started!
|
44
|
+
control_loop while alive?
|
45
|
+
end
|
46
|
+
|
47
|
+
def stop!
|
48
|
+
stopping!
|
49
|
+
# This method is called from within an interrupt, where the logger
|
50
|
+
# is unavailable
|
51
|
+
$stdout.puts 'Stopping consumers'
|
52
|
+
end
|
53
|
+
|
54
|
+
def alive?
|
55
|
+
!stopped?
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def connect_to_rabbit_mq
|
61
|
+
Warren.handler.connect
|
62
|
+
end
|
63
|
+
|
64
|
+
# Capture the term signal and set the state to stopping.
|
65
|
+
# We can't directly cancel the consumer from here as Bunny
|
66
|
+
# uses Mutex locking while checking the state. Ruby forbids this
|
67
|
+
# from inside a trap block.
|
68
|
+
# INT is triggered by Ctrl-C and we provide a manual override to
|
69
|
+
# kill things a little quicker as this will mostly happen in
|
70
|
+
# development.
|
71
|
+
def trap_signals
|
72
|
+
Signal.trap('TERM') { stop! }
|
73
|
+
Signal.trap('INT') { manual_stop! }
|
74
|
+
end
|
75
|
+
|
76
|
+
def foxes
|
77
|
+
@foxes ||= @consumers.map do |consumer|
|
78
|
+
# Very soon we'll be doing some other stuff in this block.
|
79
|
+
# rubocop:disable Style/SymbolProc
|
80
|
+
Den.new(consumer, @config, adaptor: @adaptor).tap do |den|
|
81
|
+
den.register_dead_letter_queues
|
82
|
+
end.fox
|
83
|
+
# rubocop:enable Style/SymbolProc
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Called in an interrupt. (Ctrl-C)
|
88
|
+
def manual_stop!
|
89
|
+
exit 1 if stopping?
|
90
|
+
stop!
|
91
|
+
# This method is called from within an interrupt, where the logger
|
92
|
+
# is unavailable
|
93
|
+
$stdout.puts 'Press Ctrl-C again to stop immediately.'
|
94
|
+
end
|
95
|
+
|
96
|
+
# The control loop. Checks the state of the process every three seconds
|
97
|
+
# stopping: cancels the consumers, sets the processes to stopped and breaks the loop
|
98
|
+
# stopped: (alive? returns false) terminates the loop.
|
99
|
+
# anything else: waits three seconds and tries again
|
100
|
+
def control_loop
|
101
|
+
if stopping?
|
102
|
+
foxes.each(&:stop!)
|
103
|
+
stopped!
|
104
|
+
else
|
105
|
+
# Prompt any sleeping workers to check if they need to recover
|
106
|
+
foxes.each(&:attempt_recovery)
|
107
|
+
sleep(SECONDS_TO_SLEEP)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
# We probably don't want to require this here.
|
5
|
+
require 'warren/app/exchange_config'
|
6
|
+
module Warren
|
7
|
+
# Namespace for configuration objects
|
8
|
+
module Config
|
9
|
+
# Manages the configuration of consumers. By default, consumer configuration
|
10
|
+
# is held in {DEFAULT_PATH config/warren_consumers.yml}
|
11
|
+
class Consumers
|
12
|
+
# Default path to the consumer configuration file
|
13
|
+
DEFAULT_PATH = 'config/warren_consumers.yml'
|
14
|
+
WRITE_ONLY_TRUNCATE = 'w'
|
15
|
+
|
16
|
+
def initialize(path)
|
17
|
+
@path = path
|
18
|
+
@config = load_config
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Save the configuration to `@path`
|
23
|
+
#
|
24
|
+
# @return [Void]
|
25
|
+
#
|
26
|
+
def save
|
27
|
+
File.open(@path, WRITE_ONLY_TRUNCATE) do |file|
|
28
|
+
file.write YAML.dump(@config)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
#
|
33
|
+
# Checks whether a consumer has already been registered
|
34
|
+
#
|
35
|
+
# @param name [String] The name of the consumer to check
|
36
|
+
#
|
37
|
+
# @return [Boolean] True if the consumer exists
|
38
|
+
#
|
39
|
+
def consumer_exist?(name)
|
40
|
+
@config.key?(name)
|
41
|
+
end
|
42
|
+
|
43
|
+
def consumer(name)
|
44
|
+
@config.fetch(name) { raise StandardError, "Unknown consumer '#{name}'" }
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Returns a list of all registered consumers
|
49
|
+
#
|
50
|
+
# @return [Array<string>] An array of registered consumer names
|
51
|
+
#
|
52
|
+
def all_consumers
|
53
|
+
@config.keys
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
# Register a new consumer
|
58
|
+
#
|
59
|
+
# @param name [String] The name of the consumer to register
|
60
|
+
# @param desc [String] Description of the consumer (Primarily for documentation)
|
61
|
+
# @param queue [String] Name of the queue to attach to
|
62
|
+
# @param bindings [Array<Hash>] Array of binding configuration hashed
|
63
|
+
#
|
64
|
+
# @return [Hash] The consumer configuration hash
|
65
|
+
#
|
66
|
+
def add_consumer(name, desc:, queue:, bindings:, subscribed_class:)
|
67
|
+
dead_letter_exchange = "#{name}.dead-letters"
|
68
|
+
@config[name] = {
|
69
|
+
'desc' => desc,
|
70
|
+
'queue' => queue_config(queue, bindings, dead_letter_exchange),
|
71
|
+
'subscribed_class' => subscribed_class,
|
72
|
+
# This smells wrong. I don't like the call back out to the App namespace
|
73
|
+
'dead_letters' => queue_config(dead_letter_exchange,
|
74
|
+
Warren::App::ExchangeConfig.default_dead_letter(dead_letter_exchange))
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def queue_config(queue_name, bindings, dead_letter_exchange = nil)
|
81
|
+
arguments = dead_letter_exchange ? { 'x-dead-letter-exchange' => dead_letter_exchange } : {}
|
82
|
+
{
|
83
|
+
'name' => queue_name,
|
84
|
+
'options' => { durable: true, arguments: arguments },
|
85
|
+
'bindings' => bindings
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
#
|
90
|
+
# Loads the configuration, should be a hash
|
91
|
+
#
|
92
|
+
# @return [Hash] A hash of consumer configurations indexed by name
|
93
|
+
#
|
94
|
+
def load_config
|
95
|
+
YAML.load_file(@path)
|
96
|
+
rescue Errno::ENOENT
|
97
|
+
{}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
data/lib/warren/den.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bunny'
|
4
|
+
require 'warren/fox'
|
5
|
+
require 'warren/subscription'
|
6
|
+
|
7
|
+
module Warren
|
8
|
+
# A Den is in charge of creating a Fox from a consumer configuration
|
9
|
+
# Currently its pretty simple, but in future will also handle registration of
|
10
|
+
# delay and dead-letter queues/exchanges.
|
11
|
+
class Den
|
12
|
+
#
|
13
|
+
# Create a {Warren::Fox} work pool.
|
14
|
+
# @param app_name [String] The name of the application. Corresponds to the
|
15
|
+
# subscriptions config in `config/warren.yml`
|
16
|
+
# @param config [Warren::Config::Consumers] A configuration object, loaded from `config/warren.yml` by default
|
17
|
+
# @param adaptor [#recovered?,#handle,#env] An adaptor to handle framework specifics
|
18
|
+
def initialize(app_name, config, adaptor:)
|
19
|
+
@app_name = app_name
|
20
|
+
@config = config
|
21
|
+
@fox = nil
|
22
|
+
@adaptor = adaptor
|
23
|
+
end
|
24
|
+
|
25
|
+
def fox
|
26
|
+
@fox ||= spawn_fox
|
27
|
+
end
|
28
|
+
|
29
|
+
#
|
30
|
+
# Ensures the dead_letter queues and exchanges are registered.
|
31
|
+
#
|
32
|
+
# @return [Void]
|
33
|
+
def register_dead_letter_queues
|
34
|
+
config = dead_letter_config
|
35
|
+
return unless config
|
36
|
+
|
37
|
+
channel = Warren.handler.new_channel
|
38
|
+
subscription = Warren::Subscription.new(channel: channel, config: config)
|
39
|
+
subscription.activate!
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def consumer_config
|
45
|
+
@config.consumer(@app_name)
|
46
|
+
end
|
47
|
+
|
48
|
+
#
|
49
|
+
# Spawn a new fox
|
50
|
+
#
|
51
|
+
# @return [Warren::Fox]
|
52
|
+
def spawn_fox
|
53
|
+
# We don't use with_channel as our consumer persists outside the block,
|
54
|
+
# and while we *can* share channels between consumers it results in them
|
55
|
+
# sharing the same worker pool. This process lets us control workers on
|
56
|
+
# a per-queue basis. Currently that just means one worker per consumer.
|
57
|
+
channel = Warren.handler.new_channel
|
58
|
+
subscription = Warren::Subscription.new(channel: channel, config: queue_config)
|
59
|
+
Warren::Fox.new(name: @app_name,
|
60
|
+
subscription: subscription,
|
61
|
+
adaptor: @adaptor,
|
62
|
+
subscribed_class: subscribed_class)
|
63
|
+
end
|
64
|
+
|
65
|
+
def queue_config
|
66
|
+
consumer_config.fetch('queue')
|
67
|
+
end
|
68
|
+
|
69
|
+
def dead_letter_config
|
70
|
+
consumer_config.fetch('dead_letters')
|
71
|
+
end
|
72
|
+
|
73
|
+
def subscribed_class
|
74
|
+
Object.const_get(consumer_config.fetch('subscribed_class'))
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|