knifeswitch 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8fce0afa125f824b748eae256d752487d221343c5ca839613f2fe8547288f893
4
+ data.tar.gz: 8f6bfb756e1dd154449178873c459cdba208b70a3c8290329cb1d29f7da44e46
5
+ SHA512:
6
+ metadata.gz: c87ebd2d6d76d1ed34e83c7fb582cd6ada8e1c7cfd5fea4fb0f70ba3a0d856f2c1d3caf434ed7a59251d7ae3d898bd7b8b0b59fed9f6845fc7a060c72119b3cf
7
+ data.tar.gz: 3b8cc2c3b87092340af8530003dc754d3fdf410cad2a26944a4d5575cb1bebe2b9385a25a97892ddfedb2cc49484080868444e10d782f21c9049b32d7314c6b8
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2019 Nigel Baillie
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # Knifeswitch
2
+ Yet another circuit breaker gem. This one strives to be as small and simple as possible. In the effort to remain simple, it currently only supports Rails with MySQL.
3
+
4
+ ## Usage
5
+ ```ruby
6
+ # Instantiate circuit
7
+ circuit = Knifeswitch::Circuit.new(
8
+ namespace: 'whatever',
9
+ exceptions: [TimeoutExceptionToCatch, Timeout::Error],
10
+ error_threshold: 5,
11
+ error_timeout: 60
12
+ )
13
+
14
+ response = circuit.run { client.request(...) }
15
+ # 'run' will raise Knifeswitch::CircuitOpen if its error_threshold has
16
+ # been exceeded. In this case: when a timeout occurs 5 times in a row.
17
+ #
18
+ # The error threshold counter is shared among all Knifeswitch::Circuit
19
+ # instances with the same namespace. The counters are stored in the db,
20
+ # so the state is fully distributed among all your workers/webservers.
21
+ #
22
+ # After the circuit opens, it will close back down after 60 seconds of
23
+ # rejecting requests (by raising Knifeswitch::CircuitOpen).
24
+ #
25
+ # When closed, it will just run the block like normal and return the result.
26
+ ```
27
+
28
+ ## Installation
29
+ Add this line to your application's Gemfile:
30
+
31
+ ```ruby
32
+ gem 'knifeswitch'
33
+ ```
34
+
35
+ And then execute:
36
+ ```bash
37
+ $ bundle
38
+ ```
39
+
40
+ Finally, generate migrations:
41
+ ```bash
42
+ $ rake knifeswitch:create_migrations
43
+ ```
44
+
45
+ ## Testing
46
+
47
+ ### Have Docker installed?
48
+ ``` bash
49
+ $ bin/dockertest
50
+ ```
51
+
52
+ ### Manually, without docker
53
+ You'll need to set up the test Rails app's database. Edit `test/dummy/config/database.yml` as you see fit, and run:
54
+
55
+ ```bash
56
+ $ rake knifeswitch:create_migrations db:create db:migrate
57
+ ```
58
+ inside of the `test/dummy` directory.
59
+
60
+ After that you can run:
61
+ ```bash
62
+ $ bin/test
63
+ ```
64
+ in the project root with no problem. If you end up changing the migration generation rake task, you'll have to clean up and re-run it manually.
65
+
66
+ ## Limitations
67
+
68
+ To keep the gem simple, Knifeswitch depends on [Rails](https://github.com/rails/rails). Technically, it should be pretty simple to make Knifeswitch work without the Rails dependency, but for us since we use Rails it's easier to just keep it as is.
69
+
70
+ Knifeswitch also softly depends on MySQL, in that it uses MySQL's `ON DUPLICATE KEY UPDATE` syntax.
data/Rakefile ADDED
@@ -0,0 +1,27 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Fusebox'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ require 'bundler/gem_tasks'
18
+
19
+ require 'rake/testtask'
20
+
21
+ Rake::TestTask.new(:test) do |t|
22
+ t.libs << 'test'
23
+ t.pattern = 'test/**/*_test.rb'
24
+ t.verbose = false
25
+ end
26
+
27
+ task default: :test
@@ -0,0 +1,132 @@
1
+ module Knifeswitch
2
+ # Implements the "circuit breaker" pattern using a simple MySQL table.
3
+ #
4
+ # Example usage:
5
+ #
6
+ # circuit = Knifeswitch::Circuit.new(
7
+ # namespace: 'some third-party',
8
+ # exceptions: [Example::TimeoutError],
9
+ # error_threshold: 5,
10
+ # error_timeout: 30
11
+ # )
12
+ # response = circuit.run { client.request(...) }
13
+ #
14
+ # In this example, when a TimeoutError is raised within a circuit.run
15
+ # block 5 times in a row, the circuit will "open" and further calls to
16
+ # circuit.run will raise Knifeswitch::CircuitOpen instead of executing the
17
+ # block. After 30 seconds, the circuit "closes" and circuit.run blocks
18
+ # will be run again.
19
+ #
20
+ # Two circuits with the same namespace share the same counter and
21
+ # open/closed state, as long as they're connected to the same database.
22
+ #
23
+ class Circuit
24
+ attr_reader :namespace, :exceptions, :error_threshold, :error_timeout
25
+
26
+ # Options:
27
+ #
28
+ # namespace: circuits in the same namespace share state
29
+ # exceptions: an array of error types that bump the counter
30
+ # error_threshold: number of errors required to open the circuit
31
+ # error_timeout: seconds to keep the circuit open
32
+ def initialize(
33
+ namespace: 'default',
34
+ exceptions: [Timeout::Error],
35
+ error_threshold: 10,
36
+ error_timeout: 60
37
+ )
38
+ @namespace = namespace
39
+ @exceptions = exceptions
40
+ @error_threshold = error_threshold
41
+ @error_timeout = error_timeout
42
+ end
43
+
44
+ # Call this with a block to execute the contents of the block under
45
+ # circuit breaker protection.
46
+ #
47
+ # Raises Knifeswitch::CircuitOpen when called while the circuit is open.
48
+ def run
49
+ raise CircuitOpen if open?
50
+
51
+ result = yield
52
+ reset_counter!
53
+ result
54
+ rescue Exception => error
55
+ if exceptions.any? { |watched| error.is_a?(watched) }
56
+ increment_counter!
57
+ else
58
+ reset_counter!
59
+ end
60
+
61
+ raise error
62
+ end
63
+
64
+ # Queries the database to see if the circuit is open.
65
+ #
66
+ # The circuit opens when 'error_threshold' errors occur consecutively.
67
+ # When the circuit is open, calls to `run` will raise CircuitOpen
68
+ # instead of yielding.
69
+ def open?
70
+ result = sql(:select_value, %(
71
+ SELECT COUNT(*) c FROM knifeswitch_counters
72
+ WHERE name = ? AND closetime > ?
73
+ ), namespace, DateTime.now)
74
+
75
+ result > 0
76
+ end
77
+
78
+ # Retrieves the current counter value.
79
+ def counter
80
+ result = sql(:select_value, %(
81
+ SELECT counter FROM knifeswitch_counters
82
+ WHERE name = ?
83
+ ), namespace)
84
+
85
+ result || 0
86
+ end
87
+
88
+ # Increments counter and opens the circuit if it went
89
+ # too high
90
+ def increment_counter!
91
+ # Increment the counter
92
+ sql(:execute, %(
93
+ INSERT INTO knifeswitch_counters (name,counter)
94
+ VALUES (?, 1)
95
+ ON DUPLICATE KEY UPDATE counter=counter+1
96
+ ), namespace)
97
+
98
+ # Possibly open the circuit
99
+ sql(
100
+ :execute,
101
+ %(
102
+ UPDATE knifeswitch_counters
103
+ SET closetime = ?
104
+ WHERE name = ? AND COUNTER >= ?
105
+ ),
106
+ DateTime.now + error_timeout.seconds,
107
+ namespace, error_threshold
108
+ )
109
+ end
110
+
111
+ # Sets the counter to zero
112
+ def reset_counter!
113
+ sql(:execute, %(
114
+ INSERT INTO knifeswitch_counters (name,counter)
115
+ VALUES (?, 0)
116
+ ON DUPLICATE KEY UPDATE counter=0
117
+ ), namespace)
118
+ end
119
+
120
+ private
121
+
122
+ # Executes a SQL query with the given Connection method
123
+ # (i.e. :execute, or :select_values)
124
+ def sql(method, query, *args)
125
+ query = ActiveRecord::Base.send(:sanitize_sql_array, [query] + args)
126
+
127
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
128
+ conn.send(method, query)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,8 @@
1
+ module Knifeswitch
2
+ class Railtie < ::Rails::Railtie
3
+ rake_tasks do
4
+ path = File.expand_path("#{__dir__}/..")
5
+ Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f }
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ module Knifeswitch
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,7 @@
1
+ require 'knifeswitch/railtie'
2
+ require 'knifeswitch/circuit'
3
+
4
+ module Knifeswitch
5
+ class Error < StandardError; end
6
+ class CircuitOpen < Error; end
7
+ end
@@ -0,0 +1,9 @@
1
+ namespace :knifeswitch do
2
+ desc 'Generate the migrations necessary to use Knifeswitch'
3
+ task :create_migrations do
4
+ sh 'rails g migration CreateKnifeswitchCounters ' \
5
+ 'name:string:uniq counter:integer closetime:datetime'
6
+
7
+ puts "Done. Don't forget to run `rake db:migrate`."
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: knifeswitch
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nigel Baillie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-07-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.2.2
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 5.2.2.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: 5.2.2
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 5.2.2.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: mysql2
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ description: |-
48
+ Implements the circuit breaker pattern using MySQL as a datastore.
49
+ https://martinfowler.com/bliki/CircuitBreaker.html
50
+ email:
51
+ - nbaillie@degica.com
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - MIT-LICENSE
57
+ - README.md
58
+ - Rakefile
59
+ - lib/knifeswitch.rb
60
+ - lib/knifeswitch/circuit.rb
61
+ - lib/knifeswitch/railtie.rb
62
+ - lib/knifeswitch/version.rb
63
+ - lib/tasks/knifeswitch_tasks.rake
64
+ homepage: https://github.com/degica/knifeswitch
65
+ licenses:
66
+ - MIT
67
+ metadata: {}
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.0.4
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Simple implementation of the circuit breaker pattern.
87
+ test_files: []