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.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/Dockerfile +10 -0
- data/README.md +137 -99
- data/bin/brown +19 -82
- data/brown.gemspec +3 -3
- data/lib/brown.rb +2 -0
- data/lib/brown/agent.rb +41 -411
- data/lib/brown/agent/amqp.rb +20 -0
- data/lib/brown/agent/amqp/class_methods.rb +147 -0
- data/lib/brown/agent/amqp/initializer.rb +144 -0
- data/lib/brown/agent/amqp_message.rb +26 -4
- data/lib/brown/agent/amqp_publisher.rb +68 -27
- data/lib/brown/agent/class_methods.rb +166 -0
- data/lib/brown/agent/stimulus.rb +84 -77
- data/lib/brown/agent/stimulus/metrics.rb +17 -0
- data/lib/brown/rspec.rb +16 -5
- data/lib/brown/test.rb +16 -28
- metadata +18 -20
@@ -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)
|
data/lib/brown/agent/stimulus.rb
CHANGED
@@ -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
|
-
|
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 :
|
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
|
30
|
-
#
|
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
|
36
|
-
# stimulus
|
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
|
-
|
42
|
-
|
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
|
-
@
|
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
|
-
|
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
|
-
|
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
|
-
@
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
-
#
|
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
|
87
|
-
|
88
|
-
|
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
|
-
|
95
|
-
|
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
|
-
|
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
|
-
|
112
|
-
|
113
|
-
|
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
|
-
|
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
|
-
@
|
124
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
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
|
48
|
-
|
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
|
93
|
+
def amqp_publisher(name, *args)
|
104
94
|
@amqp_publishers ||= {}
|
105
95
|
|
106
96
|
@amqp_publishers[name] = true
|
107
97
|
|
108
|
-
|
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
|
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
|
-
|
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.
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
-
|
184
|
-
include Brown::TestHelpers
|
185
|
-
end
|
173
|
+
Brown::Agent.prepend(Brown::TestHelpers)
|