semian 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -2
- data/.rubocop.yml +113 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile +5 -0
- data/LICENSE.md +1 -1
- data/README.md +488 -39
- data/Rakefile +15 -8
- data/ext/semian/extconf.rb +2 -2
- data/lib/semian.rb +16 -6
- data/lib/semian/adapter.rb +1 -1
- data/lib/semian/circuit_breaker.rb +38 -37
- data/lib/semian/mysql2.rb +21 -1
- data/lib/semian/net_http.rb +95 -0
- data/lib/semian/protected_resource.rb +7 -2
- data/lib/semian/resource.rb +1 -1
- data/lib/semian/simple_integer.rb +23 -0
- data/lib/semian/simple_sliding_window.rb +43 -0
- data/lib/semian/simple_state.rb +43 -0
- data/lib/semian/unprotected_resource.rb +4 -1
- data/lib/semian/version.rb +1 -1
- data/repodb.yml +1 -0
- data/scripts/install_toxiproxy.sh +3 -3
- data/semian.gemspec +4 -3
- data/test/circuit_breaker_test.rb +6 -2
- data/test/helpers/background_helper.rb +1 -1
- data/test/instrumentation_test.rb +1 -1
- data/test/mysql2_test.rb +57 -1
- data/test/net_http_test.rb +481 -0
- data/test/redis_test.rb +3 -3
- data/test/resource_test.rb +33 -31
- data/test/semian_test.rb +3 -2
- data/test/simple_integer_test.rb +49 -0
- data/test/simple_sliding_window_test.rb +65 -0
- data/test/simple_state_test.rb +45 -0
- data/test/test_helper.rb +5 -0
- data/test/unprotected_resource_test.rb +1 -1
- metadata +30 -27
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -1
- metadata.gz.sig +0 -0
data/Rakefile
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
require 'bundler/gem_tasks'
|
2
|
-
|
3
|
-
|
2
|
+
begin
|
3
|
+
require 'rubocop/rake_task'
|
4
|
+
RuboCop::RakeTask.new
|
5
|
+
rescue LoadError
|
6
|
+
end
|
4
7
|
|
5
8
|
# ==========================================================
|
6
9
|
# Packaging
|
@@ -9,14 +12,14 @@ task :default => :test
|
|
9
12
|
GEMSPEC = eval(File.read('semian.gemspec'))
|
10
13
|
|
11
14
|
require 'rubygems/package_task'
|
12
|
-
Gem::PackageTask.new(GEMSPEC) do |
|
15
|
+
Gem::PackageTask.new(GEMSPEC) do |_pkg|
|
13
16
|
end
|
14
17
|
|
15
18
|
# ==========================================================
|
16
19
|
# Ruby Extension
|
17
20
|
# ==========================================================
|
18
21
|
|
19
|
-
|
22
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
|
20
23
|
require 'semian/platform'
|
21
24
|
if Semian.sysv_semaphores_supported?
|
22
25
|
require 'rake/extensiontask'
|
@@ -24,9 +27,10 @@ if Semian.sysv_semaphores_supported?
|
|
24
27
|
ext.ext_dir = 'ext/semian'
|
25
28
|
ext.lib_dir = 'lib/semian'
|
26
29
|
end
|
27
|
-
task :
|
30
|
+
task build: :compile
|
28
31
|
else
|
29
|
-
task :build do
|
32
|
+
task :build do
|
33
|
+
end
|
30
34
|
end
|
31
35
|
|
32
36
|
# ==========================================================
|
@@ -35,10 +39,10 @@ end
|
|
35
39
|
|
36
40
|
require 'rake/testtask'
|
37
41
|
Rake::TestTask.new 'test' do |t|
|
38
|
-
t.libs =
|
42
|
+
t.libs = %w(lib test)
|
39
43
|
t.pattern = "test/*_test.rb"
|
40
44
|
end
|
41
|
-
task :
|
45
|
+
task test: :build
|
42
46
|
|
43
47
|
# ==========================================================
|
44
48
|
# Documentation
|
@@ -47,3 +51,6 @@ require 'rdoc/task'
|
|
47
51
|
RDoc::Task.new do |rdoc|
|
48
52
|
rdoc.rdoc_files.include("lib/*.rb", "ext/semian/*.c")
|
49
53
|
end
|
54
|
+
|
55
|
+
task default: :test
|
56
|
+
task default: :rubocop
|
data/ext/semian/extconf.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
1
|
+
$LOAD_PATH.unshift File.expand_path("../../../lib", __FILE__)
|
2
2
|
|
3
3
|
require 'semian/platform'
|
4
4
|
|
@@ -24,7 +24,7 @@ have_func 'rb_thread_blocking_region'
|
|
24
24
|
have_func 'rb_thread_call_without_gvl'
|
25
25
|
|
26
26
|
$CFLAGS = "-D_GNU_SOURCE -Werror -Wall "
|
27
|
-
if ENV.
|
27
|
+
if ENV.key?('DEBUG')
|
28
28
|
$CFLAGS << "-O0 -g"
|
29
29
|
else
|
30
30
|
$CFLAGS << "-O3"
|
data/lib/semian.rb
CHANGED
@@ -31,7 +31,7 @@ require 'semian/instrumentable'
|
|
31
31
|
# Resources also integrate a circuit breaker in order to fail faster and to let the
|
32
32
|
# resource the time to recover. If `error_threshold` errors happen in the span of `error_timeout`
|
33
33
|
# then the circuit will be opened and every attempt to acquire the resource will immediately fail.
|
34
|
-
#
|
34
|
+
#
|
35
35
|
# Once in open state, after `error_timeout` is elapsed, the ciruit will transition in the half-open state.
|
36
36
|
# In that state a single error will fully re-open the circuit, and the circuit will transition back to the closed
|
37
37
|
# state only after the resource is acquired `success_threshold` consecutive times.
|
@@ -44,7 +44,7 @@ require 'semian/instrumentable'
|
|
44
44
|
#
|
45
45
|
# Semian.register(:mysql_shard0, tickets: 10, timeout: 0.5, error_threshold: 3, error_timeout: 10, success_threshold: 2)
|
46
46
|
#
|
47
|
-
# This registers a new resource called <code>:mysql_shard0</code> that has 10 tickets
|
47
|
+
# This registers a new resource called <code>:mysql_shard0</code> that has 10 tickets and a default timeout of 500 milliseconds.
|
48
48
|
#
|
49
49
|
# After 3 failures in the span of 10 seconds the circuit will be open.
|
50
50
|
# After an additional 10 seconds it will transition to half-open.
|
@@ -78,7 +78,7 @@ module Semian
|
|
78
78
|
OpenCircuitError = Class.new(BaseError)
|
79
79
|
|
80
80
|
def semaphores_enabled?
|
81
|
-
!ENV['SEMIAN_SEMAPHORES_DISABLED']
|
81
|
+
!ENV['SEMIAN_SEMAPHORES_DISABLED'] && Semian.sysv_semaphores_supported?
|
82
82
|
end
|
83
83
|
|
84
84
|
module AdapterError
|
@@ -119,10 +119,12 @@ module Semian
|
|
119
119
|
# Returns the registered resource.
|
120
120
|
def register(name, tickets:, permissions: 0660, timeout: 0, error_threshold:, error_timeout:, success_threshold:, exceptions: [])
|
121
121
|
circuit_breaker = CircuitBreaker.new(
|
122
|
+
name,
|
122
123
|
success_threshold: success_threshold,
|
123
124
|
error_threshold: error_threshold,
|
124
125
|
error_timeout: error_timeout,
|
125
126
|
exceptions: Array(exceptions) + [::Semian::BaseError],
|
127
|
+
implementation: ::Semian::Simple,
|
126
128
|
)
|
127
129
|
resource = Resource.new(name, tickets: tickets, permissions: permissions, timeout: timeout)
|
128
130
|
resources[name] = ProtectedResource.new(resource, circuit_breaker)
|
@@ -154,11 +156,19 @@ require 'semian/circuit_breaker'
|
|
154
156
|
require 'semian/protected_resource'
|
155
157
|
require 'semian/unprotected_resource'
|
156
158
|
require 'semian/platform'
|
157
|
-
|
159
|
+
require 'semian/simple_sliding_window'
|
160
|
+
require 'semian/simple_integer'
|
161
|
+
require 'semian/simple_state'
|
162
|
+
if Semian.semaphores_enabled?
|
158
163
|
require 'semian/semian'
|
159
164
|
else
|
160
165
|
Semian::MAX_TICKETS = 0
|
161
|
-
|
162
|
-
|
166
|
+
unless Semian.sysv_semaphores_supported?
|
167
|
+
Semian.logger.info("Semian sysv semaphores are not supported on #{RUBY_PLATFORM} - all operations will no-op")
|
168
|
+
end
|
169
|
+
|
170
|
+
if ENV['SEMIAN_SEMAPHORES_DISABLED']
|
171
|
+
Semian.logger.info("Semian semaphores are disabled, is this what you really want? - all operations will no-op")
|
172
|
+
end
|
163
173
|
end
|
164
174
|
require 'semian/version'
|
data/lib/semian/adapter.rb
CHANGED
@@ -1,16 +1,20 @@
|
|
1
1
|
module Semian
|
2
|
-
class CircuitBreaker
|
3
|
-
|
2
|
+
class CircuitBreaker #:nodoc:
|
3
|
+
extend Forwardable
|
4
4
|
|
5
|
-
def initialize(exceptions:, success_threshold:, error_threshold:, error_timeout:)
|
5
|
+
def initialize(name, exceptions:, success_threshold:, error_threshold:, error_timeout:, implementation:)
|
6
|
+
@name = name.to_sym
|
6
7
|
@success_count_threshold = success_threshold
|
7
8
|
@error_count_threshold = error_threshold
|
8
9
|
@error_timeout = error_timeout
|
9
10
|
@exceptions = exceptions
|
10
|
-
|
11
|
+
|
12
|
+
@errors = implementation::SlidingWindow.new(max_size: @error_count_threshold)
|
13
|
+
@successes = implementation::Integer.new
|
14
|
+
@state = implementation::State.new
|
11
15
|
end
|
12
16
|
|
13
|
-
def acquire
|
17
|
+
def acquire
|
14
18
|
raise OpenCircuitError unless request_allowed?
|
15
19
|
|
16
20
|
result = nil
|
@@ -31,9 +35,8 @@ module Semian
|
|
31
35
|
!open?
|
32
36
|
end
|
33
37
|
|
34
|
-
def mark_failed(
|
35
|
-
push_time(@errors,
|
36
|
-
|
38
|
+
def mark_failed(_error)
|
39
|
+
push_time(@errors, duration: @error_timeout)
|
37
40
|
if closed?
|
38
41
|
open if error_threshold_reached?
|
39
42
|
elsif half_open?
|
@@ -43,70 +46,68 @@ module Semian
|
|
43
46
|
|
44
47
|
def mark_success
|
45
48
|
return unless half_open?
|
46
|
-
@successes
|
49
|
+
@successes.increment
|
47
50
|
close if success_threshold_reached?
|
48
51
|
end
|
49
52
|
|
50
53
|
def reset
|
51
|
-
@errors
|
52
|
-
@successes
|
54
|
+
@errors.clear
|
55
|
+
@successes.reset
|
53
56
|
close
|
54
57
|
end
|
55
58
|
|
59
|
+
def destroy
|
60
|
+
@errors.destroy
|
61
|
+
@successes.destroy
|
62
|
+
@state.destroy
|
63
|
+
end
|
64
|
+
|
56
65
|
private
|
57
66
|
|
58
|
-
|
59
|
-
|
60
|
-
end
|
67
|
+
def_delegators :@state, :closed?, :open?, :half_open?
|
68
|
+
private :closed?, :open?, :half_open?
|
61
69
|
|
62
70
|
def close
|
63
71
|
log_state_transition(:closed)
|
64
|
-
@state
|
65
|
-
@errors
|
66
|
-
end
|
67
|
-
|
68
|
-
def open?
|
69
|
-
state == :open
|
72
|
+
@state.close
|
73
|
+
@errors.clear
|
70
74
|
end
|
71
75
|
|
72
76
|
def open
|
73
77
|
log_state_transition(:open)
|
74
|
-
@state
|
75
|
-
end
|
76
|
-
|
77
|
-
def half_open?
|
78
|
-
state == :half_open
|
78
|
+
@state.open
|
79
79
|
end
|
80
80
|
|
81
81
|
def half_open
|
82
82
|
log_state_transition(:half_open)
|
83
|
-
@state
|
84
|
-
@successes
|
83
|
+
@state.half_open
|
84
|
+
@successes.reset
|
85
85
|
end
|
86
86
|
|
87
87
|
def success_threshold_reached?
|
88
|
-
@successes >= @success_count_threshold
|
88
|
+
@successes.value >= @success_count_threshold
|
89
89
|
end
|
90
90
|
|
91
91
|
def error_threshold_reached?
|
92
|
-
@errors.
|
92
|
+
@errors.size == @error_count_threshold
|
93
93
|
end
|
94
94
|
|
95
95
|
def error_timeout_expired?
|
96
|
-
|
96
|
+
time_ms = @errors.last
|
97
|
+
time_ms && (Time.at(time_ms / 1000) + @error_timeout < Time.now)
|
97
98
|
end
|
98
99
|
|
99
|
-
def push_time(window,
|
100
|
-
|
101
|
-
window.shift
|
102
|
-
window << time
|
100
|
+
def push_time(window, duration:, time: Time.now)
|
101
|
+
# The sliding window stores the integer amount of milliseconds since epoch as a timestamp
|
102
|
+
window.shift while window.first && window.first / 1000 + duration < time.to_i
|
103
|
+
window << (time.to_f * 1000).to_i
|
103
104
|
end
|
104
105
|
|
105
106
|
def log_state_transition(new_state)
|
106
|
-
return if @state.nil? || new_state == @state
|
107
|
+
return if @state.nil? || new_state == @state.value
|
107
108
|
|
108
|
-
str = "[#{self.class.name}] State transition from #{@state} to #{new_state}."
|
109
|
-
str << " success_count=#{@successes} error_count=#{@errors.
|
109
|
+
str = "[#{self.class.name}] State transition from #{@state.value} to #{new_state}."
|
110
|
+
str << " success_count=#{@successes.value} error_count=#{@errors.size}"
|
110
111
|
str << " success_count_threshold=#{@success_count_threshold} error_count_threshold=#{@error_count_threshold}"
|
111
112
|
str << " error_timeout=#{@error_timeout} error_last_at=\"#{@error_last_at}\""
|
112
113
|
Semian.logger.info(str)
|
data/lib/semian/mysql2.rb
CHANGED
@@ -32,6 +32,12 @@ module Semian
|
|
32
32
|
DEFAULT_HOST = 'localhost'
|
33
33
|
DEFAULT_PORT = 3306
|
34
34
|
|
35
|
+
QUERY_WHITELIST = Regexp.union(
|
36
|
+
/\A\s*ROLLBACK/i,
|
37
|
+
/\A\s*COMMIT/i,
|
38
|
+
/\A\s*RELEASE\s+SAVEPOINT/i,
|
39
|
+
)
|
40
|
+
|
35
41
|
# The naked methods are exposed as `raw_query` and `raw_connect` for instrumentation purpose
|
36
42
|
def self.included(base)
|
37
43
|
base.send(:alias_method, :raw_query, :query)
|
@@ -53,11 +59,25 @@ module Semian
|
|
53
59
|
end
|
54
60
|
|
55
61
|
def query(*args)
|
56
|
-
|
62
|
+
if query_whitelisted?(*args)
|
63
|
+
raw_query(*args)
|
64
|
+
else
|
65
|
+
acquire_semian_resource(adapter: :mysql, scope: :query) { raw_query(*args) }
|
66
|
+
end
|
57
67
|
end
|
58
68
|
|
59
69
|
private
|
60
70
|
|
71
|
+
def query_whitelisted?(sql, *)
|
72
|
+
QUERY_WHITELIST =~ sql
|
73
|
+
rescue ArgumentError
|
74
|
+
# The above regexp match can fail if the input SQL string contains binary
|
75
|
+
# data that is not recognized as a valid encoding, in which case we just
|
76
|
+
# return false.
|
77
|
+
return false unless sql.valid_encoding?
|
78
|
+
raise
|
79
|
+
end
|
80
|
+
|
61
81
|
def connect(*args)
|
62
82
|
acquire_semian_resource(adapter: :mysql, scope: :connection) { raw_connect(*args) }
|
63
83
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require 'semian'
|
2
|
+
require 'semian/adapter'
|
3
|
+
|
4
|
+
module Net
|
5
|
+
ProtocolError.include(::Semian::AdapterError)
|
6
|
+
|
7
|
+
class SemianError < ::Net::ProtocolError
|
8
|
+
def initialize(semian_identifier, *args)
|
9
|
+
super(*args)
|
10
|
+
@semian_identifier = semian_identifier
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
ResourceBusyError = Class.new(SemianError)
|
15
|
+
CircuitOpenError = Class.new(SemianError)
|
16
|
+
end
|
17
|
+
|
18
|
+
module Semian
|
19
|
+
module NetHTTP
|
20
|
+
include Semian::Adapter
|
21
|
+
|
22
|
+
ResourceBusyError = ::Net::ResourceBusyError
|
23
|
+
CircuitOpenError = ::Net::CircuitOpenError
|
24
|
+
|
25
|
+
def semian_configuration
|
26
|
+
Semian::NetHTTP.retrieve_semian_configuration(address, port)
|
27
|
+
end
|
28
|
+
|
29
|
+
def semian_identifier
|
30
|
+
"nethttp_#{semian_configuration[:name]}"
|
31
|
+
end
|
32
|
+
|
33
|
+
DEFAULT_ERRORS = [
|
34
|
+
::Timeout::Error, # includes ::Net::ReadTimeout and ::Net::OpenTimeout
|
35
|
+
::TimeoutError, # alias for above
|
36
|
+
::SocketError,
|
37
|
+
::Net::HTTPBadResponse,
|
38
|
+
::Net::HTTPHeaderSyntaxError,
|
39
|
+
::Net::ProtocolError,
|
40
|
+
::EOFError,
|
41
|
+
::IOError,
|
42
|
+
::SystemCallError, # includes ::Errno::EINVAL, ::Errno::ECONNRESET, ::Errno::ECONNREFUSED, ::Errno::ETIMEDOUT, and more
|
43
|
+
].freeze # Net::HTTP can throw many different errors, this tries to capture most of them
|
44
|
+
|
45
|
+
# The naked methods are exposed as `raw_query` and `raw_connect` for instrumentation purpose
|
46
|
+
def self.included(base)
|
47
|
+
base.send(:alias_method, :raw_request, :request)
|
48
|
+
base.send(:remove_method, :request)
|
49
|
+
|
50
|
+
base.send(:alias_method, :raw_connect, :connect)
|
51
|
+
base.send(:remove_method, :connect)
|
52
|
+
end
|
53
|
+
|
54
|
+
class << self
|
55
|
+
attr_accessor :semian_configuration
|
56
|
+
attr_accessor :exceptions
|
57
|
+
|
58
|
+
def retrieve_semian_configuration(host, port)
|
59
|
+
@semian_configuration.call(host, port) if @semian_configuration.respond_to?(:call)
|
60
|
+
end
|
61
|
+
|
62
|
+
def reset_exceptions
|
63
|
+
self.exceptions = Semian::NetHTTP::DEFAULT_ERRORS.dup
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
Semian::NetHTTP.reset_exceptions
|
68
|
+
|
69
|
+
def raw_semian_options
|
70
|
+
options = semian_configuration
|
71
|
+
options = options.dup unless options.nil?
|
72
|
+
options
|
73
|
+
end
|
74
|
+
|
75
|
+
def resource_exceptions
|
76
|
+
Semian::NetHTTP.exceptions
|
77
|
+
end
|
78
|
+
|
79
|
+
def disabled?
|
80
|
+
semian_configuration.nil?
|
81
|
+
end
|
82
|
+
|
83
|
+
def connect
|
84
|
+
return raw_connect if disabled?
|
85
|
+
acquire_semian_resource(adapter: :http, scope: :connection) { raw_connect }
|
86
|
+
end
|
87
|
+
|
88
|
+
def request(req, body = nil, &block)
|
89
|
+
return raw_request(req, body, &block) if disabled?
|
90
|
+
acquire_semian_resource(adapter: :http, scope: :query) { raw_request(req, body, &block) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
Net::HTTP.include(Semian::NetHTTP)
|
@@ -5,14 +5,19 @@ module Semian
|
|
5
5
|
extend Forwardable
|
6
6
|
|
7
7
|
def_delegators :@resource, :destroy, :count, :semid, :tickets, :name
|
8
|
-
def_delegators :@circuit_breaker, :reset, :mark_failed, :request_allowed?
|
8
|
+
def_delegators :@circuit_breaker, :reset, :mark_failed, :mark_success, :request_allowed?
|
9
9
|
|
10
10
|
def initialize(resource, circuit_breaker)
|
11
11
|
@resource = resource
|
12
12
|
@circuit_breaker = circuit_breaker
|
13
13
|
end
|
14
14
|
|
15
|
-
def
|
15
|
+
def destroy
|
16
|
+
@resource.destroy
|
17
|
+
@circuit_breaker.destroy
|
18
|
+
end
|
19
|
+
|
20
|
+
def acquire(timeout: nil, scope: nil, adapter: nil)
|
16
21
|
@circuit_breaker.acquire do
|
17
22
|
begin
|
18
23
|
@resource.acquire(timeout: timeout) do
|
data/lib/semian/resource.rb
CHANGED
@@ -2,7 +2,7 @@ module Semian
|
|
2
2
|
class Resource #:nodoc:
|
3
3
|
attr_reader :tickets, :name
|
4
4
|
|
5
|
-
def initialize(name, tickets
|
5
|
+
def initialize(name, tickets:, permissions: 0660, timeout: 0)
|
6
6
|
_initialize(name, tickets, permissions, timeout) if respond_to?(:_initialize)
|
7
7
|
@name = name
|
8
8
|
@tickets = tickets
|