faulty 0.1.2 → 0.4.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.rubocop.yml +9 -0
  4. data/CHANGELOG.md +50 -2
  5. data/Gemfile +22 -0
  6. data/README.md +836 -220
  7. data/bin/check-version +5 -1
  8. data/bin/console +1 -1
  9. data/faulty.gemspec +4 -11
  10. data/lib/faulty.rb +157 -43
  11. data/lib/faulty/cache.rb +3 -1
  12. data/lib/faulty/cache/auto_wire.rb +58 -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 +5 -6
  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 +3 -2
  31. data/lib/faulty/storage.rb +4 -1
  32. data/lib/faulty/storage/auto_wire.rb +107 -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 +51 -56
  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 +18 -122
  41. data/.travis.yml +0 -44
  42. data/lib/faulty/scope.rb +0 -117
data/bin/check-version CHANGED
@@ -3,8 +3,12 @@
3
3
  set -e
4
4
 
5
5
  tag="$(git describe --abbrev=0 2>/dev/null || echo)"
6
+ echo "Tag: ${tag}"
6
7
  tag="${tag#v}"
8
+ echo "Git Version: ${tag}"
7
9
  [ "$tag" = '' ] && exit 0
10
+ gem_version="$(ruby -r ./lib/faulty/version -e "puts Faulty.version" | tail -n1)"
11
+ echo "Gem Version: ${gem_version}"
8
12
 
9
- tag_gt_version=$(ruby -r ./lib/faulty/version -e "puts Faulty.version >= Gem::Version.new('${tag}')")
13
+ tag_gt_version="$(ruby -r ./lib/faulty/version -e "puts Faulty.version >= Gem::Version.new('${tag}')" | tail -n1)"
10
14
  test "$tag_gt_version" = true
data/bin/console CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  require 'bundler/setup'
5
5
  require 'faulty'
6
- require 'byebug'
6
+ require 'byebug' if Gem.loaded_specs['byebug']
7
7
  require 'irb'
8
8
 
9
9
  # For default cache support
data/faulty.gemspec CHANGED
@@ -23,21 +23,14 @@ Gem::Specification.new do |spec|
23
23
 
24
24
  spec.add_runtime_dependency 'concurrent-ruby', '~> 1.0'
25
25
 
26
- spec.add_development_dependency 'activesupport', '>= 4.2'
27
- spec.add_development_dependency 'bundler', '>= 1.17', '< 3'
28
- spec.add_development_dependency 'byebug', '~> 11.0'
26
+ # Only essential development tools and dependencies go here.
27
+ # Other non-essential development dependencies go in the Gemfile.
29
28
  spec.add_development_dependency 'connection_pool', '~> 2.0'
30
- spec.add_development_dependency 'irb', '~> 1.0'
31
- spec.add_development_dependency 'redcarpet', '~> 3.5'
32
- spec.add_development_dependency 'redis', '~> 3.0'
29
+ spec.add_development_dependency 'honeybadger', '>= 2.0'
30
+ spec.add_development_dependency 'redis', '>= 3.0'
33
31
  spec.add_development_dependency 'rspec', '~> 3.8'
34
- spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
35
32
  # 0.81 is the last rubocop version with Ruby 2.3 support
36
33
  spec.add_development_dependency 'rubocop', '0.81.0'
37
34
  spec.add_development_dependency 'rubocop-rspec', '1.38.1'
38
- # For now, code climate doesn't support simplecov 0.18
39
- # https://github.com/codeclimate/test-reporter/issues/413
40
- spec.add_development_dependency 'simplecov', '>= 0.17.1', '< 0.18'
41
35
  spec.add_development_dependency 'timecop', '>= 0.9'
42
- spec.add_development_dependency 'yard', '~> 0.9.25'
43
36
  end
data/lib/faulty.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'forwardable'
4
5
  require 'concurrent-ruby'
5
6
 
6
7
  require 'faulty/immutable_options'
@@ -9,14 +10,16 @@ require 'faulty/circuit'
9
10
  require 'faulty/error'
10
11
  require 'faulty/events'
11
12
  require 'faulty/result'
12
- require 'faulty/scope'
13
13
  require 'faulty/status'
14
14
  require 'faulty/storage'
15
15
 
16
- # The top-level namespace for Faulty
16
+ # The {Faulty} class has class-level methods for global state or can be
17
+ # instantiated to create an independent configuration.
17
18
  #
18
- # Fault-tolerance tools for ruby based on circuit-breakers
19
- module Faulty
19
+ # If you are using global state, call {Faulty#init} during your application's
20
+ # initialization. This is the simplest way to use {Faulty}. If you prefer, you
21
+ # can also call {Faulty.new} to create independent {Faulty} instances.
22
+ class Faulty
20
23
  class << self
21
24
  # Start the Faulty environment
22
25
  #
@@ -27,78 +30,79 @@ module Faulty
27
30
  # are spawned.
28
31
  #
29
32
  # If you prefer dependency-injection instead of global state, you can skip
30
- # init and pass a {Scope} directly to your dependencies.
33
+ # `init` and use {Faulty.new} to pass an instance directoy to your
34
+ # dependencies.
31
35
  #
32
- # @param scope_name [Symbol] The name of the default scope. Can be set to
33
- # `nil` to skip creating a default scope.
34
- # @param config [Hash] Attributes for {Scope::Options}
35
- # @yield [Scope::Options] For setting options in a block
36
+ # @param default_name [Symbol] The name of the default instance. Can be set
37
+ # to `nil` to skip creating a default instance.
38
+ # @param config [Hash] Attributes for {Faulty::Options}
39
+ # @yield [Faulty::Options] For setting options in a block
36
40
  # @return [self]
37
- def init(scope_name = :default, **config, &block)
38
- raise AlreadyInitializedError if @scopes
41
+ def init(default_name = :default, **config, &block)
42
+ raise AlreadyInitializedError if @instances
39
43
 
40
- @default_scope = scope_name
41
- @scopes = Concurrent::Map.new
42
- register(scope_name, Scope.new(**config, &block)) unless scope_name.nil?
44
+ @default_instance = default_name
45
+ @instances = Concurrent::Map.new
46
+ register(default_name, new(**config, &block)) unless default_name.nil?
43
47
  self
44
48
  rescue StandardError
45
- @scopes = nil
49
+ @instances = nil
46
50
  raise
47
51
  end
48
52
 
49
- # Get the default scope given during {.init}
53
+ # Get the default instance given during {.init}
50
54
  #
51
- # @return [Scope, nil] The default scope if it is registered
55
+ # @return [Faulty, nil] The default instance if it is registered
52
56
  def default
53
- raise UninitializedError unless @scopes
54
- raise MissingDefaultScopeError unless @default_scope
57
+ raise UninitializedError unless @instances
58
+ raise MissingDefaultInstanceError unless @default_instance
55
59
 
56
- self[@default_scope]
60
+ self[@default_instance]
57
61
  end
58
62
 
59
- # Get a scope by name
63
+ # Get an instance by name
60
64
  #
61
- # @return [Scope, nil] The named scope if it is registered
62
- def [](scope_name)
63
- raise UninitializedError unless @scopes
65
+ # @return [Faulty, nil] The named instance if it is registered
66
+ def [](name)
67
+ raise UninitializedError unless @instances
64
68
 
65
- @scopes[scope_name]
69
+ @instances[name]
66
70
  end
67
71
 
68
- # Register a scope to the global Faulty state
72
+ # Register an instance to the global Faulty state
69
73
  #
70
- # Will not replace an existing scope with the same name. Check the
71
- # return value if you need to know whether the scope already existed.
74
+ # Will not replace an existing instance with the same name. Check the
75
+ # return value if you need to know whether the instance already existed.
72
76
  #
73
- # @param name [Symbol] The name of the scope to register
74
- # @param scope [Scope] The scope to register
75
- # @return [Scope, nil] The previously-registered scope of that name if
77
+ # @param name [Symbol] The name of the instance to register
78
+ # @param instance [Faulty] The instance to register
79
+ # @return [Faulty, nil] The previously-registered instance of that name if
76
80
  # it already existed, otherwise nil.
77
- def register(name, scope)
78
- raise UninitializedError unless @scopes
81
+ def register(name, instance)
82
+ raise UninitializedError unless @instances
79
83
 
80
- @scopes.put_if_absent(name, scope)
84
+ @instances.put_if_absent(name, instance)
81
85
  end
82
86
 
83
- # Get the options for the default scope
87
+ # Get the options for the default instance
84
88
  #
85
- # @raise MissingDefaultScopeError If the default scope has not been created
86
- # @return [Scope::Options]
89
+ # @raise MissingDefaultInstanceError If the default instance has not been created
90
+ # @return [Faulty::Options]
87
91
  def options
88
92
  default.options
89
93
  end
90
94
 
91
- # Get or create a circuit for the default scope
95
+ # Get or create a circuit for the default instance
92
96
  #
93
- # @raise UninitializedError If the default scope has not been created
94
- # @param (see Scope#circuit)
95
- # @yield (see Scope#circuit)
96
- # @return (see Scope#circuit)
97
+ # @raise UninitializedError If the default instance has not been created
98
+ # @param (see Faulty#circuit)
99
+ # @yield (see Faulty#circuit)
100
+ # @return (see Faulty#circuit)
97
101
  def circuit(name, **config, &block)
98
102
  default.circuit(name, **config, &block)
99
103
  end
100
104
 
101
- # Get a list of all circuit names for the default scope
105
+ # Get a list of all circuit names for the default instance
102
106
  #
103
107
  # @return [Array<String>] The circuit names
104
108
  def list_circuits
@@ -115,4 +119,114 @@ module Faulty
115
119
  Time.now.to_i
116
120
  end
117
121
  end
122
+
123
+ attr_reader :options
124
+
125
+ # Options for {Faulty}
126
+ #
127
+ # @!attribute [r] cache
128
+ # @see Cache::AutoWire
129
+ # @return [Cache::Interface] A cache backend if you want
130
+ # to use Faulty's cache support. Automatically wrapped in a
131
+ # {Cache::AutoWire}. Default `Cache::AutoWire.new`.
132
+ # @!attribute [r] circuit_defaults
133
+ # @see Circuit::Options
134
+ # @return [Hash] A hash of default options to be used when creating
135
+ # new circuits. See {Circuit::Options} for a full list.
136
+ # @!attribute [r] storage
137
+ # @see Storage::AutoWire
138
+ # @return [Storage::Interface, Array<Storage::Interface>] The storage
139
+ # backend. Automatically wrapped in a {Storage::AutoWire}, so this can also
140
+ # be given an array of prioritized backends. Default `Storage::AutoWire.new`.
141
+ # @!attribute [r] listeners
142
+ # @see Events::ListenerInterface
143
+ # @return [Array] listeners Faulty event listeners
144
+ # @!attribute [r] notifier
145
+ # @return [Events::Notifier] A Faulty notifier. If given, listeners are
146
+ # ignored.
147
+ Options = Struct.new(
148
+ :cache,
149
+ :circuit_defaults,
150
+ :storage,
151
+ :listeners,
152
+ :notifier
153
+ ) do
154
+ include ImmutableOptions
155
+
156
+ private
157
+
158
+ def finalize
159
+ self.notifier ||= Events::Notifier.new(listeners || [])
160
+ self.storage = Storage::AutoWire.wrap(storage, notifier: notifier)
161
+ self.cache = Cache::AutoWire.wrap(cache, notifier: notifier)
162
+ end
163
+
164
+ def required
165
+ %i[cache circuit_defaults storage notifier]
166
+ end
167
+
168
+ def defaults
169
+ {
170
+ circuit_defaults: {},
171
+ listeners: [Events::LogListener.new]
172
+ }
173
+ end
174
+ end
175
+
176
+ # Create a new {Faulty} instance
177
+ #
178
+ # Note, the process of creating a new instance is not thread safe,
179
+ # so make sure instances are setup during your application's initialization
180
+ # phase.
181
+ #
182
+ # For the most part, {Faulty} instances are independent, however for some
183
+ # cache and storage backends, you will need to ensure that the cache keys
184
+ # and circuit names don't overlap between instances. For example, if using the
185
+ # {Storage::Redis} storage backend, you should specify different key
186
+ # prefixes for each instance.
187
+ #
188
+ # @see Options
189
+ # @param options [Hash] Attributes for {Options}
190
+ # @yield [Options] For setting options in a block
191
+ def initialize(**options, &block)
192
+ @circuits = Concurrent::Map.new
193
+ @options = Options.new(options, &block)
194
+ end
195
+
196
+ # Create or retrieve a circuit
197
+ #
198
+ # Within an instance, circuit instances have unique names, so if the given circuit
199
+ # name already exists, then the existing circuit will be returned, otherwise
200
+ # a new circuit will be created. If an existing circuit is returned, then
201
+ # the {options} param and block are ignored.
202
+ #
203
+ # @param name [String] The name of the circuit
204
+ # @param options [Hash] Attributes for {Circuit::Options}
205
+ # @yield [Circuit::Options] For setting options in a block
206
+ # @return [Circuit] The new circuit or the existing circuit if it already exists
207
+ def circuit(name, **options, &block)
208
+ name = name.to_s
209
+ @circuits.compute_if_absent(name) do
210
+ options = circuit_options.merge(options)
211
+ Circuit.new(name, **options, &block)
212
+ end
213
+ end
214
+
215
+ # Get a list of all circuit names
216
+ #
217
+ # @return [Array<String>] The circuit names
218
+ def list_circuits
219
+ options.storage.list
220
+ end
221
+
222
+ private
223
+
224
+ # Get circuit options from the {Faulty} options
225
+ #
226
+ # @return [Hash] The circuit options
227
+ def circuit_options
228
+ @options.to_h
229
+ .select { |k, _v| %i[cache storage notifier].include?(k) }
230
+ .merge(options.circuit_defaults)
231
+ end
118
232
  end
data/lib/faulty/cache.rb CHANGED
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Faulty
3
+ class Faulty
4
4
  # The namespace for Faulty caching
5
5
  module Cache
6
6
  end
7
7
  end
8
8
 
9
+ require 'faulty/cache/auto_wire'
9
10
  require 'faulty/cache/default'
11
+ require 'faulty/cache/circuit_proxy'
10
12
  require 'faulty/cache/fault_tolerant_proxy'
11
13
  require 'faulty/cache/mock'
12
14
  require 'faulty/cache/null'
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Cache
5
+ # Automatically configure a cache backend
6
+ #
7
+ # Used by {Faulty#initialize} to setup sensible cache defaults
8
+ class AutoWire
9
+ # Options for {AutoWire}
10
+ #
11
+ # @!attribute [r] circuit
12
+ # @return [Circuit] A circuit for {CircuitProxy} if one is created.
13
+ # When modifying this, be careful to use only a reliable circuit
14
+ # storage backend so that you don't introduce cascading failures.
15
+ # @!attribute [r] notifier
16
+ # @return [Events::Notifier] A Faulty notifier. If given, listeners are
17
+ # ignored.
18
+ Options = Struct.new(
19
+ :circuit,
20
+ :notifier
21
+ ) do
22
+ include ImmutableOptions
23
+
24
+ private
25
+
26
+ def required
27
+ %i[notifier]
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # Wrap a cache backend with sensible defaults
33
+ #
34
+ # If the cache is `nil`, create a new {Default}.
35
+ #
36
+ # If the backend is not fault tolerant, wrap it in {CircuitProxy} and
37
+ # {FaultTolerantProxy}.
38
+ #
39
+ # @param cache [Interface] A cache backend
40
+ # @param options [Hash] Attributes for {Options}
41
+ # @yield [Options] For setting options in a block
42
+ def wrap(cache, **options, &block)
43
+ options = Options.new(options, &block)
44
+ if cache.nil?
45
+ Cache::Default.new
46
+ elsif cache.fault_tolerant?
47
+ cache
48
+ else
49
+ Cache::FaultTolerantProxy.new(
50
+ Cache::CircuitProxy.new(cache, circuit: options.circuit, notifier: options.notifier),
51
+ notifier: options.notifier
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Faulty
4
+ module Cache
5
+ # A circuit wrapper for cache backends
6
+ #
7
+ # This class uses an internal {Circuit} to prevent the cache 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 circuit storage
19
+ # backend so that you don't introduce cascading failures.
20
+ # @!attribute [r] notifier
21
+ # @return [Events::Notifier] A Faulty notifier to use for failure
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 cache [Cache::Interface] The cache backend to wrap
43
+ # @param options [Hash] Attributes for {Options}
44
+ # @yield [Options] For setting options in a block
45
+ def initialize(cache, **options, &block)
46
+ @cache = cache
47
+ @options = Options.new(options, &block)
48
+ end
49
+
50
+ %i[read write].each do |method|
51
+ define_method(method) do |*args|
52
+ options.circuit.run { @cache.public_send(method, *args) }
53
+ end
54
+ end
55
+
56
+ def fault_tolerant?
57
+ @cache.fault_tolerant?
58
+ end
59
+ end
60
+ end
61
+ end