faulty 0.1.4 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.rubocop.yml +9 -0
  4. data/CHANGELOG.md +55 -0
  5. data/Gemfile +8 -3
  6. data/README.md +883 -310
  7. data/bin/check-version +5 -1
  8. data/faulty.gemspec +1 -1
  9. data/lib/faulty.rb +167 -43
  10. data/lib/faulty/cache.rb +3 -1
  11. data/lib/faulty/cache/auto_wire.rb +58 -0
  12. data/lib/faulty/cache/circuit_proxy.rb +61 -0
  13. data/lib/faulty/cache/default.rb +10 -21
  14. data/lib/faulty/cache/fault_tolerant_proxy.rb +15 -4
  15. data/lib/faulty/cache/interface.rb +1 -1
  16. data/lib/faulty/cache/mock.rb +1 -1
  17. data/lib/faulty/cache/null.rb +1 -1
  18. data/lib/faulty/cache/rails.rb +9 -10
  19. data/lib/faulty/circuit.rb +31 -16
  20. data/lib/faulty/error.rb +29 -7
  21. data/lib/faulty/events.rb +1 -1
  22. data/lib/faulty/events/callback_listener.rb +1 -1
  23. data/lib/faulty/events/honeybadger_listener.rb +1 -1
  24. data/lib/faulty/events/listener_interface.rb +1 -1
  25. data/lib/faulty/events/log_listener.rb +5 -6
  26. data/lib/faulty/events/notifier.rb +1 -1
  27. data/lib/faulty/immutable_options.rb +1 -1
  28. data/lib/faulty/patch.rb +154 -0
  29. data/lib/faulty/patch/base.rb +46 -0
  30. data/lib/faulty/patch/redis.rb +60 -0
  31. data/lib/faulty/result.rb +2 -2
  32. data/lib/faulty/status.rb +3 -2
  33. data/lib/faulty/storage.rb +4 -1
  34. data/lib/faulty/storage/auto_wire.rb +107 -0
  35. data/lib/faulty/storage/circuit_proxy.rb +64 -0
  36. data/lib/faulty/storage/fallback_chain.rb +207 -0
  37. data/lib/faulty/storage/fault_tolerant_proxy.rb +52 -57
  38. data/lib/faulty/storage/interface.rb +3 -2
  39. data/lib/faulty/storage/memory.rb +8 -4
  40. data/lib/faulty/storage/redis.rb +75 -13
  41. data/lib/faulty/version.rb +2 -2
  42. metadata +14 -7
  43. data/.travis.yml +0 -46
  44. data/lib/faulty/scope.rb +0 -117
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Patch
5
+ # Can be included in patch modules to provide common functionality
6
+ #
7
+ # The patch needs to set `@faulty_circuit`
8
+ #
9
+ # @example
10
+ # module ThingPatch
11
+ # include Faulty::Patch::Base
12
+ #
13
+ # def initialize(options = {})
14
+ # @faulty_circuit = Faulty::Patch.circuit_from_hash('thing', options[:faulty])
15
+ # end
16
+ #
17
+ # def do_something
18
+ # faulty_run { super }
19
+ # end
20
+ # end
21
+ #
22
+ # Thing.prepend(ThingPatch)
23
+ module Base
24
+ # Run a block wrapped by `@faulty_circuit`
25
+ #
26
+ # If `@faulty_circuit` is not set, the block will be run with no
27
+ # circuit.
28
+ #
29
+ # Nested calls to this method will only cause the circuit to be triggered
30
+ # once.
31
+ #
32
+ # @yield A block to run inside the circuit
33
+ # @return The block return value
34
+ def faulty_run
35
+ faulty_running_key = "faulty_running_#{object_id}"
36
+ return yield unless @faulty_circuit
37
+ return yield if Thread.current[faulty_running_key]
38
+
39
+ Thread.current[faulty_running_key] = true
40
+ @faulty_circuit.run { yield }
41
+ ensure
42
+ Thread.current[faulty_running_key] = false
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+
5
+ class Faulty
6
+ module Patch
7
+ # Patch Redis to run all network IO in a circuit
8
+ #
9
+ # This module is not required by default
10
+ #
11
+ # Pass a `:faulty` key into your redis connection options to enable
12
+ # circuit protection. This hash is a hash of circuit options for the
13
+ # internal circuit. The hash may also have a `:instance` key, which is the
14
+ # faulty instance to create the circuit from. `Faulty.default` will be
15
+ # used if no instance is given. The `:instance` key can also reference a
16
+ # registered Faulty instance or a global constantso that it can be set
17
+ # from config files. See {Patch.circuit_from_hash}.
18
+ #
19
+ # @example
20
+ # require 'faulty/patch/redis'
21
+ #
22
+ # redis = Redis.new(url: 'redis://localhost:6379', faulty: {})
23
+ # redis.connect # raises Faulty::CircuitError if connection fails
24
+ #
25
+ # # If the faulty key is not given, no circuit is used
26
+ # redis = Redis.new(url: 'redis://localhost:6379')
27
+ # redis.connect # not protected by a circuit
28
+ #
29
+ # @see Patch.circuit_from_hash
30
+ module Redis
31
+ include Base
32
+
33
+ Patch.define_circuit_errors(self, ::Redis::BaseConnectionError)
34
+
35
+ # Patches Redis to add the `:faulty` key
36
+ def initialize(options = {})
37
+ @faulty_circuit = Patch.circuit_from_hash(
38
+ 'redis',
39
+ options[:faulty],
40
+ errors: [::Redis::BaseConnectionError],
41
+ patched_error_module: Faulty::Patch::Redis
42
+ )
43
+
44
+ super
45
+ end
46
+
47
+ # The initial connection is protected by a circuit
48
+ def connect
49
+ faulty_run { super }
50
+ end
51
+
52
+ # Reads/writes to redis are protected
53
+ def io(&block)
54
+ faulty_run { super }
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ ::Redis::Client.prepend(Faulty::Patch::Redis)
data/lib/faulty/result.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # An approximation of the `Result` type from some strongly-typed languages.
5
5
  #
6
6
  # F#: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/results
@@ -53,7 +53,7 @@ module Faulty
53
53
  #
54
54
  # @param ok An ok value
55
55
  # @param error [Error] An error instance
56
- def initialize(ok: NOTHING, error: NOTHING) # rubocop:disable Naming/MethodParameterName
56
+ def initialize(ok: NOTHING, error: NOTHING)
57
57
  if ok.equal?(NOTHING) && error.equal?(NOTHING)
58
58
  raise ArgumentError, 'Result must have an ok or error value'
59
59
  end
data/lib/faulty/status.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The status of a circuit
5
5
  #
6
6
  # Includes information like the state and locks. Also calculates
@@ -144,9 +144,10 @@ module Faulty
144
144
 
145
145
  def finalize
146
146
  raise ArgumentError, "state must be a symbol in #{self.class}::STATES" unless STATES.include?(state)
147
- unless lock.nil? || LOCKS.include?(state)
147
+ unless lock.nil? || LOCKS.include?(lock)
148
148
  raise ArgumentError, "lock must be a symbol in #{self.class}::LOCKS or nil"
149
149
  end
150
+ raise ArgumentError, 'opened_at is required if state is open' if state == :open && opened_at.nil?
150
151
  end
151
152
 
152
153
  def required
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The namespace for Faulty storage
5
5
  module Storage
6
6
  end
7
7
  end
8
8
 
9
+ require 'faulty/storage/auto_wire'
10
+ require 'faulty/storage/circuit_proxy'
11
+ require 'faulty/storage/fallback_chain'
9
12
  require 'faulty/storage/fault_tolerant_proxy'
10
13
  require 'faulty/storage/memory'
11
14
  require 'faulty/storage/redis'
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Storage
5
+ # Automatically configure a storage backend
6
+ #
7
+ # Used by {Faulty#initialize} to setup sensible storage defaults
8
+ class AutoWire
9
+ # Options for {AutoWire}
10
+ #
11
+ # @!attribute [r] circuit
12
+ # @return [Circuit] A circuit for {CircuitProxy} if one is created.
13
+ # When modifying this, be careful to use only a reliable circuit
14
+ # storage backend so that you don't introduce cascading failures.
15
+ # @!attribute [r] notifier
16
+ # @return [Events::Notifier] A Faulty notifier. If given, listeners are
17
+ # ignored.
18
+ Options = Struct.new(
19
+ :circuit,
20
+ :notifier
21
+ ) do
22
+ include ImmutableOptions
23
+
24
+ private
25
+
26
+ def required
27
+ %i[notifier]
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # Wrap storage backends with sensible defaults
33
+ #
34
+ # If the cache is `nil`, create a new {Memory} storage.
35
+ #
36
+ # If a single storage backend is given and is fault tolerant, leave it
37
+ # unmodified.
38
+ #
39
+ # If a single storage backend is given and is not fault tolerant, wrap it
40
+ # in a {CircuitProxy} and a {FaultTolerantProxy}.
41
+ #
42
+ # If an array of storage backends is given, wrap each non-fault-tolerant
43
+ # entry in a {CircuitProxy} and create a {FallbackChain}. If none of the
44
+ # backends in the array are fault tolerant, also wrap the {FallbackChain}
45
+ # in a {FaultTolerantProxy}.
46
+ #
47
+ # @todo Consider using a {FallbackChain} for non-fault-tolerant storages
48
+ # by default. This would fallback to a {Memory} storage. It would
49
+ # require a more conservative implementation of {Memory} that could
50
+ # limit the number of circuits stored. For now, users need to manually
51
+ # configure fallbacks.
52
+ #
53
+ # @param storage [Interface, Array<Interface>] A storage backed or array
54
+ # of storage backends to setup.
55
+ # @param options [Hash] Attributes for {Options}
56
+ # @yield [Options] For setting options in a block
57
+ def wrap(storage, **options, &block)
58
+ options = Options.new(options, &block)
59
+ if storage.nil?
60
+ Memory.new
61
+ elsif storage.is_a?(Array)
62
+ wrap_array(storage, options)
63
+ elsif !storage.fault_tolerant?
64
+ wrap_one(storage, options)
65
+ else
66
+ storage
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Wrap an array of storage backends in a fault-tolerant FallbackChain
73
+ #
74
+ # @param [Array<Storage::Interface>] The array to wrap
75
+ # @param options [Options]
76
+ # @return [Storage::Interface] A fault-tolerant fallback chain
77
+ def wrap_array(array, options)
78
+ FaultTolerantProxy.wrap(FallbackChain.new(
79
+ array.map { |s| s.fault_tolerant? ? s : circuit_proxy(s, options) },
80
+ notifier: options.notifier
81
+ ), notifier: options.notifier)
82
+ end
83
+
84
+ # Wrap one storage backend in fault-tolerant backends
85
+ #
86
+ # @param [Storage::Interface] The storage to wrap
87
+ # @param options [Options]
88
+ # @return [Storage::Interface] A fault-tolerant storage backend
89
+ def wrap_one(storage, options)
90
+ FaultTolerantProxy.new(
91
+ circuit_proxy(storage, options),
92
+ notifier: options.notifier
93
+ )
94
+ end
95
+
96
+ # Wrap storage in a CircuitProxy
97
+ #
98
+ # @param [Storage::Interface] The storage to wrap
99
+ # @param options [Options]
100
+ # @return [CircuitProxy]
101
+ def circuit_proxy(storage, options)
102
+ CircuitProxy.new(storage, circuit: options.circuit, notifier: options.notifier)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Storage
5
+ # A circuit wrapper for storage backends
6
+ #
7
+ # This class uses an internal {Circuit} to prevent the storage backend from
8
+ # causing application issues. If the backend fails continuously, this
9
+ # circuit will trip to prevent cascading failures. This internal circuit
10
+ # uses an independent in-memory backend by default.
11
+ class CircuitProxy
12
+ attr_reader :options
13
+
14
+ # Options for {CircuitProxy}
15
+ #
16
+ # @!attribute [r] circuit
17
+ # @return [Circuit] A replacement for the internal circuit. When
18
+ # modifying this, be careful to use only a reliable storage backend
19
+ # so that you don't introduce cascading failures.
20
+ # @!attribute [r] notifier
21
+ # @return [Events::Notifier] A Faulty notifier to use for circuit
22
+ # notifications. If `circuit` is given, this is ignored.
23
+ Options = Struct.new(
24
+ :circuit,
25
+ :notifier
26
+ ) do
27
+ include ImmutableOptions
28
+
29
+ private
30
+
31
+ def finalize
32
+ raise ArgumentError, 'The circuit or notifier option must be given' unless notifier || circuit
33
+
34
+ self.circuit ||= Circuit.new(
35
+ Faulty::Storage::CircuitProxy.name,
36
+ notifier: notifier,
37
+ cache: Cache::Null.new
38
+ )
39
+ end
40
+ end
41
+
42
+ # @param storage [Storage::Interface] The storage backend to wrap
43
+ # @param options [Hash] Attributes for {Options}
44
+ # @yield [Options] For setting options in a block
45
+ def initialize(storage, **options, &block)
46
+ @storage = storage
47
+ @options = Options.new(options, &block)
48
+ end
49
+
50
+ %i[entry open reopen close lock unlock reset status history list].each do |method|
51
+ define_method(method) do |*args|
52
+ options.circuit.run { @storage.public_send(method, *args) }
53
+ end
54
+ end
55
+
56
+ # This cache makes any storage fault tolerant, so this is always `true`
57
+ #
58
+ # @return [true]
59
+ def fault_tolerant?
60
+ @storage.fault_tolerant?
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Storage
5
+ # An prioritized list of storage backends
6
+ #
7
+ # If any backend fails, the next will be tried until one succeeds. This
8
+ # should typically be used when using a fault-prone backend such as
9
+ # {Storage::Redis}.
10
+ #
11
+ # This is used by {Faulty#initialize} if the `storage` option is set to an
12
+ # array.
13
+ #
14
+ # @example
15
+ # # This storage will try Redis first, then fallback to memory storage
16
+ # # if Redis is unavailable.
17
+ # storage = Faulty::Storage::FallbackChain.new([
18
+ # Faulty::Storage::Redis.new,
19
+ # Faulty::Storage::Memory.new
20
+ # ])
21
+ class FallbackChain
22
+ attr_reader :options
23
+
24
+ # Options for {FallbackChain}
25
+ #
26
+ # @!attribute [r] notifier
27
+ # @return [Events::Notifier] A Faulty notifier
28
+ Options = Struct.new(
29
+ :notifier
30
+ ) do
31
+ include ImmutableOptions
32
+
33
+ private
34
+
35
+ def required
36
+ %i[notifier]
37
+ end
38
+ end
39
+
40
+ # Create a new {FallbackChain} to automatically fallback to reliable storage
41
+ #
42
+ # @param storages [Array<Storage::Interface>] An array of storage backends.
43
+ # The primary storage should be specified first. If that one fails,
44
+ # additional entries will be tried in sequence until one succeeds.
45
+ # @param options [Hash] Attributes for {Options}
46
+ # @yield [Options] For setting options in a block
47
+ def initialize(storages, **options, &block)
48
+ @storages = storages
49
+ @options = Options.new(options, &block)
50
+ end
51
+
52
+ # Create a circuit entry in the first available storage backend
53
+ #
54
+ # @param (see Interface#entry)
55
+ # @return (see Interface#entry)
56
+ def entry(circuit, time, success)
57
+ send_chain(:entry, circuit, time, success) do |e|
58
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :entry, error: e)
59
+ end
60
+ end
61
+
62
+ # Open a circuit in the first available storage backend
63
+ #
64
+ # @param (see Interface#open)
65
+ # @return (see Interface#open)
66
+ def open(circuit, opened_at)
67
+ send_chain(:open, circuit, opened_at) do |e|
68
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :open, error: e)
69
+ end
70
+ end
71
+
72
+ # Reopen a circuit in the first available storage backend
73
+ #
74
+ # @param (see Interface#reopen)
75
+ # @return (see Interface#reopen)
76
+ def reopen(circuit, opened_at, previous_opened_at)
77
+ send_chain(:reopen, circuit, opened_at, previous_opened_at) do |e|
78
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :reopen, error: e)
79
+ end
80
+ end
81
+
82
+ # Close a circuit in the first available storage backend
83
+ #
84
+ # @param (see Interface#close)
85
+ # @return (see Interface#close)
86
+ def close(circuit)
87
+ send_chain(:close, circuit) do |e|
88
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :close, error: e)
89
+ end
90
+ end
91
+
92
+ # Lock a circuit in all storage backends
93
+ #
94
+ # @param (see Interface#lock)
95
+ # @return (see Interface#lock)
96
+ def lock(circuit, state)
97
+ send_all(:lock, circuit, state)
98
+ end
99
+
100
+ # Unlock a circuit in all storage backends
101
+ #
102
+ # @param (see Interface#unlock)
103
+ # @return (see Interface#unlock)
104
+ def unlock(circuit)
105
+ send_all(:unlock, circuit)
106
+ end
107
+
108
+ # Reset a circuit in all storage backends
109
+ #
110
+ # @param (see Interface#reset)
111
+ # @return (see Interface#reset)
112
+ def reset(circuit)
113
+ send_all(:reset, circuit)
114
+ end
115
+
116
+ # Get the status of a circuit from the first available storage backend
117
+ #
118
+ # @param (see Interface#status)
119
+ # @return (see Interface#status)
120
+ def status(circuit)
121
+ send_chain(:status, circuit) do |e|
122
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :status, error: e)
123
+ end
124
+ end
125
+
126
+ # Get the history of a circuit from the first available storage backend
127
+ #
128
+ # @param (see Interface#history)
129
+ # @return (see Interface#history)
130
+ def history(circuit)
131
+ send_chain(:history, circuit) do |e|
132
+ options.notifier.notify(:storage_failure, circuit: circuit, action: :history, error: e)
133
+ end
134
+ end
135
+
136
+ # Get the list of circuits from the first available storage backend
137
+ #
138
+ # @param (see Interface#list)
139
+ # @return (see Interface#list)
140
+ def list
141
+ send_chain(:list) do |e|
142
+ options.notifier.notify(:storage_failure, action: :list, error: e)
143
+ end
144
+ end
145
+
146
+ # This is fault tolerant if any of the available backends are fault tolerant
147
+ #
148
+ # @param (see Interface#fault_tolerant?)
149
+ # @return (see Interface#fault_tolerant?)
150
+ def fault_tolerant?
151
+ @storages.any?(&:fault_tolerant?)
152
+ end
153
+
154
+ private
155
+
156
+ # Call a method on the backend and return the first successful result
157
+ #
158
+ # Short-circuits, so that if a call succeeds, no additional backends are
159
+ # called.
160
+ #
161
+ # @param method [Symbol] The method to call
162
+ # @param args [Array] The arguments to send
163
+ # @raise [AllFailedError] AllFailedError if all backends fail
164
+ # @return The return value from the first successful call
165
+ def send_chain(method, *args)
166
+ errors = []
167
+ @storages.each do |s|
168
+ begin
169
+ return s.public_send(method, *args)
170
+ rescue StandardError => e
171
+ errors << e
172
+ yield e
173
+ end
174
+ end
175
+
176
+ raise AllFailedError.new("#{self.class}##{method} failed for all storage backends", errors)
177
+ end
178
+
179
+ # Call a method on every backend
180
+ #
181
+ # @param method [Symbol] The method to call
182
+ # @param args [Array] The arguments to send
183
+ # @raise [AllFailedError] AllFailedError if all backends fail
184
+ # @raise [PartialFailureError] PartialFailureError if some but not all
185
+ # backends fail
186
+ # @return [nil]
187
+ def send_all(method, *args)
188
+ errors = []
189
+ @storages.each do |s|
190
+ begin
191
+ s.public_send(method, *args)
192
+ rescue StandardError => e
193
+ errors << e
194
+ end
195
+ end
196
+
197
+ if errors.empty?
198
+ nil
199
+ elsif errors.size < @storages.size
200
+ raise PartialFailureError.new("#{self.class}##{method} failed for some storage backends", errors)
201
+ else
202
+ raise AllFailedError.new("#{self.class}##{method} failed for all storage backends", errors)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end