faulty 0.1.1 → 0.3.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -0
  3. data/.travis.yml +4 -2
  4. data/CHANGELOG.md +37 -1
  5. data/Gemfile +17 -0
  6. data/README.md +333 -55
  7. data/bin/check-version +5 -1
  8. data/bin/console +1 -1
  9. data/faulty.gemspec +3 -10
  10. data/lib/faulty.rb +149 -43
  11. data/lib/faulty/cache.rb +3 -1
  12. data/lib/faulty/cache/auto_wire.rb +65 -0
  13. data/lib/faulty/cache/circuit_proxy.rb +61 -0
  14. data/lib/faulty/cache/default.rb +10 -21
  15. data/lib/faulty/cache/fault_tolerant_proxy.rb +15 -4
  16. data/lib/faulty/cache/interface.rb +1 -1
  17. data/lib/faulty/cache/mock.rb +1 -1
  18. data/lib/faulty/cache/null.rb +1 -1
  19. data/lib/faulty/cache/rails.rb +9 -10
  20. data/lib/faulty/circuit.rb +10 -5
  21. data/lib/faulty/error.rb +18 -4
  22. data/lib/faulty/events.rb +3 -2
  23. data/lib/faulty/events/callback_listener.rb +1 -1
  24. data/lib/faulty/events/honeybadger_listener.rb +53 -0
  25. data/lib/faulty/events/listener_interface.rb +1 -1
  26. data/lib/faulty/events/log_listener.rb +1 -1
  27. data/lib/faulty/events/notifier.rb +11 -2
  28. data/lib/faulty/immutable_options.rb +1 -1
  29. data/lib/faulty/result.rb +2 -2
  30. data/lib/faulty/status.rb +1 -1
  31. data/lib/faulty/storage.rb +4 -1
  32. data/lib/faulty/storage/auto_wire.rb +122 -0
  33. data/lib/faulty/storage/circuit_proxy.rb +64 -0
  34. data/lib/faulty/storage/fallback_chain.rb +207 -0
  35. data/lib/faulty/storage/fault_tolerant_proxy.rb +55 -60
  36. data/lib/faulty/storage/interface.rb +1 -1
  37. data/lib/faulty/storage/memory.rb +8 -4
  38. data/lib/faulty/storage/redis.rb +75 -13
  39. data/lib/faulty/version.rb +2 -2
  40. metadata +13 -118
  41. data/lib/faulty/scope.rb +0 -117
@@ -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
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Storage
5
5
  # A wrapper for storage backends that may raise errors
6
6
  #
7
- # {Scope} automatically wraps all non-fault-tolerant storage backends with
7
+ # {Faulty#initialize} automatically wraps all non-fault-tolerant storage backends with
8
8
  # this class.
9
9
  #
10
10
  # If the storage backend raises a `StandardError`, it will be captured and
11
11
  # sent to the notifier.
12
12
  class FaultTolerantProxy
13
+ extend Forwardable
14
+
13
15
  attr_reader :options
14
16
 
15
17
  # Options for {FaultTolerantProxy}
@@ -36,6 +38,53 @@ module Faulty
36
38
  @options = Options.new(options, &block)
37
39
  end
38
40
 
41
+ # Wrap a storage backend in a FaultTolerantProxy unless it's already
42
+ # fault tolerant
43
+ #
44
+ # @param storage [Storage::Interface] The storage to maybe wrap
45
+ # @return [Storage::Interface] The original storage or a {FaultTolerantProxy}
46
+ def self.wrap(storage, **options, &block)
47
+ return storage if storage.fault_tolerant?
48
+
49
+ new(storage, **options, &block)
50
+ end
51
+
52
+ # @!method lock(circuit, state)
53
+ # Lock is not called in normal operation, so it doesn't capture errors
54
+ #
55
+ # @see Interface#lock
56
+ # @param (see Interface#lock)
57
+ # @return (see Interface#lock)
58
+ #
59
+ # @!method unlock(circuit)
60
+ # Unlock is not called in normal operation, so it doesn't capture errors
61
+ #
62
+ # @see Interface#unlock
63
+ # @param (see Interface#unlock)
64
+ # @return (see Interface#unlock)
65
+ #
66
+ # @!method reset(circuit)
67
+ # Reset is not called in normal operation, so it doesn't capture errors
68
+ #
69
+ # @see Interface#reset
70
+ # @param (see Interface#reset)
71
+ # @return (see Interface#reset)
72
+ #
73
+ # @!method history(circuit)
74
+ # History is not called in normal operation, so it doesn't capture errors
75
+ #
76
+ # @see Interface#history
77
+ # @param (see Interface#history)
78
+ # @return (see Interface#history)
79
+ #
80
+ # @!method list
81
+ # List is not called in normal operation, so it doesn't capture errors
82
+ #
83
+ # @see Interface#list
84
+ # @param (see Interface#list)
85
+ # @return (see Interface#list)
86
+ def_delegators :@storage, :lock, :unlock, :reset, :history, :list
87
+
39
88
  # Add a history entry safely
40
89
  #
41
90
  # @see Interface#entry
@@ -53,8 +102,8 @@ module Faulty
53
102
  # @see Interface#open
54
103
  # @param (see Interface#open)
55
104
  # @return (see Interface#open)
56
- def open(circuit)
57
- @storage.open(circuit)
105
+ def open(circuit, opened_at)
106
+ @storage.open(circuit, opened_at)
58
107
  rescue StandardError => e
59
108
  options.notifier.notify(:storage_failure, circuit: circuit, action: :open, error: e)
60
109
  false
@@ -65,8 +114,8 @@ module Faulty
65
114
  # @see Interface#reopen
66
115
  # @param (see Interface#reopen)
67
116
  # @return (see Interface#reopen)
68
- def reopen(circuit)
69
- @storage.reopen(circuit)
117
+ def reopen(circuit, opened_at, previous_opened_at)
118
+ @storage.reopen(circuit, opened_at, previous_opened_at)
70
119
  rescue StandardError => e
71
120
  options.notifier.notify(:storage_failure, circuit: circuit, action: :reopen, error: e)
72
121
  false
@@ -84,36 +133,6 @@ module Faulty
84
133
  false
85
134
  end
86
135
 
87
- # Since lock is not called in normal operation, it does not capture
88
- # errors
89
- #
90
- # @see Interface#lock
91
- # @param (see Interface#lock)
92
- # @return (see Interface#lock)
93
- def lock(circuit, state)
94
- @storage.lock(circuit, state)
95
- end
96
-
97
- # Since unlock is not called in normal operation, it does not capture
98
- # errors
99
- #
100
- # @see Interface#unlock
101
- # @param (see Interface#unlock)
102
- # @return (see Interface#unlock)
103
- def unlock(circuit)
104
- @storage.unlock(circuit)
105
- end
106
-
107
- # Since reset is not called in normal operation, it does not capture
108
- # errors
109
- #
110
- # @see Interface#reset
111
- # @param (see Interface#reset)
112
- # @return (see Interface#reset)
113
- def reset(circuit)
114
- @storage.reset(circuit)
115
- end
116
-
117
136
  # Safely get the status of a circuit
118
137
  #
119
138
  # If the backend is unavailable, this returns a stub status that
@@ -129,30 +148,6 @@ module Faulty
129
148
  stub_status(circuit)
130
149
  end
131
150
 
132
- # Since history is not called in normal operation, it does not capture
133
- # errors
134
- #
135
- # @see Interface#history
136
- # @param (see Interface#history)
137
- # @return (see Interface#history)
138
- def history(circuit)
139
- @storage.history(circuit)
140
- end
141
-
142
- # Safely get the list of circuit names
143
- #
144
- # If the backend is unavailable, this returns an empty array
145
- #
146
- # @see Interface#list
147
- # @param (see Interface#list)
148
- # @return (see Interface#list)
149
- def list
150
- @storage.list
151
- rescue StandardError => e
152
- options.notifier.notify(:storage_failure, action: :list, error: e)
153
- []
154
- end
155
-
156
151
  # This cache makes any storage fault tolerant, so this is always `true`
157
152
  #
158
153
  # @return [true]
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Storage
5
5
  # The interface required for a storage backend implementation
6
6
  #
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Storage
5
5
  # The default in-memory storage for circuits
6
6
  #
7
- # This implementation is most suitable to single-process, low volume
8
- # usage. It is thread-safe and circuit state is shared across threads.
7
+ # This implementation is thread-safe and circuit state is shared across
8
+ # threads. Since state is stored in-memory, this state is not shared across
9
+ # processes, or persisted across application restarts.
9
10
  #
10
11
  # Circuit state and runs are stored in memory. Although runs have a maximum
11
12
  # size within a circuit, there is no limit on the number of circuits that
@@ -18,6 +19,9 @@ module Faulty
18
19
  #
19
20
  # This can be used as a reference implementation for storage backends that
20
21
  # store a list of circuit run entries.
22
+ #
23
+ # @todo Add a more sophsticated implmentation that can limit the number of
24
+ # circuits stored.
21
25
  class Memory
22
26
  attr_reader :options
23
27
 
@@ -83,7 +87,7 @@ module Faulty
83
87
  memory = fetch(circuit)
84
88
  memory.runs.borrow do |runs|
85
89
  runs.push([time, success])
86
- runs.pop if runs.size > options.max_sample_size
90
+ runs.shift if runs.size > options.max_sample_size
87
91
  end
88
92
  memory.status(circuit.options)
89
93
  end