knifeswitch 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []