semian 0.28.3 → 0.28.5
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 +4 -4
- data/README.md +97 -1
- data/ext/semian/resource.c +5 -6
- data/lib/semian/adapter.rb +1 -2
- data/lib/semian/circuit_breaker.rb +18 -2
- data/lib/semian/configuration_validator.rb +60 -10
- data/lib/semian/error_rate_circuit_breaker.rb +173 -0
- data/lib/semian/grpc.rb +7 -1
- data/lib/semian/resource.rb +2 -0
- data/lib/semian/simple_time_bucketed_counter.rb +98 -0
- data/lib/semian/version.rb +1 -1
- data/lib/semian.rb +36 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6794eae127eb46d682ae8b1e26932eab02aa23256eec0f69b0e08302d016ef1
|
|
4
|
+
data.tar.gz: 15d40e05ef6f272bc011fa7a0da2baeefbaba9834542057c139011a213ce5c86
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71f5b87bab4fdeefc8988dd06547b62825caf44d5afaabd8308b9cafdfaf0762cf5f6cddecb309dcdb70848e925db6e76e915d0632e83bd3a1c18aed76360128
|
|
7
|
+
data.tar.gz: 1ed4d05cd272eebb88f7f7b4842d5f106ee2f58935a4662bb5cfeb0ec64503397466569b46dafaa6074981061b86c13fb25c480b1c4e703f8be90ab7018720b4
|
data/README.md
CHANGED
|
@@ -83,7 +83,8 @@ To create a Semian adapter you must implement the following methods:
|
|
|
83
83
|
1. [`include Semian::Adapter`][semian-adapter]. Use the helpers to wrap the
|
|
84
84
|
resource. This takes care of situations such as monitoring, nested resources,
|
|
85
85
|
unsupported platforms, creating the Semian resource if it doesn't already
|
|
86
|
-
exist and so on.
|
|
86
|
+
exist and so on. Include it in an object that represents a single resource,
|
|
87
|
+
typically one connection — see [Thread Safety](#thread-safety).
|
|
87
88
|
2. `#semian_identifier`. This is responsible for returning a symbol that
|
|
88
89
|
represents every unique resource, for example `redis_master` or
|
|
89
90
|
`mysql_shard_1`. This is usually assembled from a `name` attribute on the
|
|
@@ -192,6 +193,10 @@ if it is set to `false`, it will log misconfigured parameters verbosely in outpu
|
|
|
192
193
|
If not specified, it will use `Semian.default_force_config_validation` as
|
|
193
194
|
the flag.
|
|
194
195
|
|
|
196
|
+
Resource names (strings or symbols) must not contain null bytes (`\0`). Registration
|
|
197
|
+
raises `ArgumentError` regardless of `force_config_validation`, even when Semian
|
|
198
|
+
is disabled. Bulkhead construction also rejects null bytes in `Semian.namespace`.
|
|
199
|
+
|
|
195
200
|
##### Migration Strategy for Force Config Validation
|
|
196
201
|
|
|
197
202
|
When migrating to use `force_config_validation: true`, follow these steps:
|
|
@@ -214,6 +219,27 @@ Semian's circuit breaker implementation is thread-safe by default as of
|
|
|
214
219
|
`v0.7.0`. If you'd like to disable it for performance reasons, pass
|
|
215
220
|
`thread_safety_disabled: true` to the resource options.
|
|
216
221
|
|
|
222
|
+
An adapter instance represents one resource. Once that resource has been
|
|
223
|
+
acquired, Semian treats further access through the same instance as part of the
|
|
224
|
+
same session: nested or overlapping calls do not re-enter the circuit breaker
|
|
225
|
+
and do not take a second bulkhead ticket, so a circuit that opens elsewhere in
|
|
226
|
+
the process cannot interrupt a session that is already in flight and
|
|
227
|
+
succeeding. Every adapter that ships with Semian has this shape — the mixin is
|
|
228
|
+
included in a connection object, and the session is that connection's unit of
|
|
229
|
+
work.
|
|
230
|
+
|
|
231
|
+
This is likely to give unexpected results if the adapter instance is shared
|
|
232
|
+
process-wide, such as by a singleton. While any call is in flight, calls made
|
|
233
|
+
through the same instance from other threads are treated as part of that
|
|
234
|
+
session, so they are not fast-failed when the circuit has opened since that
|
|
235
|
+
first acquire, and they cannot contribute failures. Where calls overlap
|
|
236
|
+
continuously, that keeps the circuit closed while the resource is failing.
|
|
237
|
+
Consider introducing a "session" object as the adapterized wrapper around the
|
|
238
|
+
singleton instead, created per unit of work — per request, say. Circuit breaker
|
|
239
|
+
and bulkhead state is keyed by `semian_identifier` and held in a process-wide
|
|
240
|
+
registry, so short-lived adapter instances that share an identifier all share
|
|
241
|
+
one circuit.
|
|
242
|
+
|
|
217
243
|
Bulkheads should be disabled (pass `bulkhead: false`) in a threaded environment
|
|
218
244
|
(e.g. Puma or Sidekiq), but can safely be enabled in non-threaded environments
|
|
219
245
|
(e.g. Resque and Unicorn). As described in this document, circuit breakers alone
|
|
@@ -607,6 +633,76 @@ It is possible to disable Circuit Breaker with environment variable
|
|
|
607
633
|
For more information about configuring these parameters, please read
|
|
608
634
|
[this post](https://shopify.engineering/circuit-breaker-misconfigured).
|
|
609
635
|
|
|
636
|
+
#### Error-Rate Circuit Breaker
|
|
637
|
+
|
|
638
|
+
The circuit breaker above opens after an absolute number of errors (`error_threshold`), regardless of how many
|
|
639
|
+
requests succeeded in the meantime. For a resource that is mostly healthy, such as a pool of replicas where one is
|
|
640
|
+
failing, a fixed number of errors is reached sooner the more traffic a worker sends, and the circuit then fails
|
|
641
|
+
*every* request instead of the few that were failing.
|
|
642
|
+
|
|
643
|
+
Set **error_rate_threshold** to open the circuit when that fraction of recent requests failed instead:
|
|
644
|
+
|
|
645
|
+
```ruby
|
|
646
|
+
Semian.register(
|
|
647
|
+
:mysql_replicas,
|
|
648
|
+
error_rate_threshold: 0.5, # open when 50% of requests failed...
|
|
649
|
+
minimum_request_volume: 20, # ...out of at least 20 requests...
|
|
650
|
+
error_rate_window: 10, # ...in the last 10 seconds
|
|
651
|
+
error_timeout: 5,
|
|
652
|
+
success_threshold: 2,
|
|
653
|
+
bulkhead: false,
|
|
654
|
+
)
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- **error_rate_threshold**. A number between 0 and 1 (exclusive): the fraction of requests in the window that must
|
|
658
|
+
have failed to open the circuit. Required to enable this circuit breaker.
|
|
659
|
+
- **minimum_request_volume**. The number of requests that must have happened in the window before the error rate is
|
|
660
|
+
considered, so that 1 failure out of 1 request doesn't open the circuit. Defaults to 10.
|
|
661
|
+
- **error_rate_window**. Seconds of history, rounded up to whole seconds. Defaults to 10. One-second buckets cover
|
|
662
|
+
between `error_rate_window - 1` and `error_rate_window` seconds after rounding.
|
|
663
|
+
|
|
664
|
+
Invalid values for these three options raise an `ArgumentError` when the resource is registered, regardless of
|
|
665
|
+
`force_config_validation`: `error_rate_threshold: 50` instead of `0.5`, for example, would otherwise leave a circuit
|
|
666
|
+
that never opens. So does setting `error_rate_threshold` together with `adaptive_circuit_breaker` or
|
|
667
|
+
`dual_circuit_breaker`, when the circuit breaker is enabled.
|
|
668
|
+
|
|
669
|
+
`error_timeout`, `success_threshold`, `half_open_resource_timeout` and the `exponential_backoff_*` options work as
|
|
670
|
+
described above. `error_threshold`, `error_threshold_timeout`, `error_threshold_timeout_enabled` and
|
|
671
|
+
`lumping_interval` are replaced by the options above and are ignored.
|
|
672
|
+
|
|
673
|
+
Things to know when choosing values:
|
|
674
|
+
|
|
675
|
+
- A rate needs data. State is kept per worker process, so a process that sees fewer than `minimum_request_volume`
|
|
676
|
+
requests in `error_rate_window` seconds never opens the circuit, even if all of them fail. Size both options for the
|
|
677
|
+
traffic a single process sends to the resource. These are counts within the window, not averages: bursty traffic
|
|
678
|
+
with a low average rate can still reach the minimum volume.
|
|
679
|
+
- After a resource starts failing completely, the circuit opens once failures make up `error_rate_threshold` of the
|
|
680
|
+
window. With steady traffic and a window full of successes, this takes about
|
|
681
|
+
`error_rate_threshold * error_rate_window` seconds, provided the minimum volume is met. Irregular traffic can take
|
|
682
|
+
longer.
|
|
683
|
+
- Opening the circuit fails *all* requests until it recovers, so `error_rate_threshold` should be higher than the
|
|
684
|
+
fraction of requests you expect to fail when a part of the resource is unhealthy (for example one replica out of
|
|
685
|
+
four is 25%).
|
|
686
|
+
- Successes are counted while the circuit is closed, adding a small cost to each request.
|
|
687
|
+
|
|
688
|
+
Failures include configured exceptions (and `Semian::BaseError`) and adapter-reported failures, including failed
|
|
689
|
+
connections and pings. Successful connections and pings (`:connection` and `:ping` scopes) are not counted in the
|
|
690
|
+
error rate, nor are errors not in `exceptions`, errors for which `marks_semian_circuits?` is false, and open-circuit
|
|
691
|
+
rejections. Recovery is unchanged from the circuit breaker above: in the half-open state every successful acquire,
|
|
692
|
+
including a successful connection or ping, counts towards `success_threshold`.
|
|
693
|
+
|
|
694
|
+
Each acquire counts at most one failure, however often it is reported with `mark_failed`: inside the block, or
|
|
695
|
+
afterwards with the error that the block raised, at any later time and from any thread. The identity of the error
|
|
696
|
+
object ties a report to its acquire, without modifying or retaining the error, so this also holds for frozen errors.
|
|
697
|
+
Two cases are inherently ambiguous: when the same error object ends several acquires of a resource, a later report
|
|
698
|
+
is attributed to the latest of them, and a copy of an error (for example one that was marshaled) is a different
|
|
699
|
+
object, so reporting it counts a new failure. The `:state_change` notification and state transition log include the
|
|
700
|
+
error rate at the time of the transition.
|
|
701
|
+
|
|
702
|
+
The gRPC adapter observes errors raised during the protected call. For a streaming RPC that returns an enumerator of
|
|
703
|
+
responses, the protected acquire ends and counts as a success when the enumerator is returned. Errors raised later
|
|
704
|
+
while consuming it are not observed. This is how the adapter behaves with the circuit breaker above as well.
|
|
705
|
+
|
|
610
706
|
#### Adaptive Circuit Breaker (Experimental)
|
|
611
707
|
|
|
612
708
|
Semian also includes an experimental adaptive circuit breaker that uses a [PID controller](https://en.wikipedia.org/wiki/Proportional%E2%80%93integral%E2%80%93derivative_controller)
|
data/ext/semian/resource.c
CHANGED
|
@@ -332,18 +332,17 @@ check_tickets_arg(VALUE tickets)
|
|
|
332
332
|
static const char*
|
|
333
333
|
check_id_arg(VALUE id)
|
|
334
334
|
{
|
|
335
|
-
const char *c_id_str = NULL;
|
|
336
|
-
|
|
337
335
|
if (TYPE(id) != T_SYMBOL && TYPE(id) != T_STRING) {
|
|
338
336
|
rb_raise(rb_eTypeError, "id must be a symbol or string");
|
|
339
337
|
}
|
|
340
338
|
if (TYPE(id) == T_SYMBOL) {
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
339
|
+
id = rb_sym2str(id);
|
|
340
|
+
}
|
|
341
|
+
if (memchr(RSTRING_PTR(id), '\0', RSTRING_LEN(id)) != NULL) {
|
|
342
|
+
rb_raise(rb_eArgError, "name must not contain null bytes");
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
return
|
|
345
|
+
return StringValueCStr(id);
|
|
347
346
|
}
|
|
348
347
|
|
|
349
348
|
static double
|
data/lib/semian/adapter.rb
CHANGED
|
@@ -110,6 +110,10 @@ module Semian
|
|
|
110
110
|
def transition_to_close(scope: nil, adapter: nil)
|
|
111
111
|
notify_state_transition(:closed, scope: scope, adapter: adapter)
|
|
112
112
|
log_state_transition(:closed)
|
|
113
|
+
close_state
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def close_state
|
|
113
117
|
@state.close!
|
|
114
118
|
@errors.clear
|
|
115
119
|
# Reset exponential backoff when circuit closes
|
|
@@ -119,6 +123,10 @@ module Semian
|
|
|
119
123
|
def transition_to_open(scope: nil, adapter: nil)
|
|
120
124
|
notify_state_transition(:open, scope: scope, adapter: adapter)
|
|
121
125
|
log_state_transition(:open)
|
|
126
|
+
open_state
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def open_state
|
|
122
130
|
@state.open!
|
|
123
131
|
end
|
|
124
132
|
|
|
@@ -165,9 +173,9 @@ module Semian
|
|
|
165
173
|
return if @state.nil? || new_state == @state.value
|
|
166
174
|
|
|
167
175
|
str = "[#{self.class.name}] State transition from #{@state.value} to #{new_state}."
|
|
168
|
-
str += " success_count=#{@successes.value}
|
|
176
|
+
str += " success_count=#{@successes.value}#{observed_errors_log}"
|
|
169
177
|
str += " success_count_threshold=#{@success_count_threshold}"
|
|
170
|
-
str +=
|
|
178
|
+
str += error_threshold_log
|
|
171
179
|
str += " error_timeout=#{@error_timeout} error_last_at=\"#{@errors.last}\""
|
|
172
180
|
if @exponential_backoff_error_timeout
|
|
173
181
|
str += " current_error_timeout=#{@current_error_timeout}"
|
|
@@ -180,6 +188,14 @@ module Semian
|
|
|
180
188
|
Semian.logger.info(str)
|
|
181
189
|
end
|
|
182
190
|
|
|
191
|
+
def observed_errors_log
|
|
192
|
+
" error_count=#{@errors.size}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def error_threshold_log
|
|
196
|
+
" error_count_threshold=#{@error_count_threshold}"
|
|
197
|
+
end
|
|
198
|
+
|
|
183
199
|
def notify_state_transition(new_state, scope: nil, adapter: nil)
|
|
184
200
|
Semian.notify(:state_change, self, scope, adapter, state: new_state)
|
|
185
201
|
end
|
|
@@ -66,6 +66,7 @@ module Semian
|
|
|
66
66
|
def validate_circuit_breaker_configuration!
|
|
67
67
|
return if ENV.key?("SEMIAN_CIRCUIT_BREAKER_DISABLED")
|
|
68
68
|
return unless @configuration.fetch(:circuit_breaker, true)
|
|
69
|
+
return validate_error_rate_configuration! unless @configuration[:error_rate_threshold].nil?
|
|
69
70
|
return if @configuration[:adaptive_circuit_breaker] # Skip traditional validation if using adaptive
|
|
70
71
|
|
|
71
72
|
require_keys!([:success_threshold, :error_threshold, :error_timeout], @configuration)
|
|
@@ -74,8 +75,12 @@ module Semian
|
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
def validate_thresholds!
|
|
78
|
+
validate_success_threshold!
|
|
79
|
+
validate_error_threshold!
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_success_threshold!
|
|
77
83
|
success_threshold = @configuration[:success_threshold]
|
|
78
|
-
error_threshold = @configuration[:error_threshold]
|
|
79
84
|
|
|
80
85
|
unless success_threshold.is_a?(Integer) && success_threshold > 0
|
|
81
86
|
err = "success_threshold must be a positive integer, got #{success_threshold}"
|
|
@@ -86,6 +91,10 @@ module Semian
|
|
|
86
91
|
|
|
87
92
|
raise_or_log_validation_required!(err)
|
|
88
93
|
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_error_threshold!
|
|
97
|
+
error_threshold = @configuration[:error_threshold]
|
|
89
98
|
|
|
90
99
|
unless error_threshold.is_a?(Integer) && error_threshold > 0
|
|
91
100
|
err = "error_threshold must be a positive integer, got #{error_threshold}"
|
|
@@ -98,11 +107,52 @@ module Semian
|
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
109
|
|
|
101
|
-
def
|
|
110
|
+
def validate_error_rate_configuration!
|
|
111
|
+
if @configuration[:adaptive_circuit_breaker] || @configuration[:dual_circuit_breaker]
|
|
112
|
+
raise_invalid_error_rate_option!("error_rate_threshold is not supported with adaptive_circuit_breaker or dual_circuit_breaker")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
require_keys!([:success_threshold, :error_timeout], @configuration)
|
|
116
|
+
validate_success_threshold!
|
|
117
|
+
validate_timeouts!(rate_mode: true)
|
|
118
|
+
|
|
119
|
+
error_rate_threshold = @configuration[:error_rate_threshold]
|
|
120
|
+
unless error_rate_threshold.is_a?(Numeric) && error_rate_threshold.real? && error_rate_threshold.finite? && error_rate_threshold > 0 && error_rate_threshold < 1
|
|
121
|
+
raise_invalid_error_rate_option!(
|
|
122
|
+
"error_rate_threshold must be a number between 0 and 1 (exclusive), got #{error_rate_threshold}" +
|
|
123
|
+
hint_format("It is the fraction of requests that must fail, e.g. 0.5 for 50%."),
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
minimum_request_volume = @configuration[:minimum_request_volume]
|
|
128
|
+
unless minimum_request_volume.nil? || (minimum_request_volume.is_a?(Integer) && minimum_request_volume > 0)
|
|
129
|
+
raise_invalid_error_rate_option!("minimum_request_volume must be a positive integer, got #{minimum_request_volume}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
error_rate_window = @configuration[:error_rate_window]
|
|
133
|
+
unless error_rate_window.nil? || (error_rate_window.is_a?(Numeric) && error_rate_window.real? && error_rate_window.finite? && error_rate_window > 0)
|
|
134
|
+
raise_invalid_error_rate_option!("error_rate_window must be a positive number, got #{error_rate_window}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ignored = [:error_threshold, :error_threshold_timeout, :error_threshold_timeout_enabled, :lumping_interval]
|
|
138
|
+
.reject { |key| @configuration[key].nil? }
|
|
139
|
+
unless ignored.empty?
|
|
140
|
+
raise_or_log_validation_required!("#{ignored.join(", ")} ignored when error_rate_threshold is set")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# These options are new, so no configuration relies on a warning: 50 instead of 0.5 would otherwise leave a circuit
|
|
145
|
+
# that can never open.
|
|
146
|
+
def raise_invalid_error_rate_option!(message)
|
|
147
|
+
raise ArgumentError, "Semian Resource #{@name}: #{message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_timeouts!(rate_mode: false)
|
|
102
151
|
error_timeout = @configuration[:error_timeout]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
legacy = rate_mode ? {} : @configuration
|
|
153
|
+
error_threshold_timeout_enabled = legacy[:error_threshold_timeout_enabled].nil? ? true : legacy[:error_threshold_timeout_enabled]
|
|
154
|
+
error_threshold = legacy[:error_threshold]
|
|
155
|
+
lumping_interval = legacy[:lumping_interval]
|
|
106
156
|
half_open_resource_timeout = @configuration[:half_open_resource_timeout]
|
|
107
157
|
exponential_backoff_error_timeout = @configuration[:exponential_backoff_error_timeout]
|
|
108
158
|
|
|
@@ -117,16 +167,16 @@ module Semian
|
|
|
117
167
|
end
|
|
118
168
|
|
|
119
169
|
# This state checks for contradictions between error_threshold_timeout_enabled and error_threshold_timeout.
|
|
120
|
-
unless error_threshold_timeout_enabled ||
|
|
121
|
-
err = "error_threshold_timeout_enabled and error_threshold_timeout must not contradict each other, got error_threshold_timeout_enabled: #{error_threshold_timeout_enabled}, error_threshold_timeout: #{
|
|
122
|
-
err += hint_format("Are you sure this is what you want? This will set error_threshold_timeout_enabled to #{error_threshold_timeout_enabled} while error_threshold_timeout is #{
|
|
170
|
+
unless error_threshold_timeout_enabled || !legacy[:error_threshold_timeout]
|
|
171
|
+
err = "error_threshold_timeout_enabled and error_threshold_timeout must not contradict each other, got error_threshold_timeout_enabled: #{error_threshold_timeout_enabled}, error_threshold_timeout: #{legacy[:error_threshold_timeout]}"
|
|
172
|
+
err += hint_format("Are you sure this is what you want? This will set error_threshold_timeout_enabled to #{error_threshold_timeout_enabled} while error_threshold_timeout is #{legacy[:error_threshold_timeout] ? "truthy" : "falsy"}")
|
|
123
173
|
|
|
124
174
|
raise_or_log_validation_required!(err)
|
|
125
175
|
end
|
|
126
176
|
|
|
127
177
|
# Only set this after we have checked the error_threshold_timeout_enabled condition
|
|
128
|
-
error_threshold_timeout =
|
|
129
|
-
unless error_threshold_timeout.is_a?(Numeric) && error_threshold_timeout > 0
|
|
178
|
+
error_threshold_timeout = legacy[:error_threshold_timeout] || error_timeout
|
|
179
|
+
unless rate_mode || (error_threshold_timeout.is_a?(Numeric) && error_threshold_timeout > 0)
|
|
130
180
|
err = "error_threshold_timeout must be a positive number, got #{error_threshold_timeout}"
|
|
131
181
|
|
|
132
182
|
if error_threshold_timeout == 0
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "circuit_breaker"
|
|
4
|
+
|
|
5
|
+
module Semian
|
|
6
|
+
class ErrorRateCircuitBreaker < CircuitBreaker # :nodoc:
|
|
7
|
+
NON_REQUEST_SCOPES = [:connection, :ping].freeze
|
|
8
|
+
# Fiber-local: the innermost running acquire, as [breaker, failure counted?, enclosing acquire, success deferred?].
|
|
9
|
+
ACQUISITION = :semian_error_rate_acquisition
|
|
10
|
+
TRIP_RATE = :semian_error_rate_trip_rate
|
|
11
|
+
|
|
12
|
+
attr_reader :error_rate_threshold, :minimum_request_volume
|
|
13
|
+
|
|
14
|
+
def initialize(name, error_rate_threshold:, implementation:, minimum_request_volume: 10, error_rate_window: 10,
|
|
15
|
+
**options)
|
|
16
|
+
@error_rate_threshold = error_rate_threshold
|
|
17
|
+
@minimum_request_volume = minimum_request_volume
|
|
18
|
+
@outcomes = implementation::TimeBucketedCounter.new(window: error_rate_window)
|
|
19
|
+
# Error that ended an acquire => did that acquire (the last one it ended) count a failure? Keyed by identity,
|
|
20
|
+
# so errors are neither modified nor retained. The values are immediates, which a WeakMap never drops.
|
|
21
|
+
@ended_acquisitions = ObjectSpace::WeakMap.new
|
|
22
|
+
@reports = Mutex.new
|
|
23
|
+
|
|
24
|
+
# error_threshold only sizes the parent's last-error window, which we use for error_timeout.
|
|
25
|
+
super(name, error_threshold: 1, implementation: implementation, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def acquire(resource = nil, scope: nil, adapter: nil, &block)
|
|
29
|
+
previous = Thread.current[ACQUISITION]
|
|
30
|
+
frame = Thread.current[ACQUISITION] = [self, false, previous]
|
|
31
|
+
transition_to_half_open(scope: scope, adapter: adapter) if transition_to_half_open?
|
|
32
|
+
raise OpenCircuitError unless request_allowed?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
result = maybe_with_half_open_resource_timeout(resource, &block)
|
|
36
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- adapters can report any raised exception after acquire unwinds
|
|
37
|
+
marks = @exceptions.any? { |exception| exception === error } && # rubocop:disable Style/CaseEquality -- as rescue matches
|
|
38
|
+
(!error.respond_to?(:marks_semian_circuits?) || error.marks_semian_circuits?)
|
|
39
|
+
@reports.synchronize do
|
|
40
|
+
@ended_acquisitions[error] = frame[1]
|
|
41
|
+
# Claimed below through the error; reports of other errors while that one is notified find it taken.
|
|
42
|
+
frame[1] = true if marks
|
|
43
|
+
end
|
|
44
|
+
mark_failed(error, scope: scope, adapter: adapter) if marks
|
|
45
|
+
raise
|
|
46
|
+
else
|
|
47
|
+
mark_success(scope: scope, adapter: adapter)
|
|
48
|
+
end
|
|
49
|
+
result
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[ACQUISITION] = previous
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A failure reported with the error that ended an acquire of this breaker belongs to that acquire, whenever and
|
|
55
|
+
# wherever it is reported. Any other report belongs to the innermost running acquire of this breaker in this
|
|
56
|
+
# fiber, if there is one. Each acquire counts at most one failure.
|
|
57
|
+
def mark_failed(error, scope: nil, adapter: nil)
|
|
58
|
+
return if already_counted?(error)
|
|
59
|
+
|
|
60
|
+
# A report made while another transition of this fiber is being notified must not disturb that one's rate.
|
|
61
|
+
previous_rate = Thread.current[TRIP_RATE]
|
|
62
|
+
begin
|
|
63
|
+
Thread.current[TRIP_RATE] = nil
|
|
64
|
+
@outcomes.record_failure
|
|
65
|
+
super
|
|
66
|
+
ensure
|
|
67
|
+
Thread.current[TRIP_RATE] = previous_rate
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mark_success(scope: nil, adapter: nil)
|
|
72
|
+
frame = Thread.current[ACQUISITION]
|
|
73
|
+
neutral = (frame && frame[0].equal?(self) && (frame[1] || frame[3])) || NON_REQUEST_SCOPES.include?(scope)
|
|
74
|
+
@outcomes.record_success unless neutral
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
# Identifies the innermost acquire that is running in this fiber, of any rate-based breaker.
|
|
80
|
+
def acquisition
|
|
81
|
+
Thread.current[ACQUISITION]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# An acquire that started after `since` was taken and is still running only admitted work whose outcome a later
|
|
85
|
+
# acquire records, so its success is neutral in the rate; a failure still counts. It remains a half-open
|
|
86
|
+
# success, as a successful connection is. Comparing acquires rather than asking the caller whether it acquired
|
|
87
|
+
# leaves nothing to go stale: no new acquire means nothing is deferred.
|
|
88
|
+
def defer_outcome(since:)
|
|
89
|
+
frame = Thread.current[ACQUISITION]
|
|
90
|
+
frame[3] = true if frame && !frame.equal?(since)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def destroy
|
|
95
|
+
@outcomes.destroy
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def error_rate
|
|
100
|
+
successes, failures = @outcomes.counts
|
|
101
|
+
total = successes + failures
|
|
102
|
+
total.zero? ? 0.0 : failures.fdiv(total)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Atomically claims the failure of the acquire that the report belongs to; concurrent reports cannot both win.
|
|
108
|
+
def already_counted?(error)
|
|
109
|
+
@reports.synchronize do
|
|
110
|
+
if @ended_acquisitions.key?(error)
|
|
111
|
+
return true if @ended_acquisitions[error]
|
|
112
|
+
|
|
113
|
+
@ended_acquisitions[error] = true
|
|
114
|
+
elsif (frame = running_acquisition)
|
|
115
|
+
return true if frame[1]
|
|
116
|
+
|
|
117
|
+
frame[1] = true
|
|
118
|
+
end
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def running_acquisition
|
|
124
|
+
frame = Thread.current[ACQUISITION]
|
|
125
|
+
frame = frame[2] while frame && !frame[0].equal?(self)
|
|
126
|
+
frame
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def error_threshold_reached?
|
|
130
|
+
successes, failures = @outcomes.counts
|
|
131
|
+
total = successes + failures
|
|
132
|
+
return false if total < @minimum_request_volume
|
|
133
|
+
|
|
134
|
+
rate = failures.fdiv(total)
|
|
135
|
+
return false if rate < @error_rate_threshold
|
|
136
|
+
|
|
137
|
+
Thread.current[TRIP_RATE] = [self, rate]
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Outcomes recorded before the circuit closes are forgotten and those recorded after are kept, even when they
|
|
142
|
+
# are recorded while it closes.
|
|
143
|
+
def close_state
|
|
144
|
+
@outcomes.clear { super }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A close that finished while this failure was being notified forgot when the last error happened; without it the
|
|
148
|
+
# circuit would stay open forever.
|
|
149
|
+
def open_state
|
|
150
|
+
@outcomes.synchronize do
|
|
151
|
+
@errors << Process.clock_gettime(Process::CLOCK_MONOTONIC) if @errors.empty?
|
|
152
|
+
super
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def transition_error_rate
|
|
157
|
+
trip = Thread.current[TRIP_RATE]
|
|
158
|
+
trip && trip[0].equal?(self) ? trip[1] : error_rate
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def notify_state_transition(new_state, scope: nil, adapter: nil)
|
|
162
|
+
Semian.notify(:state_change, self, scope, adapter, state: new_state, error_rate: transition_error_rate)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def observed_errors_log
|
|
166
|
+
" error_rate=#{transition_error_rate.round(4)}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def error_threshold_log
|
|
170
|
+
" error_rate_threshold=#{@error_rate_threshold} minimum_request_volume=#{@minimum_request_volume}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/semian/grpc.rb
CHANGED
|
@@ -108,9 +108,15 @@ module Semian
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def acquire_semian_resource_grpc(scope:)
|
|
111
|
+
enclosing = ErrorRateCircuitBreaker.acquisition
|
|
111
112
|
acquire_semian_resource(adapter: :grpc, scope: scope) do
|
|
112
113
|
result = yield
|
|
113
|
-
|
|
114
|
+
if result.is_a?(::GRPC::ActiveCall::Operation)
|
|
115
|
+
handle_operation(result, scope)
|
|
116
|
+
# Creating the operation ran no RPC: only its execution is an outcome for a rate-based breaker. A nested
|
|
117
|
+
# call acquired nothing, so it defers nothing.
|
|
118
|
+
ErrorRateCircuitBreaker.defer_outcome(since: enclosing)
|
|
119
|
+
end
|
|
114
120
|
result
|
|
115
121
|
end
|
|
116
122
|
end
|
data/lib/semian/resource.rb
CHANGED
|
@@ -21,6 +21,8 @@ module Semian
|
|
|
21
21
|
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
22
22
|
raise TypeError, "name must be a string or symbol, got: #{name.class}"
|
|
23
23
|
end
|
|
24
|
+
raise ArgumentError, "name must not contain null bytes" if name.to_s.b.include?("\0")
|
|
25
|
+
raise ArgumentError, "namespace must not contain null bytes" if Semian.namespace.to_s.b.include?("\0")
|
|
24
26
|
|
|
25
27
|
if Semian.semaphores_enabled?
|
|
26
28
|
if respond_to?(:initialize_semaphore)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Semian
|
|
4
|
+
module Simple
|
|
5
|
+
# Counts successes and failures over a trailing window, one bucket per second, oldest first.
|
|
6
|
+
# The window covers between `window - 1` and `window` seconds of history.
|
|
7
|
+
# Totals are kept next to the buckets, so recording and reading cost the same whatever the window.
|
|
8
|
+
class TimeBucketedCounter # :nodoc:
|
|
9
|
+
def initialize(window:)
|
|
10
|
+
@size = window.ceil
|
|
11
|
+
clear
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record_success
|
|
15
|
+
advance
|
|
16
|
+
@successes[-1] += 1
|
|
17
|
+
@total_successes += 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_failure
|
|
21
|
+
advance
|
|
22
|
+
@failures[-1] += 1
|
|
23
|
+
@total_failures += 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def counts
|
|
27
|
+
advance
|
|
28
|
+
[@total_successes, @total_failures]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Clears after running the block; a thread-safe counter records nothing in between.
|
|
32
|
+
def clear
|
|
33
|
+
yield if block_given?
|
|
34
|
+
@successes = Array.new(@size, 0)
|
|
35
|
+
@failures = Array.new(@size, 0)
|
|
36
|
+
@total_successes = @total_failures = 0
|
|
37
|
+
@epoch = current_second
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def synchronize
|
|
42
|
+
yield
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def destroy
|
|
46
|
+
clear
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Once per second at most, the buckets that left the window become its newest, empty ones. That touches every
|
|
52
|
+
# bucket, but in C and whatever the gap, so the first call after an idle period stays cheap. A clock that goes
|
|
53
|
+
# backwards keeps recording into the newest bucket.
|
|
54
|
+
def advance
|
|
55
|
+
now = current_second
|
|
56
|
+
return if now <= @epoch
|
|
57
|
+
|
|
58
|
+
elapsed = [now - @epoch, @size].min
|
|
59
|
+
@total_successes = @successes.rotate!(elapsed).fill(0, -elapsed).sum
|
|
60
|
+
@total_failures = @failures.rotate!(elapsed).fill(0, -elapsed).sum
|
|
61
|
+
@epoch = now
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def current_second
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module ThreadSafe
|
|
71
|
+
class TimeBucketedCounter < Simple::TimeBucketedCounter
|
|
72
|
+
def initialize(**)
|
|
73
|
+
@lock = Mutex.new
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def record_success
|
|
78
|
+
@lock.synchronize { super }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def record_failure
|
|
82
|
+
@lock.synchronize { super }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def counts
|
|
86
|
+
@lock.synchronize { super }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def clear(&block)
|
|
90
|
+
@lock.synchronize { super }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def synchronize(&block)
|
|
94
|
+
@lock.synchronize(&block)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/semian/version.rb
CHANGED
data/lib/semian.rb
CHANGED
|
@@ -11,11 +11,13 @@ require "semian/instrumentable"
|
|
|
11
11
|
require "semian/platform"
|
|
12
12
|
require "semian/resource"
|
|
13
13
|
require "semian/circuit_breaker"
|
|
14
|
+
require "semian/error_rate_circuit_breaker"
|
|
14
15
|
require "semian/adaptive_circuit_breaker"
|
|
15
16
|
require "semian/dual_circuit_breaker"
|
|
16
17
|
require "semian/protected_resource"
|
|
17
18
|
require "semian/unprotected_resource"
|
|
18
19
|
require "semian/simple_sliding_window"
|
|
20
|
+
require "semian/simple_time_bucketed_counter"
|
|
19
21
|
require "semian/simple_integer"
|
|
20
22
|
require "semian/simple_state"
|
|
21
23
|
require "semian/lru_hash"
|
|
@@ -199,6 +201,18 @@ module Semian
|
|
|
199
201
|
# +exceptions+: An array of exception classes that should be accounted as resource errors. Default [].
|
|
200
202
|
# (circuit breaker)
|
|
201
203
|
#
|
|
204
|
+
# +error_rate_threshold+: Open the circuit when this fraction (between 0 and 1, exclusive) of the requests in the
|
|
205
|
+
# last +error_rate_window+ seconds failed. Replaces +error_threshold+, +error_threshold_timeout+,
|
|
206
|
+
# +error_threshold_timeout_enabled+ and +lumping_interval+, which are ignored when it is set. Successes are tracked
|
|
207
|
+
# while closed. Not supported with the adaptive or dual circuit breakers. An invalid value for this option,
|
|
208
|
+
# +minimum_request_volume+ or +error_rate_window+ raises ArgumentError regardless of +force_config_validation+.
|
|
209
|
+
# (circuit breaker)
|
|
210
|
+
#
|
|
211
|
+
# +minimum_request_volume+: The number of requests that must have happened in the window before the error rate is
|
|
212
|
+
# considered. Default 10. (error_rate_threshold)
|
|
213
|
+
#
|
|
214
|
+
# +error_rate_window+: Seconds of history, rounded up to whole seconds. Default 10. (error_rate_threshold)
|
|
215
|
+
#
|
|
202
216
|
# # +exponential_backoff_error_timeout+: When set to true, instead of opening the circuit for the full
|
|
203
217
|
# error_timeout duration, it starts with a smaller timeout and increases exponentially on each subsequent
|
|
204
218
|
# opening up to error_timeout. This helps avoid over-opening the circuit for temporary issues.
|
|
@@ -227,6 +241,8 @@ module Semian
|
|
|
227
241
|
#
|
|
228
242
|
# Returns the registered resource.
|
|
229
243
|
def register(name, **options)
|
|
244
|
+
raise ArgumentError, "name must not contain null bytes" if name.to_s.b.include?("\0")
|
|
245
|
+
|
|
230
246
|
return UnprotectedResource.new(name) if ENV.key?("SEMIAN_DISABLED")
|
|
231
247
|
|
|
232
248
|
# Validate configuration before proceeding
|
|
@@ -374,6 +390,7 @@ module Semian
|
|
|
374
390
|
def create_circuit_breaker(name, is_child: false, **options)
|
|
375
391
|
return if ENV.key?("SEMIAN_CIRCUIT_BREAKER_DISABLED")
|
|
376
392
|
return unless options.fetch(:circuit_breaker, true)
|
|
393
|
+
return create_error_rate_circuit_breaker(name, **options) if !options[:error_rate_threshold].nil? && !is_child
|
|
377
394
|
|
|
378
395
|
exceptions = options[:exceptions] || []
|
|
379
396
|
cls = is_child ? DualCircuitBreaker::ChildClassicCircuitBreaker : CircuitBreaker
|
|
@@ -402,6 +419,25 @@ module Semian
|
|
|
402
419
|
)
|
|
403
420
|
end
|
|
404
421
|
|
|
422
|
+
def create_error_rate_circuit_breaker(name, **options)
|
|
423
|
+
ErrorRateCircuitBreaker.new(
|
|
424
|
+
name,
|
|
425
|
+
exceptions: Array(options[:exceptions]) + [::Semian::BaseError],
|
|
426
|
+
implementation: implementation(**options),
|
|
427
|
+
**options.slice(
|
|
428
|
+
:error_rate_threshold,
|
|
429
|
+
:minimum_request_volume,
|
|
430
|
+
:error_rate_window,
|
|
431
|
+
:success_threshold,
|
|
432
|
+
:error_timeout,
|
|
433
|
+
:half_open_resource_timeout,
|
|
434
|
+
:exponential_backoff_error_timeout,
|
|
435
|
+
:exponential_backoff_initial_timeout,
|
|
436
|
+
:exponential_backoff_multiplier,
|
|
437
|
+
).compact,
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
|
|
405
441
|
def implementation(**options)
|
|
406
442
|
# thread_safety_disabled will be replaced by a global setting
|
|
407
443
|
# Semian is thread safe by default. It is possible
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: semian
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.28.
|
|
4
|
+
version: 0.28.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Scott Francis
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- lib/semian/circuit_breaker_behaviour.rb
|
|
57
57
|
- lib/semian/configuration_validator.rb
|
|
58
58
|
- lib/semian/dual_circuit_breaker.rb
|
|
59
|
+
- lib/semian/error_rate_circuit_breaker.rb
|
|
59
60
|
- lib/semian/grpc.rb
|
|
60
61
|
- lib/semian/instrumentable.rb
|
|
61
62
|
- lib/semian/lru_hash.rb
|
|
@@ -74,6 +75,7 @@ files:
|
|
|
74
75
|
- lib/semian/simple_integer.rb
|
|
75
76
|
- lib/semian/simple_sliding_window.rb
|
|
76
77
|
- lib/semian/simple_state.rb
|
|
78
|
+
- lib/semian/simple_time_bucketed_counter.rb
|
|
77
79
|
- lib/semian/unprotected_resource.rb
|
|
78
80
|
- lib/semian/version.rb
|
|
79
81
|
homepage: https://github.com/shopify/semian
|
|
@@ -100,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
100
102
|
- !ruby/object:Gem::Version
|
|
101
103
|
version: '0'
|
|
102
104
|
requirements: []
|
|
103
|
-
rubygems_version: 4.0.
|
|
105
|
+
rubygems_version: 4.0.23
|
|
104
106
|
specification_version: 4
|
|
105
107
|
summary: Bulkheading for Ruby with SysV semaphores
|
|
106
108
|
test_files: []
|