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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65071f9b9f241c4306a81f24e696220928dd7fd42fe50f9664f51212af37be27
4
- data.tar.gz: aea78d645e703f42516e8a507887184a85ef98d547312646f609d6d145c9c2ee
3
+ metadata.gz: c6794eae127eb46d682ae8b1e26932eab02aa23256eec0f69b0e08302d016ef1
4
+ data.tar.gz: 15d40e05ef6f272bc011fa7a0da2baeefbaba9834542057c139011a213ce5c86
5
5
  SHA512:
6
- metadata.gz: f628d7359a24fe78fc5ae8885f81e181d1e1a6da1b2956079fdc19e48df63d78f76de29d0cf681ea8c9e6bfc8952b23ab69e86d7313a49628c52ed6b482b49b2
7
- data.tar.gz: f166272364381bdc17db8bfc6bf4b62827ae0ac7c99803b1ab6617acc80244d0163dd2a54b7f704501fd4790135fa2f7fbc90b181a8f656d05ecbff83e9bc7c6
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)
@@ -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
- c_id_str = rb_id2name(rb_to_id(id));
342
- } else if (TYPE(id) == T_STRING) {
343
- c_id_str = RSTRING_PTR(id);
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 c_id_str;
345
+ return StringValueCStr(id);
347
346
  }
348
347
 
349
348
  static double
@@ -79,11 +79,10 @@ module Semian
79
79
  end
80
80
 
81
81
  def mark_resource_as_acquired
82
- previous = @resource_acquired
83
82
  @resource_acquired = true
84
83
  yield
85
84
  ensure
86
- @resource_acquired = previous
85
+ @resource_acquired = false
87
86
  end
88
87
  end
89
88
  end
@@ -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} error_count=#{@errors.size}"
176
+ str += " success_count=#{@successes.value}#{observed_errors_log}"
169
177
  str += " success_count_threshold=#{@success_count_threshold}"
170
- str += " error_count_threshold=#{@error_count_threshold}"
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 validate_timeouts!
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
- error_threshold_timeout_enabled = @configuration[:error_threshold_timeout_enabled].nil? ? true : @configuration[:error_threshold_timeout_enabled]
104
- error_threshold = @configuration[:error_threshold]
105
- lumping_interval = @configuration[:lumping_interval]
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 || !@configuration[:error_threshold_timeout]
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: #{@configuration[: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 #{@configuration[:error_threshold_timeout] ? "truthy" : "falsy"}")
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 = @configuration[:error_threshold_timeout] || error_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
- handle_operation(result, scope) if result.is_a?(::GRPC::ActiveCall::Operation)
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Semian
4
- VERSION = "0.28.3"
4
+ VERSION = "0.28.5"
5
5
  end
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.3
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.19
105
+ rubygems_version: 4.0.23
104
106
  specification_version: 4
105
107
  summary: Bulkheading for Ruby with SysV semaphores
106
108
  test_files: []