semian 0.0.8 → 0.1.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 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
+ ]