all_my_circuits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 37ff5fd01761190c1ac3b39e9716cac0b242e244
4
+ data.tar.gz: 3978e9a09c6bc75e86227359036f783eb33616b6
5
+ SHA512:
6
+ metadata.gz: ea62b8b682a65ecd8b1e03f02820040db8023b0de594ae0a1153988674514a665c504284d568fce3950fab060917892e55edc1ec217fe42178b549995f63eb38
7
+ data.tar.gz: a79c6a02bcad7e76310b75899b22b0b926f1d9a6e8b750d506d29eed1eb19354de9dbccbfbb0bacf2353d7f71053b12453ac7c77daadb1b725b930942e463a66
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in all_my_circuits.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2015, Remind101, Inc.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # AllMyCircuits
2
+
3
+ [![Circle CI](https://circleci.com/gh/remind101/all_my_circuits.svg?style=svg&circle-token=3c0f7294a61e095f789f369f96bdd266ddd80258)](https://circleci.com/gh/remind101/all_my_circuits)
4
+
5
+ ![funny image goes here](https://raw.githubusercontent.com/remind101/all_my_circuits/master/all_my_circuits.jpg?token=AAc0YcX8xOhT0o4_Ko-IxKEEQk2PTUJYks5VaR0ywA%3D%3D)
6
+
7
+ AllMyCircuits is intended to be a threadsafe-ish circuit breaker implementation for Ruby.
8
+ See [Martin Fowler's article on the Circuit Breaker pattern for more info](http://martinfowler.com/bliki/CircuitBreaker.html).
9
+
10
+ # Usage
11
+
12
+ class MyService
13
+ include Singleton
14
+
15
+ def initialize
16
+ @circuit_breaker = AllMyCircuits::Breaker.new(
17
+ name: "my_service",
18
+ watch_errors: [Timeout::Error], # defaults to AllMyCircuits::Breaker.net_errors,
19
+ # which includes typical net/http and socket errors
20
+ sleep_seconds: 10, # leave circuit open for 10 seconds, than try to call the service again
21
+ strategy: AllMyCircuits::Strategies::PercentageOverWindowStrategy.new(
22
+ requests_window: 100, # number of requests to calculate the average failure rate for
23
+ failure_rate_percent_threshold: 25 # open circuit if 25% or more requests within 100-request window fail
24
+ # must trip open again if the first request fails
25
+ }
26
+ )
27
+ end
28
+
29
+ def run
30
+ begin
31
+ @breaker.run do
32
+ Timeout.timeout(1.0) { my_risky_call }
33
+ end
34
+ rescue AllMyCircuits::BreakerOpen => e
35
+ # log me somewhere
36
+ rescue
37
+ # uh-oh, risky call failed once
38
+ end
39
+ end
40
+ end
41
+
42
+ # Testing
43
+
44
+ So, what have we got:
45
+
46
+ * Time-sensitive code: ...check
47
+ * Concurrent code: ...check
48
+
49
+ Dude, that's a real headache for someone who's not confortable enough with concurrent code.
50
+ I haven't figured any awesome way of automated testing in this case.
51
+ So, in the [script](https://github.com/remind101/all_my_circuits/tree/master/script) folder, there are:
52
+
53
+ * [fake_service.rb](https://github.com/remind101/all_my_circuits/blob/master/script/fake_service.rb)
54
+ * [graphing_stress_test.rb](https://github.com/remind101/all_my_circuits/blob/master/script/graphing_stress_test.rb)
55
+
56
+ ## fake_service.rb
57
+
58
+ the Fake Service has 3 modes of operation: `up` (default), `die` and `slowdown`.
59
+
60
+ * `up` (default) - http://localhost:8081/up - normal mode of operation, latency up to 50ms
61
+ * `die` - http://localhost:8081/die - exceptions are raised left and right, slight delay in response
62
+ * `slowdown` - http://localhost:8081/slowdown - successful responses with a significant delay.
63
+
64
+ ## Graphing Stress Test
65
+
66
+ runs `WORKERS` number of workers which continuously hit http://localhost:8081. Graphs are served at http://localhost:8080.
67
+ This app allows to catch incorrect circuit breaker behavior visually.
68
+
69
+ # Logging
70
+
71
+ Logger can be configured by setting `AllMyCircuits.logger`.
72
+ Default log device is STDERR, and default level is ERROR (can be overridden with `ALL_MY_CIRCUITS_LOG_LEVEL` environment variable).
73
+
74
+ # TODO
75
+
76
+ * global controls through redis?
77
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.test_files = FileList["test/**/test_*.rb"]
7
+ t.verbose = true
8
+ end
9
+
10
+ task default: [:test]
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'all_my_circuits/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "all_my_circuits"
8
+ spec.version = AllMyCircuits::VERSION
9
+ spec.authors = ["Vlad Yarotsky"]
10
+ spec.email = ["vlad@remind101.com"]
11
+
12
+ spec.summary = %q{Circuit Breaker library with support for rolling-window absolute/percentage thresholds}
13
+ spec.description = %q{}
14
+ spec.homepage = "https://github.com/remind101/all_my_circuits"
15
+ spec.license = "BSD"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "concurrent-ruby", "~> 0.8"
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.9"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "minitest", "~> 5.6"
27
+ spec.add_development_dependency "puma", "~> 2.11"
28
+ end
Binary file
data/circle.yml ADDED
@@ -0,0 +1,12 @@
1
+ machine:
2
+ ruby:
3
+ version:
4
+ 2.2.2
5
+
6
+ dependencies:
7
+ pre:
8
+ - gem install bundler -v "~> 1.9"
9
+
10
+ test:
11
+ override:
12
+ - rake test
@@ -0,0 +1,264 @@
1
+ require "concurrent/atomic"
2
+ require "thread"
3
+
4
+ module AllMyCircuits
5
+
6
+ class Breaker
7
+ include Logging
8
+
9
+ attr_reader :name
10
+
11
+ # Public: exceptions typically thrown when using Net::HTTP
12
+ #
13
+ def self.net_errors
14
+ require "timeout"
15
+ require "net/http"
16
+
17
+ [Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError,
18
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::HTTPFatalError, Net::HTTPServerError]
19
+ end
20
+
21
+ # Public: Initializes circuit breaker instance.
22
+ #
23
+ # Options
24
+ #
25
+ # name - name of the call wrapped into circuit breaker (e.g. "That Unstable Service").
26
+ # watch_errors - exceptions to count as failures. Other exceptions will simply get re-raised
27
+ # (default: AllMyCircuits::Breaker.net_errors).
28
+ # sleep_seconds - number of seconds the circuit stays open before attempting to close.
29
+ # strategy - an AllMyCircuits::Strategies::AbstractStrategy-compliant object that controls
30
+ # when the circuit should be tripped open.
31
+ # Built-in strategies:
32
+ # AllMyCircuits::Strategies::PercentageOverWindowStrategy,
33
+ # AllMyCircuits::Strategies::NumberOverWindowStrategy.
34
+ # notifier - (optional) AllMyCircuits::Notifiers::AbstractNotifier-compliant object that
35
+ # is called whenever circuit breaker state (open, closed) changes.
36
+ # Built-in notifiers:
37
+ # AllMyCircuits::Notifiers::NullNotifier.
38
+ #
39
+ # Examples
40
+ #
41
+ # AllMyCircuits::Breaker.new(
42
+ # name: "My Unstable Service",
43
+ # sleep_seconds: 5,
44
+ # strategy: AllMyCircuits::Strategies::PercentageOverWindowStrategy.new(
45
+ # requests_window: 20, # number of requests in the window to calculate failure rate for
46
+ # failure_rate_percent_threshold: 25 # how many failures can occur within the window, in percent,
47
+ # ) # before the circuit opens
48
+ # )
49
+ #
50
+ # AllMyCircuits::Breaker.new(
51
+ # name: "Another Unstable Service",
52
+ # sleep_seconds: 5,
53
+ # strategy: AllMyCircuits::Strategies::NumberOverWindowStrategy.new(
54
+ # requests_window: 20,
55
+ # failures_threshold: 25 # how many failures can occur within the window before the circuit opens
56
+ # )
57
+ # )
58
+ #
59
+ def initialize(name:,
60
+ watch_errors: Breaker.net_errors,
61
+ sleep_seconds:,
62
+ strategy:,
63
+ notifier: Notifiers::NullNotifier.new,
64
+ clock: Clock)
65
+
66
+ @name = String(name).dup.freeze
67
+ @watch_errors = Array(watch_errors).dup
68
+ @sleep_seconds = Integer(sleep_seconds)
69
+
70
+ @strategy = strategy
71
+ @notifier = notifier
72
+
73
+ @state_lock = Mutex.new
74
+ @request_number = Concurrent::Atomic.new(0)
75
+ @last_open_or_probed = nil
76
+ @opened_at_request_number = 0
77
+ @clock = clock
78
+ end
79
+
80
+ # Public: executes supplied block of code and monitors failures.
81
+ # Once the number of failures reaches a certain threshold, the block is bypassed
82
+ # for a certain period of time.
83
+ #
84
+ # Consider the following examples of calls through circuit breaker (let it be 1 call per second,
85
+ # and let the circuit breaker be configured as in the example below):
86
+ #
87
+ # Legend
88
+ #
89
+ # S - successful request
90
+ # F - failed request
91
+ # O - skipped request (circuit open)
92
+ # | - open circuit interval end
93
+ #
94
+ #
95
+ # 1) S S F F S S F F S F O O O O O|S S S S S
96
+ #
97
+ # Here among the first 10 requests (window), 5 failures occur (50%), the circuit is tripped open
98
+ # for 5 seconds, and a few requests are skipped. Then, after 5 seconds, a request is issued to
99
+ # see whether everything is back to normal, and the circuit is then closed again.
100
+ #
101
+ # 2) S S F F S S F F S F O O O O O|F O O O O O|S S S S S
102
+ #
103
+ # Same situation, 10 requests, 5 failed, circuit is tripped open for 5 seconds. Then we
104
+ # check that service is back to normal, and it is not. The circuit is open again.
105
+ # After another 5 seconds we check again and close the circuit.
106
+ #
107
+ # Returns nothing.
108
+ # Raises AllMyCircuit::BreakerOpen with the name of the service when the circuit is open.
109
+ # Raises whatever has been raised by the supplied block.
110
+ #
111
+ # This call is thread-safe sans the supplied block.
112
+ #
113
+ # Examples
114
+ #
115
+ # @cb = AllMyCircuits::Breaker.new(
116
+ # name: "that bad service",
117
+ # sleep_seconds: 5,
118
+ # strategy: AllMyCircuits::Strategies::PercentageOverWindowStrategy.new(
119
+ # requests_window: 10,
120
+ # failure_rate_percent_threshold: 50
121
+ # )
122
+ # )
123
+ #
124
+ # @client = MyBadServiceClient.new(timeout: 2)
125
+ #
126
+ # begin
127
+ # @cb.run do
128
+ # @client.make_expensive_unreliable_http_call
129
+ # end
130
+ # rescue AllMyCircuits::BreakerOpen => e
131
+ # []
132
+ # rescue MyBadServiceClient::Error => e
133
+ # MyLog << "an error has occured in call to my bad service"
134
+ # []
135
+ # end
136
+ #
137
+ def run
138
+ unless allow_request?
139
+ debug "declining request, circuit is open", name
140
+ raise BreakerOpen, @name
141
+ end
142
+
143
+ current_request_number = generate_request_number
144
+ begin
145
+ result = yield
146
+ success(current_request_number)
147
+ result
148
+ rescue *@watch_errors
149
+ error(current_request_number)
150
+ raise
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ # Internal: checks whether the circuit is closed, or if it is time
157
+ # to try one request to see if things are back to normal.
158
+ #
159
+ def allow_request?
160
+ @state_lock.synchronize do
161
+ !open? || allow_probe_request?
162
+ end
163
+ end
164
+
165
+ def generate_request_number
166
+ current_request_number = @request_number.update { |v| v + 1 }
167
+
168
+ debug "new request", name, current_request_number
169
+ current_request_number
170
+ end
171
+
172
+ # Internal: markes request as successful. Closes the circuit if necessary.
173
+ #
174
+ # Arguments
175
+ # current_request_number - the number assigned to the request by the circuit breaker
176
+ # before it was sent.
177
+ #
178
+ def success(current_request_number)
179
+ will_notify_notifier_closed = false
180
+
181
+ @state_lock.synchronize do
182
+ if open?
183
+ # This ensures that we are not closing the circuit prematurely
184
+ # due to a response for an old request coming in.
185
+ if current_request_number > @opened_at_request_number
186
+ info "closing circuit", name, current_request_number
187
+ close!
188
+ will_notify_notifier_closed = true
189
+ else
190
+ debug "ignoring late success response", name, current_request_number
191
+ end
192
+ end
193
+ debug "request succeeded", name, current_request_number
194
+ @strategy.success
195
+ end
196
+
197
+ # We don't want to be doing this while holding the lock
198
+ if will_notify_notifier_closed
199
+ @notifier.closed
200
+ end
201
+ end
202
+
203
+ # Internal: marks request as failed. Opens the circuit if necessary.
204
+ #
205
+ def error(current_request_number)
206
+ will_notify_notifier_opened = false
207
+
208
+ @state_lock.synchronize do
209
+ if open?
210
+ debug "ignoring late error response (circuit is open)", name, current_request_number
211
+ return
212
+ end
213
+
214
+ debug "request failed. #{@strategy.inspect}", name, current_request_number
215
+ @strategy.error
216
+
217
+ if @strategy.should_open?
218
+ info "opening circuit", name, current_request_number
219
+ open!
220
+ will_notify_notifier_opened = true
221
+ end
222
+ end
223
+
224
+ # We don't want to be doing this while holding the lock
225
+ if will_notify_notifier_opened
226
+ @notifier.opened
227
+ end
228
+ end
229
+
230
+ def open?
231
+ @opened_at_request_number > 0
232
+ end
233
+
234
+ def allow_probe_request?
235
+ if open? && @clock.timestamp >= (@last_open_or_probed + @sleep_seconds)
236
+ debug "allowing probe request", name
237
+ # makes sure that we allow only one probe request by extending sleep interval
238
+ # and leaving the circuit open until closed by the success callback.
239
+ @last_open_or_probed = @clock.timestamp
240
+ return true
241
+ end
242
+ false
243
+ end
244
+
245
+ def open!
246
+ @last_open_or_probed = @clock.timestamp
247
+ # The most recent request encountered so far (may not be the current request in concurrent situation).
248
+ # This is necessary to prevent successful response to old request from opening the circuit prematurely.
249
+ # Imagine concurrent situation ("|" for request start, ">" for request end):
250
+ # 1|-----------> success
251
+ # 2|----> error, open circuit breaker
252
+ # In this case request 1) should not close the circuit.
253
+ @opened_at_request_number = @request_number.value
254
+ @strategy.opened
255
+ end
256
+
257
+ def close!
258
+ @last_open_or_probed = 0
259
+ @opened_at_request_number = 0
260
+ @strategy.closed
261
+ end
262
+ end
263
+
264
+ end
@@ -0,0 +1,9 @@
1
+ module AllMyCircuits
2
+
3
+ class Clock
4
+ def self.timestamp
5
+ Time.now
6
+ end
7
+ end
8
+
9
+ end
@@ -0,0 +1,6 @@
1
+ module AllMyCircuits
2
+
3
+ class Error < StandardError; end
4
+ class BreakerOpen < Error; end
5
+
6
+ end
@@ -0,0 +1,23 @@
1
+ module AllMyCircuits
2
+
3
+ module Logging
4
+ def debug(message, breaker_name, request_number = nil)
5
+ AllMyCircuits.logger.debug(format_line(message, breaker_name, request_number))
6
+ end
7
+
8
+ def info(message, breaker_name, request_number = nil)
9
+ AllMyCircuits.logger.info(format_line(message, breaker_name, request_number))
10
+ end
11
+
12
+ private
13
+
14
+ def format_line(message, breaker_name, request_number)
15
+ if request_number.nil?
16
+ "[%s] %s" % [breaker_name, message]
17
+ else
18
+ "[%s] req. #%s: %s" % [breaker_name, request_number, message]
19
+ end
20
+ end
21
+ end
22
+
23
+ end
@@ -0,0 +1,27 @@
1
+ module AllMyCircuits
2
+ module Notifiers
3
+
4
+ # Public: notifies some service about change of breaker's state.
5
+ # For example, one could send a Librato metric whenever a circuit
6
+ # breaker is opened or closed.
7
+ #
8
+ class AbstractNotifier
9
+ def initialize(breaker_name, **kwargs)
10
+ @breaker_name = breaker_name
11
+ end
12
+
13
+ # Public: called once the circuit is tripped open.
14
+ #
15
+ def opened
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Public: called once the circuit is closed.
20
+ #
21
+ def closed
22
+ raise NotImplementedError
23
+ end
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ module AllMyCircuits
2
+ module Notifiers
3
+
4
+ # Public: no-op implementation of AbstractNotifier.
5
+ #
6
+ class NullNotifier < AbstractNotifier
7
+ def initialize(*args, **kwargs)
8
+ end
9
+
10
+ def opened
11
+ end
12
+
13
+ def closed
14
+ end
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ module AllMyCircuits
2
+
3
+ module Notifiers
4
+ autoload :AbstractNotifier, "all_my_circuits/notifiers/abstract_notifier"
5
+ autoload :NullNotifier, "all_my_circuits/notifiers/null_notifier"
6
+ end
7
+
8
+ end
@@ -0,0 +1,23 @@
1
+ module AllMyCircuits
2
+
3
+ # Public: no-op circuit breaker implementation, useful for testing
4
+ #
5
+ class NullBreaker
6
+ attr_reader :name
7
+ attr_accessor :closed
8
+
9
+ def initialize(name:, closed:)
10
+ @name = name
11
+ @closed = closed
12
+ end
13
+
14
+ def run
15
+ if @closed
16
+ yield
17
+ else
18
+ raise AllMyCircuits::BreakerOpen, @name
19
+ end
20
+ end
21
+ end
22
+
23
+ end
@@ -0,0 +1,40 @@
1
+ module AllMyCircuits
2
+ module Strategies
3
+
4
+ # Public: determines whether the circuit breaker should be tripped open
5
+ # upon another error within the supplied block.
6
+ #
7
+ # See AllMyCircuits::Strategies::NumberOverWindowStrategy,
8
+ # AllMyCircuits::Strategies::PercentageOverWindowStrategy for examples.
9
+ #
10
+ class AbstractStrategy
11
+ # Public: called whenever a request has ran successfully through circuit breaker.
12
+ #
13
+ def success
14
+ end
15
+
16
+ # Public: called whenever a request has failed within circuit breaker.
17
+ #
18
+ def error
19
+ end
20
+
21
+ # Public: called whenever circuit is tripped open.
22
+ #
23
+ def opened
24
+ end
25
+
26
+ # Public: called whenever circuit is closed.
27
+ #
28
+ def closed
29
+ end
30
+
31
+ # Public: called after each error within circuit breaker to determine
32
+ # whether it should be tripped open.
33
+ #
34
+ def should_open?
35
+ raise NotImplementedError
36
+ end
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ module AllMyCircuits
2
+ module Strategies
3
+ class AbstractWindowStrategy
4
+
5
+ class Window
6
+ def initialize(number_of_events)
7
+ number_of_events = Integer(number_of_events)
8
+ unless number_of_events > 0
9
+ raise ArgumentError, "window size must be a natural number"
10
+ end
11
+ @number_of_events = number_of_events
12
+ reset!
13
+ end
14
+
15
+ def reset!
16
+ @window = []
17
+ @counters = Hash.new { |h, k| h[k] = 0 }
18
+ end
19
+
20
+ def <<(event)
21
+ if full?
22
+ event_to_decrement = @window.shift
23
+ @counters[event_to_decrement] -= 1
24
+ end
25
+ @window.push(event)
26
+ @counters[event] += 1
27
+ self
28
+ end
29
+
30
+ def count(event = nil)
31
+ event.nil? ? @window.length : @counters[event]
32
+ end
33
+
34
+ def full?
35
+ @window.length == @number_of_events
36
+ end
37
+
38
+ def inspect
39
+ "#<%s:0x%x size: %d, counts: %s, full: %s" % [self.class.name, object_id, @number_of_events, @counters, full?]
40
+ end
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ module AllMyCircuits
2
+ module Strategies
3
+
4
+ class AbstractWindowStrategy < AbstractStrategy
5
+ autoload :Window, "all_my_circuits/strategies/abstract_window_strategy/window"
6
+
7
+ def initialize(requests_window:)
8
+ @requests_window = requests_window
9
+ @window = Window.new(@requests_window)
10
+ end
11
+
12
+ def success
13
+ @window << :succeeded
14
+ end
15
+
16
+ def error
17
+ @window << :failed
18
+ end
19
+
20
+ def opened
21
+ end
22
+
23
+ def closed
24
+ @window.reset!
25
+ end
26
+
27
+ def should_open?
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ module AllMyCircuits
2
+ module Strategies
3
+
4
+ # Public: opens the circuit whenever failures threshold is reached
5
+ # within the window. Threshold is represented by absolute number of
6
+ # failures within the window.
7
+ #
8
+ class NumberOverWindowStrategy < AbstractWindowStrategy
9
+
10
+ # Public: initializes a new instance.
11
+ #
12
+ # Options
13
+ #
14
+ # requests_window - number of consecutive requests tracked by the window.
15
+ # failures_threshold - number of failures within the window after which
16
+ # the circuit is tripped open.
17
+ #
18
+ def initialize(failures_threshold:, **kwargs)
19
+ @failures_threshold = failures_threshold
20
+ super(**kwargs)
21
+ end
22
+
23
+ def should_open?
24
+ return unless @window.full?
25
+
26
+ failures = @window.count(:failed)
27
+ failures >= @failures_threshold
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ module AllMyCircuits
2
+ module Strategies
3
+
4
+ # Public: opens the circuit whenever failures threshold is reached
5
+ # within the window. Threshold is represented by a percentage of
6
+ # failures within the window.
7
+ #
8
+ class PercentageOverWindowStrategy < AbstractWindowStrategy
9
+
10
+ # Public: initializes a new instance.
11
+ #
12
+ # Options
13
+ #
14
+ # requests_window - number of consecutive requests tracked by the window.
15
+ # failure_rate_percent_threshold - percent rate of failures within the window after which
16
+ # the circuit is tripped open.
17
+ #
18
+ def initialize(failure_rate_percent_threshold:, **kwargs)
19
+ @failure_rate_percent_threshold = failure_rate_percent_threshold
20
+ super(**kwargs)
21
+ end
22
+
23
+ def should_open?
24
+ return false unless @window.full?
25
+
26
+ failure_rate_percent = ((@window.count(:failed).to_f / @window.count) * 100).ceil
27
+ failure_rate_percent >= @failure_rate_percent_threshold
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ module AllMyCircuits
2
+
3
+ module Strategies
4
+ autoload :AbstractStrategy, "all_my_circuits/strategies/abstract_strategy"
5
+ autoload :AbstractWindowStrategy, "all_my_circuits/strategies/abstract_window_strategy"
6
+ autoload :PercentageOverWindowStrategy, "all_my_circuits/strategies/percentage_over_window_strategy"
7
+ autoload :NumberOverWindowStrategy, "all_my_circuits/strategies/number_over_window_strategy"
8
+ end
9
+
10
+ end
@@ -0,0 +1,3 @@
1
+ module AllMyCircuits
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,20 @@
1
+ require "all_my_circuits/version"
2
+ require "logger"
3
+
4
+ module AllMyCircuits
5
+ require "all_my_circuits/exceptions"
6
+
7
+ autoload :Breaker, "all_my_circuits/breaker"
8
+ autoload :Clock, "all_my_circuits/clock"
9
+ autoload :Logging, "all_my_circuits/logging"
10
+ autoload :Notifiers, "all_my_circuits/notifiers"
11
+ autoload :NullBreaker, "all_my_circuits/null_breaker"
12
+ autoload :Strategies, "all_my_circuits/strategies"
13
+ autoload :VERSION, "all_my_circuits/version"
14
+
15
+ class << self
16
+ attr_accessor :logger
17
+ end
18
+ @logger = Logger.new(STDERR)
19
+ @logger.level = Integer(ENV["ALL_MY_CIRCUITS_LOG_LEVEL"]) rescue Logger::ERROR
20
+ end
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rack"
4
+ require "rack/handler/puma"
5
+ require "rack/builder"
6
+
7
+ class FakeService
8
+ attr_accessor :state
9
+
10
+ def initialize
11
+ @random = Random.new
12
+ @state = :up
13
+ end
14
+
15
+ def call(env)
16
+ case @state
17
+ when :up
18
+ sleep @random.rand(0.100)
19
+ [200, { "Content-Type" => "text/html" }, ["up"]]
20
+ when :die
21
+ sleep @random.rand(1.000)
22
+ [500, { "Content-Type" => "text/html" }, ["down"]]
23
+ when :slowdown
24
+ sleep @random.rand(5.000)
25
+ [200, { "Content-Type" => "text/html" }, ["slooow"]]
26
+ else
27
+ fail "what is that again?"
28
+ end
29
+ end
30
+ end
31
+
32
+ $service = FakeService.new
33
+
34
+ def set_state_action(state)
35
+ lambda { |_| $service.state = state; [200, { "Content-Type" => "text/html" }, ["OK"]] }
36
+ end
37
+
38
+ app = Rack::Builder.new do
39
+ map("/" ) { run $service }
40
+ map("/die") { run set_state_action(:die) }
41
+ map("/slowdown") { run set_state_action(:slowdown) }
42
+ map("/up") { run set_state_action(:up) }
43
+ end
44
+
45
+ server = ::Rack::Handler::Puma
46
+ server.run app, Port: 8081
@@ -0,0 +1,126 @@
1
+ require "rack"
2
+ require "rack/handler/puma"
3
+ require "json"
4
+
5
+ class Stream
6
+ def initialize(data_queue)
7
+ @data_queue = data_queue
8
+ end
9
+
10
+ def each
11
+ batch_proto = { succeeded: 0, failed: 0, skipped: 0 }.freeze
12
+ next_batch = batch_proto.dup
13
+ loop do
14
+ data_batch = next_batch
15
+ next_batch = batch_proto.dup
16
+ frame_end = Time.now + 1
17
+ begin
18
+ while r = @data_queue.pop(true)
19
+ if r[:finished] <= frame_end
20
+ data_batch[r[:status]] += 1
21
+ else
22
+ next_batch[r[:status]] += 1
23
+ break
24
+ end
25
+ end
26
+ rescue ThreadError # queue exhausted
27
+ sleep 0.0001
28
+ retry
29
+ end
30
+ data_batch[:currentTime] = frame_end.to_f * 1000
31
+ serialized = "data: %s\n\n" % data_batch.to_json
32
+ yield serialized
33
+ end
34
+ end
35
+ end
36
+
37
+ class GraphingServer
38
+ def self.start(data_queue)
39
+ server = ::Rack::Handler::Puma
40
+ server.run Rack::Chunked.new(new(Stream.new(data_queue)))
41
+ rescue => e
42
+ warn "graphing server wtf #{e.inspect} #{e.backtrace}"
43
+ raise
44
+ end
45
+
46
+ PAGE = <<-PAGE
47
+ <!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <title>AllMyCircuits Stress Test Graph</title>
51
+ <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js" type="text/javascript"></script>
52
+ <script src="https://rawgit.com/joewalnes/smoothie/master/smoothie.js" type="text/javascript"></script>
53
+ <style type="text/css">
54
+ html{
55
+ height: 100%;
56
+ }
57
+ body {
58
+ min-height: 100%;
59
+ }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <canvas id="workersSuccess" width="1000" height="300"></canvas>
64
+ <canvas id="workersFailure" width="1000" height="300"></canvas>
65
+ <canvas id="workersSkip" width="1000" height="300"></canvas>
66
+ <script type="text/javascript">
67
+ var chartHeight = window.innerHeight / 3 - 20;
68
+ var chartWidth = window.innerWidth - 20;
69
+
70
+ var charts = {
71
+ succeeded: {
72
+ elementId: "workersSuccess",
73
+ color: "rgb(0, 255, 0)"
74
+ },
75
+ failed: {
76
+ elementId: "workersFailure",
77
+ color: "rgb(255, 0, 0)"
78
+ },
79
+ skipped: {
80
+ elementId: "workersSkip",
81
+ color: "rgb(255, 255, 255)"
82
+ }
83
+ };
84
+
85
+ for (var chart in charts) {
86
+ var chartConfig = charts[chart];
87
+ var el = document.getElementById(chartConfig.elementId);
88
+ el.height = chartHeight;
89
+ el.width = chartWidth;
90
+
91
+ var chart = new SmoothieChart({ interpolation: 'step', minValue: 0, maxValueScale: 1.2 });
92
+ chart.streamTo(el, 1000);
93
+
94
+ chartConfig.series = new TimeSeries();
95
+ chart.addTimeSeries(chartConfig.series, { strokeStyle: chartConfig.color });
96
+ }
97
+
98
+ var source = new EventSource("/data");
99
+ source.onmessage = function(event) {
100
+ var points = JSON.parse(event.data);
101
+ var currentTime = points.currentTime;
102
+ charts.succeeded.series.append(currentTime, points.succeeded);
103
+ charts.failed.series.append(currentTime, points.failed);
104
+ charts.skipped.series.append(currentTime, points.skipped);
105
+ };
106
+ </script>
107
+ </body>
108
+ </html>
109
+ PAGE
110
+
111
+ def initialize(stream)
112
+ @stream = stream
113
+ end
114
+
115
+ def call(env)
116
+ req = Rack::Request.new(env)
117
+ case req.path
118
+ when "/"
119
+ [200, { "Content-Type" => "text/html" }, [PAGE]]
120
+ when "/data"
121
+ [200, { "Content-Type" => "text/event-stream", "Connection" => "keepalive", "Cache-Control" => "no-cache, no-store" }, @stream]
122
+ else
123
+ [404, { "Content-Type" => "text/html" }, []]
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.expand_path("../../lib", __FILE__)
4
+ require "all_my_circuits"
5
+ require_relative "./graphing_server"
6
+ require "thread"
7
+ require "net/http"
8
+ require "uri"
9
+ require "timeout"
10
+ require "securerandom"
11
+
12
+ WORKERS = Integer(ENV["WORKERS"]) rescue 25
13
+
14
+ def setup
15
+ @responses_queue = Queue.new
16
+ @breaker = AllMyCircuits::Breaker.new(
17
+ name: "test service circuit breaker",
18
+ sleep_seconds: 5,
19
+ strategy: AllMyCircuits::Strategies::PercentageOverWindowStrategy.new(
20
+ requests_window: 40,
21
+ failure_rate_percent_threshold: 25
22
+ )
23
+ )
24
+ @datapoints_queue = Queue.new
25
+ @commands_queue = Queue.new
26
+ end
27
+
28
+ def run
29
+ setup
30
+ workers = run_workers
31
+ graphing_server = run_graphing_server
32
+
33
+ workers.each(&:join)
34
+ graphing_server.join
35
+ end
36
+
37
+ def run_workers
38
+ WORKERS.times.map do
39
+ Thread.new(@responses_queue, @breaker) do |responses, breaker|
40
+ loop do
41
+ begin
42
+ t1 = Time.now
43
+ @breaker.run do
44
+ uri = URI("http://localhost:8081")
45
+ http = Net::HTTP.new(uri.host, uri.port)
46
+ http.open_timeout = 2
47
+ http.read_timeout = 2
48
+ http.start do |h|
49
+ request = Net::HTTP::Get.new(uri)
50
+ response = h.request(request)
51
+ response.value
52
+ log "success"
53
+ responses.push(status: :succeeded, started: t1, finished: Time.now)
54
+ end
55
+ end
56
+ rescue AllMyCircuits::BreakerOpen
57
+ log "breaker open"
58
+ responses.push(status: :skipped, started: t1, finished: Time.now)
59
+ rescue
60
+ log "failure #{$!.inspect}: #{$!.backtrace.first(2).join(", ")}"
61
+ responses.push(status: :failed, started: t1, finished: Time.now)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def run_graphing_server
69
+ Thread.new(@responses_queue) do |responses_queue|
70
+ GraphingServer.start(responses_queue)
71
+ end
72
+ end
73
+
74
+ def log(msg)
75
+ @mtx ||= Mutex.new
76
+ timestamp = "%10.6f" % Time.now.to_f
77
+ @mtx.synchronize { puts "[#{Thread.current.object_id}] #{timestamp}: #{msg}" if ENV["DEBUG"] }
78
+ end
79
+
80
+ run
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: all_my_circuits
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vlad Yarotsky
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2015-05-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.9'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: puma
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.11'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.11'
83
+ description: ''
84
+ email:
85
+ - vlad@remind101.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".gitignore"
91
+ - Gemfile
92
+ - LICENSE
93
+ - README.md
94
+ - Rakefile
95
+ - all_my_circuits.gemspec
96
+ - all_my_circuits.jpg
97
+ - circle.yml
98
+ - lib/all_my_circuits.rb
99
+ - lib/all_my_circuits/breaker.rb
100
+ - lib/all_my_circuits/clock.rb
101
+ - lib/all_my_circuits/exceptions.rb
102
+ - lib/all_my_circuits/logging.rb
103
+ - lib/all_my_circuits/notifiers.rb
104
+ - lib/all_my_circuits/notifiers/abstract_notifier.rb
105
+ - lib/all_my_circuits/notifiers/null_notifier.rb
106
+ - lib/all_my_circuits/null_breaker.rb
107
+ - lib/all_my_circuits/strategies.rb
108
+ - lib/all_my_circuits/strategies/abstract_strategy.rb
109
+ - lib/all_my_circuits/strategies/abstract_window_strategy.rb
110
+ - lib/all_my_circuits/strategies/abstract_window_strategy/window.rb
111
+ - lib/all_my_circuits/strategies/number_over_window_strategy.rb
112
+ - lib/all_my_circuits/strategies/percentage_over_window_strategy.rb
113
+ - lib/all_my_circuits/version.rb
114
+ - script/fake_service.rb
115
+ - script/graphing_server.rb
116
+ - script/graphing_stress_test.rb
117
+ homepage: https://github.com/remind101/all_my_circuits
118
+ licenses:
119
+ - BSD
120
+ metadata: {}
121
+ post_install_message:
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubyforge_project:
137
+ rubygems_version: 2.4.5
138
+ signing_key:
139
+ specification_version: 4
140
+ summary: Circuit Breaker library with support for rolling-window absolute/percentage
141
+ thresholds
142
+ test_files: []