faulty 0.1.4 → 0.5.1

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.
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