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.
@@ -1,10 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'base'
4
+
3
5
  module Warren
4
6
  module Handler
5
7
  # Class Warren::Test provides provides a dummy RabbitMQ
6
- # connection pool for use during testing
7
- class Test
8
+ # connection pool for use during testing.
9
+ #
10
+ # = Set up a test warren
11
+ #
12
+ # By default, the test warren is disabled during testing to avoid storing
13
+ # messages unnecessarily. Instead you must explicitly enable it when you
14
+ # wish to test message receipt.
15
+ #
16
+ # If using rspec it is suggested that you add the following to your
17
+ # spec_helper.rb
18
+ #
19
+ # config.around(:each, warren: true) do |ex|
20
+ # Warren.handler.enable!
21
+ # ex.run
22
+ # Warren.handler.disable!
23
+ # end
24
+ #
25
+ # = Making assertions
26
+ #
27
+ # It is possible to query the test warren about the messages it has seen.
28
+ # In particular the following methods are useful:
29
+ #
30
+ # {render:#messages}
31
+ #
32
+ # {render:#last_message}
33
+ #
34
+ # {render:#message_count}
35
+ #
36
+ # {render:#messages_matching}
37
+ #
38
+ # = Example
39
+ #
40
+ # describe QcResult, warren: true do
41
+ # let(:warren) { Warren.handler }
42
+ #
43
+ # setup { warren.clear_messages }
44
+ # let(:resource) { build :qc_result }
45
+ # let(:routing_key) { 'test.message.qc_result.' }
46
+ #
47
+ # it 'broadcasts the resource' do
48
+ # resource.save!
49
+ # expect(warren.messages_matching(routing_key)).to eq(1)
50
+ # end
51
+ # end
52
+ class Test < Warren::Handler::Base
53
+ # Warning displayed if the user attempts to make assertions against the
54
+ # handler without having enabled it.
8
55
  DISABLED_WARNING = <<~DISABLED_WARREN
9
56
  Test made against a disabled warren.
10
57
  Warren::Handler::Test must be explicitly enabled to track messages,
@@ -23,7 +70,7 @@ module Warren
23
70
 
24
71
  You can then tag tests with warren: true to enable warren testing.
25
72
  DISABLED_WARREN
26
- # Stand in for {Bunny::Channel}, provides a store of messages to use
73
+ # Stand in for {Broadcast::Channel}, provides a store of messages to use
27
74
  # in test assertions
28
75
  class Channel
29
76
  def initialize(warren)
@@ -33,7 +80,12 @@ module Warren
33
80
  def <<(message)
34
81
  @warren << message
35
82
  end
83
+
84
+ def add_exchange(name, options)
85
+ @warren.add_exchange(name, options)
86
+ end
36
87
  end
88
+
37
89
  #
38
90
  # Creates a test warren with no messages.
39
91
  # Test warrens are shared across all threads.
@@ -41,45 +93,74 @@ module Warren
41
93
  # @param [_] _args Configuration arguments are ignored.
42
94
  #
43
95
  def initialize(*_args)
96
+ super()
44
97
  @messages = []
98
+ @exchanges = []
45
99
  @enabled = false
46
100
  end
47
101
 
48
102
  #
49
- # Provide API compatibility with the RabbitMQ versions
50
- # Do nothing in this case
51
- #
52
- def connect; end
53
-
54
- def disconnect; end
55
-
56
- #
57
- # Yields an exchange which gets returned to the pool on block closure
58
- #
103
+ # Yields a new chanel, which proxies all message back to {messages} on the
104
+ # {Warren::Handler::Test}
59
105
  #
60
106
  # @return [void]
61
107
  #
62
108
  # @yieldreturn [Warren::Test::Channel] A rabbitMQ channel that logs messaged to the test warren
63
109
  def with_channel
64
- yield Channel.new(self)
110
+ yield new_channel
65
111
  end
66
112
 
113
+ #
114
+ # Returns a new chanel, which proxies all message back to {messages} on the
115
+ # {Warren::Handler::Test}
116
+ #
117
+ # @return [Warren::Test::Channel] A rabbitMQ channel that logs messaged to the test warren
118
+ #
119
+ def new_channel
120
+ Channel.new(@logger, routing_key_template: @routing_key_template)
121
+ end
122
+
123
+ #
124
+ # Clear any logged messaged
125
+ #
126
+ # @return [Array] The new empty array, lacking messages
127
+ #
67
128
  def clear_messages
68
129
  @messages = []
130
+ @exchanges = []
69
131
  end
70
132
 
133
+ #
134
+ # Returns the last message received by the warren
135
+ #
136
+ # @return [#routing_key#payload] The last message object received by the warren
137
+ #
71
138
  def last_message
72
139
  messages.last
73
140
  end
74
141
 
142
+ #
143
+ # Returns the total number message received by the warren since it was enabled
144
+ #
145
+ # @return [Integer] The total number of messages
146
+ #
75
147
  def message_count
76
148
  messages.length
77
149
  end
78
150
 
151
+ #
152
+ # Returns the total number message received by the warren matching the given
153
+ # routing_key since it was enabled
154
+ #
155
+ # @param routing_key [String] The routing key to filter by
156
+ #
157
+ # @return [Integer] The number of matching messages
158
+ #
79
159
  def messages_matching(routing_key)
80
160
  messages.count { |message| message.routing_key == routing_key }
81
161
  end
82
162
 
163
+ # Enable the warren
83
164
  def enable!
84
165
  @enabled = true
85
166
  clear_messages
@@ -91,6 +172,9 @@ module Warren
91
172
  clear_messages
92
173
  end
93
174
 
175
+ # Returns an array of all message received by the warren since it was enabled
176
+ #
177
+ # @return [Array<#routing_key#payload>] All received messages
94
178
  def messages
95
179
  raise_if_not_tracking
96
180
  @messages
@@ -101,6 +185,10 @@ module Warren
101
185
  @messages << message if @enabled
102
186
  end
103
187
 
188
+ def add_exchange(name, options)
189
+ @exchanges << [name, options] if @enabled
190
+ end
191
+
104
192
  private
105
193
 
106
194
  def raise_if_not_tracking
@@ -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,13 @@
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'
10
5
 
6
+ # Namespace to collect message formats
7
+ # A Warren compatible message must implement:
8
+ # routing_key: returns the routing_key for the message
9
+ # payload: returns the message payload
10
+ #
11
11
  # Additionally, if you wish to use the Message with the ActiveRecord
12
12
  # helpers, then the initialize should take the ActiveRecord::Base object
13
13
  # as a single argument
@@ -4,18 +4,55 @@ 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
19
56
  end
20
57
  end
21
58
  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,123 @@
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
28
+
29
+ #
30
+ # Construct a basic subscriber for each received message. Call {#process}
31
+ # to handle to processing of the message
32
+ #
33
+ # @param fox [Warren::Fox] The fox consumer that provided the message. Used to acknowledge messages
34
+ # @param delivery_info [Bunny::DeliveryInfo] Contains the information necessary for acknowledging the message
35
+ # @param properties [Bunny::MessageProperties] Contains additional information about the received message
36
+ # @param payload [String] The message contents
37
+ #
38
+ def initialize(fox, delivery_info, properties, payload)
39
+ @fox = fox
40
+ @delivery_info = delivery_info
41
+ @properties = properties
42
+ @payload = payload
43
+ @acknowledged = false
44
+ end
45
+
46
+ # Called by {Warren::Fox} to trigger processing of the message and acknowledgment
47
+ # on success. In most cases the {#process} method should be used to customize behaviour.
48
+ #
49
+ # @return [Void]
50
+ def _process_
51
+ process
52
+ ack unless @acknowledged
53
+ end
54
+
55
+ # Triggers processing of the method. Over-ride this in subclasses to customize your
56
+ # handler.
57
+ def process
58
+ true
59
+ end
60
+
61
+ # Reject the message and re-queue ready for
62
+ # immediate reprocessing.
63
+ #
64
+ # @param exception [StandardError] The exception which triggered message requeue
65
+ #
66
+ # @return [Void]
67
+ #
68
+ def requeue(exception)
69
+ warn "Re-queue: #{payload}"
70
+ warn "Re-queue Exception: #{exception.message}"
71
+ raise_if_acknowledged
72
+ subscription.nack(delivery_tag, false, true)
73
+ @acknowledged = true
74
+ warn 'Re-queue nacked'
75
+ end
76
+
77
+ # Reject the message without re-queuing
78
+ # Will end up getting dead-lettered
79
+ #
80
+ # @param exception [StandardError] The exception which triggered message dead-letter
81
+ #
82
+ # @return [Void]
83
+ #
84
+ def dead_letter(exception)
85
+ error "Dead-letter: #{payload}"
86
+ error "Dead-letter Exception: #{exception.message}"
87
+ raise_if_acknowledged
88
+ subscription.nack(delivery_tag)
89
+ @acknowledged = true
90
+ error 'Dead-letter nacked'
91
+ end
92
+
93
+ private
94
+
95
+ def headers
96
+ # Annoyingly it appears that a message with no headers
97
+ # returns nil, not an empty hash
98
+ properties.headers || {}
99
+ end
100
+
101
+ def delivery_tag
102
+ delivery_info.delivery_tag
103
+ end
104
+
105
+ # Acknowledge the message as successfully processed.
106
+ # Will raise {Warren::MultipleAcknowledgements} if the message has been
107
+ # acknowledged or rejected already.
108
+ def ack
109
+ raise_if_acknowledged
110
+ subscription.ack(delivery_tag)
111
+ @acknowledged = true
112
+ end
113
+
114
+ def raise_if_acknowledged
115
+ return unless @acknowledged
116
+
117
+ message = "Multiple acks/nacks for: #{payload}"
118
+ error message
119
+ raise Warren::Exceptions::MultipleAcknowledgements, message
120
+ end
121
+ end
122
+ end
123
+ end