semian_extension 0.11.4.1

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.
@@ -0,0 +1,167 @@
1
+ module Semian
2
+ class CircuitBreaker #:nodoc:
3
+ extend Forwardable
4
+
5
+ def_delegators :@state, :closed?, :open?, :half_open?
6
+
7
+ attr_reader :name, :half_open_resource_timeout, :error_timeout, :state, :last_error
8
+
9
+ def initialize(name, exceptions:, success_threshold:, error_threshold:,
10
+ error_timeout:, implementation:, half_open_resource_timeout: nil)
11
+ @name = name.to_sym
12
+ @success_count_threshold = success_threshold
13
+ @error_count_threshold = error_threshold
14
+ @error_timeout = error_timeout
15
+ @exceptions = exceptions
16
+ @half_open_resource_timeout = half_open_resource_timeout
17
+
18
+ @errors = implementation::SlidingWindow.new(max_size: @error_count_threshold)
19
+ @successes = implementation::Integer.new
20
+ @state = implementation::State.new
21
+
22
+ reset
23
+ end
24
+
25
+ def acquire(resource = nil, &block)
26
+ return yield if disabled?
27
+ transition_to_half_open if transition_to_half_open?
28
+
29
+ raise OpenCircuitError unless request_allowed?
30
+
31
+ result = nil
32
+ begin
33
+ result = maybe_with_half_open_resource_timeout(resource, &block)
34
+ rescue *@exceptions => error
35
+ if !error.respond_to?(:marks_semian_circuits?) || error.marks_semian_circuits?
36
+ mark_failed(error)
37
+ end
38
+ raise error
39
+ else
40
+ mark_success
41
+ end
42
+ result
43
+ end
44
+
45
+ def transition_to_half_open?
46
+ open? && error_timeout_expired? && !half_open?
47
+ end
48
+
49
+ def request_allowed?
50
+ closed? || half_open? || transition_to_half_open?
51
+ end
52
+
53
+ def mark_failed(error)
54
+ push_error(error)
55
+ push_time(@errors)
56
+ if closed?
57
+ transition_to_open if error_threshold_reached?
58
+ elsif half_open?
59
+ transition_to_open
60
+ end
61
+ end
62
+
63
+ def mark_success
64
+ return unless half_open?
65
+ @successes.increment
66
+ transition_to_close if success_threshold_reached?
67
+ end
68
+
69
+ def reset
70
+ @errors.clear
71
+ @successes.reset
72
+ transition_to_close
73
+ end
74
+
75
+ def destroy
76
+ @errors.destroy
77
+ @successes.destroy
78
+ @state.destroy
79
+ end
80
+
81
+ def in_use?
82
+ return false if error_timeout_expired?
83
+ @errors.size > 0
84
+ end
85
+
86
+ private
87
+
88
+ def transition_to_close
89
+ notify_state_transition(:closed)
90
+ log_state_transition(:closed)
91
+ @state.close!
92
+ @errors.clear
93
+ end
94
+
95
+ def transition_to_open
96
+ notify_state_transition(:open)
97
+ log_state_transition(:open)
98
+ @state.open!
99
+ end
100
+
101
+ def transition_to_half_open
102
+ notify_state_transition(:half_open)
103
+ log_state_transition(:half_open)
104
+ @state.half_open!
105
+ @successes.reset
106
+ end
107
+
108
+ def success_threshold_reached?
109
+ @successes.value >= @success_count_threshold
110
+ end
111
+
112
+ def error_threshold_reached?
113
+ @errors.size == @error_count_threshold
114
+ end
115
+
116
+ def error_timeout_expired?
117
+ last_error_time = @errors.last
118
+ return false unless last_error_time
119
+ Time.at(last_error_time) + @error_timeout < Time.now
120
+ end
121
+
122
+ def push_error(error)
123
+ @last_error = error
124
+ end
125
+
126
+ def push_time(window, time: Time.now)
127
+ window.reject! { |err_time| err_time + @error_timeout < time.to_i }
128
+ window << time.to_i
129
+ end
130
+
131
+ def log_state_transition(new_state)
132
+ return if @state.nil? || new_state == @state.value
133
+
134
+ str = "[#{self.class.name}] State transition from #{@state.value} to #{new_state}."
135
+ str << " success_count=#{@successes.value} error_count=#{@errors.size}"
136
+ str << " success_count_threshold=#{@success_count_threshold} error_count_threshold=#{@error_count_threshold}"
137
+ str << " error_timeout=#{@error_timeout} error_last_at=\"#{@errors.last}\""
138
+ str << " name=\"#{@name}\""
139
+ if new_state == :open && @last_error
140
+ str << " last_error_message=#{@last_error.message.inspect}"
141
+ end
142
+
143
+ Semian.logger.info(str)
144
+ end
145
+
146
+ def notify_state_transition(new_state)
147
+ Semian.notify(:state_change, self, nil, nil, state: new_state)
148
+ end
149
+
150
+ def disabled?
151
+ ENV['SEMIAN_CIRCUIT_BREAKER_DISABLED'] || ENV['SEMIAN_DISABLED']
152
+ end
153
+
154
+ def maybe_with_half_open_resource_timeout(resource, &block)
155
+ result =
156
+ if half_open? && @half_open_resource_timeout && resource.respond_to?(:with_resource_timeout)
157
+ resource.with_resource_timeout(@half_open_resource_timeout) do
158
+ block.call
159
+ end
160
+ else
161
+ block.call
162
+ end
163
+
164
+ result
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,104 @@
1
+ require 'semian/adapter'
2
+ require 'grpc'
3
+
4
+ module GRPC
5
+ GRPC::Unavailable.include(::Semian::AdapterError)
6
+ GRPC::Unknown.include(::Semian::AdapterError)
7
+ GRPC::ResourceExhausted.include(::Semian::AdapterError)
8
+
9
+ class SemianError < GRPC::Unavailable
10
+ attr_reader :details
11
+
12
+ def initialize(semian_identifier, *args)
13
+ super(*args)
14
+ @details = message
15
+ @semian_identifier = semian_identifier
16
+ end
17
+ end
18
+
19
+ ResourceBusyError = Class.new(SemianError)
20
+ CircuitOpenError = Class.new(SemianError)
21
+ end
22
+
23
+ module Semian
24
+ module GRPC
25
+ attr_reader :raw_semian_options
26
+ include Semian::Adapter
27
+
28
+ ResourceBusyError = ::GRPC::ResourceBusyError
29
+ CircuitOpenError = ::GRPC::CircuitOpenError
30
+
31
+ class SemianConfigurationChangedError < RuntimeError
32
+ def initialize(msg = "Cannot re-initialize semian_configuration")
33
+ super
34
+ end
35
+ end
36
+
37
+ class << self
38
+ attr_accessor :exceptions
39
+ attr_reader :semian_configuration
40
+
41
+ def semian_configuration=(configuration)
42
+ raise Semian::GRPC::SemianConfigurationChangedError unless @semian_configuration.nil?
43
+ @semian_configuration = configuration
44
+ end
45
+
46
+ def retrieve_semian_configuration(host)
47
+ @semian_configuration.call(host) if @semian_configuration.respond_to?(:call)
48
+ end
49
+ end
50
+
51
+ def raw_semian_options
52
+ @raw_semian_options ||= begin
53
+ # If the host is empty, it's possible that the adapter was initialized
54
+ # with the channel. Therefore, we look into the channel to find the host
55
+ if @host.empty?
56
+ host = @ch.target
57
+ else
58
+ host = @host
59
+ end
60
+ @raw_semian_options = Semian::GRPC.retrieve_semian_configuration(host)
61
+ @raw_semian_options = @raw_semian_options.dup unless @raw_semian_options.nil?
62
+ end
63
+ end
64
+
65
+ def semian_identifier
66
+ @semian_identifier ||= raw_semian_options[:name]
67
+ end
68
+
69
+ def resource_exceptions
70
+ [
71
+ ::GRPC::DeadlineExceeded,
72
+ ::GRPC::ResourceExhausted,
73
+ ::GRPC::Unavailable,
74
+ ::GRPC::Unknown,
75
+ ]
76
+ end
77
+
78
+ def disabled?
79
+ raw_semian_options.nil?
80
+ end
81
+
82
+ def request_response(*, **)
83
+ return super if disabled?
84
+ acquire_semian_resource(adapter: :grpc, scope: :request_response) { super }
85
+ end
86
+
87
+ def client_streamer(*, **)
88
+ return super if disabled?
89
+ acquire_semian_resource(adapter: :grpc, scope: :client_streamer) { super }
90
+ end
91
+
92
+ def server_streamer(*, **)
93
+ return super if disabled?
94
+ acquire_semian_resource(adapter: :grpc, scope: :server_streamer) { super }
95
+ end
96
+
97
+ def bidi_streamer(*, **)
98
+ return super if disabled?
99
+ acquire_semian_resource(adapter: :grpc, scope: :bidi_streamer) { super }
100
+ end
101
+ end
102
+ end
103
+
104
+ ::GRPC::ClientStub.prepend(Semian::GRPC)
@@ -0,0 +1,28 @@
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
+ # Args:
13
+ # event (string)
14
+ # resource (Object)
15
+ # scope (string)
16
+ # adapter (string)
17
+ # payload (optional)
18
+ def notify(*args)
19
+ subscribers.values.each { |subscriber| subscriber.call(*args) }
20
+ end
21
+
22
+ private
23
+
24
+ def subscribers
25
+ @subscribers ||= {}
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,174 @@
1
+ class LRUHash
2
+ # This LRU (Least Recently Used) hash will allow
3
+ # the cleaning of resources as time goes on.
4
+ # The goal is to remove the least recently used resources
5
+ # everytime we set a new resource. A default window of
6
+ # 5 minutes will allow empty item to stay in the hash
7
+ # for a maximum of 5 minutes
8
+ class NoopMutex
9
+ def synchronize(*)
10
+ yield
11
+ end
12
+
13
+ def try_lock
14
+ true
15
+ end
16
+
17
+ def unlock
18
+ true
19
+ end
20
+
21
+ def locked?
22
+ true
23
+ end
24
+
25
+ def owned?
26
+ true
27
+ end
28
+ end
29
+
30
+ def keys
31
+ @lock.synchronize { @table.keys }
32
+ end
33
+
34
+ def clear
35
+ @lock.synchronize { @table.clear }
36
+ end
37
+
38
+ # Create an LRU hash
39
+ #
40
+ # Arguments:
41
+ # +max_size+ The maximum size of the table
42
+ # +min_time+ The minimum time a resource can live in the cache
43
+ #
44
+ # Note:
45
+ # The +min_time+ is a stronger guarantee than +max_size+. That is, if there are
46
+ # more than +max_size+ entries in the cache, but they've all been updated more
47
+ # recently than +min_time+, the garbage collection will not remove them and the
48
+ # cache can grow without bound. This usually means that you have many active
49
+ # circuits to disparate endpoints (or your circuit names are bad).
50
+ # If the max_size is 0, the garbage collection will be very aggressive and
51
+ # potentially computationally expensive.
52
+ def initialize(max_size: Semian.maximum_lru_size, min_time: Semian.minimum_lru_time)
53
+ @max_size = max_size
54
+ @min_time = min_time
55
+ @table = {}
56
+ @lock =
57
+ if Semian.thread_safe?
58
+ Mutex.new
59
+ else
60
+ NoopMutex.new
61
+ end
62
+ end
63
+
64
+ def size
65
+ @lock.synchronize { @table.size }
66
+ end
67
+
68
+ def count(&block)
69
+ @lock.synchronize { @table.count(&block) }
70
+ end
71
+
72
+ def empty?
73
+ @lock.synchronize { @table.empty? }
74
+ end
75
+
76
+ def values
77
+ @lock.synchronize { @table.values }
78
+ end
79
+
80
+ def set(key, resource)
81
+ @lock.synchronize do
82
+ @table.delete(key)
83
+ @table[key] = resource
84
+ resource.updated_at = Time.now
85
+ end
86
+ clear_unused_resources if @table.length > @max_size
87
+ end
88
+
89
+ # This method uses the property that "Hashes enumerate their values in the
90
+ # order that the corresponding keys were inserted." Deleting a key and
91
+ # re-inserting it effectively moves it to the front of the cache.
92
+ # Update the `updated_at` field so we can use it later do decide if the
93
+ # resource is "in use".
94
+ def get(key)
95
+ @lock.synchronize do
96
+ found = @table.delete(key)
97
+ if found
98
+ @table[key] = found
99
+ found.updated_at = Time.now
100
+ end
101
+ found
102
+ end
103
+ end
104
+
105
+ def delete(key)
106
+ @lock.synchronize do
107
+ @table.delete(key)
108
+ end
109
+ end
110
+
111
+ def []=(key, resource)
112
+ set(key, resource)
113
+ end
114
+
115
+ def [](key)
116
+ get(key)
117
+ end
118
+
119
+ private
120
+
121
+ def clear_unused_resources
122
+ payload = {
123
+ size: @table.size,
124
+ examined: 0,
125
+ cleared: 0,
126
+ elapsed: nil,
127
+ }
128
+ timer_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+
130
+ ran = try_synchronize do
131
+ # Clears resources that have not been used in the last 5 minutes.
132
+
133
+ stop_time = Time.now - @min_time # Don't process resources updated after this time
134
+ @table.each do |_, resource|
135
+ payload[:examined] += 1
136
+
137
+ # The update times of the resources in the LRU are monotonically increasing,
138
+ # time, so we can stop looking once we find the first resource with an
139
+ # update time after the stop_time.
140
+ break if resource.updated_at > stop_time
141
+
142
+ next if resource.in_use?
143
+
144
+ resource = @table.delete(resource.name)
145
+ if resource
146
+ payload[:cleared] += 1
147
+ resource.destroy
148
+ end
149
+ end
150
+ end
151
+
152
+ if ran
153
+ payload[:elapsed] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - timer_start
154
+ Semian.notify(:lru_hash_gc, self, nil, nil, payload)
155
+ end
156
+ end
157
+
158
+ EXCEPTION_NEVER = {Exception => :never}.freeze
159
+ EXCEPTION_IMMEDIATE = {Exception => :immediate}.freeze
160
+ private_constant :EXCEPTION_NEVER
161
+ private_constant :EXCEPTION_IMMEDIATE
162
+
163
+ def try_synchronize
164
+ Thread.handle_interrupt(EXCEPTION_NEVER) do
165
+ begin
166
+ return false unless @lock.try_lock
167
+ Thread.handle_interrupt(EXCEPTION_IMMEDIATE) { yield }
168
+ true
169
+ ensure
170
+ @lock.unlock if @lock.owned?
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,135 @@
1
+ require 'semian/adapter'
2
+ require 'mysql2'
3
+
4
+ module Mysql2
5
+ Mysql2::Error.include(::Semian::AdapterError)
6
+
7
+ class SemianError < Mysql2::Error
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 Mysql2
20
+ include Semian::Adapter
21
+
22
+ CONNECTION_ERROR = Regexp.union(
23
+ /Can't connect to MySQL server on/i,
24
+ /Lost connection to MySQL server/i,
25
+ /MySQL server has gone away/i,
26
+ /Too many connections/i,
27
+ /closed MySQL connection/i,
28
+ /Timeout waiting for a response/i,
29
+ )
30
+
31
+ ResourceBusyError = ::Mysql2::ResourceBusyError
32
+ CircuitOpenError = ::Mysql2::CircuitOpenError
33
+ PingFailure = Class.new(::Mysql2::Error)
34
+
35
+ DEFAULT_HOST = 'localhost'
36
+ DEFAULT_PORT = 3306
37
+
38
+ QUERY_WHITELIST = Regexp.union(
39
+ /\A(?:\/\*.*?\*\/)?\s*ROLLBACK/i,
40
+ /\A(?:\/\*.*?\*\/)?\s*COMMIT/i,
41
+ /\A(?:\/\*.*?\*\/)?\s*RELEASE\s+SAVEPOINT/i,
42
+ )
43
+
44
+ # The naked methods are exposed as `raw_query` and `raw_connect` for instrumentation purpose
45
+ def self.included(base)
46
+ base.send(:alias_method, :raw_query, :query)
47
+ base.send(:remove_method, :query)
48
+
49
+ base.send(:alias_method, :raw_connect, :connect)
50
+ base.send(:remove_method, :connect)
51
+
52
+ base.send(:alias_method, :raw_ping, :ping)
53
+ base.send(:remove_method, :ping)
54
+ end
55
+
56
+ def semian_identifier
57
+ @semian_identifier ||= begin
58
+ unless name = semian_options && semian_options[:name]
59
+ host = query_options[:host] || DEFAULT_HOST
60
+ port = query_options[:port] || DEFAULT_PORT
61
+ name = "#{host}:#{port}"
62
+ end
63
+ :"mysql_#{name}"
64
+ end
65
+ end
66
+
67
+ def ping
68
+ result = nil
69
+ acquire_semian_resource(adapter: :mysql, scope: :ping) do
70
+ result = raw_ping
71
+ raise PingFailure.new(result.to_s) unless result
72
+ end
73
+ result
74
+ rescue ResourceBusyError, CircuitOpenError, PingFailure
75
+ false
76
+ end
77
+
78
+ def query(*args)
79
+ if query_whitelisted?(*args)
80
+ raw_query(*args)
81
+ else
82
+ acquire_semian_resource(adapter: :mysql, scope: :query) { raw_query(*args) }
83
+ end
84
+ end
85
+
86
+ # TODO: write_timeout and connect_timeout can't be configured currently
87
+ # dynamically, await https://github.com/brianmario/mysql2/pull/955
88
+ def with_resource_timeout(temp_timeout)
89
+ prev_read_timeout = @read_timeout
90
+
91
+ begin
92
+ # C-ext reads this directly, writer method will configure
93
+ # properly on the client but based on my read--this is good enough
94
+ # until we get https://github.com/brianmario/mysql2/pull/955 in
95
+ @read_timeout = temp_timeout
96
+ yield
97
+ ensure
98
+ @read_timeout = prev_read_timeout
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def query_whitelisted?(sql, *)
105
+ QUERY_WHITELIST =~ sql
106
+ rescue ArgumentError
107
+ # The above regexp match can fail if the input SQL string contains binary
108
+ # data that is not recognized as a valid encoding, in which case we just
109
+ # return false.
110
+ return false unless sql.valid_encoding?
111
+ raise
112
+ end
113
+
114
+ def connect(*args)
115
+ acquire_semian_resource(adapter: :mysql, scope: :connection) { raw_connect(*args) }
116
+ end
117
+
118
+ def acquire_semian_resource(**)
119
+ super
120
+ rescue ::Mysql2::Error => error
121
+ if error.is_a?(PingFailure) || (!error.is_a?(::Mysql2::SemianError) && error.message.match?(CONNECTION_ERROR))
122
+ semian_resource.mark_failed(error)
123
+ error.semian_identifier = semian_identifier
124
+ end
125
+ raise
126
+ end
127
+
128
+ def raw_semian_options
129
+ return query_options[:semian] if query_options.key?(:semian)
130
+ return query_options['semian'.freeze] if query_options.key?('semian'.freeze)
131
+ end
132
+ end
133
+ end
134
+
135
+ ::Mysql2::Client.include(Semian::Mysql2)