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.
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