semian 0.0.8 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8660f920372dc867f6b4e8bbca1ad080a8bcf293
4
- data.tar.gz: 477bc269271d94d9903ee2fb1c38b8929e5b4976
3
+ metadata.gz: 219d1e2e6ab5b6bbc6c90e0e418e47a38a6415cd
4
+ data.tar.gz: dfe7b83c1f95e26d0df6daa2cde3a914e98d2520
5
5
  SHA512:
6
- metadata.gz: d050ac79d5e120acc0a5f8ac945ab090eac4d52c4df3b5a642ce45a11c1d580085136237b1a92dafd2a035fd63a92fc90b290b6b12ff88e13f8a4fa80056dad2
7
- data.tar.gz: 46108108c10f883e833b13e8c5b1e4cd90c3a0cb8de17cbcbef79d868ea5955e11b355c80ea43b90f2a79b54ea11c9337058b71d758c450f3f54fd3da6a4b930
6
+ metadata.gz: 79f643b811ba718f89a1021c2227b210e4b3045cb5ebc919e6017dcc0334d65efe31a7d30695abf1287f24894794f2c513128e6b07b52553eb0e9cac4cb11e65
7
+ data.tar.gz: 267d1b19a1c8c6ab7e9786b639a0c326dc9e4669636dfb20a19f36f43386b669029b172ea3f38bf790cc173f7cef9470c1c5b488a694a2c73eb0385ae4ceaa5c
checksums.yaml.gz.sig CHANGED
Binary file
data/.travis.yml CHANGED
@@ -1,6 +1,9 @@
1
1
  language: ruby
2
2
 
3
- sudo: false
3
+ sudo: true
4
+
5
+ before_install:
6
+ - scripts/install_toxiproxy.sh
4
7
 
5
8
  rvm:
6
9
  - 2.1.1
data/README.md CHANGED
@@ -50,7 +50,7 @@ In a master process, register a resource with a specified number of tickets
50
50
  (number of concurrent clients):
51
51
 
52
52
  ```ruby
53
- Semian.register(:mysql_master, tickets: 3, timeout: 0.5)
53
+ Semian.register(:mysql_master tickets: 3, timeout: 0.5, error_threshold: 3, error_timeout: 10, success_threshold: 2)
54
54
  ```
55
55
 
56
56
  Then in your child processes, you can use the resource:
data/Rakefile CHANGED
@@ -29,19 +29,20 @@ else
29
29
  task :build do; end
30
30
  end
31
31
 
32
+ task :populate_proxy do
33
+ require 'toxiproxy'
34
+ Toxiproxy.populate(File.expand_path('../test/fixtures/toxiproxy.json', __FILE__))
35
+ end
36
+
32
37
  # ==========================================================
33
38
  # Testing
34
39
  # ==========================================================
35
40
 
36
41
  require 'rake/testtask'
37
42
  Rake::TestTask.new 'test' do |t|
38
- t.test_files = if Semian.supported_platform?
39
- FileList['test/test_semian.rb']
40
- else
41
- FileList['test/test_unsupported.rb']
42
- end
43
+ t.pattern = "test/test_*.rb"
43
44
  end
44
- task :test => :build
45
+ task :test => [:build, :populate_proxy]
45
46
 
46
47
  # ==========================================================
47
48
  # Documentation
data/ext/semian/semian.c CHANGED
@@ -449,10 +449,10 @@ semian_resource_id(VALUE self)
449
449
 
450
450
  void Init_semian()
451
451
  {
452
- VALUE cSemian, cResource, eBaseError;
452
+ VALUE cSemian, cResource;
453
453
  struct seminfo info_buf;
454
454
 
455
- cSemian = rb_define_class("Semian", rb_cObject);
455
+ cSemian = rb_const_get(rb_cObject, rb_intern("Semian"));
456
456
 
457
457
  /*
458
458
  * Document-class: Semian::Resource
@@ -462,25 +462,19 @@ void Init_semian()
462
462
  *
463
463
  * You should not create this class directly, it will be created indirectly via Semian.register.
464
464
  */
465
- cResource = rb_define_class_under(cSemian, "Resource", rb_cObject);
466
-
467
- /* Document-class: Semian::BaseError
468
- *
469
- * Base error class for all other Semian errors.
470
- */
471
- eBaseError = rb_define_class_under(cSemian, "BaseError", rb_eStandardError);
465
+ cResource = rb_const_get(cSemian, rb_intern("Resource"));
472
466
 
473
467
  /* Document-class: Semian::SyscallError
474
468
  *
475
469
  * Represents a Semian error that was caused by an underlying syscall failure.
476
470
  */
477
- eSyscall = rb_define_class_under(cSemian, "SyscallError", eBaseError);
471
+ eSyscall = rb_const_get(cSemian, rb_intern("SyscallError"));
478
472
 
479
473
  /* Document-class: Semian::TimeoutError
480
474
  *
481
475
  * Raised when a Semian operation timed out.
482
476
  */
483
- eTimeout = rb_define_class_under(cSemian, "TimeoutError", eBaseError);
477
+ eTimeout = rb_const_get(cSemian, rb_intern("TimeoutError"));
484
478
 
485
479
  /* Document-class: Semian::InternalError
486
480
  *
@@ -492,10 +486,10 @@ void Init_semian()
492
486
  * using the <code>ipcrm</code> command line tool. Semian will re-initialize
493
487
  * the semaphore in this case.
494
488
  */
495
- eInternal = rb_define_class_under(cSemian, "InternalError", eBaseError);
489
+ eInternal = rb_const_get(cSemian, rb_intern("InternalError"));
496
490
 
497
491
  rb_define_alloc_func(cResource, semian_resource_alloc);
498
- rb_define_method(cResource, "initialize", semian_resource_initialize, 4);
492
+ rb_define_method(cResource, "_initialize", semian_resource_initialize, 4);
499
493
  rb_define_method(cResource, "acquire", semian_resource_acquire, -1);
500
494
  rb_define_method(cResource, "count", semian_resource_count, 0);
501
495
  rb_define_method(cResource, "semid", semian_resource_id, 0);
@@ -0,0 +1,135 @@
1
+ module Semian
2
+ class CircuitBreaker
3
+ attr_reader :state
4
+
5
+ def initialize(exceptions:, success_threshold:, error_threshold:, error_timeout:)
6
+ @success_count_threshold = success_threshold
7
+ @error_count_threshold = error_threshold
8
+ @error_timeout = error_timeout
9
+ @exceptions = exceptions
10
+ reset
11
+ end
12
+
13
+ def acquire(&block)
14
+ raise OpenCircuitError unless request_allowed?
15
+
16
+ result = nil
17
+ begin
18
+ result = yield
19
+ rescue *@exceptions => error
20
+ mark_failed(error)
21
+ raise error
22
+ else
23
+ mark_success
24
+ end
25
+ result
26
+ end
27
+
28
+ def with_fallback(fallback, &block)
29
+ acquire(&block)
30
+ rescue *@exceptions
31
+ evaluate_fallback(fallback)
32
+ rescue OpenCircuitError
33
+ evaluate_fallback(fallback)
34
+ end
35
+
36
+ def request_allowed?
37
+ return true if closed?
38
+ half_open if error_timeout_expired?
39
+ !open?
40
+ end
41
+
42
+ def mark_failed(error)
43
+ increment_recent_errors
44
+
45
+ if closed?
46
+ open if error_threshold_reached?
47
+ elsif half_open?
48
+ open
49
+ end
50
+ end
51
+
52
+ def mark_success
53
+ return unless half_open?
54
+ @success_count += 1
55
+ close if success_threshold_reached?
56
+ end
57
+
58
+ def reset
59
+ @success_count = 0
60
+ @error_count = 0
61
+ @error_last_at = nil
62
+ close
63
+ end
64
+
65
+ private
66
+
67
+ def evaluate_fallback(fallback_value_or_block)
68
+ if fallback_value_or_block.respond_to?(:call)
69
+ fallback_value_or_block.call
70
+ else
71
+ fallback_value_or_block
72
+ end
73
+ end
74
+
75
+ def closed?
76
+ state == :closed
77
+ end
78
+
79
+ def close
80
+ log_state_transition(:closed)
81
+ @state = :closed
82
+ @error_count = 0
83
+ end
84
+
85
+ def open?
86
+ state == :open
87
+ end
88
+
89
+ def open
90
+ log_state_transition(:open)
91
+ @state = :open
92
+ end
93
+
94
+ def half_open?
95
+ state == :half_open
96
+ end
97
+
98
+ def half_open
99
+ log_state_transition(:half_open)
100
+ @state = :half_open
101
+ @success_count = 0
102
+ end
103
+
104
+ def increment_recent_errors
105
+ if error_timeout_expired?
106
+ @error_count = 0
107
+ end
108
+
109
+ @error_count += 1
110
+ @error_last_at = Time.now
111
+ end
112
+
113
+ def success_threshold_reached?
114
+ @success_count >= @success_count_threshold
115
+ end
116
+
117
+ def error_threshold_reached?
118
+ @error_count >= @error_count_threshold
119
+ end
120
+
121
+ def error_timeout_expired?
122
+ @error_last_at && (@error_last_at + @error_timeout < Time.now)
123
+ end
124
+
125
+ def log_state_transition(new_state)
126
+ return if @state.nil? || new_state == @state
127
+
128
+ str = "[#{self.class.name}] State transition from #{@state} to #{new_state}."
129
+ str << " success_count=#{@success_count} error_count=#{@error_count}"
130
+ str << " success_count_threshold=#{@success_count_threshold} error_count_threshold=#{@error_count_threshold}"
131
+ str << " error_timeout=#{@error_timeout} error_last_at=\"#{@error_last_at}\""
132
+ Semian.logger.info(str)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,22 @@
1
+ module Semian
2
+ module Instrumentable
3
+ def subscribe(name = rand, &block)
4
+ subscribers[name] = block
5
+ name
6
+ end
7
+
8
+ def unsubscribe(name)
9
+ subscribers.delete(name)
10
+ end
11
+
12
+ def notify(*args)
13
+ subscribers.values.each { |subscriber| subscriber.call(*args) }
14
+ end
15
+
16
+ private
17
+
18
+ def subscribers
19
+ @subscribers ||= {}
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,77 @@
1
+ require 'semian'
2
+ require 'mysql2'
3
+
4
+ module Mysql2
5
+ class SemianError < Mysql2::Error
6
+ def initialize(semian_identifier, *args)
7
+ super(*args)
8
+ @semian_identifier = semian_identifier
9
+ end
10
+
11
+ def to_s
12
+ "[#{@semian_identifier}] #{super}"
13
+ end
14
+ end
15
+
16
+ ResourceOccupiedError = Class.new(SemianError)
17
+ CircuitOpenError = Class.new(SemianError)
18
+ end
19
+
20
+ module Semian
21
+ module Mysql2
22
+ DEFAULT_HOST = 'localhost'
23
+ DEFAULT_PORT = 3306
24
+
25
+ # The naked methods are exposed as `raw_query` and `raw_connect` for instrumentation purpose
26
+ def self.included(base)
27
+ base.send(:alias_method, :raw_query, :query)
28
+ base.send(:remove_method, :query)
29
+
30
+ base.send(:alias_method, :raw_connect, :connect)
31
+ base.send(:remove_method, :connect)
32
+ end
33
+
34
+ def semian_identifier
35
+ @semian_identifier ||= begin
36
+ semian_options = query_options[:semian] || {}
37
+ unless name = semian_options['name'.freeze] || semian_options[:name]
38
+ host = query_options[:host] || DEFAULT_HOST
39
+ port = query_options[:port] || DEFAULT_PORT
40
+ name = "#{host}:#{port}"
41
+ end
42
+ :"mysql_#{name}"
43
+ end
44
+ end
45
+
46
+ def query(*args)
47
+ semian_resource.acquire(scope: :query) { raw_query(*args) }
48
+ rescue ::Semian::OpenCircuitError => error
49
+ raise ::Mysql2::CircuitOpenError.new(semian_identifier, error)
50
+ rescue ::Semian::BaseError => error
51
+ raise ::Mysql2::ResourceOccupiedError.new(semian_identifier, error)
52
+ end
53
+
54
+ private
55
+
56
+ def connect(*args)
57
+ semian_resource.acquire(scope: :connect) { raw_connect(*args) }
58
+ rescue ::Semian::OpenCircuitError => error
59
+ raise ::Mysql2::CircuitOpenError.new(semian_identifier, error)
60
+ rescue ::Semian::BaseError => error
61
+ raise ::Mysql2::ResourceOccupiedError.new(semian_identifier, error)
62
+ end
63
+
64
+ def semian_resource
65
+ @semian_resource ||= ::Semian.retrieve_or_register(semian_identifier, **semian_options)
66
+ end
67
+
68
+ def semian_options
69
+ options = query_options[:semian] || {}
70
+ options = options.map { |k, v| [k.to_sym, v] }.to_h
71
+ options.delete(:name)
72
+ options
73
+ end
74
+ end
75
+ end
76
+
77
+ ::Mysql2::Client.include(Semian::Mysql2)
@@ -1,6 +1,6 @@
1
- class Semian
1
+ module Semian
2
2
  # Determines if Semian supported on the current platform.
3
3
  def self.supported_platform?
4
- RUBY_PLATFORM.end_with?('-linux')
4
+ /linux/.match(RUBY_PLATFORM)
5
5
  end
6
6
  end
@@ -0,0 +1,36 @@
1
+ require 'forwardable'
2
+
3
+ module Semian
4
+ class ProtectedResource
5
+ extend Forwardable
6
+
7
+ def_delegators :@resource, :destroy, :count, :semid, :tickets, :name
8
+ def_delegators :@circuit_breaker, :reset
9
+
10
+ def initialize(resource, circuit_breaker)
11
+ @resource = resource
12
+ @circuit_breaker = circuit_breaker
13
+ end
14
+
15
+ def acquire(timeout: nil, scope: nil, &block)
16
+ @circuit_breaker.acquire do
17
+ begin
18
+ @resource.acquire(timeout: timeout) do
19
+ Semian.notify(:success, self, scope)
20
+ yield self
21
+ end
22
+ rescue ::Semian::TimeoutError
23
+ Semian.notify(:occupied, self, scope)
24
+ raise
25
+ end
26
+ end
27
+ rescue ::Semian::OpenCircuitError
28
+ Semian.notify(:circuit_open, self, scope)
29
+ raise
30
+ end
31
+
32
+ def with_fallback(fallback, &block)
33
+ @circuit_breaker.with_fallback(fallback) { @resource.acquire(&block) }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ module Semian
2
+ class Resource #:nodoc:
3
+ attr_reader :tickets, :name
4
+
5
+ def initialize(name, tickets: , permissions: 0660, timeout: 0)
6
+ _initialize(name, tickets, permissions, timeout) if respond_to?(:_initialize)
7
+ @name = name
8
+ @tickets = tickets
9
+ end
10
+
11
+ def destroy
12
+ end
13
+
14
+ def acquire(*)
15
+ yield self
16
+ end
17
+
18
+ def count
19
+ 0
20
+ end
21
+
22
+ def semid
23
+ 0
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
- class Semian
2
- VERSION = '0.0.8'
1
+ module Semian
2
+ VERSION = '0.1.0'
3
3
  end
data/lib/semian.rb CHANGED
@@ -1,3 +1,6 @@
1
+ require 'logger'
2
+ require 'semian/instrumentable'
3
+
1
4
  #
2
5
  # === Overview
3
6
  #
@@ -25,16 +28,28 @@
25
28
  # the timeout period has elapsed, the client will be unable to access the service and
26
29
  # an error will be raised.
27
30
  #
31
+ # Resources also integrate a circuit breaker in order to fail faster and to let the
32
+ # resource the time to recover. If `error_threshold` errors happen in the span of `error_timeout`
33
+ # then the circuit will be opened and every attempt to acquire the resource will immediately fail.
34
+ #
35
+ # Once in open state, after `error_timeout` is elapsed, the ciruit will transition in the half-open state.
36
+ # In that state a single error will fully re-open the circuit, and the circuit will transition back to the closed
37
+ # state only after the resource is acquired `success_threshold` consecutive times.
38
+ #
28
39
  # A resource is registered by using the Semian.register method.
29
40
  #
30
41
  # ==== Examples
31
42
  #
32
43
  # ===== Registering a resource
33
44
  #
34
- # Semian.register :mysql_shard0, tickets: 10, timeout: 0.5
45
+ # Semian.register(:mysql_shard0, tickets: 10, timeout: 0.5, error_threshold: 3, error_timeout: 10, success_threshold: 2)
35
46
  #
36
47
  # This registers a new resource called <code>:mysql_shard0</code> that has 10 tickets andd a default timeout of 500 milliseconds.
37
48
  #
49
+ # After 3 failures in the span of 10 seconds the circuit will be open.
50
+ # After an additional 10 seconds it will transition to half-open.
51
+ # And finally after 2 successulf acquisitions of the resource it will transition back to the closed state.
52
+ #
38
53
  # ===== Using a resource
39
54
  #
40
55
  # Semian[:mysql_shard0].acquire do
@@ -52,42 +67,80 @@
52
67
  #
53
68
  # This is the same as the previous example, but overrides the timeout from the default value of 500 milliseconds to 1 second.
54
69
  #
55
- class Semian
56
- class << self
57
- # Registers a resource.
58
- #
59
- # +name+: Name of the resource - this can be either a string or symbol.
60
- #
61
- # +tickets+: Number of tickets. If this value is 0, the ticket count will not be set,
62
- # but the resource must have been previously registered otherwise an error will be raised.
63
- #
64
- # +permissions+: Octal permissions of the resource.
65
- #
66
- # +timeout+: Default timeout in seconds.
67
- #
68
- # Returns the registered resource.
69
- def register(name, tickets: 0, permissions: 0660, timeout: 1)
70
- resource = Resource.new(name, tickets, permissions, timeout)
71
- resources[name] = resource
72
- end
70
+ module Semian
71
+ extend self
72
+ extend Instrumentable
73
73
 
74
- # Retrieves a resource by name.
75
- def [](name)
76
- resources[name]
77
- end
74
+ BaseError = Class.new(StandardError)
75
+ SyscallError = Class.new(BaseError)
76
+ TimeoutError = Class.new(BaseError)
77
+ InternalError = Class.new(BaseError)
78
+ OpenCircuitError = Class.new(BaseError)
79
+
80
+ attr_accessor :logger
78
81
 
79
- # Retrieves a hash of all registered resources.
80
- def resources
81
- @resources ||= {}
82
+ self.logger = Logger.new(nil)
83
+
84
+ # Registers a resource.
85
+ #
86
+ # +name+: Name of the resource - this can be either a string or symbol.
87
+ #
88
+ # +tickets+: Number of tickets. If this value is 0, the ticket count will not be set,
89
+ # but the resource must have been previously registered otherwise an error will be raised.
90
+ #
91
+ # +permissions+: Octal permissions of the resource.
92
+ #
93
+ # +timeout+: Default timeout in seconds.
94
+ #
95
+ # +error_threshold+: The number of errors that will trigger the circuit opening.
96
+ #
97
+ # +error_timeout+: The duration in seconds since the last error after which the error count is reset to 0.
98
+ #
99
+ # +success_threshold+: The number of consecutive success after which an half-open circuit will be fully closed.
100
+ #
101
+ # +exceptions+: An array of exception classes that should be accounted as resource errors.
102
+ #
103
+ # Returns the registered resource.
104
+ def register(name, tickets:, permissions: 0660, timeout: 0, error_threshold:, error_timeout:, success_threshold:, exceptions: [])
105
+ circuit_breaker = CircuitBreaker.new(
106
+ success_threshold: success_threshold,
107
+ error_threshold: error_threshold,
108
+ error_timeout: error_timeout,
109
+ exceptions: Array(exceptions) + [::Semian::BaseError],
110
+ )
111
+ resource = Resource.new(name, tickets: tickets, permissions: permissions, timeout: timeout)
112
+ resources[name] = ProtectedResource.new(resource, circuit_breaker)
113
+ end
114
+
115
+ def retrieve_or_register(name, **args)
116
+ self[name] || register(name, **args)
117
+ end
118
+
119
+ # Retrieves a resource by name.
120
+ def [](name)
121
+ resources[name]
122
+ end
123
+
124
+ def destroy(name)
125
+ if resource = resources.delete(name)
126
+ resource.destroy
82
127
  end
83
128
  end
129
+
130
+ # Retrieves a hash of all registered resources.
131
+ def resources
132
+ @resources ||= {}
133
+ end
84
134
  end
85
135
 
136
+ require 'semian/resource'
137
+ require 'semian/circuit_breaker'
138
+ require 'semian/protected_resource'
86
139
  require 'semian/platform'
87
140
  if Semian.supported_platform?
88
141
  require 'semian/semian'
89
142
  else
90
- require 'semian/unsupported'
143
+ Semian::MAX_TICKETS = 0
91
144
  $stderr.puts "Semian is not supported on #{RUBY_PLATFORM} - all operations will no-op"
92
145
  end
93
146
  require 'semian/version'
@@ -0,0 +1,25 @@
1
+ set -e
2
+
3
+ if which toxiproxy > /dev/null; then
4
+ echo "Toxiproxy is already installed."
5
+ exit 0
6
+ fi
7
+
8
+ if which apt-get > /dev/null; then
9
+ echo "Installing toxiproxy-1.0.0.deb"
10
+ wget -O /tmp/toxiproxy-1.0.0.deb https://github.com/Shopify/toxiproxy/releases/download/v1.0.0/toxiproxy_1.0.0_amd64.deb
11
+ sudo dpkg -i /tmp/toxiproxy-1.0.0.deb
12
+ sudo service toxiproxy start
13
+ exit 0
14
+ fi
15
+
16
+ if which brew > /dev/null; then
17
+ echo "Installing toxiproxy from homebrew."
18
+ brew tap shopify/shopify
19
+ brew install toxiproxy
20
+ brew info toxiproxy
21
+ exit 0
22
+ fi
23
+
24
+ echo "Sorry, there is no toxiproxy package available for your system. You might need to build it from source."
25
+ exit 1
data/semian.gemspec CHANGED
@@ -19,4 +19,7 @@ Gem::Specification.new do |s|
19
19
  s.files = `git ls-files`.split("\n")
20
20
  s.extensions = ['ext/semian/extconf.rb']
21
21
  s.add_development_dependency 'rake-compiler', '~> 0.9'
22
+ s.add_development_dependency 'timecop'
23
+ s.add_development_dependency 'mysql2'
24
+ s.add_development_dependency 'toxiproxy'
22
25
  end
@@ -0,0 +1,7 @@
1
+ [
2
+ {
3
+ "name": "semian_test_mysql",
4
+ "upstream": "localhost:3306",
5
+ "listen": "localhost:13306"
6
+ }
7
+ ]