brown 2.2.2 → 2.2.2.25.g85ddf08

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,166 @@
1
+ require 'logger'
2
+ require 'securerandom'
3
+
4
+ module Brown::Agent::ClassMethods
5
+ # The available stimuli.
6
+ #
7
+ # @return [Array<Hash<Symbol, Object>>]
8
+ #
9
+ attr_reader :stimuli
10
+
11
+ # The available memos.
12
+ #
13
+ # @return [Array<Hash<Symbol, Object>>]
14
+ #
15
+ attr_reader :memos
16
+
17
+ # Define a generic stimulus for this agent.
18
+ #
19
+ # This is a fairly low-level method, designed to provide a means for
20
+ # defining stimuli for which there isn't a higher-level, more-specific
21
+ # stimulus definition approach.
22
+ #
23
+ # When the agent is started (see {.run}), the block you provide will be
24
+ # executed in a dedicated thread. Every time the block finishes, it will be
25
+ # run again. Your block should do whatever it needs to do to detect when a
26
+ # stimuli is available (preferably by blocking somehow, rather than polling,
27
+ # because polling sucks). When your code detects that a stimulus has been
28
+ # received, it should run `worker.call`, passing in any arguments that are
29
+ # required to process the stimulus. That will then spawn a new thread and
30
+ # call the specified `method_name` on the agent object, passing in the
31
+ # arguments that were passed to `worker.call`.
32
+ #
33
+ # @see .every
34
+ #
35
+ # @param method_name [Symbol] the name of the method to call when the
36
+ # stimulus is triggered.
37
+ #
38
+ # @yieldparam worker [Proc] call this when you want a stimulus
39
+ # processed, passing in anything that the stimulus processing method
40
+ # (as specified by `method_name`) needs to do its job.
41
+ #
42
+ def stimulate(method_name, stimulus_name, &blk)
43
+ @stimuli ||= []
44
+
45
+ @stimuli << {
46
+ name: stimulus_name,
47
+ method_name: method_name,
48
+ stimuli_proc: blk,
49
+ }
50
+ end
51
+
52
+ # Define a "memo" for this agent.
53
+ #
54
+ # A "memo" is an object which is common across all instances of a
55
+ # particular agent, and which is (usually) local to that agent. The
56
+ # intended purpose is for anything that is needed for processing
57
+ # stimuli, but which you don't want to recreate for every stimuli.
58
+ # Examples of this sort of thing include connection pools (database,
59
+ # HTTP connections, etc), config files, and caches. Basically,
60
+ # anything that has a non-trivial setup time, or which you *want* to
61
+ # share across all stimuli processing, should go in a memo.
62
+ #
63
+ # Because we do everything in threads, and because dealing with locking by
64
+ # hand is a nightmare, access to memos is protected by a mutex. This means
65
+ # that any time you want to do something with a memo, you call its name and
66
+ # pass a block to do whatever you want to do with the value, like this:
67
+ #
68
+ # config { |cfg| puts "foo is #{cfg[:foo]}" }
69
+ #
70
+ # Now, you can, if you want, "leak" this object out of the mutex, with
71
+ # various sorts of assignment. DO NOT SUCCUMB TO THIS TEMPTATION. If
72
+ # you do this, you will risk all sorts of concurrency bugs, where two
73
+ # threads try to read and/or manipulate the object at the same time
74
+ # and all hell breaks loose.
75
+ #
76
+ # Note that there is intentionally no way to reassign a memo object.
77
+ # This doesn't mean that memo objects are "read-only", however. The
78
+ # state of the object can be mutated by calling any method on the
79
+ # object that modifies it. If you want more read-only(ish) memos, you
80
+ # probably want to call `#freeze` on your object when you create it
81
+ # (although all the usual caveats about the many limitations of `#freeze`
82
+ # still apply).
83
+ #
84
+ # @param name [Symbol] the name of the memo, and hence the name of the
85
+ # method that should be called to retrieve the memo's value.
86
+ #
87
+ # @return void
88
+ #
89
+ def memo(name, &generator)
90
+ define_method(name) do |&blk|
91
+ raise RuntimeError, "Memo values are only available inside a block" unless blk
92
+
93
+ @memo_mutexes_mutex.synchronize do
94
+ @memo_mutexes[name] ||= Mutex.new
95
+ end
96
+
97
+ @memo_mutexes[name].synchronize do
98
+ @memo_values[name] ||= instance_eval(&generator)
99
+ blk.call(@memo_values[name])
100
+ end
101
+ end
102
+ end
103
+
104
+ # Execute a block of code periodically.
105
+ #
106
+ # This pretty much does what it says on the tin. Every
107
+ # `n` seconds (where `n` can be a float) the given block
108
+ # of code is executed.
109
+ #
110
+ # Don't expect too much precision in the interval; we just sleep
111
+ # between triggers, so there might be a bit of an extra delay between
112
+ # invocations.
113
+ #
114
+ # @param n [Numeric] The amount of time which should elapse between
115
+ # invocations of the block.
116
+ #
117
+ # @param desc [#to_s] a descriptive name to use for the trigger in
118
+ # metrics and the suchlike. If you define two stimuli with the same
119
+ # periodicity, you *must* give them different descriptions.
120
+ #
121
+ # @yield every `n` seconds.
122
+ #
123
+ def every(n, desc = "every_#{n}", &blk)
124
+ method_name = desc.to_sym
125
+ define_method(method_name, &blk)
126
+
127
+ stimulate(method_name, desc) { |worker| Kernel.sleep n; worker.call }
128
+ end
129
+
130
+ # Keep a block of code running, calling it again whenever it exits.
131
+ #
132
+ # @param desc [#to_s] a descriptive name to use for the trigger.
133
+ # Each `respawn` block must have its own description.
134
+ #
135
+ # @yield every time the code block finishes.
136
+ #
137
+ def respawn(desc, &blk)
138
+ mutex = Mutex.new
139
+ cv = ConditionVariable.new
140
+ running = false
141
+
142
+ method_name = desc.to_sym
143
+ define_method(method_name) do
144
+ mutex.synchronize do
145
+ begin
146
+ instance_eval(&blk)
147
+ ensure
148
+ running = false
149
+ cv.signal
150
+ end
151
+ end
152
+ end
153
+
154
+ stimulate(method_name, desc) do |worker|
155
+ mutex.synchronize do
156
+ while running
157
+ cv.wait(mutex)
158
+ end
159
+ running = true
160
+ worker.call
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ Brown::Agent.extend(Brown::Agent::ClassMethods)
@@ -1,3 +1,5 @@
1
+ require "service_skeleton/background_worker"
2
+
1
3
  # A single stimulus group in a Brown Agent.
2
4
  #
3
5
  # This is all the behind-the-scenes plumbing that's required to make the
@@ -6,11 +8,13 @@
6
8
  # terribly, terribly wrong somewhere.
7
9
  #
8
10
  class Brown::Agent::Stimulus
9
- # The name of the method to call when the stimulus is triggered.
11
+ include ServiceSkeleton::BackgroundWorker
12
+
13
+ # The (bound) method to call when the stimulus is triggered.
10
14
  #
11
15
  # @return [String]
12
16
  #
13
- attr_reader :method_name
17
+ attr_reader :method
14
18
 
15
19
  # The chunk of code to call over and over again to listen for the stimulus.
16
20
  #
@@ -18,35 +22,40 @@ class Brown::Agent::Stimulus
18
22
  #
19
23
  attr_reader :stimuli_proc
20
24
 
21
- # The class to instantiate to process a stimulus.
22
- #
23
- # @return [Class]
24
- #
25
- attr_reader :agent_class
26
-
27
25
  # Create a new stimulus.
28
26
  #
29
- # @param method_name [String] The method to call on an instance of
30
- # `agent_class` to process a single stimulus event.
31
- #
32
- # @param stimuli_proc [Proc] What to call over and over again to listen
33
- # for new stimulus events.
27
+ # @param method [String] The (bound) method to call to process a single
28
+ # stimulus event.
34
29
  #
35
- # @param agent_class [Class] The class to instantiate when processing
36
- # stimulus events.
30
+ # @param stimuli_proc [Proc] What to call over and over again to collect
31
+ # the next stimulus event.
37
32
  #
38
33
  # @param logger [Logger] Where to log things to for this stimulus.
39
34
  # If left as the default, no logging will be done.
40
35
  #
41
- def initialize(method_name:, stimuli_proc:, agent_class:, logger: Logger.new("/dev/null"))
42
- @method_name = method_name
36
+ # @param metrics [Brown::Agent::Stimulus::Metrics] Somewhere to record
37
+ # all the important numbers about the stimulus.
38
+ #
39
+ def initialize(method:, stimuli_proc:, logger: Logger.new("/dev/null"), metrics:)
40
+ puts caller if method.nil?
41
+ @method = method
43
42
  @stimuli_proc = stimuli_proc
44
- @agent_class = agent_class
45
- @thread_group = ThreadGroup.new
43
+ @threads = ThreadGroup.new
46
44
  @logger = logger
45
+ @metrics = metrics
46
+
47
+ super
47
48
  end
48
49
 
49
- # Fire off the stimulus listener.
50
+ def start
51
+ @running = true
52
+
53
+ while @running
54
+ run
55
+ end
56
+ end
57
+
58
+ # Run the stimulus listener.
50
59
  #
51
60
  # @param once [Symbol, NilClass] Ordinarily, when the stimulus is run, it
52
61
  # just keeps going forever (or until stopped, at least). If you just
@@ -55,89 +64,87 @@ class Brown::Agent::Stimulus
55
64
  #
56
65
  def run(once = nil)
57
66
  if once == :once
58
- stimuli_proc.call(->(*args) { process(*args) })
67
+ @metrics.last_trigger.set({}, Time.now.to_f)
68
+ @metrics.processing_ruler.measure do
69
+ stimuli_proc.call(->(*args) { process(*args) })
70
+ end
59
71
  else
60
- @runner_thread = Thread.current
61
- begin
62
- while @runner_thread
63
- begin
64
- stimuli_proc.call(method(:spawn_worker))
65
- rescue Brown::StopSignal, Brown::FinishSignal
66
- raise
67
- rescue Exception => ex
68
- log_failure("Stimuli listener", ex)
72
+ @running = true
73
+ logger.debug(logloc) { "Running stimulus listener for stimulus proc at #{@method.source_location.join(":")}" }
74
+ Thread.handle_interrupt(Exception => :never) do
75
+ begin
76
+ while @running
77
+ begin
78
+ logger.debug(logloc) { "Calling stimulus_proc" }
79
+ Thread.handle_interrupt(Exception => :immediate) do
80
+ @metrics.last_trigger.set({}, Time.now.to_f)
81
+ @metrics.processing_ruler.measure do
82
+ stimuli_proc.call(->(*args) { spawn_worker(*args) })
83
+ end
84
+ end
85
+ rescue StandardError => ex
86
+ log_exception(ex) { "Stimuli listener proc raised exception" }
87
+ sleep 1
88
+ end
69
89
  end
90
+ rescue ServiceSkeleton::BackgroundWorker.const_get(:TerminateBackgroundThread) => ex
91
+ @threads.list.each { |th| th.raise(ex.class) }
92
+ raise unless Thread.current == @bg_worker_thread
93
+ rescue StandardError => ex
94
+ log_exception(ex) { "Mysterious exception while running stimulus listener for #{method.name}" }
70
95
  end
71
- rescue Brown::StopSignal
72
- stop
73
- rescue Brown::FinishSignal
74
- finish
75
- rescue Exception => ex
76
- log_failure("Stimuli runner", ex)
77
96
  end
78
97
  end
79
98
  end
80
99
 
81
- # Signal the stimulus to immediately shut down everything.
82
- #
83
- # This will cause all stimulus processing threads to be terminated
84
- # immediately. You probably want to use {#finish} instead, normally.
100
+ # Gracefully stop all stimuli workers.
85
101
  #
86
- def stop
87
- @thread_group.list.each do |th|
88
- th.raise Brown::StopSignal.new("stimulus thread_group")
89
- end
102
+ def shutdown
103
+ logger.info(progname) { "shutting down" }
104
+ @running = false
90
105
 
91
- finish
92
- end
106
+ logger.debug(progname) { "waiting for #{@threads.list.length} stimuli worker(s) to finish" }
93
107
 
94
- # Stop the stimulus listener, and wait gracefull for all currently
95
- # in-progress stimuli processing to finish before returning.
96
- #
97
- def finish
98
- if @runner_thread and @runner_thread != Thread.current
99
- @runner_thread.raise(Brown::StopSignal.new("stimulus loop"))
108
+ until @threads.list.empty? do
109
+ @threads.list.first.join
100
110
  end
101
- @runner_thread = nil
102
111
 
103
- @thread_group.list.each { |th| th.join }
112
+ logger.debug(progname) { "terminating stimulus listener" }
113
+ super
114
+
115
+ logger.info(progname) { "shutdown complete." }
104
116
  end
105
117
 
106
118
  private
107
119
 
120
+ attr_reader :logger
121
+
122
+ def progname
123
+ @progname ||= "StimulusWorker->#{method.name rescue nil}"
124
+ end
125
+
108
126
  # Process a single stimulus event.
109
127
  #
110
128
  def process(*args)
111
- instance = agent_class.new
112
-
113
- if instance.method(method_name).arity == 0
114
- instance.__send__(method_name)
129
+ logger.debug(progname) { "Processing stimulus. Arguments: #{args.inspect}" }
130
+ if method.arity == 0
131
+ method.call
115
132
  else
116
- instance.__send__(method_name, *args)
133
+ method.call(*args)
117
134
  end
118
135
  end
119
136
 
120
137
  # Fire off a new thread to process a single stimulus event.
121
138
  #
122
139
  def spawn_worker(*args)
123
- @thread_group.add(
124
- Thread.new(args) do |args|
125
- begin
126
- process(*args)
127
- rescue Brown::StopSignal, Brown::FinishSignal
128
- # We're OK with this; the thread will now
129
- # quietly die.
130
- rescue Exception => ex
131
- log_failure("Stimulus worker", ex)
132
- end
133
- end
134
- )
135
- end
140
+ @threads.add(Thread.new(args) do |args|
141
+ logger.debug(progname) { "Spawned new worker" }
136
142
 
137
- # Standard log formatting for caught exceptions.
138
- #
139
- def log_failure(what, ex)
140
- @logger.error { "#{what} failed: #{ex.message} (#{ex.class})" }
141
- @logger.info { ex.backtrace.map { |l| " #{l}" }.join("\n") }
143
+ begin
144
+ process(*args)
145
+ rescue StandardError => ex
146
+ log_exception(ex) { "Stimulus worker raised exception" }
147
+ end
148
+ end)
142
149
  end
143
150
  end
@@ -0,0 +1,17 @@
1
+ require "frankenstein/request"
2
+
3
+ # A bundle of metrics intended for a single arbitrary stimulus.
4
+ #
5
+ #
6
+ class Brown::Agent::Stimulus::Metrics
7
+ # The Frankenstein::Request instance for the stimulus processing.
8
+ attr_reader :processing_ruler
9
+
10
+ # When the stimulus last fired.
11
+ attr_reader :last_trigger
12
+
13
+ def initialize(prefix, registry:)
14
+ @processing_ruler = Frankenstein::Request.new(prefix, outgoing: false, registry: registry)
15
+ @last_trigger = registry.gauge(:"#{prefix}_last_trigger_timestamp_seconds", "When this stimulus was most recently triggered")
16
+ end
17
+ end
data/lib/brown/rspec.rb CHANGED
@@ -1,10 +1,21 @@
1
1
  require 'brown/test'
2
2
 
3
- RSpec.configure do |c|
4
- c.after(:each) do
5
- ObjectSpace.each_object(Class).each do |klass|
6
- if klass != Brown::Agent and klass.ancestors.include?(Brown::Agent)
7
- klass.reset_memos
3
+ module Brown::SpecHelpers
4
+ def self.included(mod)
5
+ mod.let(:agent_env) { { "AMQP_URL" => "amqp://spec.example.invalid" } }
6
+ mod.let(:agent) { described_class.new(agent_env) }
7
+
8
+ if mod.described_class.respond_to?(:amqp_publishers)
9
+ (mod.described_class.amqp_publishers || []).each do |publisher|
10
+ mod.let(:"#{publisher[:name]}_publisher") { instance_double(Brown::Agent::AMQPPublisher, publisher[:name]) }
11
+ end
12
+ end
13
+
14
+ mod.before(:each) do
15
+ if described_class.respond_to?(:amqp_publishers)
16
+ (described_class.amqp_publishers || []).each do |publisher|
17
+ allow(agent).to receive(publisher[:name]).and_return(send(:"#{publisher[:name]}_publisher"))
18
+ end
8
19
  end
9
20
  end
10
21
  end
data/lib/brown/test.rb CHANGED
@@ -8,16 +8,6 @@ require 'brown/agent/amqp_message_mock'
8
8
  # require 'brown/test'
9
9
  #
10
10
  module Brown::TestHelpers
11
- #:nodoc:
12
- def self.included(base)
13
- base.class_eval do
14
- %i{memo amqp_publisher amqp_listener}.each do |m|
15
- alias_method "#{m}_without_test".to_sym, m
16
- alias_method m, "#{m}_with_test".to_sym
17
- end
18
- end
19
- end
20
-
21
11
  # Is there not an arbitrary stimulus with the specified name registered
22
12
  # on this agent?
23
13
  #
@@ -44,8 +34,8 @@ module Brown::TestHelpers
44
34
  # Test-specific decorator to replace the "real" memo container object
45
35
  # with a test-enabled alternative.
46
36
  #
47
- def memo_with_test(name, safe=false, &generator)
48
- memo_without_test(name, safe, &generator)
37
+ def memo(name, safe=false, &generator)
38
+ super
49
39
 
50
40
  # Throw out the real memo, replace with our own testing-enabled variant
51
41
  @memos[name] = Brown::Agent::Memo.new(generator, safe, true)
@@ -100,12 +90,15 @@ module Brown::TestHelpers
100
90
  #
101
91
  # Test-specific decorator to record the existence of a publisher.
102
92
  #
103
- def amqp_publisher_with_test(name, *args)
93
+ def amqp_publisher(name, *args)
104
94
  @amqp_publishers ||= {}
105
95
 
106
96
  @amqp_publishers[name] = true
107
97
 
108
- amqp_publisher_without_test(name, *args)
98
+ publisher =
99
+ self.define_singleton_method
100
+
101
+ super
109
102
  end
110
103
 
111
104
  # Is there a publisher with the specified name registered on this agent?
@@ -122,7 +115,7 @@ module Brown::TestHelpers
122
115
  #
123
116
  # Test-specific decorator to record the details of a listener.
124
117
  #
125
- def amqp_listener_with_test(exchange_name, *args, &blk)
118
+ def amqp_listener(exchange_name, *args, &blk)
126
119
  @amqp_listeners ||= {}
127
120
 
128
121
  if exchange_name.is_a? Array
@@ -133,7 +126,7 @@ module Brown::TestHelpers
133
126
  @amqp_listeners[exchange_name.to_s] = blk
134
127
  end
135
128
 
136
- amqp_listener_without_test(exchange_name, *args, &blk)
129
+ super
137
130
  end
138
131
 
139
132
  # Is there a listener on the specified exchange name registered on this agent?
@@ -163,23 +156,18 @@ module Brown::TestHelpers
163
156
  # that isn't being listened on.
164
157
  #
165
158
  def amqp_receive(exchange_name, payload, **opts)
166
- unless amqp_listener?(exchange_name)
167
- raise ArgumentError,
168
- "Unknown exchange: #{exchange_name}"
169
- end
170
-
171
159
  msg = Brown::Agent::AMQPMessageMock.new(opts.merge(payload: payload))
172
160
 
173
- self.new.tap do |p|
174
- uuid = SecureRandom.uuid
175
- p.singleton_class.__send__(:define_method, uuid, &@amqp_listeners[exchange_name])
176
- p.__send__(uuid, msg)
161
+ (self.class.amqp_listeners || []).each do |listener|
162
+ if listener[:exchange_list].include?(exchange_name.to_s)
163
+ m = SecureRandom.uuid
164
+ define_singleton_method(m.to_sym, &listener[:callback])
165
+ __send__(m.to_sym, msg)
166
+ end
177
167
  end
178
168
 
179
169
  msg.acked?
180
170
  end
181
171
  end
182
172
 
183
- class << Brown::Agent
184
- include Brown::TestHelpers
185
- end
173
+ Brown::Agent.prepend(Brown::TestHelpers)