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,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
@@ -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
@@ -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