brown 2.2.2 → 2.2.2.25.g85ddf08

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)