supervision 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: 11acf39bc8b8bc7f6c2ab1ac8f58696aa305c7f5
4
+ data.tar.gz: 697bf64670a3fe4fed371cc6f2ed7b8c700b1ae6
5
+ SHA512:
6
+ metadata.gz: 6e81cdae2a3f09db0bc05511d1291e2291b54150bdd8ac7d4580e01a4010769fb05b428cd02e874f8c2766f97239ca2e941c71057e10f38f8f424b5bf25e1366
7
+ data.tar.gz: 04449ffd0306979981482efaf67818a37e4a2bda9cc4173933f532402c7f8c9b3ac8242b62dd3ff09444d696f2aec38e0a1c5b509509c16a10c9c5e70afc4669
data/.gitignore ADDED
@@ -0,0 +1,23 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ *.sw[a-z]
23
+ mkmf.log
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ supervision
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.0.0
data/.travis.yml ADDED
@@ -0,0 +1,22 @@
1
+ language: ruby
2
+ bundler_args: --without yard guard benchmarks
3
+ script: "bundle exec rake ci"
4
+ rvm:
5
+ - 1.9.3
6
+ - 2.0.0
7
+ - 2.1.0
8
+ - ruby-head
9
+ matrix:
10
+ include:
11
+ - rvm: jruby-19mode
12
+ - rvm: jruby-20mode
13
+ - rvm: jruby-21mode
14
+ - rvm: jruby-head
15
+ - rvm: rbx
16
+ allow_failures:
17
+ - rvm: ruby-head
18
+ - rvm: jruby-head
19
+ - rvm: rbx
20
+ fast_finish: true
21
+ branches:
22
+ only: master
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development do
6
+ gem 'rake', '~> 10.2.2'
7
+ gem 'rspec', '~> 2.14.1'
8
+ gem 'yard', '~> 0.8.7'
9
+ end
10
+
11
+ group :metrics do
12
+ gem 'coveralls', '~> 0.7.0'
13
+ gem 'simplecov', '~> 0.8.2'
14
+ gem 'yardstick', '~> 0.9.9'
15
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Piotr Murach
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # Supervision
2
+ [![Gem Version](https://badge.fury.io/rb/supervision.png)][gem]
3
+ [![Build Status](https://secure.travis-ci.org/peter-murach/supervision.png?branch=master)][travis]
4
+ [![Code Climate](https://codeclimate.com/github/peter-murach/supervision.png)][codeclimate]
5
+
6
+ [gem]: http://badge.fury.io/rb/supervision
7
+ [travis]: http://travis-ci.org/peter-murach/supervision
8
+ [codeclimate]: https://codeclimate.com/github/peter-murach/supervision
9
+
10
+ Write distributed systems that are resilient and self-heal. Remote calls can fail or hang indefinietly without a response.
11
+ **Supervision** will help to isolate failure and keep individual components from bringing down the whole system.
12
+ The basic idea is to wrap dangerous method call inside protected `supervise` helper that will monitor for failure and
13
+ handle it according to the specified rules to prevent it from cascading.
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ gem 'supervision'
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install supervision
28
+
29
+ ## 1 Usage
30
+
31
+ **Supervision** instance takes the following configuration options:
32
+
33
+ * `:max_failure` - maximum failure count allowed before **Supervision** raises `CircuitBreakerOpenError`. By default `5 failures` are allowed.
34
+ * `:call_timeout` - duration time for a method before it is assumed to have failed. By default `10 milliseconds`.
35
+ * `:reset_timeout` - duration before a method is allowed to attempt a call. Subsequent calls will fail fast if failure is detected. By default `100 milliseconds`
36
+
37
+ Next to instantiate the **Supervision** in order to protect a call to external/remote service that has potential to fail do:
38
+
39
+ ```ruby
40
+ @supervision = Supervision.new { |arg| remote_api_call(arg) }
41
+ ```
42
+
43
+ or alternatively use `supervise` helper
44
+
45
+ ```ruby
46
+ @supervision = Supervision.supervise { |arg| remote_api_call(arg) }
47
+ ```
48
+
49
+ Once the call is wrapped you can execute it by sending `call` messsage with arguments like so:
50
+
51
+ ```ruby
52
+ @supervision.call({user: 'Piotr'})
53
+ ```
54
+
55
+ Finally, you can also register **Supervision** instance by name
56
+
57
+ ```ruby
58
+ Supervision.supervise_as(:danger) { remote_api_call }
59
+ ```
60
+
61
+ The name under which method is registerd will be available as a method call
62
+
63
+ ```ruby
64
+ Supervision.danger.call
65
+ ```
66
+
67
+ ## 2 Mixin
68
+
69
+ **Supervision** can also act as a mixin and expose `supervise` and `supervise_as` accordingly.
70
+
71
+ ```ruby
72
+ class Api
73
+ include Supervision
74
+
75
+ def remote_call
76
+ ...
77
+ end
78
+ supervise :danger { remote_call }
79
+
80
+ def fetch(repository)
81
+ danger.call(repository)
82
+ rescue Supervision::CircuitBreakerOpenError
83
+ nil
84
+ end
85
+ end
86
+
87
+ @api = Api.new
88
+ @api.fetch('github_api')
89
+ ```
90
+
91
+ ## 3 Callbacks
92
+
93
+ You can listen for `failure` and `success` by attaching `on_failure`, `on_success` listeners respectively:
94
+
95
+ ```ruby
96
+ @supervision.on_failure { notify_me }
97
+
98
+ def notify_me
99
+ puts("The circuit breaker is now open")
100
+ end
101
+ ```
102
+
103
+ ## 4 Configuration
104
+
105
+ If you want to configure **Supervision**, you can either pass options directly
106
+
107
+ ```ruby
108
+ @supervision = Supervison.new max_failures: 2, call_timeout: 10.milli, reset_timeout: 0.1.sec do
109
+ remote_api_call
110
+ end
111
+ ```
112
+
113
+ or use `configure` helper
114
+
115
+ ```ruby
116
+ @supervision.configure do
117
+ max_failures 5
118
+ call_timeout 10.sec
119
+ reset_timeout 1.min
120
+ end
121
+ ```
122
+
123
+ ## 5 Time
124
+
125
+ All the numeric types are extended with time related helpers to allow for more fluid parameters when creating **Supervision**
126
+
127
+ ```ruby
128
+ call_timeout: 10.milliseconds
129
+ call_timeout: 10.millis
130
+ call_timeout: 1.millisecond
131
+ call_timeout: 1.milli
132
+ call_timeout: 1.second
133
+ call_timeout: 1.sec
134
+ call_timeout: 10.secs
135
+ call_timeout: 10.seconds
136
+ call_timeout: 1.minute
137
+ call_timeout: 1.min
138
+ call_timeout: 10.minutes
139
+ call_timeout: 10.mins
140
+ call_timeout: 1.hour
141
+ call_timeout: 10.hours
142
+ ```
143
+
144
+ ## Contributing
145
+
146
+ 1. Fork it ( https://github.com/[my-github-username]/supervision/fork )
147
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
148
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
149
+ 4. Push to the branch (`git push origin my-new-feature`)
150
+ 5. Create a new Pull Request
151
+
152
+ ## Copyright
153
+
154
+ Copyright (c) 2014 Piotr Murach. See LICENSE for further details.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # encoding: utf-8
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ FileList['tasks/**/*.rake'].each(&method(:import))
6
+
7
+ desc 'Run all specs'
8
+ task ci: %w[ spec ]
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for creating threadsafe value objects
5
+ class Atomic
6
+
7
+ # Initialize an Atomic instance
8
+ #
9
+ # @param [Numeric] value
10
+ #
11
+ # @api public
12
+ def initialize(value = nil)
13
+ @mutex = Mutex.new
14
+ @value = value
15
+ end
16
+
17
+ # Retrieve value
18
+ #
19
+ # @api public
20
+ def get
21
+ @mutex.synchronize { @value }
22
+ end
23
+ alias_method :value, :get
24
+
25
+ # Set value
26
+ #
27
+ # @api public
28
+ def set(new_value)
29
+ @mutex.synchronize { @value = new_value}
30
+ end
31
+ alias_method :value=, :set
32
+
33
+ # Update value
34
+ #
35
+ # @api public
36
+ def update
37
+ set(new_value = yield(get)) if block_given?
38
+ new_value
39
+ end
40
+ end # Atomic
41
+ end # Supervision
@@ -0,0 +1,89 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for protecting remote calls
5
+ class CircuitBreaker
6
+ include Timeout
7
+
8
+ attr_reader :control
9
+
10
+ def initialize(options = {}, &block)
11
+ if block.nil?
12
+ raise ArgumentError, 'CircuitBreaker.new requires a block'
13
+ end
14
+ @control = CircuitControl.new(options)
15
+ @circuit = Atomic.new(block)
16
+ @mutex = Mutex.new
17
+ @before_hook = -> {}
18
+ @success_hook = -> {}
19
+ @failure_hook = -> {}
20
+ end
21
+
22
+ # Configure circuit instance parameters
23
+ #
24
+ # @yield [Configuration]
25
+ #
26
+ # @api public
27
+ def configure(&block)
28
+ if block.arity.zero?
29
+ control.config.instance_eval(&block)
30
+ else
31
+ yield control.config
32
+ end
33
+ end
34
+
35
+ # Executes the dangerous call
36
+ #
37
+ # # TODO: this should distribute calls so we don't wait
38
+ # in sync call for timeout
39
+ #
40
+ # @api public
41
+ def call(*args)
42
+ @before_hook.call
43
+ begin
44
+ result = dispatch(*args)
45
+ @success_hook.call
46
+ result
47
+ rescue Exception => error
48
+ control.handle(error)
49
+ @failure_hook.call
50
+ end
51
+ end
52
+
53
+ def force_open
54
+ end
55
+
56
+ def force_close
57
+ end
58
+
59
+ def before(&block)
60
+ @before_hook = block
61
+ end
62
+
63
+ # Callback executed on successful call
64
+ #
65
+ # @api public
66
+ def on_success(&block)
67
+ @success_hook = block
68
+ end
69
+ alias_method :on_closed, :on_success
70
+
71
+ def on_failure(&block)
72
+ @failure_hook = block
73
+ end
74
+ alias_method :on_open, :on_failure
75
+
76
+ private
77
+
78
+ # Dispatch message to the current circuit
79
+ #
80
+ # @api private
81
+ def dispatch(*args)
82
+ result = timeout(control.call_timeout) do
83
+ @circuit.value.call(*args)
84
+ end
85
+ control.record_success
86
+ result
87
+ end
88
+ end # CircuitBreaker
89
+ end # Supervision
@@ -0,0 +1,182 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for controling state of the circuit
5
+ class CircuitControl
6
+ extend Forwardable
7
+
8
+ def_delegators :@config, :max_failures, :call_timeout, :failure_count
9
+
10
+ attr_reader :config
11
+
12
+ MAX_THREAD_LIFETIME = 5
13
+
14
+ # Create a circuit control
15
+ #
16
+ # @param [Hash] options
17
+ #
18
+ # @api public
19
+ def initialize(options = {})
20
+ @config = Configuration.new(options)
21
+ @failure_count = Atomic.new(0)
22
+ @last_failure_time = Atomic.new
23
+ @lock = Mutex.new
24
+ fsm
25
+ end
26
+
27
+ # Creates internal finite state machine to
28
+ # transitions through three states :closed,
29
+ # :open and :half_open.
30
+ #
31
+ # @return [FiniteMachine]
32
+ #
33
+ # @api private
34
+ def fsm
35
+ context = self
36
+ @fsm ||= FiniteMachine.define do
37
+ initial :closed
38
+
39
+ target context
40
+
41
+ events {
42
+ event :trip, [:closed, :half_open] => :open
43
+ event :attempt_reset, :open => :half_open
44
+ event :reset, :half_open => :closed
45
+ }
46
+
47
+ callbacks {
48
+ on_enter :closed do |event|
49
+ reset_failure
50
+ end
51
+
52
+ on_enter :open do |event|
53
+ measure_timeout
54
+ fail_fast!
55
+ end
56
+
57
+ on_enter :half_open do |event|
58
+ end
59
+ }
60
+ end
61
+ end
62
+
63
+ def_delegators :@fsm, :trip, :attempt_reset, :reset, :current
64
+
65
+ # Total failure count for current circuit
66
+ #
67
+ # @return [Integer]
68
+ #
69
+ # @api public
70
+ def failure_count
71
+ @failure_count.value
72
+ end
73
+
74
+ # Last time failure occured
75
+ #
76
+ # @return [Time]
77
+ #
78
+ # @api public
79
+ def last_failure_time
80
+ @last_failure_time.value
81
+ end
82
+
83
+ # Fail fast on any call
84
+ #
85
+ # @raise [CircuitBreakerOpenError]
86
+ #
87
+ # @api private
88
+ def fail_fast!
89
+ # monitor.record_open
90
+ raise CircuitBreakerOpenError
91
+ end
92
+
93
+ # @return [Boolean]
94
+ #
95
+ # @api private
96
+ def failure_count_exceeded?
97
+ failure_count > @config.max_failures
98
+ end
99
+
100
+ # @return [Boolean]
101
+ #
102
+ # @api private
103
+ def tripped?
104
+ fsm.open? && timeout_exceeded?
105
+ end
106
+
107
+ # Check if remaining duration until reset has been exceeded
108
+ #
109
+ # @return [Boolean]
110
+ # whether or not the breaker will attempt a reset by transitioning
111
+ # to :half_open state
112
+ #
113
+ # @api private
114
+ def timeout_exceeded?
115
+ return false unless last_failure_time
116
+ timeout = Time.now - last_failure_time
117
+ timeout > @config.reset_timeout
118
+ end
119
+
120
+ # Handler exception
121
+ #
122
+ # @api public
123
+ def handle(error = nil)
124
+ fail_fast! if fsm.open?
125
+ record_failure
126
+ trip if failure_count_exceeded? || fsm.half_open?
127
+ end
128
+
129
+ def record_success
130
+ reset if fsm.half_open?
131
+ reset_failure
132
+ end
133
+
134
+ # Records failure count
135
+ #
136
+ # @api public
137
+ def record_failure
138
+ if fsm.closed? || fsm.half_open?
139
+ @failure_count.update { |v| v + 1 }
140
+ @last_failure_time.value = Time.now
141
+ end
142
+ end
143
+
144
+ # Resets failure count
145
+ #
146
+ # @api public
147
+ def reset_failure
148
+ @failure_count.value = 0
149
+ @last_failure_time.value = nil
150
+ end
151
+
152
+ # Measure remaining timeout
153
+ #
154
+ # @api private
155
+ def measure_timeout
156
+ Thread.new do
157
+ Thread.current.abort_on_exception = true
158
+ thread = Thread.current
159
+ thread[:created_at] = Time.now
160
+ @lock.synchronize do
161
+ loop do
162
+ if tripped?
163
+ attempt_reset
164
+ break
165
+ elsif Time.now - thread[:created_at] > max_thread_lifetime
166
+ thread.kill
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ # Estimate maximum duration the scheduling thread should live
174
+ #
175
+ # @return [Time]
176
+ #
177
+ # @api private
178
+ def max_thread_lifetime
179
+ @config.reset_timeout + 100.milli
180
+ end
181
+ end # CircuitControl
182
+ end # Supervision
@@ -0,0 +1,13 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for recording circuit performance
5
+ class CircuitMonitor
6
+
7
+ def initialize
8
+ end
9
+
10
+ def alert(type)
11
+ end
12
+ end
13
+ end # Supervision
@@ -0,0 +1,16 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for registering circuits
5
+ class CircuitSystem
6
+ extend Forwardable
7
+
8
+ def_delegators '@registry', :[], :get, :[]=, :set,
9
+ :register, :delete, :unregister
10
+
11
+ def initialize
12
+ @registry = Registry.new
13
+ end
14
+
15
+ end # CircuitSystem
16
+ end # Supervision
@@ -0,0 +1,73 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsbile for the circuit configuration options
5
+ class Configuration
6
+ DEFAULT_MAX_FAILURES = 5
7
+
8
+ DEFAULT_CALL_TIMEOUT = 10.milli
9
+
10
+ DEFAULT_RESET_TIMEOUT = 100.milli
11
+
12
+ # Create a Configuration options
13
+ #
14
+ # @api public
15
+ def initialize(options = {})
16
+ verify_options!(options)
17
+ @max_failures = Atomic.new(options.fetch(:max_failures,
18
+ DEFAULT_MAX_FAILURES))
19
+ @call_timeout = Atomic.new(options.fetch(:call_timeout,
20
+ DEFAULT_CALL_TIMEOUT))
21
+ @reset_timeout = Atomic.new(options.fetch(:reset_timeout,
22
+ DEFAULT_RESET_TIMEOUT))
23
+ end
24
+
25
+ def max_failures=(value)
26
+ @max_failures.set(value)
27
+ end
28
+
29
+ def max_failures(number = nil)
30
+ return @max_failures.value unless number
31
+
32
+ self.max_failures = number
33
+ end
34
+
35
+ def call_timeout=(value)
36
+ @call_timeout.set(value)
37
+ end
38
+
39
+ def call_timeout(time = nil)
40
+ return @call_timeout.value unless time
41
+
42
+ self.call_timeout = time
43
+ end
44
+
45
+ def reset_timeout=(value)
46
+ @reset_timeout.set(value)
47
+ end
48
+
49
+ def reset_timeout(time = nil)
50
+ return @reset_timeout.value unless time
51
+
52
+ self.reset_timeout = time
53
+ end
54
+
55
+ private
56
+
57
+ def known_options
58
+ [:max_failures, :call_timeout, :reset_timeout]
59
+ end
60
+
61
+ def verify_options!(options)
62
+ options.keys.each do |key|
63
+ raise_unknown_config_option(key) unless known_options.include?(key)
64
+ end
65
+ end
66
+
67
+ # TODO: replace with custom error
68
+ def raise_unknown_config_option(option)
69
+ raise ArgumentError, "`#{option}` isn`t recognized as valid parameter." \
70
+ " Please use one of `#{known_options.join(', ')}`"
71
+ end
72
+ end # Configuration
73
+ end # Supervision
@@ -0,0 +1,7 @@
1
+ # encoding: utf-8
2
+
3
+ module Supervision
4
+ # A class responsible for creating circuits
5
+ class Factory
6
+ end
7
+ end