faulty 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -11,6 +11,8 @@ class Faulty
11
11
  # - If ActiveSupport is available, it will use an `ActiveSupport::Cache::MemoryStore`
12
12
  # - Otherwise it will use a {Faulty::Cache::Null}
13
13
  class Default
14
+ extend Forwardable
15
+
14
16
  def initialize
15
17
  @cache = if defined?(::Rails)
16
18
  Cache::Rails.new(::Rails.cache)
@@ -21,28 +23,15 @@ class Faulty
21
23
  end
22
24
  end
23
25
 
24
- # Read from the internal cache by key
26
+ # @!method read(key)
27
+ # (see Faulty::Cache::Interface#read)
25
28
  #
26
- # @param (see Cache::Interface#read)
27
- # @return (see Cache::Interface#read)
28
- def read(key)
29
- @cache.read(key)
30
- end
31
-
32
- # Write to the internal cache
29
+ # @!method write(key, value, expires_in: expires_in)
30
+ # (see Faulty::Cache::Interface#write)
33
31
  #
34
- # @param (see Cache::Interface#read)
35
- # @return (see Cache::Interface#read)
36
- def write(key, value, expires_in: nil)
37
- @cache.write(key, value, expires_in: expires_in)
38
- end
39
-
40
- # This cache is fault tolerant if the internal one is
41
- #
42
- # @return [Boolean]
43
- def fault_tolerant?
44
- @cache.fault_tolerant?
45
- end
32
+ # @!method fault_tolerant
33
+ # (see Faulty::Cache::Interface#fault_tolerant?)
34
+ def_delegators :@cache, :read, :write, :fault_tolerant?
46
35
  end
47
36
  end
48
37
  end
@@ -8,7 +8,8 @@ class Faulty
8
8
  # this class.
9
9
  #
10
10
  # If the cache backend raises a `StandardError`, it will be captured and
11
- # sent to the notifier.
11
+ # sent to the notifier. Reads errors will return `nil`, and writes will be
12
+ # a no-op.
12
13
  class FaultTolerantProxy
13
14
  attr_reader :options
14
15
 
@@ -36,6 +37,16 @@ class Faulty
36
37
  @options = Options.new(options, &block)
37
38
  end
38
39
 
40
+ # Wrap a cache in a FaultTolerantProxy unless it's already fault tolerant
41
+ #
42
+ # @param cache [Cache::Interface] The cache to maybe wrap
43
+ # @return [Cache::Interface] The original cache or a {FaultTolerantProxy}
44
+ def self.wrap(cache, **options, &block)
45
+ return cache if cache.fault_tolerant?
46
+
47
+ new(cache, **options, &block)
48
+ end
49
+
39
50
  # Read from the cache safely
40
51
  #
41
52
  # If the backend raises a `StandardError`, this will return `nil`.
@@ -58,7 +69,7 @@ class Faulty
58
69
  # @return [void]
59
70
  def write(key, value, expires_in: nil)
60
71
  @cache.write(key, value, expires_in: expires_in)
61
- rescue StandardError
72
+ rescue StandardError => e
62
73
  options.notifier.notify(:cache_failure, key: key, action: :write, error: e)
63
74
  nil
64
75
  end
@@ -5,6 +5,8 @@ class Faulty
5
5
  # A wrapper for a Rails or ActiveSupport cache
6
6
  #
7
7
  class Rails
8
+ extend Forwardable
9
+
8
10
  # @param cache The Rails cache to wrap
9
11
  # @param fault_tolerant [Boolean] Whether the Rails cache is
10
12
  # fault_tolerant. See {#fault_tolerant?} for more details
@@ -13,15 +15,12 @@ class Faulty
13
15
  @fault_tolerant = fault_tolerant
14
16
  end
15
17
 
16
- # (see Interface#read)
17
- def read(key)
18
- @cache.read(key)
19
- end
20
-
21
- # (see Interface#read)
22
- def write(key, value, expires_in: nil)
23
- @cache.write(key, value, expires_in: expires_in)
24
- end
18
+ # @!method read(key)
19
+ # (see Faulty::Cache::Interface#read)
20
+ #
21
+ # @!method write(key, value, expires_in: expires_in)
22
+ # (see Faulty::Cache::Interface#write)
23
+ def_delegators :@cache, :read, :write
25
24
 
26
25
  # Although ActiveSupport cache implementations are fault-tolerant,
27
26
  # Rails.cache is not guranteed to be fault tolerant. For this reason,
@@ -66,14 +66,19 @@ class Faulty
66
66
  # @return [Error, Array<Error>] An array of errors that are considered circuit
67
67
  # failures. Default `[StandardError]`.
68
68
  # @!attribute [r] exclude
69
- # @return [Error, Array<Error>] An array of errors that will be captured and
70
- # considered circuit failures. Default `[]`.
69
+ # @return [Error, Array<Error>] An array of errors that will not be
70
+ # captured by Faulty. These errors will not be considered circuit
71
+ # failures. Default `[]`.
71
72
  # @!attribute [r] cache
72
- # @return [Cache::Interface] The cache backend. Default `Cache::Null.new`
73
+ # @return [Cache::Interface] The cache backend. Default
74
+ # `Cache::Null.new`. Unlike {Faulty#initialize}, this is not wrapped in
75
+ # {Cache::AutoWire} by default.
73
76
  # @!attribute [r] notifier
74
77
  # @return [Events::Notifier] A Faulty notifier. Default `Events::Notifier.new`
75
78
  # @!attribute [r] storage
76
- # @return [Storage::Interface] The storage backend. Default `Storage::Memory.new`
79
+ # @return [Storage::Interface] The storage backend. Default
80
+ # `Storage::Memory.new`. Unlike {Faulty#initialize}, this is not wrapped
81
+ # in {Storage::AutoWire} by default.
77
82
  Options = Struct.new(
78
83
  :cache_expires_in,
79
84
  :cache_refreshes_after,
@@ -59,8 +59,22 @@ class Faulty
59
59
  # Raised if calling get or error on a result without checking it
60
60
  class UncheckedResultError < FaultyError; end
61
61
 
62
+ # An error that wraps multiple other errors
63
+ class FaultyMultiError < FaultyError
64
+ def initialize(message, errors)
65
+ message = "#{message}: #{errors.map(&:message).join(', ')}"
66
+ super(message)
67
+ end
68
+ end
69
+
62
70
  # Raised if getting the wrong result type.
63
71
  #
64
72
  # For example, calling get on an error result will raise this
65
73
  class WrongResultError < FaultyError; end
74
+
75
+ # Raised if a FallbackChain partially fails
76
+ class PartialFailureError < FaultyMultiError; end
77
+
78
+ # Raised if all FallbackChain backends fail
79
+ class AllFailedError < FaultyMultiError; end
66
80
  end
@@ -6,6 +6,9 @@ class Faulty
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,122 @@
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
+ extend Forwardable
10
+
11
+ # Options for {AutoWire}
12
+ Options = Struct.new(
13
+ :notifier
14
+ ) do
15
+ include ImmutableOptions
16
+
17
+ private
18
+
19
+ def required
20
+ %i[notifier]
21
+ end
22
+ end
23
+
24
+ # Wrap storage backends with sensible defaults
25
+ #
26
+ # If the cache is `nil`, create a new {Memory} storage.
27
+ #
28
+ # If a single storage backend is given and is fault tolerant, leave it
29
+ # unmodified.
30
+ #
31
+ # If a single storage backend is given and is not fault tolerant, wrap it
32
+ # in a {CircuitProxy} and a {FaultTolerantProxy}.
33
+ #
34
+ # If an array of storage backends is given, wrap each non-fault-tolerant
35
+ # entry in a {CircuitProxy} and create a {FallbackChain}. If none of the
36
+ # backends in the array are fault tolerant, also wrap the {FallbackChain}
37
+ # in a {FaultTolerantProxy}.
38
+ #
39
+ # @todo Consider using a {FallbackChain} for non-fault-tolerant storages
40
+ # by default. This would fallback to a {Memory} storage. It would
41
+ # require a more conservative implementation of {Memory} that could
42
+ # limit the number of circuits stored. For now, users need to manually
43
+ # configure fallbacks.
44
+ #
45
+ # @param storage [Interface, Array<Interface>] A storage backed or array
46
+ # of storage backends to setup.
47
+ # @param options [Hash] Attributes for {Options}
48
+ # @yield [Options] For setting options in a block
49
+ def initialize(storage, **options, &block)
50
+ @options = Options.new(options, &block)
51
+ @storage = if storage.nil?
52
+ Memory.new
53
+ elsif storage.is_a?(Array)
54
+ wrap_array(storage)
55
+ elsif !storage.fault_tolerant?
56
+ wrap_one(storage)
57
+ else
58
+ storage
59
+ end
60
+
61
+ freeze
62
+ end
63
+
64
+ # @!method entry(circuit, time, success)
65
+ # (see Faulty::Storage::Interface#entry)
66
+ #
67
+ # @!method open(circuit, opened_at)
68
+ # (see Faulty::Storage::Interface#open)
69
+ #
70
+ # @!method reopen(circuit, opened_at, previous_opened_at)
71
+ # (see Faulty::Storage::Interface#reopen)
72
+ #
73
+ # @!method close(circuit)
74
+ # (see Faulty::Storage::Interface#close)
75
+ #
76
+ # @!method lock(circuit, state)
77
+ # (see Faulty::Storage::Interface#lock)
78
+ #
79
+ # @!method unlock(circuit)
80
+ # (see Faulty::Storage::Interface#unlock)
81
+ #
82
+ # @!method reset(circuit)
83
+ # (see Faulty::Storage::Interface#reset)
84
+ #
85
+ # @!method status(circuit)
86
+ # (see Faulty::Storage::Interface#status)
87
+ #
88
+ # @!method history(circuit)
89
+ # (see Faulty::Storage::Interface#history)
90
+ #
91
+ # @!method list
92
+ # (see Faulty::Storage::Interface#list)
93
+ #
94
+ def_delegators :@storage,
95
+ :entry, :open, :reopen, :close, :lock,
96
+ :unlock, :reset, :status, :history, :list
97
+
98
+ def fault_tolerant?
99
+ true
100
+ end
101
+
102
+ private
103
+
104
+ # Wrap an array of storage backends in a fault-tolerant FallbackChain
105
+ #
106
+ # @return [Storage::Interface] A fault-tolerant fallback chain
107
+ def wrap_array(array)
108
+ FaultTolerantProxy.wrap(FallbackChain.new(
109
+ array.map { |s| s.fault_tolerant? ? s : CircuitProxy.new(s, notifier: @options.notifier) },
110
+ notifier: @options.notifier
111
+ ), notifier: @options.notifier)
112
+ end
113
+
114
+ def wrap_one(storage)
115
+ FaultTolerantProxy.new(
116
+ CircuitProxy.new(storage, notifier: @options.notifier),
117
+ notifier: @options.notifier
118
+ )
119
+ end
120
+ end
121
+ end
122
+ 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