faulty 0.8.0 → 0.8.5

Sign up to get free protection for your applications and to get access to all the features.
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