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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Configures and wraps up delay exchange on a Bunny Channel/Queue
5
+ # A delay exchange routes immediately onto a queue with a ttl
6
+ # once messages on this queue expire they are dead-lettered back onto
7
+ # to original exchange
8
+ # Note: This does not currently support the rabbitmq-delayed-message-exchange
9
+ # plugin.
10
+ class DelayExchange
11
+ extend Forwardable
12
+
13
+ attr_reader :channel
14
+
15
+ #
16
+ # Create a new delay exchange. Handles queue creation, binding and attaching
17
+ # consumers to the queues
18
+ #
19
+ # @param channel [Warren::Handler::Broadcast::Channel] A channel on which to register queues
20
+ # @param config [Hash] queue configuration hash
21
+ #
22
+ def initialize(channel:, config:)
23
+ @channel = channel
24
+ @exchange_config = config&.fetch('exchange', nil)
25
+ @bindings = config&.fetch('bindings', [])
26
+ end
27
+
28
+ def_delegators :channel, :nack, :ack
29
+
30
+ # Ensures the queues and channels are set up to receive messages
31
+ # keys: additional routing_keys to bind
32
+ def activate!
33
+ establish_bindings!
34
+ end
35
+
36
+ #
37
+ # Post a message to the delay exchange.
38
+ #
39
+ # @param payload [String] The message payload
40
+ # @param routing_key [String] The routing key of the re-sent message
41
+ # @param headers [Hash] A hash of headers. Typically: { attempts: <Integer> }
42
+ # @option headers [Integer] :attempts The number of times the message has been processed
43
+ #
44
+ # @return [Void]
45
+ #
46
+ def publish(payload, routing_key:, headers: {})
47
+ raise StandardError, 'No delay queue configured' unless configured?
48
+
49
+ message = Warren::Message::Simple.new(routing_key, payload, headers)
50
+ channel.publish(message, exchange: exchange)
51
+ end
52
+
53
+ private
54
+
55
+ def configured?
56
+ @exchange_config&.key?('name')
57
+ end
58
+
59
+ def add_binding(queue, options)
60
+ queue.bind(exchange, options)
61
+ end
62
+
63
+ def exchange
64
+ @exchange ||= channel.exchange(*@exchange_config.values_at('name', 'options'))
65
+ end
66
+
67
+ def queue(config)
68
+ channel.queue(*config.values_at('name', 'options'))
69
+ end
70
+
71
+ def establish_bindings!
72
+ @bindings.each do |binding_config|
73
+ queue = queue(binding_config['queue'])
74
+ transformed_options = merge_routing_key_prefix(binding_config['options'])
75
+ add_binding(queue, transformed_options)
76
+ end
77
+ end
78
+
79
+ def merge_routing_key_prefix(options)
80
+ options.transform_values do |value|
81
+ format(value, routing_key_prefix: channel.routing_key_prefix)
82
+ end
83
+ end
84
+ end
85
+ end
data/lib/warren/den.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bunny'
4
+ require 'warren/fox'
5
+ require 'warren/subscription'
6
+ require 'warren/delay_exchange'
7
+
8
+ module Warren
9
+ # A Den is in charge of creating a Fox from a consumer configuration
10
+ # It handles the registration of dead-letter queues, and configuration of
11
+ # {Warren::Subscription subscriptions} and
12
+ # {Warren::DelayExchange delay exchanges}
13
+ class Den
14
+ # The number of simultaneous workers generated by default
15
+ DEFAULT_WORKER_COUNT = 3
16
+
17
+ #
18
+ # Create a {Warren::Fox} work pool.
19
+ # @param app_name [String] The name of the application. Corresponds to the
20
+ # subscriptions config in `config/warren.yml`
21
+ # @param config [Warren::Config::Consumers] A configuration object, loaded from `config/warren.yml` by default
22
+ # @param adaptor [#recovered?,#handle,#env] An adaptor to handle framework specifics
23
+ def initialize(app_name, config, adaptor:)
24
+ @app_name = app_name
25
+ @config = config
26
+ @fox = nil
27
+ @adaptor = adaptor
28
+ end
29
+
30
+ def fox
31
+ @fox ||= spawn_fox
32
+ end
33
+
34
+ #
35
+ # Ensures the dead_letter queues and exchanges are registered.
36
+ #
37
+ # @return [Void]
38
+ def register_dead_letter_queues
39
+ config = dead_letter_config
40
+ return unless config
41
+
42
+ Warren.handler.with_channel do |channel|
43
+ subscription = Warren::Subscription.new(channel: channel, config: config)
44
+ subscription.activate!
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def consumer_config
51
+ @config.consumer(@app_name)
52
+ end
53
+
54
+ #
55
+ # Spawn a new fox
56
+ #
57
+ # @return [Warren::Fox]
58
+ def spawn_fox
59
+ # We don't use with_channel as our consumer persists outside the block,
60
+ # and while we *can* share channels between consumers it results in them
61
+ # sharing the same worker pool. This process lets us control workers on
62
+ # a per-queue basis. Currently that just means one worker per consumer.
63
+ channel = Warren.handler.new_channel(worker_count: worker_count)
64
+ subscription = Warren::Subscription.new(channel: channel, config: queue_config)
65
+ delay = Warren::DelayExchange.new(channel: channel, config: delay_config)
66
+ Warren::Fox.new(name: @app_name,
67
+ subscription: subscription,
68
+ adaptor: @adaptor,
69
+ subscribed_class: subscribed_class,
70
+ delayed: delay)
71
+ end
72
+
73
+ def worker_count
74
+ consumer_config.fetch('worker_count', DEFAULT_WORKER_COUNT)
75
+ end
76
+
77
+ def queue_config
78
+ consumer_config.fetch('queue')
79
+ end
80
+
81
+ def dead_letter_config
82
+ consumer_config.fetch('dead_letters')
83
+ end
84
+
85
+ def delay_config
86
+ consumer_config.fetch('delay', nil)
87
+ end
88
+
89
+ def subscribed_class
90
+ Object.const_get(consumer_config.fetch('subscribed_class'))
91
+ end
92
+ end
93
+ end
@@ -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,165 @@
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, :delayed
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
+ # @param subscribed_class [Warren::Subscriber::Base] The class to process received messages
34
+ # @param delayed [Warren::DelayExchange] The details handling delayed message broadcast
35
+ #
36
+ def initialize(name:, subscription:, adaptor:, subscribed_class:, delayed:)
37
+ @consumer_tag = "#{adaptor.env}_#{name}_#{Process.pid}"
38
+ @subscription = subscription
39
+ @delayed = delayed
40
+ @logger = Warren::LogTagger.new(logger: adaptor.logger, tag: "#{FOX} #{@consumer_tag}")
41
+ @adaptor = adaptor
42
+ @subscribed_class = subscribed_class
43
+ @state = :initialized
44
+ end
45
+
46
+ states :stopping, :stopped, :paused, :starting, :started, :running
47
+ def_delegators :@logger, :warn, :info, :error, :debug
48
+
49
+ #
50
+ # Starts up the fox, automatically registering the configured queues and bindings
51
+ # before subscribing to the queue.
52
+ #
53
+ # @return [Void]
54
+ #
55
+ def run!
56
+ starting!
57
+ subscription.activate! # Set up the queues
58
+ delayed.activate!
59
+ running! # Transition to running state
60
+ subscribe! # Subscribe to the queue
61
+
62
+ info { 'Started consumer' }
63
+ end
64
+
65
+ #
66
+ # Stop the consumer and unsubscribes from the queue. Blocks until fully unsubscribed.
67
+ #
68
+ # @return [Void]
69
+ #
70
+ def stop!
71
+ info { 'Stopping consumer' }
72
+ stopping!
73
+ unsubscribe!
74
+ info { 'Stopped consumer' }
75
+ stopped!
76
+ end
77
+
78
+ #
79
+ # Temporarily unsubscribes the consumer, and schedules an attempted recovery.
80
+ # Recovery is triggered by the {#attempt_recovery} method which gets called
81
+ # periodically by {Warren::Client}
82
+ #
83
+ # @return [Void]
84
+ #
85
+ def pause!
86
+ return unless running?
87
+
88
+ unsubscribe!
89
+ @recovery_attempts = 0
90
+ @recover_at = Time.now
91
+ paused!
92
+ end
93
+
94
+ # If the fox is paused, and a recovery attempt is scheduled, will prompt
95
+ # the framework adaptor to attempt to recover. (Such as reconnecting to the
96
+ # database). If this operation is successful will resubscribe to the queue,
97
+ # otherwise a further recovery attempt will be scheduled. Successive recovery
98
+ # attempts will be gradually further apart, up to the MAX_RECONNECT_DELAY
99
+ # of 5 minutes.
100
+ def attempt_recovery
101
+ return unless paused? && recovery_due?
102
+
103
+ warn { "Attempting recovery: #{@recovery_attempts}" }
104
+ if recovered?
105
+ running!
106
+ subscribe!
107
+ else
108
+ @recovery_attempts += 1
109
+ @recover_at = Time.now + delay_for_attempt
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Our consumer operates in another thread. It is non blocking.
116
+ def subscribe!
117
+ raise StandardError, 'Consumer already exists' unless @consumer.nil?
118
+
119
+ @consumer = @subscription.subscribe(@consumer_tag) do |delivery_info, properties, payload|
120
+ process(delivery_info, properties, payload)
121
+ end
122
+ end
123
+
124
+ # Cancels the consumer and un-registers it
125
+ def unsubscribe!
126
+ info { 'Unsubscribing' }
127
+ @consumer&.cancel
128
+ @consumer = nil
129
+ info { 'Unsubscribed' }
130
+ end
131
+
132
+ def delay_for_attempt
133
+ [2**@recovery_attempts, MAX_RECONNECT_DELAY].min
134
+ end
135
+
136
+ def recovery_due?
137
+ Time.now > @recover_at
138
+ end
139
+
140
+ def recovered?
141
+ @adaptor.recovered?
142
+ end
143
+
144
+ def process(delivery_info, properties, payload)
145
+ log_message(payload) do
146
+ message = @subscribed_class.new(self, delivery_info, properties, payload)
147
+ @adaptor.handle { message._process_ }
148
+ rescue Warren::Exceptions::TemporaryIssue => e
149
+ warn { "Temporary Issue: #{e.message}" }
150
+ pause!
151
+ message.requeue(e)
152
+ rescue StandardError => e
153
+ message.dead_letter(e)
154
+ end
155
+ end
156
+
157
+ def log_message(payload)
158
+ debug { 'Started message process' }
159
+ debug { payload }
160
+ yield
161
+ ensure
162
+ debug { 'Finished message process' }
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Namespace for framework adaptors.
5
+ #
6
+ # A FrameworkAdaptor should implement the following instance methods:
7
+ #
8
+ # == recovered? => Bool
9
+ # Indicates that any temporary issues (such as database connectivity problems)
10
+ # are resolved and consumers may restart.
11
+ #
12
+ # == handle
13
+ #
14
+ # Wraps the processing of each message, is expected to `yield` to allow
15
+ # processing. May be responsible for handling connection pools, and
16
+ # framework-specific exceptions. Raising {Warren::Exceptions::TemporaryIssue}
17
+ # here will cause consumers to sleep until `recovered?` returns true.
18
+ #
19
+ # == env => String
20
+ #
21
+ # Returns the current environment of the application.
22
+ #
23
+ # == logger => Logger
24
+ #
25
+ # Returns your application logger. Is expected to be compatible with the
26
+ # standard library Logger class.
27
+ # @see https://ruby-doc.org/stdlib-2.7.0/libdoc/logger/rdoc/Logger.html
28
+ #
29
+ # == load_application
30
+ #
31
+ # Called upon running `warren consumer start`. Should ensure your application
32
+ # is correctly loaded sufficiently for processing messages
33
+ #
34
+ module FrameworkAdaptor
35
+ # The RailsAdaptor provides error handling and application
36
+ # loading for Rails applications
37
+ class RailsAdaptor
38
+ # Matches errors associated with database connection loss.
39
+ # To understand exactly how this works, we need to go under the hood of
40
+ # `rescue`.
41
+ # When an exception is raised in Ruby, the interpreter begins unwinding
42
+ # the stack, looking for `rescue` statements. For each one it
43
+ # finds it performs the check `ExceptionClass === raised_exception`,
44
+ # and if this returns true, it enters the rescue block, otherwise it
45
+ # continues unwinding the stack.
46
+ # Under normal circumstances Class#=== returns true for instances of that
47
+ # class. Here we override that behaviour and explicitly check for a
48
+ # database connection instead. This ensures that regardless of what
49
+ # exception gets thrown if we loose access to the database, we correctly
50
+ # handle the message
51
+ class ConnectionMissing
52
+ def self.===(_)
53
+ # We used to inspect the exception, and try and check it against a list
54
+ # of errors that might indicate connectivity issues. But this list
55
+ # just grew and grew over time. So instead we just explicitly check
56
+ # the outcome
57
+ !ActiveRecord::Base.connection.active?
58
+ rescue StandardError => _e
59
+ # Unfortunately ActiveRecord::Base.connection.active? can throw an
60
+ # exception if it is unable to connect, and furthermore the class
61
+ # depends on the adapter used.
62
+ true
63
+ end
64
+ end
65
+
66
+ #
67
+ # Checks that the database has recovered to allow message processing
68
+ #
69
+ # @return [Bool] Returns true if the application has recovered
70
+ #
71
+ def recovered?
72
+ ActiveRecord::Base.connection.reconnect!
73
+ true
74
+ rescue StandardError
75
+ false
76
+ end
77
+
78
+ #
79
+ # Checks ensures a database connection has been checked out before
80
+ # yielding to allow message processing. Rescues loss of the database
81
+ # connection and raises {Warren::Exceptions::TemporaryIssue} to send
82
+ # the consumers to sleep until it recovers.
83
+ #
84
+ # @return [Void]
85
+ #
86
+ def handle
87
+ with_connection do
88
+ yield
89
+ rescue ConnectionMissing => e
90
+ raise Warren::Exceptions::TemporaryIssue, e.message
91
+ end
92
+ end
93
+
94
+ def with_connection
95
+ begin
96
+ ActiveRecord::Base.connection
97
+ rescue StandardError => e
98
+ raise Warren::Exceptions::TemporaryIssue, e.message
99
+ end
100
+
101
+ yield
102
+ ensure
103
+ ActiveRecord::Base.clear_active_connections!
104
+ end
105
+
106
+ # Returns the rails environment
107
+ #
108
+ # @return [ActiveSupport::StringInquirer] The rails environment
109
+ def env
110
+ Rails.env
111
+ end
112
+
113
+ # Returns the configured logger
114
+ #
115
+ # @return [Logger,ActiveSupport::Logger,...] The application logger
116
+ def logger
117
+ Rails.logger
118
+ end
119
+
120
+ # Triggers full loading of the rails application and dependencies
121
+ #
122
+ # @return [Void]
123
+ def load_application
124
+ $stdout.puts 'Loading application...'
125
+ require './config/environment'
126
+ Warren.load_configuration
127
+ $stdout.puts 'Loaded!'
128
+ rescue LoadError
129
+ # Need to work out an elegant way to handle non-rails
130
+ # apps
131
+ $stdout.puts 'Could not auto-load application'
132
+ end
133
+ end
134
+ end
135
+ end