sanger_warren 0.1.0 → 0.3.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +39 -0
  3. data/.rubocop.yml +11 -5
  4. data/.yardopts +3 -0
  5. data/CHANGELOG.md +34 -1
  6. data/Gemfile +6 -1
  7. data/Gemfile.lock +71 -39
  8. data/README.md +133 -43
  9. data/bin/console +3 -6
  10. data/bin/warren +6 -0
  11. data/lefthook.yml +53 -0
  12. data/lib/sanger_warren.rb +8 -0
  13. data/lib/warren.rb +49 -4
  14. data/lib/warren/app.rb +9 -0
  15. data/lib/warren/app/cli.rb +35 -0
  16. data/lib/warren/app/config.rb +110 -0
  17. data/lib/warren/app/consumer.rb +65 -0
  18. data/lib/warren/app/consumer_add.rb +131 -0
  19. data/lib/warren/app/consumer_start.rb +40 -0
  20. data/lib/warren/app/exchange_config.rb +151 -0
  21. data/lib/warren/app/templates/subscriber.tt +32 -0
  22. data/lib/warren/callback.rb +2 -7
  23. data/lib/warren/callback/broadcast_with_warren.rb +1 -1
  24. data/lib/warren/client.rb +111 -0
  25. data/lib/warren/config/consumers.rb +123 -0
  26. data/lib/warren/delay_exchange.rb +85 -0
  27. data/lib/warren/den.rb +93 -0
  28. data/lib/warren/exceptions.rb +15 -0
  29. data/lib/warren/fox.rb +165 -0
  30. data/lib/warren/framework_adaptor/rails_adaptor.rb +135 -0
  31. data/lib/warren/handler.rb +16 -0
  32. data/lib/warren/handler/base.rb +20 -0
  33. data/lib/warren/handler/broadcast.rb +54 -18
  34. data/lib/warren/handler/log.rb +50 -10
  35. data/lib/warren/handler/test.rb +101 -14
  36. data/lib/warren/helpers/state_machine.rb +55 -0
  37. data/lib/warren/log_tagger.rb +58 -0
  38. data/lib/warren/message.rb +7 -5
  39. data/lib/warren/message/full.rb +20 -0
  40. data/lib/warren/message/short.rb +49 -4
  41. data/lib/warren/message/simple.rb +15 -0
  42. data/lib/warren/railtie.rb +12 -0
  43. data/lib/warren/subscriber/base.rb +151 -0
  44. data/lib/warren/subscription.rb +78 -0
  45. data/lib/warren/version.rb +2 -1
  46. data/sanger-warren.gemspec +5 -4
  47. metadata +49 -6
  48. data/.travis.yml +0 -6
@@ -0,0 +1,40 @@
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
+ #
11
+ # Starts up a warren client process for the configured consumers.
12
+ #
13
+ # @param shell [Thor::Shell::Basic] Thor shell instance for feedback
14
+ # @param options [Hash] Hash of command line arguments from Thor
15
+ # @option options [String] :path Path to the `warren_consumers.yml `file
16
+ # @option options [Array<String>] :consumers Array of configured consumers to start.
17
+ # Defaults to all consumers
18
+ #
19
+ # @return [Void]
20
+ #
21
+ def self.invoke(shell, options)
22
+ new(shell, options).invoke
23
+ end
24
+
25
+ def initialize(shell, options)
26
+ @shell = shell
27
+ @config = Warren::Config::Consumers.new(options[:path])
28
+ @consumers = options[:consumers]
29
+ end
30
+
31
+ #
32
+ # Starts up a warren client process for the configured consumers.
33
+ #
34
+ # @return [Void]
35
+ def invoke
36
+ Warren::Client.new(@config, consumers: @consumers).run
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,151 @@
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_tip
106
+ routing_key = @shell.ask 'Specify a routing_key: '
107
+ add_binding('direct', exchange, { routing_key: routing_key })
108
+ end
109
+
110
+ def ask_fanout_binding
111
+ exchange = ask_exchange
112
+ add_binding('fanout', exchange, {})
113
+ end
114
+
115
+ def ask_header_binding
116
+ exchange = ask_exchange
117
+ @shell.say 'Please manually configure the arguments parameter in the yaml'
118
+ add_binding('header', exchange, { arguments: {} })
119
+ end
120
+
121
+ def ask_topic_binding
122
+ exchange = ask_exchange
123
+ routing_key_tip
124
+ loop do
125
+ routing_key = @shell.ask 'Specify a routing_key [Leave blank to stop adding]: '
126
+ break if routing_key == ''
127
+
128
+ add_binding('topic', exchange, { routing_key: routing_key })
129
+ end
130
+ end
131
+
132
+ def config(type, name, options)
133
+ {
134
+ 'exchange' => { 'name' => name, 'options' => { type: type, durable: true } },
135
+ 'options' => options
136
+ }
137
+ end
138
+
139
+ def routing_key_tip
140
+ # Suggested cop style of %<routing_key_prefix>s but prefer suggesting the simpler option as it
141
+ # would be all to easy to miss out the 's', resulting in varying behaviour depending on the following
142
+ # character
143
+ # rubocop:disable Style/FormatStringToken
144
+ @shell.say(
145
+ 'Tip: Use %{routing_key_prefix} in routing keys to reference the routing_key_prefix specified in warren.yml'
146
+ )
147
+ # rubocop:enable Style/FormatStringToken
148
+ end
149
+ end
150
+ end
151
+ 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
@@ -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::BroadcastMessages provides methods to assist with
9
+ # Module Warren::Callback provides methods to assist with
15
10
  # setting up message broadcast
16
11
  #
17
12
  module Callback
@@ -15,7 +15,7 @@ module Warren
15
15
  # Creates the callback object
16
16
  #
17
17
  # @param handler [Warren::Handler] The handler to take the messaged
18
- # @param message_class [Warren::Message] The adpater to render the messages
18
+ # @param message_class [Warren::Message] The adaptor to render the messages
19
19
  #
20
20
  def initialize(handler:, message_class: Warren::Message::Short)
21
21
  @handler = handler
@@ -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,123 @@
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
+ # @param delay [Integer] Delay on the generated delay exchange
64
+ #
65
+ # @return [Hash] The consumer configuration hash
66
+ #
67
+ # rubocop:todo Metrics/ParameterLists
68
+ def add_consumer(name, desc:, queue:, bindings:, subscribed_class:, delay:)
69
+ dead_letter_exchange = "#{name}.dead-letters"
70
+ @config[name] = {
71
+ 'desc' => desc,
72
+ 'queue' => queue_config(queue, bindings, dead_letter_exchange),
73
+ 'subscribed_class' => subscribed_class,
74
+ # This smells wrong. I don't like the call back out to the App namespace
75
+ 'dead_letters' => queue_config(dead_letter_exchange,
76
+ Warren::App::ExchangeConfig.default_dead_letter(dead_letter_exchange)),
77
+ 'delay' => delay_exchange_configuration(ttl: delay, original_queue: queue, consumer_name: name),
78
+ 'worker_count' => 3
79
+ }
80
+ end
81
+ # rubocop:enable Metrics/ParameterLists
82
+
83
+ private
84
+
85
+ def queue_config(queue_name, bindings, dead_letter_exchange = nil)
86
+ arguments = dead_letter_exchange ? { 'x-dead-letter-exchange' => dead_letter_exchange } : {}
87
+ {
88
+ 'name' => queue_name,
89
+ 'options' => { durable: true, arguments: arguments },
90
+ 'bindings' => bindings
91
+ }
92
+ end
93
+
94
+ # rubocop:todo Metrics/MethodLength
95
+ def delay_exchange_configuration(ttl:, original_queue:, consumer_name:)
96
+ return {} if ttl.nil? || ttl.zero?
97
+
98
+ {
99
+ 'exchange' => { 'name' => "#{consumer_name}.delay", 'options' => { type: 'fanout', durable: true } },
100
+ 'bindings' => [{
101
+ 'queue' => { 'name' => "#{consumer_name}.delay", 'options' => {
102
+ durable: true, arguments: {
103
+ 'x-dead-letter-exchange' => '', 'x-message-ttl' => ttl, 'x-dead-letter-routing-key' => original_queue
104
+ }
105
+ } }, 'options' => {}
106
+ }]
107
+ }
108
+ end
109
+ # rubocop:enable Metrics/MethodLength
110
+
111
+ #
112
+ # Loads the configuration, should be a hash
113
+ #
114
+ # @return [Hash] A hash of consumer configurations indexed by name
115
+ #
116
+ def load_config
117
+ YAML.load_file(@path)
118
+ rescue Errno::ENOENT
119
+ {}
120
+ end
121
+ end
122
+ end
123
+ end