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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Namespace for utility modules
5
+ module Helpers
6
+ # Provides an incredibly simple state machine. It merely lets you define
7
+ # states with {#state} which defines two methods `{state}!` to transition
8
+ # into the state and `{state}?` to query if we are in the state.
9
+ #
10
+ # == Usage:
11
+ #
12
+ # @example Basic usage
13
+ # class Machine
14
+ # extend Warren::Helpers::StateMachine
15
+ # states :started, :started
16
+ # end
17
+ #
18
+ # machine = Machine.new
19
+ # machine.started!
20
+ # machine.started? # => true
21
+ # machine.stopped? # => false
22
+ # machine.stopped!
23
+ # machine.started? # => false
24
+ # machine.stopped? # => stopped
25
+ #
26
+ module StateMachine
27
+ #
28
+ # Define a new state, generates two methods `{state}!` to transition
29
+ # into the state and `{state}?` to query if we are in the state.
30
+ #
31
+ # @param state_name [Symbol, String] The name of the state
32
+ #
33
+ # @return [Void]
34
+ #
35
+ def state(state_name)
36
+ define_method(:"#{state_name}!") { @state = state_name }
37
+ define_method(:"#{state_name}?") { @state == state_name }
38
+ end
39
+
40
+ #
41
+ # Define new states, generates two methods for each state `{state}!` to
42
+ # transition into the state and `{state}?` to query if we are in the state.
43
+ #
44
+ # @overload push2(state_name, ...)
45
+ # @param [Symbol, String] state_name The name of the state
46
+ # @param [Symbol, String] ... More states
47
+ #
48
+ # @return [Void]
49
+ #
50
+ def states(*state_names)
51
+ state_names.each { |state_name| state(state_name) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Applies a tag to any messages sent to the logger.
5
+ class LogTagger
6
+ #
7
+ # Create a new log tagger, which applies a tag to all messages before
8
+ # forwarding them on to the logger
9
+ #
10
+ # @param logger [Logger] A ruby Logger, or compatible interface
11
+ # @param tag [String] The tag to apply to each message
12
+ #
13
+ def initialize(logger:, tag:)
14
+ @logger = logger
15
+ @tag = tag
16
+ end
17
+
18
+ #
19
+ # Define `name` methods which forward on to the similarly named method
20
+ # on logger, with the tag applied
21
+ #
22
+ # @param name [Symbol] The method to define
23
+ #
24
+ # @return [Void]
25
+ def self.level(name)
26
+ define_method(name) do |arg = nil, &block|
27
+ if block
28
+ @logger.public_send(name, arg) { tag(block.call) }
29
+ else
30
+ @logger.public_send(name, tag(arg))
31
+ end
32
+ end
33
+ end
34
+
35
+ # @!method debug(message)
36
+ # Forwards message on to logger with {#tag} prefix
37
+ # See Logger::debug
38
+ level :debug
39
+ # @!method info(message)
40
+ # Forwards message on to logger with {#tag} prefix
41
+ # See Logger::info
42
+ level :info
43
+ # @!method warn(message)
44
+ # Forwards message on to logger with {#tag} prefix
45
+ # See Logger::warn
46
+ level :warn
47
+ # @!method error(message)
48
+ # Forwards message on to logger with {#tag} prefix
49
+ # See Logger::error
50
+ level :error
51
+
52
+ private
53
+
54
+ def tag(message)
55
+ "#{@tag}: #{message}"
56
+ end
57
+ end
58
+ end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Namespace to collect message formats
4
- # A Warren compatible message must implement:
5
- # routing_key: returns the routing_key for the message
6
- # payloadL returns the message payload
7
-
8
3
  require_relative 'message/short'
9
4
  require_relative 'message/full'
5
+ require_relative 'message/simple'
10
6
 
7
+ # Namespace to collect message formats
8
+ # A Warren compatible message must implement:
9
+ # routing_key: returns the routing_key for the message
10
+ # payload: returns the message payload
11
+ # headers: Returns a headers hash
12
+ #
11
13
  # Additionally, if you wish to use the Message with the ActiveRecord
12
14
  # helpers, then the initialize should take the ActiveRecord::Base object
13
15
  # as a single argument
@@ -10,6 +10,13 @@ module Warren
10
10
  @record = record
11
11
  end
12
12
 
13
+ #
14
+ # The routing key that will be used for the message, not including the
15
+ # routing_key_prefix configured in warren.yml. If {#record} responds
16
+ # to `routing_key` will use that instead
17
+ #
18
+ # @return [String] The routing key.
19
+ #
13
20
  def routing_key
14
21
  if record.respond_to?(:routing_key)
15
22
  record.routing_key
@@ -18,9 +25,22 @@ module Warren
18
25
  end
19
26
  end
20
27
 
28
+ #
29
+ # The payload of the message.
30
+ # @see https://github.com/intridea/multi_json
31
+ #
32
+ # @return [String] The message payload
21
33
  def payload
22
34
  MultiJson.dump(record)
23
35
  end
36
+
37
+ #
38
+ # For compatibility. Returns an empty hash.
39
+ #
40
+ # @return [{}] Empty hash
41
+ def headers
42
+ {}
43
+ end
24
44
  end
25
45
  end
26
46
  end
@@ -4,18 +4,63 @@ module Warren
4
4
  module Message
5
5
  # Light-weight interim message which can be expanded to a full payload later.
6
6
  class Short
7
+ begin
8
+ include AfterCommitEverywhere
9
+ rescue NameError
10
+ # After commit everywhere is not included in the gemfile.
11
+ end
12
+
7
13
  attr_reader :record
8
14
 
9
- def initialize(record)
10
- @record = record
15
+ #
16
+ # Create a 'short' message, where the payload is just the class name and id.
17
+ # Designed for when you wish to use a delayed broadcast.
18
+ #
19
+ # @param record [ActiveRecord::Base] An Active Record object
20
+ #
21
+ def initialize(record = nil, class_name: nil, id: nil)
22
+ if record
23
+ @class_name = record.class.name
24
+ @id = record.id
25
+ else
26
+ @class_name = class_name
27
+ @id = id
28
+ end
29
+ end
30
+
31
+ # Queues the message for broadcast at the end of the transaction. Actually want to make this the default
32
+ # behaviour, but only realised the need when doing some last minute integration tests. Will revisit this
33
+ # in the next version. (Or possibly post code review depending)
34
+ def queue(warren)
35
+ after_commit { warren << self }
36
+ rescue NoMethodError
37
+ raise StandardError, '#queue depends on the after_commit_everywhere gem. Please add this to your gemfile'
11
38
  end
12
39
 
40
+ # The routing key for the message.
41
+ #
42
+ # @return [String] The routing key
43
+ #
13
44
  def routing_key
14
- "queue_broadcast.#{record.class.name.underscore}.#{record.id}"
45
+ "queue_broadcast.#{@class_name.underscore}.#{@id}"
15
46
  end
16
47
 
48
+ #
49
+ # The contents of the message, a string in the form:
50
+ # ["<ClassName>",<id>]
51
+ #
52
+ # @return [String] The payload of the message
53
+ #
17
54
  def payload
18
- [record.class.name, record.id].to_json
55
+ [@class_name, @id].to_json
56
+ end
57
+
58
+ #
59
+ # For compatibility. Returns an empty hash.
60
+ #
61
+ # @return [{}] Empty hash
62
+ def headers
63
+ {}
19
64
  end
20
65
  end
21
66
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Namespace for Warren message wrappers.
5
+ module Message
6
+ # A simple message simply wraps the routing key and payload together
7
+ # @!attribute [rw] routing_key
8
+ # @return [String] The routing key of the message
9
+ # @!attribute [rw] payload
10
+ # @return [String] The payload of the message
11
+ # @!attribute [rw] headers
12
+ # @return [Hash] Hash of header attributes. Can be empty hash.
13
+ Simple = Struct.new(:routing_key, :payload, :headers)
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Railtie for automatic configuration in Rails apps. Reduces the need to
5
+ # modify the application when adding Warren.
6
+ # @see https://api.rubyonrails.org/classes/Rails/Railtie.html
7
+ class Railtie < Rails::Railtie
8
+ config.to_prepare do
9
+ Warren.load_configuration
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'warren/exceptions'
5
+
6
+ module Warren
7
+ # Namespace for warren subscriber objects
8
+ module Subscriber
9
+ # A message takes a rabbitMQ message, and handles its acknowledgement
10
+ # or rejection.
11
+ class Base
12
+ extend Forwardable
13
+
14
+ # @return [Warren::Fox] The fox consumer that provided the message. Used to acknowledge messages
15
+ attr_reader :fox
16
+ # @return [Bunny::DeliveryInfo] Contains the information necessary for acknowledging the message
17
+ attr_reader :delivery_info
18
+ # @return [Bunny::MessageProperties] Contains additional information about the received message
19
+ attr_reader :properties
20
+ # @return [String] The message contents
21
+ attr_reader :payload
22
+
23
+ # We don't add an active-support dependency, so instead use the plain-ruby
24
+ # delegators (Supplied by Forwardable)
25
+ # Essentially syntax is:
26
+ # def_delegators <target>, *<methods_to_delegate>
27
+ def_delegators :fox, :subscription, :warn, :info, :error, :debug, :delayed
28
+ def_delegators :delivery_info, :routing_key, :delivery_tag
29
+
30
+ #
31
+ # Construct a basic subscriber for each received message. Call {#process}
32
+ # to handle to processing of the message
33
+ #
34
+ # @param fox [Warren::Fox] The fox consumer that provided the message. Used to acknowledge messages
35
+ # @param delivery_info [Bunny::DeliveryInfo] Contains the information necessary for acknowledging the message
36
+ # @param properties [Bunny::MessageProperties] Contains additional information about the received message
37
+ # @param payload [String] The message contents
38
+ #
39
+ def initialize(fox, delivery_info, properties, payload)
40
+ @fox = fox
41
+ @delivery_info = delivery_info
42
+ @properties = properties
43
+ @payload = payload
44
+ @acknowledged = false
45
+ end
46
+
47
+ # Called by {Warren::Fox} to trigger processing of the message and acknowledgment
48
+ # on success. In most cases the {#process} method should be used to customize behaviour.
49
+ #
50
+ # @return [Void]
51
+ def _process_
52
+ process
53
+ ack unless @acknowledged
54
+ end
55
+
56
+ # Triggers processing of the method. Over-ride this in subclasses to customize your
57
+ # handler.
58
+ def process
59
+ true
60
+ end
61
+
62
+ # Reject the message and re-queue ready for
63
+ # immediate reprocessing.
64
+ #
65
+ # @param exception [StandardError] The exception which triggered message requeue
66
+ #
67
+ # @return [Void]
68
+ #
69
+ def requeue(exception)
70
+ warn "Re-queue: #{payload}"
71
+ warn "Re-queue Exception: #{exception.message}"
72
+ raise_if_acknowledged
73
+ # nack arguments: delivery_tag, multiple, requeue
74
+ # http://reference.rubybunny.info/Bunny/Channel.html#nack-instance_method
75
+ subscription.nack(delivery_tag, false, true)
76
+ @acknowledged = true
77
+ warn 'Re-queue nacked'
78
+ end
79
+
80
+ # Reject the message without re-queuing
81
+ # Will end up getting dead-lettered
82
+ #
83
+ # @param exception [StandardError] The exception which triggered message dead-letter
84
+ #
85
+ # @return [Void]
86
+ #
87
+ def dead_letter(exception)
88
+ error "Dead-letter: #{payload}"
89
+ error "Dead-letter Exception: #{exception.message}"
90
+ raise_if_acknowledged
91
+ subscription.nack(delivery_tag)
92
+ @acknowledged = true
93
+ error 'Dead-letter nacked'
94
+ end
95
+
96
+ #
97
+ # Re-post the message to the delay exchange and acknowledges receipt of
98
+ # the original message. The delay exchange will return the messages to
99
+ # the original queue after a delay.
100
+ #
101
+ # @param exception [StandardError] The exception that has caused the
102
+ # message to require a delay
103
+ #
104
+ # @return [Void]
105
+ #
106
+ def delay(exception)
107
+ return dead_letter(exception) if attempt > max_retries
108
+
109
+ warn "Delay: #{payload}"
110
+ warn "Delay Exception: #{exception.message}"
111
+ # Publish the message to the delay queue
112
+ delayed.publish(payload, routing_key: routing_key, headers: { attempts: attempt + 1 })
113
+ # Acknowledge the original message
114
+ ack
115
+ end
116
+
117
+ private
118
+
119
+ def max_retries
120
+ 30
121
+ end
122
+
123
+ def attempt
124
+ headers.fetch('attempts', 0)
125
+ end
126
+
127
+ def headers
128
+ # Annoyingly it appears that a message with no headers
129
+ # returns nil, not an empty hash
130
+ properties.headers || {}
131
+ end
132
+
133
+ # Acknowledge the message as successfully processed.
134
+ # Will raise {Warren::MultipleAcknowledgements} if the message has been
135
+ # acknowledged or rejected already.
136
+ def ack
137
+ raise_if_acknowledged
138
+ subscription.ack(delivery_tag)
139
+ @acknowledged = true
140
+ end
141
+
142
+ def raise_if_acknowledged
143
+ return unless @acknowledged
144
+
145
+ message = "Multiple acks/nacks for: #{payload}"
146
+ error message
147
+ raise Warren::Exceptions::MultipleAcknowledgements, message
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Warren
4
+ # Configures and wraps up subscriptions on a Bunny Channel/Queue
5
+ class Subscription
6
+ extend Forwardable
7
+
8
+ attr_reader :channel
9
+
10
+ #
11
+ # Great a new subscription. Handles queue creation, binding and attaching
12
+ # consumers to the queues
13
+ #
14
+ # @param channel [Warren::Handler::Broadcast::Channel] A channel on which to register queues
15
+ # @param config [Hash] queue configuration hash
16
+ #
17
+ def initialize(channel:, config:)
18
+ @channel = channel
19
+ @queue_name = config&.fetch('name')
20
+ @queue_options = config&.fetch('options')
21
+ @bindings = config&.fetch('bindings')
22
+ end
23
+
24
+ def_delegators :channel, :nack, :ack
25
+
26
+ #
27
+ # Subscribes to the given queue
28
+ #
29
+ # @param consumer_tag [String] Identifier for the consumer
30
+ #
31
+ # @yieldparam [Bunny::DeliveryInfo] delivery_info Metadata about the delivery
32
+ # @yieldparam [Bunny::MessageProperties] properties
33
+ # @yieldparam [String] payload the contents of the message
34
+ #
35
+ # @return [Bunny::Consumer] The bunny consumer object
36
+ #
37
+ def subscribe(consumer_tag, &block)
38
+ channel.prefetch(10)
39
+ queue.subscribe(manual_ack: true, block: false, consumer_tag: consumer_tag, durable: true, &block)
40
+ end
41
+
42
+ # Ensures the queues and channels are set up to receive messages
43
+ # keys: additional routing_keys to bind
44
+ def activate!
45
+ establish_bindings!
46
+ end
47
+
48
+ private
49
+
50
+ def add_binding(exchange, options)
51
+ queue.bind(exchange, options)
52
+ end
53
+
54
+ def exchange(config)
55
+ channel.exchange(*config.values_at('name', 'options'))
56
+ end
57
+
58
+ def queue
59
+ raise StandardError, 'No queue configured' if @queue_name.nil?
60
+
61
+ @queue ||= channel.queue(@queue_name, @queue_options)
62
+ end
63
+
64
+ def establish_bindings!
65
+ @bindings.each do |binding_config|
66
+ exchange = exchange(binding_config['exchange'])
67
+ transformed_options = merge_routing_key_prefix(binding_config['options'])
68
+ add_binding(exchange, transformed_options)
69
+ end
70
+ end
71
+
72
+ def merge_routing_key_prefix(options)
73
+ options.transform_values do |value|
74
+ format(value, routing_key_prefix: channel.routing_key_prefix)
75
+ end
76
+ end
77
+ end
78
+ end