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.
- 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)
|