semian 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1,6 +1,9 @@
1
1
  require 'bundler/gem_tasks'
2
-
3
- task :default => :test
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 |pkg|
15
+ Gem::PackageTask.new(GEMSPEC) do |_pkg|
13
16
  end
14
17
 
15
18
  # ==========================================================
16
19
  # Ruby Extension
17
20
  # ==========================================================
18
21
 
19
- $:.unshift File.expand_path("../lib", __FILE__)
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 :build => :compile
30
+ task build: :compile
28
31
  else
29
- task :build do; end
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 = ['lib', 'test']
42
+ t.libs = %w(lib test)
39
43
  t.pattern = "test/*_test.rb"
40
44
  end
41
- task :test => :build
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
@@ -1,4 +1,4 @@
1
- $:.unshift File.expand_path("../../../lib", __FILE__)
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.has_key?('DEBUG')
27
+ if ENV.key?('DEBUG')
28
28
  $CFLAGS << "-O0 -g"
29
29
  else
30
30
  $CFLAGS << "-O3"
@@ -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 andd a default timeout of 500 milliseconds.
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
- if Semian.sysv_semaphores_supported? && Semian.semaphores_enabled?
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
- Semian.logger.info("Semian sysv semaphores are not supported on #{RUBY_PLATFORM} - all operations will no-op") unless Semian.sysv_semaphores_supported?
162
- Semian.logger.info("Semian semaphores are disabled, is this what you really want? - all operations will no-op") unless Semian.semaphores_enabled?
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'
@@ -5,7 +5,7 @@ module Semian
5
5
  end
6
6
 
7
7
  def semian_resource
8
- @semian_options ||= case semian_options
8
+ @semian_resource ||= case semian_options
9
9
  when false
10
10
  UnprotectedResource.new(semian_identifier)
11
11
  when nil
@@ -1,16 +1,20 @@
1
1
  module Semian
2
- class CircuitBreaker
3
- attr_reader :state
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
- reset
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(&block)
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(error)
35
- push_time(@errors, @error_count_threshold, duration: @error_timeout)
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 += 1
49
+ @successes.increment
47
50
  close if success_threshold_reached?
48
51
  end
49
52
 
50
53
  def reset
51
- @errors = []
52
- @successes = 0
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
- def closed?
59
- state == :closed
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 = :closed
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 = :open
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 = :half_open
84
- @successes = 0
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.count == @error_count_threshold
92
+ @errors.size == @error_count_threshold
93
93
  end
94
94
 
95
95
  def error_timeout_expired?
96
- @errors.last && (@errors.last + @error_timeout < Time.now)
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, max_size, duration:, time: Time.now)
100
- window.shift while window.first && window.first + duration < time
101
- window.shift if window.size == max_size
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.count}"
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)
@@ -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
- acquire_semian_resource(adapter: :mysql, scope: :query) { raw_query(*args) }
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 acquire(timeout: nil, scope: nil, adapter: nil, &block)
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
@@ -2,7 +2,7 @@ module Semian
2
2
  class Resource #:nodoc:
3
3
  attr_reader :tickets, :name
4
4
 
5
- def initialize(name, tickets: , permissions: 0660, timeout: 0)
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