supervision 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: 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