semian 0.3.0 → 0.4.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 +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
|