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