all_my_circuits 0.1.0

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 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: []