faulty 0.6.0 → 0.8.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
  SHA256:
3
- metadata.gz: 1fe02d7203e9d26a8f859d55efa9ffbe099ab6c2cf025fe7ca831c6ccdfd684f
4
- data.tar.gz: bed9fe698b66b367dab9066ffec98567c60fd8dd57f9ea3ed7ea3a2a3a4a3b48
3
+ metadata.gz: 815cc1b7ac571e9dc69546efd1b3892795a5ef284cc43448b8a6c1feadcf49c3
4
+ data.tar.gz: 575c2e0e7abb413f8866a0e159129a6f1ecc76149e8d8cb97c65c7ffd9913ba9
5
5
  SHA512:
6
- metadata.gz: c69668b5b99fc2dad979031bd3a61e61d608c8f1c8ba095924709f3cea1b653a4116edcc45a72fb4d3b72e367a3e8b56d7c3cd3d56ed03aadb0ae48be6f3d7bc
7
- data.tar.gz: 97d9ff9d9f6bb368ca395b9338bc52b83ef545f67a1f412729b52aef997f6187d57f28fbfda989584e7d26e5e300127f075699f60b888746eaa0c48a2e3b3656
6
+ metadata.gz: 6effebfa578717256dc4ef7ec1c59a5f694eff2c78b21edb1649375a3f7ab62bbad597e3e4a0dc63cd729b4e66b4079ea9cbfd72795a1bfd34a9fab489415847
7
+ data.tar.gz: 2ba730eaa396ce24cb9d4eaf3a35db84180eb5c8ba4e3f3bc803d389436870480bd542aa57c93089ef33b77afe3f9b0c8614f972d232b971d8310f2dd067eb39
data/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## Release v0.8.0
2
+
3
+ * Store circuit options in the backend when run #34 justinhoward
4
+
5
+ ### Breaking Changes
6
+
7
+ * Added #get_options and #set_options to Faulty::Storage::Interface.
8
+ These will need to be added to any custom backends
9
+ * Faulty::Storage::Interface#reset now requires removing options in
10
+ addition to other stored values
11
+ * Circuit options will now be supplemented by stored options until they
12
+ are run. This is technically a breaking change in behavior, although
13
+ in most cases this should cause the expected result.
14
+ * Circuits are not memoized until they are run. Subsequent calls
15
+ to Faulty#circuit can return different instances if the circuit is
16
+ not run. However, once run, options are synchronized between
17
+ instances, so likely this will not be a breaking change for most
18
+ cases.
19
+
20
+ ## Release v0.7.2
21
+
22
+ * Add Faulty.disable! for disabling globally #38 justinhoward
23
+ * Suppress circuit_success for proxy circuits #39 justinhoward
24
+
25
+ ## Release v0.7.1
26
+
27
+ * Fix success event crash in log listener #37 justinhoward
28
+
29
+ ## Release v0.7.0
30
+
31
+ * Add initial benchmarks and performance improvements #36 justinhoward
32
+
33
+ ### Breaking Changes
34
+
35
+ The `circuit_success` event no longer contains the status value. Computing this
36
+ value was causing performance problems.
37
+
1
38
  ## Release v0.6.0
2
39
 
3
40
  * docs, use correct state in description for skipped event #27 senny
data/README.md CHANGED
@@ -88,6 +88,7 @@ Also see "Release It!: Design and Deploy Production-Ready Software" by
88
88
  + [CallbackListener](#callbacklistener)
89
89
  + [Other Built-in Listeners](#other-built-in-listeners)
90
90
  + [Custom Listeners](#custom-listeners)
91
+ * [Disabling Faulty Globally](#disabling-faulty-globally)
91
92
  * [How it Works](#how-it-works)
92
93
  + [Caching](#caching)
93
94
  + [Fault Tolerance](#fault-tolerance)
@@ -607,7 +608,7 @@ faulty.circuit('standalone_circuit')
607
608
  ```
608
609
 
609
610
  Calling `#circuit` on the instance still has the same memoization behavior that
610
- `Faulty.circuit` has, so subsequent calls to the same circuit will return a
611
+ `Faulty.circuit` has, so subsequent runs for the same circuit will use a
611
612
  memoized circuit object.
612
613
 
613
614
 
@@ -732,7 +733,7 @@ Both options can even be specified together.
732
733
  ```ruby
733
734
  Faulty.circuit(
734
735
  'api',
735
- errors: [ActiveRecord::ActiveRecordError]
736
+ errors: [ActiveRecord::ActiveRecordError],
736
737
  exclude: [ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique]
737
738
  ).run do
738
739
  # This only captures ActiveRecord::ActiveRecordError errors, but not
@@ -812,7 +813,7 @@ the options are retained within the context of each instance. All options given
812
813
  after the first call to `Faulty.circuit` (or `Faulty#circuit`) are ignored.
813
814
 
814
815
  ```ruby
815
- Faulty.circuit('api', rate_threshold: 0.7)
816
+ Faulty.circuit('api', rate_threshold: 0.7).run { api.call }
816
817
 
817
818
  # These options are ignored since with already initialized the circuit
818
819
  circuit = Faulty.circuit('api', rate_threshold: 0.3)
@@ -820,7 +821,7 @@ circuit.options.rate_threshold # => 0.7
820
821
  ```
821
822
 
822
823
  This is because the circuit objects themselves are internally memoized, and are
823
- read-only once created.
824
+ read-only once they are run.
824
825
 
825
826
  The following example represents the defaults for a new circuit:
826
827
 
@@ -1064,8 +1065,7 @@ events. The full list of events is available from
1064
1065
  half-open. Payload: `circuit`, `error`.
1065
1066
  - `circuit_skipped` - A circuit execution was skipped because the circuit is
1066
1067
  open. Payload: `circuit`
1067
- - `circuit_success` - A circuit execution was successful. Payload: `circuit`,
1068
- `status`
1068
+ - `circuit_success` - A circuit execution was successful. Payload: `circuit`
1069
1069
  - `storage_failure` - A storage backend raised an error. Payload `circuit` (can
1070
1070
  be nil), `action`, `error`
1071
1071
 
@@ -1125,6 +1125,21 @@ Faulty.init do |config|
1125
1125
  end
1126
1126
  ```
1127
1127
 
1128
+ ## Disabling Faulty Globally
1129
+
1130
+ For testing or for some environments, you may wish to disable Faulty circuits
1131
+ at a global level.
1132
+
1133
+ ```ruby
1134
+ Faulty.disable!
1135
+ ```
1136
+
1137
+ This only affects the process where you run the `#disable!` method and it does
1138
+ not affect the stored state of circuits.
1139
+
1140
+ Faulty will **still use the cache** even when disabled. If you also want to
1141
+ disable the cache, configure Faulty to use a `Faulty::Cache::Null` cache.
1142
+
1128
1143
  ## How it Works
1129
1144
 
1130
1145
  Faulty implements a version of circuit breakers inspired by "Release It!: Design
data/bin/benchmark ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'benchmark'
6
+ require 'faulty'
7
+
8
+ n = 100_000
9
+
10
+ puts "Starting circuit benchmarks with #{n} iterations each\n\n"
11
+
12
+ Benchmark.bm(25) do |b|
13
+ in_memory = Faulty.new(listeners: [])
14
+ b.report('memory storage') do
15
+ n.times { in_memory.circuit(:memory).run { true } }
16
+ end
17
+
18
+ b.report('memory storage failures') do
19
+ n.times do
20
+ begin
21
+ in_memory.circuit(:memory_fail, sample_threshold: n + 1).run { raise 'fail' }
22
+ rescue StandardError
23
+ # Expected to raise here
24
+ end
25
+ end
26
+ end
27
+
28
+ in_memory_large = Faulty.new(listeners: [], storage: Faulty::Storage::Memory.new(max_sample_size: 1000))
29
+ b.report('large memory storage') do
30
+ n.times { in_memory_large.circuit(:memory_large).run { true } }
31
+ end
32
+ end
33
+
34
+ n = 1_000_000
35
+
36
+ puts "\n\nStarting extra benchmarks with #{n} iterations each\n\n"
37
+
38
+ Benchmark.bm(25) do |b|
39
+ in_memory = Faulty.new(listeners: [])
40
+
41
+ log_listener = Faulty::Events::LogListener.new(Logger.new(File::NULL))
42
+ log_circuit = in_memory.circuit(:log_listener)
43
+ log_status = log_circuit.status
44
+ b.report('log listener success') do
45
+ n.times { log_listener.handle(:circuit_success, circuit: log_circuit, status: log_status) }
46
+ end
47
+
48
+ log_error = StandardError.new('test error')
49
+ b.report('log listener failure') do
50
+ n.times { log_listener.handle(:circuit_failure, error: log_error, circuit: log_circuit, status: log_status) }
51
+ end
52
+ end
data/faulty.gemspec CHANGED
@@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
26
26
  # Only essential development tools and dependencies go here.
27
27
  # Other non-essential development dependencies go in the Gemfile.
28
28
  spec.add_development_dependency 'connection_pool', '~> 2.0'
29
+ spec.add_development_dependency 'json'
29
30
  spec.add_development_dependency 'redis', '>= 3.0'
30
31
  spec.add_development_dependency 'rspec', '~> 3.8'
31
32
  # 0.81 is the last rubocop version with Ruby 2.3 support
@@ -21,8 +21,6 @@ class Faulty
21
21
  ) do
22
22
  include ImmutableOptions
23
23
 
24
- private
25
-
26
24
  def required
27
25
  %i[notifier]
28
26
  end
@@ -26,14 +26,12 @@ class Faulty
26
26
  ) do
27
27
  include ImmutableOptions
28
28
 
29
- private
30
-
31
29
  def finalize
32
30
  raise ArgumentError, 'The circuit or notifier option must be given' unless notifier || circuit
33
31
 
34
32
  self.circuit ||= Circuit.new(
35
33
  Faulty::Storage::CircuitProxy.name,
36
- notifier: notifier,
34
+ notifier: Events::FilterNotifier.new(notifier, exclude: %i[circuit_success]),
37
35
  cache: Cache::Null.new
38
36
  )
39
37
  end
@@ -22,8 +22,6 @@ class Faulty
22
22
  ) do
23
23
  include ImmutableOptions
24
24
 
25
- private
26
-
27
25
  def required
28
26
  %i[notifier]
29
27
  end
@@ -27,7 +27,6 @@ class Faulty
27
27
  CACHE_REFRESH_SUFFIX = '.faulty_refresh'
28
28
 
29
29
  attr_reader :name
30
- attr_reader :options
31
30
 
32
31
  # Options for {Circuit}
33
32
  #
@@ -82,6 +81,9 @@ class Faulty
82
81
  # @return [Storage::Interface] The storage backend. Default
83
82
  # `Storage::Memory.new`. Unlike {Faulty#initialize}, this is not wrapped
84
83
  # in {Storage::AutoWire} by default.
84
+ # @!attribute [r] registry
85
+ # @return [CircuitRegistry] For use by {Faulty} instances to facilitate
86
+ # memoization of circuits.
85
87
  Options = Struct.new(
86
88
  :cache_expires_in,
87
89
  :cache_refreshes_after,
@@ -95,11 +97,22 @@ class Faulty
95
97
  :exclude,
96
98
  :cache,
97
99
  :notifier,
98
- :storage
100
+ :storage,
101
+ :registry
99
102
  ) do
100
103
  include ImmutableOptions
101
104
 
102
- private
105
+ # Get the options stored in the storage backend
106
+ #
107
+ # @return [Hash] A hash of stored options
108
+ def for_storage
109
+ {
110
+ cool_down: cool_down,
111
+ evaluation_window: evaluation_window,
112
+ rate_threshold: rate_threshold,
113
+ sample_threshold: sample_threshold
114
+ }
115
+ end
103
116
 
104
117
  def defaults
105
118
  {
@@ -150,7 +163,59 @@ class Faulty
150
163
  raise ArgumentError, 'name must be a String' unless name.is_a?(String)
151
164
 
152
165
  @name = name
153
- @options = Options.new(options, &block)
166
+ @given_options = Options.new(options, &block)
167
+ @pulled_options = nil
168
+ @options_pushed = false
169
+ end
170
+
171
+ # Get the options for this circuit
172
+ #
173
+ # If this circuit has been run, these will the options exactly as given
174
+ # to {.new}. However, if this circuit has not yet been run, these options
175
+ # will be supplemented by the last-known options from the circuit storage.
176
+ #
177
+ # Once a circuit is run, the given options are pushed to circuit storage to
178
+ # be persisted.
179
+ #
180
+ # This is to allow circuit objects to behave as expected in contexts where
181
+ # the exact options for a circuit are not known such as an admin dashboard
182
+ # or in a debug console.
183
+ #
184
+ # Note that this distinction isn't usually important unless using
185
+ # distributed circuit storage like the Redis storage backend.
186
+ #
187
+ # @example
188
+ # Faulty.circuit('api', cool_down: 5).run { api.users }
189
+ # # This status will be calculated using the cool_down of 5 because
190
+ # # the circuit was already run
191
+ # Faulty.circuit('api').status
192
+ #
193
+ # @example
194
+ # # This status will be calculated using the cool_down in circuit storage
195
+ # # if it is available instead of using the default value.
196
+ # Faulty.circuit('api').status
197
+ #
198
+ # @example
199
+ # # For typical usage, this behaves as expected, but note that it's
200
+ # # possible to run into some unexpected behavior when creating circuits
201
+ # # in unusual ways.
202
+ #
203
+ # # For example, this status will be calculated using the cool_down in
204
+ # # circuit storage if it is available despite the given value of 5.
205
+ # Faulty.circuit('api', cool_down: 5).status
206
+ # Faulty.circuit('api').run { api.users }
207
+ # # However now, after the circuit is run, status will be calculated
208
+ # # using the given cool_down of 5 and the value of 5 will be pushed
209
+ # # permanently to circuit storage
210
+ # Faulty.circuit('api').status
211
+ #
212
+ # @return [Options] The resolved options
213
+ def options
214
+ return @given_options if @options_pushed
215
+ return @pulled_options if @pulled_options
216
+
217
+ stored = @given_options.storage.get_options(self)
218
+ @pulled_options = stored ? @given_options.dup_with(stored) : @given_options
154
219
  end
155
220
 
156
221
  # Run the circuit as with {#run}, but return a {Result}
@@ -204,6 +269,8 @@ class Faulty
204
269
  # a second cool down period. However, if the circuit completes successfully,
205
270
  # the circuit will be closed and reset to its initial state.
206
271
  #
272
+ # When this is run, the given options are persisted to the storage backend.
273
+ #
207
274
  # @param cache [String, nil] A cache key, or nil if caching is not desired
208
275
  # @yield The block to protect with this circuit
209
276
  # @raise If the block raises an error not in the error list, or if the error
@@ -216,6 +283,7 @@ class Faulty
216
283
  # circuit to trip
217
284
  # @return The return value of the block
218
285
  def run(cache: nil, &block)
286
+ push_options
219
287
  cached_value = cache_read(cache)
220
288
  # return cached unless cached.nil?
221
289
  return cached_value if !cached_value.nil? && !cache_should_refresh?(cache)
@@ -256,6 +324,8 @@ class Faulty
256
324
  #
257
325
  # @return [self]
258
326
  def reset!
327
+ @options_pushed = false
328
+ @pulled_options = nil
259
329
  storage.reset(self)
260
330
  self
261
331
  end
@@ -284,6 +354,25 @@ class Faulty
284
354
 
285
355
  private
286
356
 
357
+ # Push the given options to circuit storage and set those as the current
358
+ # options
359
+ #
360
+ # @return [void]
361
+ def push_options
362
+ return if @options_pushed
363
+
364
+ @pulled_options = nil
365
+ @options_pushed = true
366
+ resolved = options.registry&.resolve(self)
367
+ if resolved
368
+ # If another circuit instance was resolved, don't store these options
369
+ # Instead, copy the options from that circuit as if we were given those
370
+ @given_options = resolved.options
371
+ else
372
+ storage.set_options(self, @given_options.for_storage)
373
+ end
374
+ end
375
+
287
376
  # Process a skipped run
288
377
  #
289
378
  # @param cached_value The cached value if one is available
@@ -319,12 +408,10 @@ class Faulty
319
408
 
320
409
  # @return [Boolean] True if the circuit transitioned to closed
321
410
  def success!(status)
322
- entries = storage.entry(self, Faulty.current_time, true)
323
- status = Status.from_entries(entries, **status.to_h)
324
- closed = false
325
- closed = close! if should_close?(status)
411
+ storage.entry(self, Faulty.current_time, true)
412
+ closed = close! if status.half_open?
326
413
 
327
- options.notifier.notify(:circuit_success, circuit: self, status: status)
414
+ options.notifier.notify(:circuit_success, circuit: self)
328
415
  closed
329
416
  end
330
417
 
@@ -370,16 +457,6 @@ class Faulty
370
457
  closed
371
458
  end
372
459
 
373
- # Test whether we should close after a successful run
374
- #
375
- # Currently this is always true if the circuit is half-open, which is the
376
- # traditional behavior for a circuit-breaker
377
- #
378
- # @return [Boolean] True if we should close the circuit from half-open
379
- def should_close?(status)
380
- status.half_open?
381
- end
382
-
383
460
  # Read from the cache if it is configured
384
461
  #
385
462
  # @param key The key to read from the cache
@@ -443,9 +520,13 @@ class Faulty
443
520
 
444
521
  # Alias to the storage engine from options
445
522
  #
523
+ # Always returns the value from the given options
524
+ #
446
525
  # @return [Storage::Interface]
447
526
  def storage
448
- options.storage
527
+ return Faulty::Storage::Null.new if Faulty.disabled?
528
+
529
+ @given_options.storage
449
530
  end
450
531
  end
451
532
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ # Used by Faulty instances to track and memoize Circuits
5
+ #
6
+ # Whenever a circuit is requested by `Faulty#circuit`, it calls
7
+ # `#retrieve`. That will return a resolved circuit if there is one, or
8
+ # otherwise, it will create a new circuit instance.
9
+ #
10
+ # Once any circuit is run, the circuit calls `#resolve`. That saves
11
+ # the instance into the registry. Any calls to `#retrieve` after
12
+ # the circuit is resolved will result in the same instance being returned.
13
+ #
14
+ # However, before a circuit is resolved, calling `Faulty#circuit` will result
15
+ # in a new Circuit instance being created for every call. If multiples of
16
+ # these call `resolve`, only the first one will "win" and be memoized.
17
+ class CircuitRegistry
18
+ def initialize(circuit_options)
19
+ @circuit_options = circuit_options
20
+ @circuit_options[:registry] = self
21
+ @circuits = Concurrent::Map.new
22
+ end
23
+
24
+ # Retrieve a memoized circuit with the same name, or if none is yet
25
+ # resolved, create a new one.
26
+ #
27
+ # @param name [String] The name of the circuit
28
+ # @param options [Hash] Options for {Circuit::Options}
29
+ # @yield [Circuit::Options] For setting options in a block
30
+ # @return [Circuit] The new or memoized circuit
31
+ def retrieve(name, options, &block)
32
+ @circuits.fetch(name) do
33
+ options = @circuit_options.merge(options)
34
+ Circuit.new(name, **options, &block)
35
+ end
36
+ end
37
+
38
+ # Save and memoize the given circuit as the "canonical" instance for
39
+ # the circuit name
40
+ #
41
+ # If the name is already resolved, this will be ignored
42
+ #
43
+ # @return [Circuit, nil] If this circuit name is already resolved, the
44
+ # already-resolved circuit
45
+ def resolve(circuit)
46
+ @circuits.put_if_absent(circuit.name, circuit)
47
+ end
48
+ end
49
+ end
@@ -23,7 +23,7 @@ class Faulty
23
23
  # @param (see ListenerInterface#handle)
24
24
  # @return [void]
25
25
  def handle(event, payload)
26
- return unless EVENTS.include?(event)
26
+ return unless EVENT_SET.include?(event)
27
27
  return unless @handlers.key?(event)
28
28
 
29
29
  @handlers[event].each do |handler|
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Events
5
+ # Wraps a Notifier and filters events by name
6
+ class FilterNotifier
7
+ # @param notifier [Notifier] The internal notifier to filter events for
8
+ # @param events [Array, nil] An array of events to allow. If nil, all
9
+ # {EVENTS} will be used
10
+ # @param exclude [Array, nil] An array of events to disallow. If nil,
11
+ # no events will be disallowed. Takes priority over `events`.
12
+ def initialize(notifier, events: nil, exclude: nil)
13
+ @notifier = notifier
14
+ @events = Set.new(events || EVENTS)
15
+ exclude&.each { |e| @events.delete(e) }
16
+ end
17
+
18
+ # Notify all listeners of an event
19
+ #
20
+ # If a listener raises an error while handling an event, that error will
21
+ # be captured and written to STDERR.
22
+ #
23
+ # @param (see Notifier)
24
+ def notify(event, payload)
25
+ return unless @events.include?(event)
26
+
27
+ @notifier.notify(event, payload)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -8,11 +8,19 @@ class Faulty
8
8
  #
9
9
  # The honeybadger gem must be available.
10
10
  class HoneybadgerListener
11
+ HONEYBADGER_EVENTS = Set[
12
+ :circuit_failure,
13
+ :circuit_opened,
14
+ :circuit_reopened,
15
+ :cache_failure,
16
+ :storage_failure
17
+ ].freeze
18
+
11
19
  # (see ListenerInterface#handle)
12
20
  def handle(event, payload)
13
- return unless EVENTS.include?(event)
21
+ return unless HONEYBADGER_EVENTS.include?(event)
14
22
 
15
- send(event, payload) if respond_to?(event, true)
23
+ send(event, payload)
16
24
  end
17
25
 
18
26
  private
@@ -16,9 +16,9 @@ class Faulty
16
16
 
17
17
  # (see ListenerInterface#handle)
18
18
  def handle(event, payload)
19
- return unless EVENTS.include?(event)
19
+ return unless EVENT_SET.include?(event)
20
20
 
21
- send(event, payload) if respond_to?(event, true)
21
+ send(event, payload)
22
22
  end
23
23
 
24
24
  private
@@ -36,7 +36,7 @@ class Faulty
36
36
  end
37
37
 
38
38
  def circuit_success(payload)
39
- log(:debug, 'Circuit succeeded', payload[:circuit].name, state: payload[:status].state)
39
+ log(:debug, 'Circuit succeeded', payload[:circuit].name)
40
40
  end
41
41
 
42
42
  def circuit_failure(payload)
@@ -79,8 +79,12 @@ class Faulty
79
79
  end
80
80
 
81
81
  def log(level, msg, action, extra = {})
82
- extra_str = extra.map { |k, v| "#{k}=#{v}" }.join(' ')
83
- logger.public_send(level, "#{msg}: #{action} #{extra_str}")
82
+ @logger.public_send(level) do
83
+ extra_str = extra.map { |k, v| "#{k}=#{v}" }.join(' ')
84
+ extra_str = " #{extra_str}" unless extra_str.empty?
85
+
86
+ "#{msg}: #{action}#{extra_str}"
87
+ end
84
88
  end
85
89
  end
86
90
  end
data/lib/faulty/events.rb CHANGED
@@ -17,6 +17,8 @@ class Faulty
17
17
  circuit_success
18
18
  storage_failure
19
19
  ].freeze
20
+
21
+ EVENT_SET = Set.new(EVENTS)
20
22
  end
21
23
  end
22
24
 
@@ -24,3 +26,4 @@ require 'faulty/events/callback_listener'
24
26
  require 'faulty/events/honeybadger_listener'
25
27
  require 'faulty/events/log_listener'
26
28
  require 'faulty/events/notifier'
29
+ require 'faulty/events/filter_notifier'
@@ -5,18 +5,23 @@ class Faulty
5
5
  module ImmutableOptions
6
6
  # @param hash [Hash] A hash of attributes to initialize with
7
7
  # @yield [self] Yields itself to the block to set options before freezing
8
- def initialize(hash)
9
- defaults.merge(hash).each { |key, value| self[key] = value }
8
+ def initialize(hash, &block)
9
+ setup(defaults.merge(hash), &block)
10
+ end
11
+
12
+ def dup_with(hash, &block)
13
+ dup.setup(hash, &block)
14
+ end
15
+
16
+ def setup(hash)
17
+ hash&.each { |key, value| self[key] = value }
10
18
  yield self if block_given?
11
19
  finalize
12
- required.each do |key|
13
- raise ArgumentError, "Missing required attribute #{key}" if self[key].nil?
14
- end
20
+ guard_required!
15
21
  freeze
22
+ self
16
23
  end
17
24
 
18
- private
19
-
20
25
  # A hash of default values to set before yielding to the block
21
26
  #
22
27
  # @return [Hash<Symbol, Object>]
@@ -36,5 +41,14 @@ class Faulty
36
41
  # @return [void]
37
42
  def finalize
38
43
  end
44
+
45
+ private
46
+
47
+ # Raise an error if required options are missing
48
+ def guard_required!
49
+ required.each do |key|
50
+ raise ArgumentError, "Missing required attribute #{key}" if self[key].nil?
51
+ end
52
+ end
39
53
  end
40
54
  end
data/lib/faulty/status.rb CHANGED
@@ -64,10 +64,17 @@ class Faulty
64
64
  # sample_size
65
65
  # @return [Status]
66
66
  def self.from_entries(entries, **hash)
67
+ window_start = Faulty.current_time - hash[:options].evaluation_window
68
+ size = entries.size
69
+ i = 0
67
70
  failures = 0
68
71
  sample_size = 0
69
- entries.each do |(time, success)|
70
- next unless time > Faulty.current_time - hash[:options].evaluation_window
72
+
73
+ # This is a hot loop, and while is slightly faster than each
74
+ while i < size
75
+ time, success = entries[i]
76
+ i += 1
77
+ next unless time > window_start
71
78
 
72
79
  sample_size += 1
73
80
  failures += 1 unless success
@@ -140,8 +147,6 @@ class Faulty
140
147
  failure_rate >= options.rate_threshold
141
148
  end
142
149
 
143
- private
144
-
145
150
  def finalize
146
151
  raise ArgumentError, "state must be a symbol in #{self.class}::STATES" unless STATES.include?(state)
147
152
  unless lock.nil? || LOCKS.include?(lock)
@@ -21,8 +21,6 @@ class Faulty
21
21
  ) do
22
22
  include ImmutableOptions
23
23
 
24
- private
25
-
26
24
  def required
27
25
  %i[notifier]
28
26
  end
@@ -26,14 +26,12 @@ class Faulty
26
26
  ) do
27
27
  include ImmutableOptions
28
28
 
29
- private
30
-
31
29
  def finalize
32
30
  raise ArgumentError, 'The circuit or notifier option must be given' unless notifier || circuit
33
31
 
34
32
  self.circuit ||= Circuit.new(
35
33
  Faulty::Storage::CircuitProxy.name,
36
- notifier: notifier,
34
+ notifier: Events::FilterNotifier.new(notifier, exclude: %i[circuit_success]),
37
35
  cache: Cache::Null.new
38
36
  )
39
37
  end
@@ -47,7 +45,20 @@ class Faulty
47
45
  @options = Options.new(options, &block)
48
46
  end
49
47
 
50
- %i[entry open reopen close lock unlock reset status history list].each do |method|
48
+ %i[
49
+ get_options
50
+ set_options
51
+ entry
52
+ open
53
+ reopen
54
+ close
55
+ lock
56
+ unlock
57
+ reset
58
+ status
59
+ history
60
+ list
61
+ ].each do |method|
51
62
  define_method(method) do |*args|
52
63
  options.circuit.run { @storage.public_send(method, *args) }
53
64
  end
@@ -30,8 +30,6 @@ class Faulty
30
30
  ) do
31
31
  include ImmutableOptions
32
32
 
33
- private
34
-
35
33
  def required
36
34
  %i[notifier]
37
35
  end
@@ -49,6 +47,24 @@ class Faulty
49
47
  @options = Options.new(options, &block)
50
48
  end
51
49
 
50
+ # Get options from the first available storage backend
51
+ #
52
+ # @param (see Interface#get_options)
53
+ # @return (see Interface#get_options)
54
+ def get_options(circuit)
55
+ send_chain(:get_options, circuit) do |e|
56
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :get_options, error: e)
57
+ end
58
+ end
59
+
60
+ # Try to set circuit options on all backends
61
+ #
62
+ # @param (see Interface#set_options)
63
+ # @return (see Interface#set_options)
64
+ def set_options(circuit, stored_options)
65
+ send_all(:set_options, circuit, stored_options)
66
+ end
67
+
52
68
  # Create a circuit entry in the first available storage backend
53
69
  #
54
70
  # @param (see Interface#entry)
@@ -23,8 +23,6 @@ class Faulty
23
23
  ) do
24
24
  include ImmutableOptions
25
25
 
26
- private
27
-
28
26
  def required
29
27
  %i[notifier]
30
28
  end
@@ -85,6 +83,30 @@ class Faulty
85
83
  # @return (see Interface#list)
86
84
  def_delegators :@storage, :lock, :unlock, :reset, :history, :list
87
85
 
86
+ # Get circuit options safely
87
+ #
88
+ # @see Interface#get_options
89
+ # @param (see Interface#get_options)
90
+ # @return (see Interface#get_options)
91
+ def get_options(circuit)
92
+ @storage.get_options(circuit)
93
+ rescue StandardError => e
94
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :get_options, error: e)
95
+ nil
96
+ end
97
+
98
+ # Set circuit options safely
99
+ #
100
+ # @see Interface#get_options
101
+ # @param (see Interface#set_options)
102
+ # @return (see Interface#set_options)
103
+ def set_options(circuit, stored_options)
104
+ @storage.set_options(circuit, stored_options)
105
+ rescue StandardError => e
106
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :set_options, error: e)
107
+ nil
108
+ end
109
+
88
110
  # Add a history entry safely
89
111
  #
90
112
  # @see Interface#entry
@@ -6,6 +6,29 @@ class Faulty
6
6
  #
7
7
  # This is for documentation only and is not loaded
8
8
  class Interface
9
+ # Get the options stored for circuit
10
+ #
11
+ # They should be returned exactly as given by {#set_options}
12
+ #
13
+ # @return [Hash] A hash of the options stored by {#set_options}. The keys
14
+ # must be symbols.
15
+ def get_options(circuit)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Store the options for a circuit
20
+ #
21
+ # They should be returned exactly as given by {#set_options}
22
+ #
23
+ # @param circuit [Circuit] The circuit to set options for
24
+ # @param options [Hash<Symbol, Object>] A hash of symbol option names to
25
+ # circuit options. These option values are guranteed to be primive
26
+ # values.
27
+ # @return [void]
28
+ def set_options(circuit, stored_options)
29
+ raise NotImplementedError
30
+ end
31
+
9
32
  # Add a circuit run entry to storage
10
33
  #
11
34
  # The backend may choose to store this in whatever manner it chooses as
@@ -99,7 +122,7 @@ class Faulty
99
122
  # Reset the circuit to a fresh state
100
123
  #
101
124
  # Clears all circuit status including entries, state, locks,
102
- # opened_at, and any other values that would affect Status.
125
+ # opened_at, options, and any other values that would affect Status.
103
126
  #
104
127
  # No concurrency gurantees are provided for resetting
105
128
  #
@@ -33,8 +33,6 @@ class Faulty
33
33
  Options = Struct.new(:max_sample_size) do
34
34
  include ImmutableOptions
35
35
 
36
- private
37
-
38
36
  def defaults
39
37
  { max_sample_size: 100 }
40
38
  end
@@ -43,7 +41,7 @@ class Faulty
43
41
  # The internal object for storing a circuit
44
42
  #
45
43
  # @private
46
- MemoryCircuit = Struct.new(:state, :runs, :opened_at, :lock) do
44
+ MemoryCircuit = Struct.new(:state, :runs, :opened_at, :lock, :options) do
47
45
  def initialize
48
46
  self.state = Concurrent::Atom.new(:closed)
49
47
  self.runs = Concurrent::MVar.new([], dup_on_deref: true)
@@ -78,6 +76,24 @@ class Faulty
78
76
  @options = Options.new(options, &block)
79
77
  end
80
78
 
79
+ # Get the options stored for circuit
80
+ #
81
+ # @see Interface#get_options
82
+ # @param (see Interface#get_options)
83
+ # @return (see Interface#get_options)
84
+ def get_options(circuit)
85
+ fetch(circuit).options
86
+ end
87
+
88
+ # Store the options for a circuit
89
+ #
90
+ # @see Interface#set_options
91
+ # @param (see Interface#set_options)
92
+ # @return (see Interface#set_options)
93
+ def set_options(circuit, stored_options)
94
+ fetch(circuit).options = stored_options
95
+ end
96
+
81
97
  # Add an entry to storage
82
98
  #
83
99
  # @see Interface#entry
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Storage
5
+ # A no-op backend for disabling circuits
6
+ class Null
7
+ # Define a single global instance
8
+ @instance = new
9
+
10
+ def self.new
11
+ @instance
12
+ end
13
+
14
+ # @param (see Interface#get_options)
15
+ # @return (see Interface#get_options)
16
+ def get_options(_circuit)
17
+ {}
18
+ end
19
+
20
+ # @param (see Interface#set_options)
21
+ # @return (see Interface#set_options)
22
+ def set_options(_circuit, _stored_options)
23
+ end
24
+
25
+ # @param (see Interface#entry)
26
+ # @return (see Interface#entry)
27
+ def entry(_circuit, _time, _success)
28
+ []
29
+ end
30
+
31
+ # @param (see Interface#open)
32
+ # @return (see Interface#open)
33
+ def open(_circuit, _opened_at)
34
+ true
35
+ end
36
+
37
+ # @param (see Interface#reopen)
38
+ # @return (see Interface#reopen)
39
+ def reopen(_circuit, _opened_at, _previous_opened_at)
40
+ true
41
+ end
42
+
43
+ # @param (see Interface#close)
44
+ # @return (see Interface#close)
45
+ def close(_circuit)
46
+ true
47
+ end
48
+
49
+ # @param (see Interface#lock)
50
+ # @return (see Interface#lock)
51
+ def lock(_circuit, _state)
52
+ end
53
+
54
+ # @param (see Interface#unlock)
55
+ # @return (see Interface#unlock)
56
+ def unlock(_circuit)
57
+ end
58
+
59
+ # @param (see Interface#reset)
60
+ # @return (see Interface#reset)
61
+ def reset(_circuit)
62
+ end
63
+
64
+ # @param (see Interface#status)
65
+ # @return (see Interface#status)
66
+ def status(circuit)
67
+ Faulty::Status.new(
68
+ options: circuit.options,
69
+ stub: true
70
+ )
71
+ end
72
+
73
+ # @param (see Interface#history)
74
+ # @return (see Interface#history)
75
+ def history(_circuit)
76
+ []
77
+ end
78
+
79
+ # @param (see Interface#list)
80
+ # @return (see Interface#list)
81
+ def list
82
+ []
83
+ end
84
+
85
+ # This backend is fault tolerant
86
+ #
87
+ # @param (see Interface#fault_tolerant?)
88
+ # @return (see Interface#fault_tolerant?)
89
+ def fault_tolerant?
90
+ true
91
+ end
92
+ end
93
+ end
94
+ end
@@ -57,8 +57,6 @@ class Faulty
57
57
  ) do
58
58
  include ImmutableOptions
59
59
 
60
- private
61
-
62
60
  def defaults
63
61
  {
64
62
  key_prefix: 'faulty',
@@ -85,9 +83,37 @@ class Faulty
85
83
  def initialize(**options, &block)
86
84
  @options = Options.new(options, &block)
87
85
 
86
+ # Ensure JSON is available since we don't explicitly require it
87
+ JSON # rubocop:disable Lint/Void
88
+
88
89
  check_client_options!
89
90
  end
90
91
 
92
+ # Get the options stored for circuit
93
+ #
94
+ # @see Interface#get_options
95
+ # @param (see Interface#get_options)
96
+ # @return (see Interface#get_options)
97
+ def get_options(circuit)
98
+ json = redis { |r| r.get(options_key(circuit)) }
99
+ return if json.nil?
100
+
101
+ JSON.parse(json, symbolize_names: true)
102
+ end
103
+
104
+ # Store the options for a circuit
105
+ #
106
+ # These will be serialized as JSON
107
+ #
108
+ # @see Interface#set_options
109
+ # @param (see Interface#set_options)
110
+ # @return (see Interface#set_options)
111
+ def set_options(circuit, stored_options)
112
+ redis do |r|
113
+ r.set(options_key(circuit), JSON.dump(stored_options), ex: options.circuit_ttl)
114
+ end
115
+ end
116
+
91
117
  # Add an entry to storage
92
118
  #
93
119
  # @see Interface#entry
@@ -173,7 +199,8 @@ class Faulty
173
199
  r.del(
174
200
  entries_key(circuit),
175
201
  opened_at_key(circuit),
176
- lock_key(circuit)
202
+ lock_key(circuit),
203
+ options_key(circuit)
177
204
  )
178
205
  r.set(state_key(circuit), 'closed', ex: options.circuit_ttl)
179
206
  end
@@ -239,6 +266,11 @@ class Faulty
239
266
  key('circuit', circuit.name, *parts)
240
267
  end
241
268
 
269
+ # @return [String] The key for circuit options
270
+ def options_key(circuit)
271
+ ckey(circuit, 'options')
272
+ end
273
+
242
274
  # @return [String] The key for circuit state
243
275
  def state_key(circuit)
244
276
  ckey(circuit, 'state')
@@ -10,5 +10,6 @@ require 'faulty/storage/auto_wire'
10
10
  require 'faulty/storage/circuit_proxy'
11
11
  require 'faulty/storage/fallback_chain'
12
12
  require 'faulty/storage/fault_tolerant_proxy'
13
+ require 'faulty/storage/null'
13
14
  require 'faulty/storage/memory'
14
15
  require 'faulty/storage/redis'
@@ -3,6 +3,6 @@
3
3
  class Faulty
4
4
  # The current Faulty version
5
5
  def self.version
6
- Gem::Version.new('0.6.0')
6
+ Gem::Version.new('0.8.0')
7
7
  end
8
8
  end
data/lib/faulty.rb CHANGED
@@ -10,6 +10,7 @@ require 'faulty/circuit'
10
10
  require 'faulty/error'
11
11
  require 'faulty/events'
12
12
  require 'faulty/patch'
13
+ require 'faulty/circuit_registry'
13
14
  require 'faulty/result'
14
15
  require 'faulty/status'
15
16
  require 'faulty/storage'
@@ -128,6 +129,33 @@ class Faulty
128
129
  def current_time
129
130
  Time.now.to_i
130
131
  end
132
+
133
+ # Disable Faulty circuits
134
+ #
135
+ # This allows circuits to run as if they were always closed. Does
136
+ # not disable caching.
137
+ #
138
+ # Intended for use in tests, or to disable Faulty entirely for an
139
+ # environment.
140
+ #
141
+ # @return [void]
142
+ def disable!
143
+ @disabled = true
144
+ end
145
+
146
+ # Re-enable Faulty if disabled with {#disable!}
147
+ #
148
+ # @return [void]
149
+ def enable!
150
+ @disabled = false
151
+ end
152
+
153
+ # Check whether Faulty was disabled with {#disable!}
154
+ #
155
+ # @return [Boolean] True if disabled
156
+ def disabled?
157
+ @disabled == true
158
+ end
131
159
  end
132
160
 
133
161
  attr_reader :options
@@ -199,8 +227,8 @@ class Faulty
199
227
  # @param options [Hash] Attributes for {Options}
200
228
  # @yield [Options] For setting options in a block
201
229
  def initialize(**options, &block)
202
- @circuits = Concurrent::Map.new
203
230
  @options = Options.new(options, &block)
231
+ @registry = CircuitRegistry.new(circuit_options)
204
232
  end
205
233
 
206
234
  # Create or retrieve a circuit
@@ -216,10 +244,7 @@ class Faulty
216
244
  # @return [Circuit] The new circuit or the existing circuit if it already exists
217
245
  def circuit(name, **options, &block)
218
246
  name = name.to_s
219
- @circuits.compute_if_absent(name) do
220
- options = circuit_options.merge(options)
221
- Circuit.new(name, **options, &block)
222
- end
247
+ @registry.retrieve(name, options, &block)
223
248
  end
224
249
 
225
250
  # Get a list of all circuit names
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faulty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Howard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-06-10 00:00:00.000000000 Z
11
+ date: 2021-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: json
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: redis
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -124,6 +138,7 @@ files:
124
138
  - Gemfile
125
139
  - LICENSE.txt
126
140
  - README.md
141
+ - bin/benchmark
127
142
  - bin/check-version
128
143
  - bin/console
129
144
  - bin/rspec
@@ -143,9 +158,11 @@ files:
143
158
  - lib/faulty/cache/null.rb
144
159
  - lib/faulty/cache/rails.rb
145
160
  - lib/faulty/circuit.rb
161
+ - lib/faulty/circuit_registry.rb
146
162
  - lib/faulty/error.rb
147
163
  - lib/faulty/events.rb
148
164
  - lib/faulty/events/callback_listener.rb
165
+ - lib/faulty/events/filter_notifier.rb
149
166
  - lib/faulty/events/honeybadger_listener.rb
150
167
  - lib/faulty/events/listener_interface.rb
151
168
  - lib/faulty/events/log_listener.rb
@@ -164,6 +181,7 @@ files:
164
181
  - lib/faulty/storage/fault_tolerant_proxy.rb
165
182
  - lib/faulty/storage/interface.rb
166
183
  - lib/faulty/storage/memory.rb
184
+ - lib/faulty/storage/null.rb
167
185
  - lib/faulty/storage/redis.rb
168
186
  - lib/faulty/version.rb
169
187
  homepage: https://github.com/ParentSquare/faulty