faulty 0.8.0 → 0.8.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: 815cc1b7ac571e9dc69546efd1b3892795a5ef284cc43448b8a6c1feadcf49c3
4
- data.tar.gz: 575c2e0e7abb413f8866a0e159129a6f1ecc76149e8d8cb97c65c7ffd9913ba9
3
+ metadata.gz: 54ef7d6bc1b23815e02563c80e4c17fef2735812a8c424882e8283996506edcc
4
+ data.tar.gz: c4c2d074b118f3077a3cddb862fddc5c3ace18c62a206e4523cfb830b7b6e35b
5
5
  SHA512:
6
- metadata.gz: 6effebfa578717256dc4ef7ec1c59a5f694eff2c78b21edb1649375a3f7ab62bbad597e3e4a0dc63cd729b4e66b4079ea9cbfd72795a1bfd34a9fab489415847
7
- data.tar.gz: 2ba730eaa396ce24cb9d4eaf3a35db84180eb5c8ba4e3f3bc803d389436870480bd542aa57c93089ef33b77afe3f9b0c8614f972d232b971d8310f2dd067eb39
6
+ metadata.gz: 75fa840ff865e39e381894bdce59ab13f67ac001cbae57696360692c495b8d0ca2f643261a7a4ea083fdb1964d8e14ad0237815f90e9b436d17eff04a0abe0f5
7
+ data.tar.gz: dfcaede2d29e34bd466a21ad5037ddaeebfaa947bb44f09ef9c64b34ac0f207dd2a8e4d038067b47ce3ac76ccbba744721dc1fb4337854e7e9af0df739798169
@@ -22,6 +22,11 @@ jobs:
22
22
  image: redis
23
23
  ports:
24
24
  - 6379:6379
25
+ elasticsearch:
26
+ image: elasticsearch:7.13.4
27
+ ports:
28
+ - 9200:9200
29
+ options: -e="discovery.type=single-node" --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=3s --health-timeout=5s --health-retries=20
25
30
  steps:
26
31
  - uses: actions/checkout@v2
27
32
  - uses: ruby/setup-ruby@v1
@@ -32,6 +37,9 @@ jobs:
32
37
  bundler-cache: true
33
38
  - run: bundle exec rubocop
34
39
  if: matrix.ruby == '2.7'
40
+ - run: bin/yardoc --fail-on-warning
41
+ if: matrix.ruby == '2.7'
42
+ - run: bin/check-version
35
43
  - name: start MySQL
36
44
  run: sudo /etc/init.d/mysql start
37
45
  - run: bundle exec rspec --format doc
@@ -43,7 +51,6 @@ jobs:
43
51
  with:
44
52
  project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
45
53
  coverage-reports: coverage/lcov/faulty.lcov
46
- - run: bin/check-version
47
54
 
48
55
  release:
49
56
  needs: test
data/.yardopts CHANGED
@@ -1,3 +1,3 @@
1
1
  --markup markdown
2
2
  --markup-provider redcarpet
3
- - guides/*
3
+ - LICENSE.txt CHANGELOG.md
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## Release v0.8.5
2
+
3
+ * Fix yard warnings #49 justinhoward
4
+ * Fix crash in Redis storage backend if opened_at was missing #46 justinhoward
5
+ * Add granular errors for Elasticsearch patch #48 justinhoward
6
+ * Return status conditionally for Storage::Interface#entry #45 justinhoward
7
+
8
+ ## Release v0.8.4
9
+
10
+ * Add Elasticsearch client patch #44 justinhoward
11
+
12
+ ## Release v0.8.2
13
+
14
+ * Fix crash for older versions of concurrent-ruby #42 justinhoward
15
+
16
+ ## Release v0.8.1
17
+
18
+ * Add cause message to CircuitTrippedError #40 justinhoward
19
+ * Record failures for cache hits #41 justinhoward
20
+
21
+
1
22
  ## Release v0.8.0
2
23
 
3
24
  * Store circuit options in the backend when run #34 justinhoward
data/Gemfile CHANGED
@@ -13,6 +13,8 @@ not_jruby = %i[ruby mingw x64_mingw].freeze
13
13
  gem 'activesupport', '>= 4.2'
14
14
  gem 'bundler', '>= 1.17', '< 3'
15
15
  gem 'byebug', platforms: not_jruby
16
+ # Open source licensed elasticsearch
17
+ gem 'elasticsearch', '> 7', '< 7.14'
16
18
  gem 'honeybadger', '>= 2.0'
17
19
  gem 'irb', '~> 1.0'
18
20
  # Minimum of 0.5.0 for specific error classes
data/README.md CHANGED
@@ -84,6 +84,7 @@ Also see "Release It!: Design and Deploy Production-Ready Software" by
84
84
  * [Patches](#patches)
85
85
  + [Patch::Redis](#patchredis)
86
86
  + [Patch::Mysql2](#patchmysql2)
87
+ + [Patch::Elasticsearch](#patchelasticsearch)
87
88
  * [Event Handling](#event-handling)
88
89
  + [CallbackListener](#callbacklistener)
89
90
  + [Other Built-in Listeners](#other-built-in-listeners)
@@ -1046,6 +1047,38 @@ mysql = Mysql2::Client.new(host: '127.0.0.1')
1046
1047
  mysql.query('SELECT * FROM users') # not protected by a circuit
1047
1048
  ```
1048
1049
 
1050
+ ### Patch::Elasticsearch
1051
+
1052
+ [`Faulty::Patch::Elasticsearch`](https://www.rubydoc.info/gems/faulty/Faulty/Patch/Elasticsearch)
1053
+ protects a `Elasticsearch::Client` with an internal circuit. Pass a `:faulty` key along
1054
+ with your client options to enable the circuit breaker.
1055
+
1056
+ ```ruby
1057
+ require 'faulty/patch/elasticsearch'
1058
+
1059
+ es = Elasticsearch::Client.new(url: 'localhost:9200', faulty: {
1060
+ # The name for the Elasticsearch::Client circuit
1061
+ name: 'elasticsearch'
1062
+
1063
+ # The faulty instance to use
1064
+ # This can also be a registered faulty instance or a constant name. See API
1065
+ # docs for more details
1066
+ instance: Faulty.default
1067
+
1068
+ # By default, circuit errors will be subclasses of
1069
+ # Elasticsearch::Transport::Transport::Error
1070
+ # To disable this behavior, set patch_errors to false and Faulty
1071
+ # will raise its default errors
1072
+ patch_errors: true
1073
+ })
1074
+ ```
1075
+
1076
+ If you're using Searchkick, you can configure Faulty with `client_options`.
1077
+
1078
+ ```ruby
1079
+ Searchkick.client_options[:faulty] = { name: 'searchkick' }
1080
+ ```
1081
+
1049
1082
  ## Event Handling
1050
1083
 
1051
1084
  Faulty uses an event-dispatching model to deliver notifications of internal
@@ -1288,18 +1321,20 @@ but there are and have been many other options:
1288
1321
  ### Currently Active
1289
1322
 
1290
1323
  - [semian](https://github.com/Shopify/semian): A resiliency toolkit that
1291
- includes circuit breakers. It uses adapters to auto-wire circuits, and it has
1292
- only in-memory storage by design.
1293
- - [circuitbox](https://github.com/yammer/circuitbox): Similar in design to
1294
- Faulty, but with a different API. It uses Moneta to abstract circuit storage
1295
- to allow any key-value store.
1324
+ includes circuit breakers. It auto-wires circuits for MySQL, Net::HTTP, and
1325
+ Redis. It has only in-memory storage by design. Its core components are
1326
+ written in C, which allows it to be faster than pure ruby.
1327
+ - [circuitbox](https://github.com/yammer/circuitbox): Also uses a block syntax
1328
+ to manually define circuits. It uses Moneta to abstract circuit storage to
1329
+ allow any key-value store.
1296
1330
 
1297
1331
  ### Previous Work
1298
1332
 
1299
1333
  - [circuit_breaker-ruby](https://github.com/scripbox/circuit_breaker-ruby) (no
1300
1334
  recent activity)
1301
1335
  - [stoplight](https://github.com/orgsync/stoplight) (unmaintained)
1302
- - [circuit_breaker](https://github.com/wooga/circuit_breaker) (archived)
1336
+ - [circuit_breaker](https://github.com/wsargent/circuit_breaker) (no recent
1337
+ activity)
1303
1338
  - [simple_circuit_breaker](https://github.com/soundcloud/simple_circuit_breaker)
1304
1339
  (unmaintained)
1305
1340
  - [breaker](https://github.com/ahawkins/breaker) (unmaintained)
@@ -1309,6 +1344,7 @@ but there are and have been many other options:
1309
1344
 
1310
1345
  - Simple API but configurable for advanced users
1311
1346
  - Pluggable storage backends (circuitbox also has this)
1347
+ - Patches for common core dependencies (semian also has this)
1312
1348
  - Protected storage access with fallback to safe storage
1313
1349
  - Global, or object-oriented configuration with multiple instances
1314
1350
  - Integrated caching support tailored for fault-tolerance
data/bin/benchmark CHANGED
@@ -4,12 +4,13 @@
4
4
  require 'bundler/setup'
5
5
  require 'benchmark'
6
6
  require 'faulty'
7
+ require 'redis'
8
+ require 'json'
7
9
 
8
10
  n = 100_000
9
-
10
- puts "Starting circuit benchmarks with #{n} iterations each\n\n"
11
-
12
- Benchmark.bm(25) do |b|
11
+ width = 25
12
+ puts "In memory circuits x#{n}"
13
+ Benchmark.bm(width) do |b|
13
14
  in_memory = Faulty.new(listeners: [])
14
15
  b.report('memory storage') do
15
16
  n.times { in_memory.circuit(:memory).run { true } }
@@ -31,11 +32,33 @@ Benchmark.bm(25) do |b|
31
32
  end
32
33
  end
33
34
 
34
- n = 1_000_000
35
+ n = 1000
36
+ puts "\n\Redis circuits x#{n}"
37
+ Benchmark.bm(width) do |b|
38
+ redis = Faulty.new(listeners: [], storage: Faulty::Storage::Redis.new)
39
+ b.report('redis storage') do
40
+ n.times { redis.circuit(:memory).run { true } }
41
+ end
35
42
 
36
- puts "\n\nStarting extra benchmarks with #{n} iterations each\n\n"
43
+ b.report('redis storage failures') do
44
+ n.times do
45
+ begin
46
+ redis.circuit(:memory_fail, sample_threshold: n + 1).run { raise 'fail' }
47
+ rescue StandardError
48
+ # Expected to raise here
49
+ end
50
+ end
51
+ end
37
52
 
38
- Benchmark.bm(25) do |b|
53
+ redis_large = Faulty.new(listeners: [], storage: Faulty::Storage::Redis.new(max_sample_size: 1000))
54
+ b.report('large redis storage') do
55
+ n.times { redis_large.circuit(:memory).run { true } }
56
+ end
57
+ end
58
+
59
+ n = 1_000_000
60
+ puts "\n\nExtra x#{n}"
61
+ Benchmark.bm(width) do |b|
39
62
  in_memory = Faulty.new(listeners: [])
40
63
 
41
64
  log_listener = Faulty::Events::LogListener.new(Logger.new(File::NULL))
@@ -49,9 +49,13 @@ class Faulty
49
49
  # @!attribute [r] cool_down
50
50
  # @return [Integer] The number of seconds the circuit will
51
51
  # stay open after it is tripped. Default 300.
52
- # @!attribute [r] error_module
53
- # @return [Module] Used by patches to set the namespace module for
54
- # the faulty errors that will be raised. Default `Faulty`
52
+ # @!attribute [r] error_mapper
53
+ # @return [Module, #call] Used by patches to set the namespace module for
54
+ # the faulty errors that will be raised. Should be a module or a callable.
55
+ # If given a module, the circuit assumes the module has error classes
56
+ # in that module. If given an object that responds to `#call` (a proc
57
+ # or lambda), the return value of the callable will be used. The callable
58
+ # is called with (`error_name`, `cause_error`, `circuit`). Default `Faulty`
55
59
  # @!attribute [r] evaluation_window
56
60
  # @return [Integer] The number of seconds of history that
57
61
  # will be evaluated to determine the failure rate for a circuit.
@@ -93,6 +97,7 @@ class Faulty
93
97
  :rate_threshold,
94
98
  :sample_threshold,
95
99
  :errors,
100
+ :error_mapper,
96
101
  :error_module,
97
102
  :exclude,
98
103
  :cache,
@@ -120,7 +125,7 @@ class Faulty
120
125
  cache_refreshes_after: 900,
121
126
  cool_down: 300,
122
127
  errors: [StandardError],
123
- error_module: Faulty,
128
+ error_mapper: Faulty,
124
129
  exclude: [],
125
130
  evaluation_window: 60,
126
131
  rate_threshold: 0.5,
@@ -133,7 +138,7 @@ class Faulty
133
138
  cache
134
139
  cool_down
135
140
  errors
136
- error_module
141
+ error_mapper
137
142
  exclude
138
143
  evaluation_window
139
144
  rate_threshold
@@ -153,6 +158,17 @@ class Faulty
153
158
  unless cache_refreshes_after.nil?
154
159
  self.cache_refresh_jitter = 0.2 * cache_refreshes_after
155
160
  end
161
+
162
+ deprecated_error_module
163
+ end
164
+
165
+ private
166
+
167
+ def deprecated_error_module
168
+ return unless error_module
169
+
170
+ Deprecation.method(self.class, :error_module, note: 'See :error_mapper', sunset: '0.9.0')
171
+ self.error_mapper = error_module
156
172
  end
157
173
  end
158
174
 
@@ -379,7 +395,7 @@ class Faulty
379
395
  # @return The result from cache if available
380
396
  def run_skipped(cached_value)
381
397
  skipped!
382
- raise options.error_module::OpenCircuitError.new(nil, self) if cached_value.nil?
398
+ raise map_error(:OpenCircuitError) if cached_value.nil?
383
399
 
384
400
  cached_value
385
401
  end
@@ -397,10 +413,13 @@ class Faulty
397
413
  rescue *options.errors => e
398
414
  raise if options.exclude.any? { |ex| e.is_a?(ex) }
399
415
 
416
+ opened = failure!(status, e)
400
417
  if cached_value.nil?
401
- raise options.error_module::CircuitTrippedError.new(nil, self) if failure!(status, e)
402
-
403
- raise options.error_module::CircuitFailureError.new(nil, self)
418
+ if opened
419
+ raise map_error(:CircuitTrippedError, e)
420
+ else
421
+ raise map_error(:CircuitFailureError, e)
422
+ end
404
423
  else
405
424
  cached_value
406
425
  end
@@ -408,7 +427,11 @@ class Faulty
408
427
 
409
428
  # @return [Boolean] True if the circuit transitioned to closed
410
429
  def success!(status)
411
- storage.entry(self, Faulty.current_time, true)
430
+ if deprecated_entry?
431
+ storage.entry(self, Faulty.current_time, true, nil)
432
+ else
433
+ storage.entry(self, Faulty.current_time, true)
434
+ end
412
435
  closed = close! if status.half_open?
413
436
 
414
437
  options.notifier.notify(:circuit_success, circuit: self)
@@ -417,8 +440,11 @@ class Faulty
417
440
 
418
441
  # @return [Boolean] True if the circuit transitioned to open
419
442
  def failure!(status, error)
420
- entries = storage.entry(self, Faulty.current_time, false)
421
- status = Status.from_entries(entries, **status.to_h)
443
+ status = if deprecated_entry?
444
+ storage.entry(self, Faulty.current_time, false, status)
445
+ else
446
+ deprecated_entry(status)
447
+ end
422
448
  options.notifier.notify(:circuit_failure, circuit: self, status: status, error: error)
423
449
 
424
450
  opened = if status.half_open?
@@ -432,6 +458,23 @@ class Faulty
432
458
  opened
433
459
  end
434
460
 
461
+ def deprecated_entry?
462
+ return @deprecated_entry unless @deprecated_entry.nil?
463
+
464
+ @deprecated_entry = storage.method(:entry).arity == 4
465
+ end
466
+
467
+ def deprecated_entry(status)
468
+ Faulty::Deprecation.deprecate(
469
+ 'Returning entries array from entry',
470
+ note: 'see Storate::Interface#entry',
471
+ sunset: '0.9'
472
+ )
473
+
474
+ entries = storage.entry(self, Faulty.current_time, false)
475
+ Status.from_entries(entries, **status.to_h)
476
+ end
477
+
435
478
  def skipped!
436
479
  options.notifier.notify(:circuit_skipped, circuit: self)
437
480
  end
@@ -528,5 +571,13 @@ class Faulty
528
571
 
529
572
  @given_options.storage
530
573
  end
574
+
575
+ def map_error(error_name, cause = nil)
576
+ if options.error_mapper.respond_to?(:call)
577
+ options.error_mapper.call(error_name, cause, self)
578
+ else
579
+ options.error_mapper.const_get(error_name).new(cause&.message, self)
580
+ end
581
+ end
531
582
  end
532
583
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ # Support deprecating Faulty features
5
+ module Deprecation
6
+ class << self
7
+ # Call to raise errors instead of logging warnings for Faulty deprecations
8
+ def raise_errors!(enabled = true)
9
+ @raise_errors = (enabled == true)
10
+ end
11
+
12
+ def silenced
13
+ @silence = true
14
+ yield
15
+ ensure
16
+ @silence = false
17
+ end
18
+
19
+ # @private
20
+ def method(klass, name, note: nil, sunset: nil)
21
+ deprecate("#{klass}##{name}", note: note, sunset: sunset)
22
+ end
23
+
24
+ # @private
25
+ def deprecate(subject, note: nil, sunset: nil)
26
+ return if @silence
27
+
28
+ message = "#{subject} is deprecated"
29
+ message += " and will be removed in #{sunset}" if sunset
30
+ message += " (#{note})" if note
31
+ raise DeprecationError, message if @raise_errors
32
+
33
+ Kernel.warn("DEPRECATION: #{message}")
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/faulty/error.rb CHANGED
@@ -28,6 +28,9 @@ class Faulty
28
28
  end
29
29
  end
30
30
 
31
+ class DeprecationError < FaultyError
32
+ end
33
+
31
34
  # Included in faulty circuit errors to provide common features for
32
35
  # native and patched errors
33
36
  module CircuitErrorBase
@@ -36,10 +39,11 @@ class Faulty
36
39
  # @param message [String]
37
40
  # @param circuit [Circuit] The circuit that raised the error
38
41
  def initialize(message, circuit)
39
- message ||= %(circuit error for "#{circuit.name}")
40
- @circuit = circuit
42
+ full_message = %(circuit error for "#{circuit.name}")
43
+ full_message = %(#{full_message}: #{message}) if message
41
44
 
42
- super(message)
45
+ @circuit = circuit
46
+ super(full_message)
43
47
  end
44
48
  end
45
49
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'elasticsearch'
4
+
5
+ class Faulty
6
+ module Patch
7
+ # Patch Elasticsearch to run requests in a circuit
8
+ #
9
+ # This module is not required by default
10
+ #
11
+ # Pass a `:faulty` key into your Elasticsearch client options to enable
12
+ # circuit protection. See {Patch.circuit_from_hash} for the available
13
+ # options.
14
+ #
15
+ # By default, all circuit errors raised by this patch inherit from
16
+ # `::Elasticsearch::Transport::Transport::Error`. One side effect of the way
17
+ # this patch wraps errors is that `host_unreachable_exceptions` raised by
18
+ # the inner transport adapters are converted into
19
+ # `Elasticsearch::Transport::Transport::Error` instead of the transport
20
+ # error type such as `Faraday::ConnectionFailed`.
21
+ #
22
+ # @example
23
+ # require 'faulty/patch/elasticsearch'
24
+ #
25
+ # es = Elasticsearch::Client.new(url: 'http://localhost:9200', faulty: {})
26
+ # es.search(q: 'test') # raises Faulty::CircuitError if connection fails
27
+ #
28
+ # # If the faulty key is not given, no circuit is used
29
+ # es = Elasticsearch::Client.new(url: 'http://localhost:9200', faulty: {})
30
+ # es.search(q: 'test') # not protected by a circuit
31
+ #
32
+ # # With Searchkick
33
+ # Searchkick.client_options[:faulty] = {}
34
+ #
35
+ # @see Patch.circuit_from_hash
36
+ module Elasticsearch
37
+ include Base
38
+
39
+ module Error; end
40
+ module SnifferTimeoutError; end
41
+ module ServerError; end
42
+
43
+ # We will freeze this after adding the dynamic error classes
44
+ MAPPED_ERRORS = { # rubocop:disable Style/MutableConstant
45
+ ::Elasticsearch::Transport::Transport::Error => Error,
46
+ ::Elasticsearch::Transport::Transport::SnifferTimeoutError => SnifferTimeoutError,
47
+ ::Elasticsearch::Transport::Transport::ServerError => ServerError
48
+ }
49
+
50
+ module Errors
51
+ ::Elasticsearch::Transport::Transport::ERRORS.each do |_code, klass|
52
+ MAPPED_ERRORS[klass] = const_set(klass.name.split('::').last, Module.new)
53
+ end
54
+ end
55
+
56
+ MAPPED_ERRORS.freeze
57
+ MAPPED_ERRORS.each do |klass, mod|
58
+ Patch.define_circuit_errors(mod, klass)
59
+ end
60
+
61
+ ERROR_MAPPER = lambda do |error_name, cause, circuit|
62
+ MAPPED_ERRORS.fetch(cause&.class, Error).const_get(error_name).new(cause&.message, circuit)
63
+ end
64
+ private_constant :ERROR_MAPPER, :MAPPED_ERRORS
65
+
66
+ def initialize(arguments = {}, &block)
67
+ super
68
+
69
+ errors = [::Elasticsearch::Transport::Transport::Error]
70
+ errors.concat(@transport.host_unreachable_exceptions)
71
+
72
+ @faulty_circuit = Patch.circuit_from_hash(
73
+ 'elasticsearch',
74
+ arguments[:faulty],
75
+ errors: errors,
76
+ exclude: ::Elasticsearch::Transport::Transport::Errors::NotFound,
77
+ patched_error_mapper: ERROR_MAPPER
78
+ )
79
+ end
80
+
81
+ # Protect all elasticsearch requests
82
+ def perform_request(*args)
83
+ faulty_run { super }
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ module Elasticsearch
90
+ module Transport
91
+ class Client
92
+ prepend(Faulty::Patch::Elasticsearch)
93
+ end
94
+ end
95
+ end
@@ -20,6 +20,9 @@ class Faulty
20
20
  # protected by the circuit. This is to allow open transactions to be closed
21
21
  # if possible.
22
22
  #
23
+ # By default, all circuit errors raised by this patch inherit from
24
+ # `::Mysql2::Error::ConnectionError`
25
+ #
23
26
  # @example
24
27
  # require 'faulty/patch/mysql2'
25
28
  #
@@ -50,7 +53,7 @@ class Faulty
50
53
  ::Mysql2::Error::ConnectionError,
51
54
  ::Mysql2::Error::TimeoutError
52
55
  ],
53
- patched_error_module: Faulty::Patch::Mysql2
56
+ patched_error_mapper: Faulty::Patch::Mysql2
54
57
  )
55
58
 
56
59
  super
@@ -78,4 +81,8 @@ class Faulty
78
81
  end
79
82
  end
80
83
 
81
- ::Mysql2::Client.prepend(Faulty::Patch::Mysql2)
84
+ module Mysql2
85
+ class Client
86
+ prepend(Faulty::Patch::Mysql2)
87
+ end
88
+ end
@@ -12,6 +12,9 @@ class Faulty
12
12
  # circuit protection. See {Patch.circuit_from_hash} for the available
13
13
  # options.
14
14
  #
15
+ # By default, all circuit errors raised by this patch inherit from
16
+ # `::Redis::BaseConnectionError`
17
+ #
15
18
  # @example
16
19
  # require 'faulty/patch/redis'
17
20
  #
@@ -40,7 +43,7 @@ class Faulty
40
43
  ::Redis::BaseConnectionError,
41
44
  BusyError
42
45
  ],
43
- patched_error_module: Faulty::Patch::Redis
46
+ patched_error_mapper: Faulty::Patch::Redis
44
47
  )
45
48
 
46
49
  super
@@ -90,4 +93,8 @@ class Faulty
90
93
  end
91
94
  end
92
95
 
93
- ::Redis::Client.prepend(Faulty::Patch::Redis)
96
+ class Redis
97
+ class Client
98
+ prepend(Faulty::Patch::Redis)
99
+ end
100
+ end
data/lib/faulty/patch.rb CHANGED
@@ -64,14 +64,15 @@ class Faulty
64
64
  # option and these additional options
65
65
  # @option hash [String] :name The circuit name. Defaults to `default_name`
66
66
  # @option hash [Boolean] :patch_errors By default, circuit errors will be
67
- # subclasses of `options[:patched_error_module]`. The user can disable
67
+ # subclasses of `options[:patched_error_mapper]`. The user can disable
68
68
  # this by setting this option to false.
69
69
  # @option hash [Faulty, String, Symbol, Hash{ constant: String }] :instance
70
70
  # A reference to a faulty instance. See examples.
71
71
  # @param options [Hash] Additional override options. Supports any circuit
72
72
  # option and these additional ones.
73
- # @option options [Module] :patched_error_module The namespace module
74
- # for patched errors
73
+ # @option options [Module] :patched_error_mapper The namespace module
74
+ # for patched errors or a mapping proc. See {Faulty::Circuit::Options}
75
+ # `:error_mapper`
75
76
  # @yield [Circuit::Options] For setting override options in a block
76
77
  # @return [Circuit, nil] The circuit if one was created
77
78
  def circuit_from_hash(default_name, hash, **options, &block)
@@ -80,8 +81,8 @@ class Faulty
80
81
  hash = symbolize_keys(hash)
81
82
  name = hash.delete(:name) || default_name
82
83
  patch_errors = hash.delete(:patch_errors) != false
83
- error_module = options.delete(:patched_error_module)
84
- hash[:error_module] ||= error_module if error_module && patch_errors
84
+ error_mapper = options.delete(:patched_error_mapper)
85
+ hash[:error_mapper] ||= error_mapper if error_mapper && patch_errors
85
86
  faulty = resolve_instance(hash.delete(:instance))
86
87
  faulty.circuit(name, **hash, **options, &block)
87
88
  end
@@ -69,7 +69,7 @@ class Faulty
69
69
 
70
70
  # Wrap an array of storage backends in a fault-tolerant FallbackChain
71
71
  #
72
- # @param [Array<Storage::Interface>] The array to wrap
72
+ # @param array [Array<Storage::Interface>] The array to wrap
73
73
  # @param options [Options]
74
74
  # @return [Storage::Interface] A fault-tolerant fallback chain
75
75
  def wrap_array(array, options)
@@ -81,7 +81,7 @@ class Faulty
81
81
 
82
82
  # Wrap one storage backend in fault-tolerant backends
83
83
  #
84
- # @param [Storage::Interface] The storage to wrap
84
+ # @param storage [Storage::Interface] The storage to wrap
85
85
  # @param options [Options]
86
86
  # @return [Storage::Interface] A fault-tolerant storage backend
87
87
  def wrap_one(storage, options)
@@ -93,7 +93,7 @@ class Faulty
93
93
 
94
94
  # Wrap storage in a CircuitProxy
95
95
  #
96
- # @param [Storage::Interface] The storage to wrap
96
+ # @param storage [Storage::Interface] The storage to wrap
97
97
  # @param options [Options]
98
98
  # @return [CircuitProxy]
99
99
  def circuit_proxy(storage, options)
@@ -69,8 +69,8 @@ class Faulty
69
69
  #
70
70
  # @param (see Interface#entry)
71
71
  # @return (see Interface#entry)
72
- def entry(circuit, time, success)
73
- send_chain(:entry, circuit, time, success) do |e|
72
+ def entry(circuit, time, success, status)
73
+ send_chain(:entry, circuit, time, success, status) do |e|
74
74
  options.notifier.notify(:storage_failure, circuit: circuit, action: :entry, error: e)
75
75
  end
76
76
  end
@@ -112,11 +112,11 @@ class Faulty
112
112
  # @see Interface#entry
113
113
  # @param (see Interface#entry)
114
114
  # @return (see Interface#entry)
115
- def entry(circuit, time, success)
116
- @storage.entry(circuit, time, success)
115
+ def entry(circuit, time, success, status)
116
+ @storage.entry(circuit, time, success, status)
117
117
  rescue StandardError => e
118
118
  options.notifier.notify(:storage_failure, circuit: circuit, action: :entry, error: e)
119
- []
119
+ stub_status(circuit) if status
120
120
  end
121
121
 
122
122
  # Safely mark a circuit as open
@@ -21,7 +21,7 @@ class Faulty
21
21
  # They should be returned exactly as given by {#set_options}
22
22
  #
23
23
  # @param circuit [Circuit] The circuit to set options for
24
- # @param options [Hash<Symbol, Object>] A hash of symbol option names to
24
+ # @param stored_options [Hash<Symbol, Object>] A hash of symbol option names to
25
25
  # circuit options. These option values are guranteed to be primive
26
26
  # values.
27
27
  # @return [void]
@@ -37,9 +37,10 @@ class Faulty
37
37
  # @param circuit [Circuit] The circuit that ran
38
38
  # @param time [Integer] The unix timestamp for the run
39
39
  # @param success [Boolean] True if the run succeeded
40
- # @return [Array<Array>] An array of the new history tuples after adding
41
- # the new entry, see {#history}
42
- def entry(circuit, time, success)
40
+ # @param status [Status, nil] The previous status. If given, this method must
41
+ # return an updated status object from the new entry data.
42
+ # @return [Status, nil] If `status` is not nil, the updated status object.
43
+ def entry(circuit, time, success, status)
43
44
  raise NotImplementedError
44
45
  end
45
46
 
@@ -99,13 +99,14 @@ class Faulty
99
99
  # @see Interface#entry
100
100
  # @param (see Interface#entry)
101
101
  # @return (see Interface#entry)
102
- def entry(circuit, time, success)
102
+ def entry(circuit, time, success, status)
103
103
  memory = fetch(circuit)
104
104
  memory.runs.borrow do |runs|
105
105
  runs.push([time, success])
106
106
  runs.shift if runs.size > options.max_sample_size
107
107
  end
108
- memory.runs.value
108
+
109
+ Status.from_entries(memory.runs.value, **status.to_h) if status
109
110
  end
110
111
 
111
112
  # Mark a circuit as open
@@ -24,8 +24,8 @@ class Faulty
24
24
 
25
25
  # @param (see Interface#entry)
26
26
  # @return (see Interface#entry)
27
- def entry(_circuit, _time, _success)
28
- []
27
+ def entry(circuit, _time, _success, status)
28
+ stub_status(circuit) if status
29
29
  end
30
30
 
31
31
  # @param (see Interface#open)
@@ -64,10 +64,7 @@ class Faulty
64
64
  # @param (see Interface#status)
65
65
  # @return (see Interface#status)
66
66
  def status(circuit)
67
- Faulty::Status.new(
68
- options: circuit.options,
69
- stub: true
70
- )
67
+ stub_status(circuit)
71
68
  end
72
69
 
73
70
  # @param (see Interface#history)
@@ -89,6 +86,15 @@ class Faulty
89
86
  def fault_tolerant?
90
87
  true
91
88
  end
89
+
90
+ private
91
+
92
+ def stub_status(circuit)
93
+ Faulty::Status.new(
94
+ options: circuit.options,
95
+ stub: true
96
+ )
97
+ end
92
98
  end
93
99
  end
94
100
  end
@@ -119,7 +119,7 @@ class Faulty
119
119
  # @see Interface#entry
120
120
  # @param (see Interface#entry)
121
121
  # @return (see Interface#entry)
122
- def entry(circuit, time, success)
122
+ def entry(circuit, time, success, status)
123
123
  key = entries_key(circuit)
124
124
  result = pipe do |r|
125
125
  r.sadd(list_key, circuit.name)
@@ -127,9 +127,10 @@ class Faulty
127
127
  r.lpush(key, "#{time}#{ENTRY_SEPARATOR}#{success ? 1 : 0}")
128
128
  r.ltrim(key, 0, options.max_sample_size - 1)
129
129
  r.expire(key, options.sample_ttl) if options.sample_ttl
130
- r.lrange(key, 0, -1)
130
+ r.lrange(key, 0, -1) if status
131
131
  end
132
- map_entries(result.last)
132
+
133
+ Status.from_entries(map_entries(result.last), **status.to_h) if status
133
134
  end
134
135
 
135
136
  # Mark a circuit as open
@@ -138,11 +139,14 @@ class Faulty
138
139
  # @param (see Interface#open)
139
140
  # @return (see Interface#open)
140
141
  def open(circuit, opened_at)
141
- redis do |r|
142
- opened = compare_and_set(r, state_key(circuit), ['closed', nil], 'open', ex: options.circuit_ttl)
143
- r.set(opened_at_key(circuit), opened_at, ex: options.circuit_ttl) if opened
144
- opened
142
+ key = state_key(circuit)
143
+ ex = options.circuit_ttl
144
+ result = watch_exec(key, ['closed', nil]) do |m|
145
+ m.set(key, 'open', ex: ex)
146
+ m.set(opened_at_key(circuit), opened_at, ex: ex)
145
147
  end
148
+
149
+ result && result[0] == 'OK'
146
150
  end
147
151
 
148
152
  # Mark a circuit as reopened
@@ -151,9 +155,12 @@ class Faulty
151
155
  # @param (see Interface#reopen)
152
156
  # @return (see Interface#reopen)
153
157
  def reopen(circuit, opened_at, previous_opened_at)
154
- redis do |r|
155
- compare_and_set(r, opened_at_key(circuit), [previous_opened_at.to_s], opened_at, ex: options.circuit_ttl)
158
+ key = opened_at_key(circuit)
159
+ result = watch_exec(key, [previous_opened_at.to_s]) do |m|
160
+ m.set(key, opened_at, ex: options.circuit_ttl)
156
161
  end
162
+
163
+ result && result[0] == 'OK'
157
164
  end
158
165
 
159
166
  # Mark a circuit as closed
@@ -162,11 +169,14 @@ class Faulty
162
169
  # @param (see Interface#close)
163
170
  # @return (see Interface#close)
164
171
  def close(circuit)
165
- redis do |r|
166
- closed = compare_and_set(r, state_key(circuit), ['open'], 'closed', ex: options.circuit_ttl)
167
- r.del(entries_key(circuit)) if closed
168
- closed
172
+ key = state_key(circuit)
173
+ ex = options.circuit_ttl
174
+ result = watch_exec(key, ['open']) do |m|
175
+ m.set(key, 'closed', ex: ex)
176
+ m.del(entries_key(circuit))
169
177
  end
178
+
179
+ result && result[0] == 'OK'
170
180
  end
171
181
 
172
182
  # Lock a circuit open or closed
@@ -220,11 +230,15 @@ class Faulty
220
230
  futures[:entries] = r.lrange(entries_key(circuit), 0, -1)
221
231
  end
222
232
 
233
+ state = futures[:state].value&.to_sym || :closed
234
+ opened_at = futures[:opened_at].value ? futures[:opened_at].value.to_i : nil
235
+ opened_at = Faulty.current_time - options.circuit_ttl if state == :open && opened_at.nil?
236
+
223
237
  Faulty::Status.from_entries(
224
238
  map_entries(futures[:entries].value),
225
- state: futures[:state].value&.to_sym || :closed,
239
+ state: state,
226
240
  lock: futures[:lock].value&.to_sym,
227
- opened_at: futures[:opened_at].value ? futures[:opened_at].value.to_i : nil,
241
+ opened_at: opened_at,
228
242
  options: circuit.options
229
243
  )
230
244
  end
@@ -329,23 +343,28 @@ class Faulty
329
343
  (Faulty.current_time.to_f / options.list_granularity).floor
330
344
  end
331
345
 
332
- # Set a value in Redis only if it matches a list of current values
346
+ # Watch a Redis key and exec commands only if the key matches the expected
347
+ # value. Internally this uses Redis transactions with WATCH/MULTI/EXEC.
333
348
  #
334
- # @param redis [Redis] The redis connection
335
- # @param key [String] The redis key to CAS
336
- # @param old [Array<String>] A list of previous values that pass the
337
- # comparison
338
- # @param new [String] The new value to set if the compare passes
339
- # @return [Boolean] True if the value was set to `new`, false if the CAS
340
- # failed
341
- def compare_and_set(redis, key, old, new, ex:)
342
- redis.watch(key) do
343
- if old.include?(redis.get(key))
344
- result = redis.multi { |m| m.set(key, new, ex: ex) }
345
- result && result[0] == 'OK'
346
- else
347
- redis.unwatch
348
- false
349
+ # @param key [String] The redis key to watch
350
+ # @param old [Array<String>] A list of previous values. The block will be
351
+ # run only if key is one of these values.
352
+ # @yield [Redis] A redis client. Commands executed using this client
353
+ # will be executed inside the MULTI context and will only be run if
354
+ # the watch succeeds and the comparison passes
355
+ # @return [Array] An array of Redis results from the commands executed
356
+ # inside the block
357
+ def watch_exec(key, old)
358
+ redis do |r|
359
+ r.watch(key) do
360
+ if old.include?(r.get(key))
361
+ r.multi do |m|
362
+ yield m
363
+ end
364
+ else
365
+ r.unwatch
366
+ nil
367
+ end
349
368
  end
350
369
  end
351
370
  end
@@ -3,6 +3,6 @@
3
3
  class Faulty
4
4
  # The current Faulty version
5
5
  def self.version
6
- Gem::Version.new('0.8.0')
6
+ Gem::Version.new('0.8.5')
7
7
  end
8
8
  end
data/lib/faulty.rb CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  require 'securerandom'
4
4
  require 'forwardable'
5
- require 'concurrent-ruby'
5
+ require 'concurrent'
6
6
 
7
+ require 'faulty/deprecation'
7
8
  require 'faulty/immutable_options'
8
9
  require 'faulty/cache'
9
10
  require 'faulty/circuit'
@@ -143,14 +144,14 @@ class Faulty
143
144
  @disabled = true
144
145
  end
145
146
 
146
- # Re-enable Faulty if disabled with {#disable!}
147
+ # Re-enable Faulty if disabled with {.disable!}
147
148
  #
148
149
  # @return [void]
149
150
  def enable!
150
151
  @disabled = false
151
152
  end
152
153
 
153
- # Check whether Faulty was disabled with {#disable!}
154
+ # Check whether Faulty was disabled with {.disable!}
154
155
  #
155
156
  # @return [Boolean] True if disabled
156
157
  def disabled?
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.8.0
4
+ version: 0.8.5
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-09-14 00:00:00.000000000 Z
11
+ date: 2022-02-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -159,6 +159,7 @@ files:
159
159
  - lib/faulty/cache/rails.rb
160
160
  - lib/faulty/circuit.rb
161
161
  - lib/faulty/circuit_registry.rb
162
+ - lib/faulty/deprecation.rb
162
163
  - lib/faulty/error.rb
163
164
  - lib/faulty/events.rb
164
165
  - lib/faulty/events/callback_listener.rb
@@ -170,6 +171,7 @@ files:
170
171
  - lib/faulty/immutable_options.rb
171
172
  - lib/faulty/patch.rb
172
173
  - lib/faulty/patch/base.rb
174
+ - lib/faulty/patch/elasticsearch.rb
173
175
  - lib/faulty/patch/mysql2.rb
174
176
  - lib/faulty/patch/redis.rb
175
177
  - lib/faulty/result.rb