Skip to content

Commit 1b99d48

Browse files
authored
Merge pull request #55 from ParentSquare/clear-circuits
Add a Faulty#clear method to reset all circuits
2 parents ef57573 + 3ea0743 commit 1b99d48

14 files changed

+154
-35
lines changed

.rubocop.yml

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ RSpec/VerifiedDoubleReference: { Enabled: true }
7373

7474
Metrics/AbcSize: { Max: 40 }
7575
Metrics/BlockLength: { Enabled: false }
76+
Metrics/ClassLength: { Enabled: false }
7677
Metrics/CyclomaticComplexity: { Enabled: false }
7778
Metrics/MethodLength: { Max: 30 }
7879
Metrics/PerceivedComplexity: { Enabled: false }

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Also see "Release It!: Design and Deploy Production-Ready Software" by
9090
+ [Other Built-in Listeners](#other-built-in-listeners)
9191
+ [Custom Listeners](#custom-listeners)
9292
* [Disabling Faulty Globally](#disabling-faulty-globally)
93+
* [Testing with Faulty](#testing-with-faulty)
9394
* [How it Works](#how-it-works)
9495
+ [Caching](#caching)
9596
+ [Fault Tolerance](#fault-tolerance)
@@ -1173,6 +1174,24 @@ not affect the stored state of circuits.
11731174
Faulty will **still use the cache** even when disabled. If you also want to
11741175
disable the cache, configure Faulty to use a `Faulty::Cache::Null` cache.
11751176

1177+
## Testing with Faulty
1178+
1179+
Depending on your application, you could choose to
1180+
[disable Faulty globally](#disabling-faulty-globally), but sometimes you may
1181+
want to test your application's behavior in a failure scenario.
1182+
1183+
If you have such tests, you will want to prevent failures in one test from
1184+
affecting other tests. To clear all circuit states between tests, use `#clear!`.
1185+
For example, with rspec:
1186+
1187+
```ruby
1188+
RSpec.configure do |config|
1189+
config.after do
1190+
Faulty.clear!
1191+
end
1192+
end
1193+
```
1194+
11761195
## How it Works
11771196

11781197
Faulty implements a version of circuit breakers inspired by "Release It!: Design

lib/faulty.rb

+23
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def circuit(name, **config, &block)
116116

117117
# Get a list of all circuit names for the default instance
118118
#
119+
# @see #list_circuits
119120
# @return [Array<String>] The circuit names
120121
def list_circuits
121122
options.storage.list
@@ -157,6 +158,14 @@ def enable!
157158
def disabled?
158159
@disabled == true
159160
end
161+
162+
# Reset all circuits for the default instance
163+
#
164+
# @see #clear
165+
# @return [void]
166+
def clear!
167+
default.clear
168+
end
160169
end
161170

162171
attr_reader :options
@@ -255,6 +264,20 @@ def list_circuits
255264
options.storage.list
256265
end
257266

267+
# Reset all circuits
268+
#
269+
# Intended for use in tests. This can be expensive and is not appropriate
270+
# to call in production code
271+
#
272+
# See the documentation for your chosen backend for specific semantics and
273+
# safety concerns. For example, the Redis backend resets all circuits, but
274+
# it does not clear the circuit list to maintain thread-safety.
275+
#
276+
# @return [void]
277+
def clear!
278+
options.storage.clear
279+
end
280+
258281
private
259282

260283
# Get circuit options from the {Faulty} options

lib/faulty/circuit.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Faulty
2323
# write your own code to periodically check how long it has been running.
2424
# If you're sure you want ruby's generic Timeout, you can apply it yourself
2525
# inside the circuit run block.
26-
class Circuit # rubocop:disable Metrics/ClassLength
26+
class Circuit
2727
CACHE_REFRESH_SUFFIX = '.faulty_refresh'
2828

2929
attr_reader :name

lib/faulty/storage/circuit_proxy.rb

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def initialize(storage, **options, &block)
5858
status
5959
history
6060
list
61+
clear
6162
].each do |method|
6263
define_method(method) do |*args|
6364
options.circuit.run { @storage.public_send(method, *args) }

lib/faulty/storage/fallback_chain.rb

+8
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ def list
159159
end
160160
end
161161

162+
# Clears circuits in all storage backends
163+
#
164+
# @param (see Interface#clear)
165+
# @return (see Interface#clear)
166+
def clear
167+
send_all(:clear)
168+
end
169+
162170
# This is fault tolerant if any of the available backends are fault tolerant
163171
#
164172
# @param (see Interface#fault_tolerant?)

lib/faulty/storage/fault_tolerant_proxy.rb

+8-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,14 @@ def self.wrap(storage, **options, &block)
8181
# @see Interface#list
8282
# @param (see Interface#list)
8383
# @return (see Interface#list)
84-
def_delegators :@storage, :lock, :unlock, :reset, :history, :list
84+
#
85+
# @!method clear
86+
# Clear is not called in normal operation, so it doesn't capture errors
87+
#
88+
# @see Interface#list
89+
# @param (see Interface#list)
90+
# @return (see Interface#list)
91+
def_delegators :@storage, :lock, :unlock, :reset, :history, :list, :clear
8592

8693
# Get circuit options safely
8794
#

lib/faulty/storage/interface.rb

+11
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ def list
172172
raise NotImplementedError
173173
end
174174

175+
# Reset all circuits
176+
#
177+
# Some implementions may clear circuits on a best-effort basis since
178+
# all circuits may not be known.
179+
#
180+
# @raise NotImplementedError If the storage backend does not support clearing.
181+
# @return [void]
182+
def clear
183+
raise NotImplementedError
184+
end
185+
175186
# Can this storage backend raise an error?
176187
#
177188
# If the storage backend returns false from this method, it will be wrapped

lib/faulty/storage/memory.rb

+7
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ def list
196196
@circuits.keys
197197
end
198198

199+
# Clears all circuits
200+
#
201+
# @return [void]
202+
def clear
203+
@circuits.clear
204+
end
205+
199206
# Memory storage is fault-tolerant by default
200207
#
201208
# @return [true]

lib/faulty/storage/null.rb

+5
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ def list
7979
[]
8080
end
8181

82+
# @param (see Interface#clear)
83+
# @return (see Interface#clear)
84+
def clear
85+
end
86+
8287
# This backend is fault tolerant
8388
#
8489
# @param (see Interface#fault_tolerant?)

lib/faulty/storage/redis.rb

+49-33
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ module Storage
99
# cascading failures in your application when evaluating circuits. Always
1010
# wrap this backend with a {FaultTolerantProxy} to limit the effect of
1111
# these types of events.
12-
class Redis # rubocop:disable Metrics/ClassLength
12+
class Redis
1313
# Separates the time/status for history entry strings
1414
ENTRY_SEPARATOR = ':'
1515

@@ -95,7 +95,7 @@ def initialize(**options, &block)
9595
# @param (see Interface#get_options)
9696
# @return (see Interface#get_options)
9797
def get_options(circuit)
98-
json = redis { |r| r.get(options_key(circuit)) }
98+
json = redis { |r| r.get(options_key(circuit.name)) }
9999
return if json.nil?
100100

101101
JSON.parse(json, symbolize_names: true)
@@ -110,7 +110,7 @@ def get_options(circuit)
110110
# @return (see Interface#set_options)
111111
def set_options(circuit, stored_options)
112112
redis do |r|
113-
r.set(options_key(circuit), JSON.dump(stored_options), ex: options.circuit_ttl)
113+
r.set(options_key(circuit.name), JSON.dump(stored_options), ex: options.circuit_ttl)
114114
end
115115
end
116116

@@ -120,7 +120,7 @@ def set_options(circuit, stored_options)
120120
# @param (see Interface#entry)
121121
# @return (see Interface#entry)
122122
def entry(circuit, time, success, status)
123-
key = entries_key(circuit)
123+
key = entries_key(circuit.name)
124124
result = pipe do |r|
125125
r.sadd(list_key, circuit.name)
126126
r.expire(list_key, options.circuit_ttl + options.list_granularity) if options.circuit_ttl
@@ -139,11 +139,11 @@ def entry(circuit, time, success, status)
139139
# @param (see Interface#open)
140140
# @return (see Interface#open)
141141
def open(circuit, opened_at)
142-
key = state_key(circuit)
142+
key = state_key(circuit.name)
143143
ex = options.circuit_ttl
144144
result = watch_exec(key, ['closed', nil]) do |m|
145145
m.set(key, 'open', ex: ex)
146-
m.set(opened_at_key(circuit), opened_at, ex: ex)
146+
m.set(opened_at_key(circuit.name), opened_at, ex: ex)
147147
end
148148

149149
result && result[0] == 'OK'
@@ -155,7 +155,7 @@ def open(circuit, opened_at)
155155
# @param (see Interface#reopen)
156156
# @return (see Interface#reopen)
157157
def reopen(circuit, opened_at, previous_opened_at)
158-
key = opened_at_key(circuit)
158+
key = opened_at_key(circuit.name)
159159
result = watch_exec(key, [previous_opened_at.to_s]) do |m|
160160
m.set(key, opened_at, ex: options.circuit_ttl)
161161
end
@@ -169,11 +169,11 @@ def reopen(circuit, opened_at, previous_opened_at)
169169
# @param (see Interface#close)
170170
# @return (see Interface#close)
171171
def close(circuit)
172-
key = state_key(circuit)
172+
key = state_key(circuit.name)
173173
ex = options.circuit_ttl
174174
result = watch_exec(key, ['open']) do |m|
175175
m.set(key, 'closed', ex: ex)
176-
m.del(entries_key(circuit))
176+
m.del(entries_key(circuit.name))
177177
end
178178

179179
result && result[0] == 'OK'
@@ -187,7 +187,7 @@ def close(circuit)
187187
# @param (see Interface#lock)
188188
# @return (see Interface#lock)
189189
def lock(circuit, state)
190-
redis { |r| r.set(lock_key(circuit), state) }
190+
redis { |r| r.set(lock_key(circuit.name), state) }
191191
end
192192

193193
# Unlock a circuit
@@ -196,7 +196,7 @@ def lock(circuit, state)
196196
# @param (see Interface#unlock)
197197
# @return (see Interface#unlock)
198198
def unlock(circuit)
199-
redis { |r| r.del(lock_key(circuit)) }
199+
redis { |r| r.del(lock_key(circuit.name)) }
200200
end
201201

202202
# Reset a circuit
@@ -205,14 +205,15 @@ def unlock(circuit)
205205
# @param (see Interface#reset)
206206
# @return (see Interface#reset)
207207
def reset(circuit)
208+
name = circuit.is_a?(Circuit) ? circuit.name : circuit
208209
pipe do |r|
209210
r.del(
210-
entries_key(circuit),
211-
opened_at_key(circuit),
212-
lock_key(circuit),
213-
options_key(circuit)
211+
entries_key(name),
212+
opened_at_key(name),
213+
lock_key(name),
214+
options_key(name)
214215
)
215-
r.set(state_key(circuit), 'closed', ex: options.circuit_ttl)
216+
r.set(state_key(name), 'closed', ex: options.circuit_ttl)
216217
end
217218
end
218219

@@ -224,10 +225,10 @@ def reset(circuit)
224225
def status(circuit)
225226
futures = {}
226227
pipe do |r|
227-
futures[:state] = r.get(state_key(circuit))
228-
futures[:lock] = r.get(lock_key(circuit))
229-
futures[:opened_at] = r.get(opened_at_key(circuit))
230-
futures[:entries] = r.lrange(entries_key(circuit), 0, -1)
228+
futures[:state] = r.get(state_key(circuit.name))
229+
futures[:lock] = r.get(lock_key(circuit.name))
230+
futures[:opened_at] = r.get(opened_at_key(circuit.name))
231+
futures[:entries] = r.lrange(entries_key(circuit.name), 0, -1)
231232
end
232233

233234
state = futures[:state].value&.to_sym || :closed
@@ -249,7 +250,7 @@ def status(circuit)
249250
# @param (see Interface#history)
250251
# @return (see Interface#history)
251252
def history(circuit)
252-
entries = redis { |r| r.lrange(entries_key(circuit), 0, -1) }
253+
entries = redis { |r| r.lrange(entries_key(circuit.name), 0, -1) }
253254
map_entries(entries).reverse
254255
end
255256

@@ -260,6 +261,21 @@ def list
260261
redis { |r| r.sunion(*all_list_keys) }
261262
end
262263

264+
# Reset all circuits
265+
#
266+
# This does not empty the list of circuits as returned by {#list}. This is
267+
# because that would be a thread-usafe operation that could result in
268+
# circuits not being in the list.
269+
#
270+
# This implmenentation resets circuits individually, and will be very
271+
# slow for large numbers of circuits. It should not be used in production
272+
# code.
273+
#
274+
# @return [void]
275+
def clear
276+
list.each { |c| reset(c) }
277+
end
278+
263279
# Redis storage is not fault-tolerant
264280
#
265281
# @return [true]
@@ -276,33 +292,33 @@ def key(*parts)
276292
[options.key_prefix, *parts].join(options.key_separator)
277293
end
278294

279-
def ckey(circuit, *parts)
280-
key('circuit', circuit.name, *parts)
295+
def ckey(circuit_name, *parts)
296+
key('circuit', circuit_name, *parts)
281297
end
282298

283299
# @return [String] The key for circuit options
284-
def options_key(circuit)
285-
ckey(circuit, 'options')
300+
def options_key(circuit_name)
301+
ckey(circuit_name, 'options')
286302
end
287303

288304
# @return [String] The key for circuit state
289-
def state_key(circuit)
290-
ckey(circuit, 'state')
305+
def state_key(circuit_name)
306+
ckey(circuit_name, 'state')
291307
end
292308

293309
# @return [String] The key for circuit run history entries
294-
def entries_key(circuit)
295-
ckey(circuit, 'entries')
310+
def entries_key(circuit_name)
311+
ckey(circuit_name, 'entries')
296312
end
297313

298314
# @return [String] The key for circuit locks
299-
def lock_key(circuit)
300-
ckey(circuit, 'lock')
315+
def lock_key(circuit_name)
316+
ckey(circuit_name, 'lock')
301317
end
302318

303319
# @return [String] The key for circuit opened_at
304-
def opened_at_key(circuit)
305-
ckey(circuit, 'opened_at')
320+
def opened_at_key(circuit_name)
321+
ckey(circuit_name, 'opened_at')
306322
end
307323

308324
# Get the current key to add circuit names to

spec/faulty_spec.rb

+6
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,10 @@
179179
described_class.enable!
180180
expect(described_class.disabled?).to be(false)
181181
end
182+
183+
it 'clears circuits' do
184+
instance.circuit('test').run { 'ok' }
185+
instance.clear!
186+
expect(instance.circuit('test').history).to eq([])
187+
end
182188
end

0 commit comments

Comments
 (0)