faulty 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -1,7 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  module Storage
5
+ # A storage backend for storing circuit state in Redis.
6
+ #
7
+ # When using this or any networked backend, be sure to evaluate the risk,
8
+ # and set conservative timeouts so that the circuit storage does not cause
9
+ # cascading failures in your application when evaluating circuits. Always
10
+ # wrap this backend with a {FaultTolerantProxy} to limit the effect of
11
+ # these types of events.
5
12
  class Redis # rubocop:disable Metrics/ClassLength
6
13
  # Separates the time/status for history entry strings
7
14
  ENTRY_SEPARATOR = ':'
@@ -27,12 +34,17 @@ module Faulty
27
34
  # circuit run history entry. Default `100`.
28
35
  # @!attribute [r] circuit_ttl
29
36
  # @return [Integer] The maximum number of seconds to keep a circuit.
30
- # A value of `nil` disables circuit expiration.
37
+ # A value of `nil` disables circuit expiration. This does not apply to
38
+ # locks, which have an indefinite storage time.
31
39
  # Default `604_800` (1 week).
32
40
  # @!attribute [r] list_granularity
33
41
  # @return [Integer] The number of seconds after which a new set is
34
42
  # created to store circuit names. The old set is kept until
35
43
  # circuit_ttl expires. Default `3600` (1 hour).
44
+ # @!attribute [r] disable_warnings
45
+ # @return [Boolean] By default, this class warns if the client options
46
+ # are outside the recommended values. Set to true to disable these
47
+ # warnings.
36
48
  Options = Struct.new(
37
49
  :client,
38
50
  :key_prefix,
@@ -40,7 +52,8 @@ module Faulty
40
52
  :max_sample_size,
41
53
  :sample_ttl,
42
54
  :circuit_ttl,
43
- :list_granularity
55
+ :list_granularity,
56
+ :disable_warnings
44
57
  ) do
45
58
  include ImmutableOptions
46
59
 
@@ -53,7 +66,8 @@ module Faulty
53
66
  max_sample_size: 100,
54
67
  sample_ttl: 1800,
55
68
  circuit_ttl: 604_800,
56
- list_granularity: 3600
69
+ list_granularity: 3600,
70
+ disable_warnings: false
57
71
  }
58
72
  end
59
73
 
@@ -62,7 +76,7 @@ module Faulty
62
76
  end
63
77
 
64
78
  def finalize
65
- self.client = ::Redis.new unless client
79
+ self.client = ::Redis.new(timeout: 1) unless client
66
80
  end
67
81
  end
68
82
 
@@ -70,6 +84,8 @@ module Faulty
70
84
  # @yield [Options] For setting options in a block
71
85
  def initialize(**options, &block)
72
86
  @options = Options.new(options, &block)
87
+
88
+ check_client_options!
73
89
  end
74
90
 
75
91
  # Add an entry to storage
@@ -97,7 +113,7 @@ module Faulty
97
113
  # @return (see Interface#open)
98
114
  def open(circuit, opened_at)
99
115
  redis do |r|
100
- opened = compare_and_set(r, state_key(circuit), ['closed', nil], 'open')
116
+ opened = compare_and_set(r, state_key(circuit), ['closed', nil], 'open', ex: options.circuit_ttl)
101
117
  r.set(opened_at_key(circuit), opened_at, ex: options.circuit_ttl) if opened
102
118
  opened
103
119
  end
@@ -110,7 +126,7 @@ module Faulty
110
126
  # @return (see Interface#reopen)
111
127
  def reopen(circuit, opened_at, previous_opened_at)
112
128
  redis do |r|
113
- compare_and_set(r, opened_at_key(circuit), [previous_opened_at.to_s], opened_at)
129
+ compare_and_set(r, opened_at_key(circuit), [previous_opened_at.to_s], opened_at, ex: options.circuit_ttl)
114
130
  end
115
131
  end
116
132
 
@@ -121,7 +137,7 @@ module Faulty
121
137
  # @return (see Interface#close)
122
138
  def close(circuit)
123
139
  redis do |r|
124
- closed = compare_and_set(r, state_key(circuit), ['open'], 'closed')
140
+ closed = compare_and_set(r, state_key(circuit), ['open'], 'closed', ex: options.circuit_ttl)
125
141
  r.del(entries_key(circuit)) if closed
126
142
  closed
127
143
  end
@@ -196,6 +212,9 @@ module Faulty
196
212
  map_entries(entries).reverse
197
213
  end
198
214
 
215
+ # List all unexpired circuits
216
+ #
217
+ # @return (see Interface#list)
199
218
  def list
200
219
  redis { |r| r.sunion(*all_list_keys) }
201
220
  end
@@ -287,16 +306,16 @@ module Faulty
287
306
  # @param new [String] The new value to set if the compare passes
288
307
  # @return [Boolean] True if the value was set to `new`, false if the CAS
289
308
  # failed
290
- def compare_and_set(redis, key, old, new)
291
- result = redis.watch(key) do
309
+ def compare_and_set(redis, key, old, new, ex:)
310
+ redis.watch(key) do
292
311
  if old.include?(redis.get(key))
293
- redis.multi { |m| m.set(key, new) }
312
+ result = redis.multi { |m| m.set(key, new, ex: ex) }
313
+ result && result[0] == 'OK'
294
314
  else
295
315
  redis.unwatch
316
+ false
296
317
  end
297
318
  end
298
-
299
- result[0] == 'OK'
300
319
  end
301
320
 
302
321
  # Yield a Redis connection
@@ -330,6 +349,49 @@ module Faulty
330
349
  [time.to_i, state == '1']
331
350
  end
332
351
  end
352
+
353
+ def check_client_options!
354
+ return if options.disable_warnings
355
+
356
+ check_redis_options!
357
+ check_pool_options!
358
+ rescue StandardError => e
359
+ warn "Faulty error while checking client options: #{e.message}"
360
+ end
361
+
362
+ def check_redis_options!
363
+ ropts = redis { |r| r.client.options }
364
+
365
+ bad_timeouts = {}
366
+ %i[connect_timeout read_timeout write_timeout].each do |time_opt|
367
+ bad_timeouts[time_opt] = ropts[time_opt] if ropts[time_opt] > 2
368
+ end
369
+
370
+ unless bad_timeouts.empty?
371
+ warn <<~MSG
372
+ Faulty recommends setting Redis timeouts <= 2 to prevent cascading
373
+ failures when evaluating circuits. Your options are:
374
+ #{bad_timeouts}
375
+ MSG
376
+ end
377
+
378
+ if ropts[:reconnect_attempts] > 1
379
+ warn <<~MSG
380
+ Faulty recommends setting Redis reconnect_attempts to <= 1 to
381
+ prevent cascading failures. Your setting is #{ropts[:reconnect_attempts]}
382
+ MSG
383
+ end
384
+ end
385
+
386
+ def check_pool_options!
387
+ if options.client.is_a?(ConnectionPool)
388
+ timeout = options.client.instance_variable_get(:@timeout)
389
+ warn(<<~MSG) if timeout > 2
390
+ Faulty recommends setting ConnectionPool timeouts <= 2 to prevent
391
+ cascading failures when evaluating circuits. Your setting is #{timeout}
392
+ MSG
393
+ end
394
+ end
333
395
  end
334
396
  end
335
397
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The current Faulty version
5
5
  def self.version
6
- Gem::Version.new('0.1.1')
6
+ Gem::Version.new('0.3.0')
7
7
  end
8
8
  end
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.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Howard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-08-28 00:00:00.000000000 Z
11
+ date: 2020-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -24,54 +24,6 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.0'
27
- - !ruby/object:Gem::Dependency
28
- name: activesupport
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '4.2'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '4.2'
41
- - !ruby/object:Gem::Dependency
42
- name: bundler
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '1.17'
48
- - - "<"
49
- - !ruby/object:Gem::Version
50
- version: '3'
51
- type: :development
52
- prerelease: false
53
- version_requirements: !ruby/object:Gem::Requirement
54
- requirements:
55
- - - ">="
56
- - !ruby/object:Gem::Version
57
- version: '1.17'
58
- - - "<"
59
- - !ruby/object:Gem::Version
60
- version: '3'
61
- - !ruby/object:Gem::Dependency
62
- name: byebug
63
- requirement: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '11.0'
68
- type: :development
69
- prerelease: false
70
- version_requirements: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '11.0'
75
27
  - !ruby/object:Gem::Dependency
76
28
  name: connection_pool
77
29
  requirement: !ruby/object:Gem::Requirement
@@ -87,33 +39,19 @@ dependencies:
87
39
  - !ruby/object:Gem::Version
88
40
  version: '2.0'
89
41
  - !ruby/object:Gem::Dependency
90
- name: irb
42
+ name: honeybadger
91
43
  requirement: !ruby/object:Gem::Requirement
92
44
  requirements:
93
- - - "~>"
94
- - !ruby/object:Gem::Version
95
- version: '1.0'
96
- type: :development
97
- prerelease: false
98
- version_requirements: !ruby/object:Gem::Requirement
99
- requirements:
100
- - - "~>"
101
- - !ruby/object:Gem::Version
102
- version: '1.0'
103
- - !ruby/object:Gem::Dependency
104
- name: redcarpet
105
- requirement: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - "~>"
45
+ - - ">="
108
46
  - !ruby/object:Gem::Version
109
- version: '3.5'
47
+ version: '2.0'
110
48
  type: :development
111
49
  prerelease: false
112
50
  version_requirements: !ruby/object:Gem::Requirement
113
51
  requirements:
114
- - - "~>"
52
+ - - ">="
115
53
  - !ruby/object:Gem::Version
116
- version: '3.5'
54
+ version: '2.0'
117
55
  - !ruby/object:Gem::Dependency
118
56
  name: redis
119
57
  requirement: !ruby/object:Gem::Requirement
@@ -142,20 +80,6 @@ dependencies:
142
80
  - - "~>"
143
81
  - !ruby/object:Gem::Version
144
82
  version: '3.8'
145
- - !ruby/object:Gem::Dependency
146
- name: rspec_junit_formatter
147
- requirement: !ruby/object:Gem::Requirement
148
- requirements:
149
- - - "~>"
150
- - !ruby/object:Gem::Version
151
- version: '0.4'
152
- type: :development
153
- prerelease: false
154
- version_requirements: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - "~>"
157
- - !ruby/object:Gem::Version
158
- version: '0.4'
159
83
  - !ruby/object:Gem::Dependency
160
84
  name: rubocop
161
85
  requirement: !ruby/object:Gem::Requirement
@@ -184,26 +108,6 @@ dependencies:
184
108
  - - '='
185
109
  - !ruby/object:Gem::Version
186
110
  version: 1.38.1
187
- - !ruby/object:Gem::Dependency
188
- name: simplecov
189
- requirement: !ruby/object:Gem::Requirement
190
- requirements:
191
- - - ">="
192
- - !ruby/object:Gem::Version
193
- version: 0.17.1
194
- - - "<"
195
- - !ruby/object:Gem::Version
196
- version: '0.18'
197
- type: :development
198
- prerelease: false
199
- version_requirements: !ruby/object:Gem::Requirement
200
- requirements:
201
- - - ">="
202
- - !ruby/object:Gem::Version
203
- version: 0.17.1
204
- - - "<"
205
- - !ruby/object:Gem::Version
206
- version: '0.18'
207
111
  - !ruby/object:Gem::Dependency
208
112
  name: timecop
209
113
  requirement: !ruby/object:Gem::Requirement
@@ -218,20 +122,6 @@ dependencies:
218
122
  - - ">="
219
123
  - !ruby/object:Gem::Version
220
124
  version: '0.9'
221
- - !ruby/object:Gem::Dependency
222
- name: yard
223
- requirement: !ruby/object:Gem::Requirement
224
- requirements:
225
- - - "~>"
226
- - !ruby/object:Gem::Version
227
- version: 0.9.25
228
- type: :development
229
- prerelease: false
230
- version_requirements: !ruby/object:Gem::Requirement
231
- requirements:
232
- - - "~>"
233
- - !ruby/object:Gem::Version
234
- version: 0.9.25
235
125
  description:
236
126
  email:
237
127
  - jmhoward0@gmail.com
@@ -258,6 +148,8 @@ files:
258
148
  - faulty.gemspec
259
149
  - lib/faulty.rb
260
150
  - lib/faulty/cache.rb
151
+ - lib/faulty/cache/auto_wire.rb
152
+ - lib/faulty/cache/circuit_proxy.rb
261
153
  - lib/faulty/cache/default.rb
262
154
  - lib/faulty/cache/fault_tolerant_proxy.rb
263
155
  - lib/faulty/cache/interface.rb
@@ -268,14 +160,17 @@ files:
268
160
  - lib/faulty/error.rb
269
161
  - lib/faulty/events.rb
270
162
  - lib/faulty/events/callback_listener.rb
163
+ - lib/faulty/events/honeybadger_listener.rb
271
164
  - lib/faulty/events/listener_interface.rb
272
165
  - lib/faulty/events/log_listener.rb
273
166
  - lib/faulty/events/notifier.rb
274
167
  - lib/faulty/immutable_options.rb
275
168
  - lib/faulty/result.rb
276
- - lib/faulty/scope.rb
277
169
  - lib/faulty/status.rb
278
170
  - lib/faulty/storage.rb
171
+ - lib/faulty/storage/auto_wire.rb
172
+ - lib/faulty/storage/circuit_proxy.rb
173
+ - lib/faulty/storage/fallback_chain.rb
279
174
  - lib/faulty/storage/fault_tolerant_proxy.rb
280
175
  - lib/faulty/storage/interface.rb
281
176
  - lib/faulty/storage/memory.rb
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Faulty
4
- # A {Scope} is a group of options and circuits
5
- #
6
- # For most use-cases the default scope should be used, however, it's possible
7
- # to create any number of scopes for applications that require a more complex
8
- # configuration or for testing.
9
- #
10
- # For the most part, scopes are independent, however for some cache and
11
- # storage backends, you will need to ensure that the cache keys and circuit
12
- # names don't overlap between scopes. For example, if using the Redis storage
13
- # backend, you should specify different key prefixes for each scope.
14
- class Scope
15
- attr_reader :options
16
-
17
- # Options for {Scope}
18
- #
19
- # @!attribute [r] cache
20
- # @return [Cache::Interface] A cache backend if you want
21
- # to use Faulty's cache support. Automatically wrapped in a
22
- # {Cache::FaultTolerantProxy}. Default `Cache::Default.new`.
23
- # @!attribute [r] storage
24
- # @return [Storage::Interface] The storage backend.
25
- # Automatically wrapped in a {Storage::FaultTolerantProxy}.
26
- # Default `Storage::Memory.new`.
27
- # @!attribute [r] listeners
28
- # @return [Array] listeners Faulty event listeners
29
- # @!attribute [r] notifier
30
- # @return [Events::Notifier] A Faulty notifier. If given, listeners are
31
- # ignored.
32
- Options = Struct.new(
33
- :cache,
34
- :storage,
35
- :listeners,
36
- :notifier
37
- ) do
38
- include ImmutableOptions
39
-
40
- private
41
-
42
- def finalize
43
- self.notifier ||= Events::Notifier.new(listeners || [])
44
-
45
- self.storage ||= Storage::Memory.new
46
- unless storage.fault_tolerant?
47
- self.storage = Storage::FaultTolerantProxy.new(storage, notifier: notifier)
48
- end
49
-
50
- self.cache ||= Cache::Default.new
51
- unless cache.fault_tolerant?
52
- self.cache = Cache::FaultTolerantProxy.new(cache, notifier: notifier)
53
- end
54
- end
55
-
56
- def required
57
- %i[cache storage notifier]
58
- end
59
-
60
- def defaults
61
- {
62
- listeners: [Events::LogListener.new]
63
- }
64
- end
65
- end
66
-
67
- # Create a new Faulty Scope
68
- #
69
- # Note, the process of creating a new scope is not thread safe,
70
- # so make sure scopes are setup before spawning threads.
71
- #
72
- # @see Options
73
- # @param options [Hash] Attributes for {Options}
74
- # @yield [Options] For setting options in a block
75
- def initialize(**options, &block)
76
- @circuits = Concurrent::Map.new
77
- @options = Options.new(options, &block)
78
- end
79
-
80
- # Create or retrieve a circuit
81
- #
82
- # Within a scope, circuit instances have unique names, so if the given circuit
83
- # name already exists, then the existing circuit will be returned, otherwise
84
- # a new circuit will be created. If an existing circuit is returned, then
85
- # the {options} param and block are ignored.
86
- #
87
- # @param name [String] The name of the circuit
88
- # @param options [Hash] Attributes for {Circuit::Options}
89
- # @yield [Circuit::Options] For setting options in a block
90
- # @return [Circuit] The new circuit or the existing circuit if it already exists
91
- def circuit(name, **options, &block)
92
- name = name.to_s
93
- options = options.merge(circuit_options)
94
- @circuits.compute_if_absent(name) do
95
- Circuit.new(name, **options, &block)
96
- end
97
- end
98
-
99
- # Get a list of all circuit names
100
- #
101
- # @return [Array<String>] The circuit names
102
- def list_circuits
103
- options.storage.list
104
- end
105
-
106
- private
107
-
108
- # Get circuit options from the scope options
109
- #
110
- # @return [Hash] The circuit options
111
- def circuit_options
112
- options = @options.to_h
113
- options.delete(:listeners)
114
- options
115
- end
116
- end
117
- end