workarea-circuit_breaker 1.0.3

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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  3. data/.github/ISSUE_TEMPLATE/documentation-request.md +17 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.github/workflows/ci.yml +66 -0
  6. data/.gitignore +20 -0
  7. data/.rubocop.yml +2 -0
  8. data/.stylelintrc.json +8 -0
  9. data/CHANGELOG.md +54 -0
  10. data/Gemfile +12 -0
  11. data/LICENSE +52 -0
  12. data/README.md +103 -0
  13. data/Rakefile +59 -0
  14. data/app/assets/stylesheets/circuit_breaker/.keep +0 -0
  15. data/app/controllers/workarea/admin/circuits_controller.rb +26 -0
  16. data/app/view_models/workarea/admin/circuit_view_model.rb +33 -0
  17. data/app/view_models/workarea/admin/circuits_view_model.rb +41 -0
  18. data/app/views/workarea/admin/circuits/index.html.haml +69 -0
  19. data/app/views/workarea/admin/shared/_circuit_breaker_link.html.haml +1 -0
  20. data/bin/rails +25 -0
  21. data/config/initializers/appends.rb +4 -0
  22. data/config/initializers/workarea.rb +11 -0
  23. data/config/locales/en.yml +29 -0
  24. data/config/routes.rb +10 -0
  25. data/lib/workarea/circuit_breaker.rb +96 -0
  26. data/lib/workarea/circuit_breaker/circuit.rb +137 -0
  27. data/lib/workarea/circuit_breaker/engine.rb +12 -0
  28. data/lib/workarea/circuit_breaker/failure_message.rb +51 -0
  29. data/lib/workarea/circuit_breaker/version.rb +5 -0
  30. data/script/admin_ci +5 -0
  31. data/script/ci +8 -0
  32. data/script/core_ci +5 -0
  33. data/script/plugins_ci +5 -0
  34. data/script/storefront_ci +5 -0
  35. data/test/dummy/.ruby-version +1 -0
  36. data/test/dummy/Rakefile +6 -0
  37. data/test/dummy/bin/bundle +3 -0
  38. data/test/dummy/bin/rails +4 -0
  39. data/test/dummy/bin/rake +4 -0
  40. data/test/dummy/bin/setup +28 -0
  41. data/test/dummy/bin/update +28 -0
  42. data/test/dummy/bin/yarn +11 -0
  43. data/test/dummy/config.ru +5 -0
  44. data/test/dummy/config/application.rb +33 -0
  45. data/test/dummy/config/boot.rb +5 -0
  46. data/test/dummy/config/environment.rb +5 -0
  47. data/test/dummy/config/environments/development.rb +52 -0
  48. data/test/dummy/config/environments/production.rb +83 -0
  49. data/test/dummy/config/environments/test.rb +45 -0
  50. data/test/dummy/config/initializers/application_controller_renderer.rb +8 -0
  51. data/test/dummy/config/initializers/assets.rb +14 -0
  52. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  53. data/test/dummy/config/initializers/content_security_policy.rb +25 -0
  54. data/test/dummy/config/initializers/cookies_serializer.rb +5 -0
  55. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  56. data/test/dummy/config/initializers/inflections.rb +16 -0
  57. data/test/dummy/config/initializers/mime_types.rb +4 -0
  58. data/test/dummy/config/initializers/workarea.rb +5 -0
  59. data/test/dummy/config/initializers/wrap_parameters.rb +9 -0
  60. data/test/dummy/config/locales/en.yml +33 -0
  61. data/test/dummy/config/puma.rb +34 -0
  62. data/test/dummy/config/routes.rb +5 -0
  63. data/test/dummy/config/secrets.yml +33 -0
  64. data/test/dummy/config/spring.rb +6 -0
  65. data/test/dummy/db/seeds.rb +2 -0
  66. data/test/dummy/lib/assets/.keep +0 -0
  67. data/test/dummy/log/.keep +0 -0
  68. data/test/integration/workarea/admin/circuits_integration_test.rb +27 -0
  69. data/test/lib/workarea/circuit_breaker/circuit_test.rb +135 -0
  70. data/test/lib/workarea/circuit_breaker/failure_message_test.rb +41 -0
  71. data/test/lib/workarea/circuit_breaker_test.rb +42 -0
  72. data/test/support/circuit_breaker_support.rb +16 -0
  73. data/test/teaspoon_env.rb +6 -0
  74. data/test/test_helper.rb +15 -0
  75. data/workarea-circuit_breaker.gemspec +17 -0
  76. metadata +151 -0
File without changes
@@ -0,0 +1,26 @@
1
+ module Workarea
2
+ module Admin
3
+ class CircuitsController < Admin::ApplicationController
4
+ def index
5
+ @circuits = Admin::CircuitsViewModel.new
6
+ end
7
+
8
+ def disable
9
+ circuit = CircuitViewModel.new(CircuitBreaker[params[:id]])
10
+ circuit.add_failure(message: t('workarea.admin.circuit_breaker.manually_broke_circuit', user: current_user.name))
11
+ circuit.break!
12
+
13
+ flash[:success] = t('workarea.admin.circuit_breaker.flash_messages.turned_off', circuit: circuit.name, break_for: circuit.break_for.inspect)
14
+ redirect_to circuits_path
15
+ end
16
+
17
+ def enable
18
+ circuit = CircuitViewModel.new(CircuitBreaker[params[:id]])
19
+ circuit.set_healthy!
20
+
21
+ flash[:success] = t('workarea.admin.circuit_breaker.flash_messages.turned_on', circuit: circuit.name)
22
+ redirect_to circuits_path
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ module Workarea
2
+ module Admin
3
+ class CircuitViewModel < ApplicationViewModel
4
+ def name
5
+ model.name.to_s.humanize
6
+ end
7
+
8
+ def slug
9
+ model.name
10
+ end
11
+
12
+ def current_fails
13
+ timeline.length
14
+ end
15
+
16
+ def broken_until
17
+ return unless options[:broken_until].present?
18
+
19
+ Time.at(options[:broken_until].to_i)
20
+ end
21
+
22
+ def healthy?
23
+ options[:broken_until].blank?
24
+ end
25
+
26
+ def timeline
27
+ @timeline ||= options.fetch(:timeline, []).map do |(failure_string, timestamp)|
28
+ CircuitBreaker::FailureMessage.from_string(failure_string, Time.at(timestamp))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ module Workarea
2
+ module Admin
3
+ class CircuitsViewModel < ApplicationViewModel
4
+ delegate :each, to: :all
5
+
6
+ private
7
+
8
+ def all
9
+ @all ||= circuits.zip(circuits_broken_until, circuits_timeline).map do |circuit, broken_until, timeline|
10
+ CircuitViewModel.new(
11
+ circuit,
12
+ broken_until: broken_until,
13
+ timeline: timeline
14
+ )
15
+ end
16
+ end
17
+
18
+ def circuits
19
+ @circuits ||= CircuitBreaker.config.circuits.map do |name, options|
20
+ CircuitBreaker::Circuit.new(name, options)
21
+ end
22
+ end
23
+
24
+ def circuits_timeline
25
+ @circuits_timeline ||= CircuitBreaker.redis.with do |redis|
26
+ redis.pipelined do
27
+ circuits.each do |circuit|
28
+ redis.zrange(circuit.redis_failure_set_key, 0, -1, with_scores: true)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def circuits_broken_until
35
+ @circuits_broken_until ||= CircuitBreaker.redis.with do |redis|
36
+ redis.mget(circuits.map(&:redis_broken_key))
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ .view
2
+ .view__header
3
+ .view__heading
4
+ = link_to "↑ #{t('workarea.admin.dashboards.main_dashboard')}", root_path
5
+ %h1= t('workarea.admin.circuit_breaker.title')
6
+
7
+ .view__container
8
+ %p= t('workarea.admin.circuit_breaker.help_text')
9
+
10
+ - if @circuits.each.present?
11
+ %table
12
+ %thead
13
+ %tr
14
+ %th= t('workarea.admin.circuit_breaker.circuit')
15
+ %th
16
+ = t('workarea.admin.circuit_breaker.max_fails')
17
+ = link_to '#circuit-max-fails', data: { tooltip: '' } do
18
+ = inline_svg('workarea/admin/icons/help.svg', class: 'svg-icon svg-icon--small svg-icon--blue', title: t('workarea.admin.circuit_breaker.max_fails'))
19
+
20
+ #circuit-max-fails.tooltip-content
21
+ %p= t('workarea.admin.circuit_breaker.help.max_fails')
22
+ %th
23
+ = t('workarea.admin.circuit_breaker.window')
24
+ = link_to '#circuit-window', data: { tooltip: '' } do
25
+ = inline_svg('workarea/admin/icons/help.svg', class: 'svg-icon svg-icon--small svg-icon--blue', title: t('workarea.admin.circuit_breaker.window'))
26
+ #circuit-window.tooltip-content
27
+ %p= t('workarea.admin.circuit_breaker.help.window')
28
+ %th
29
+ = t('workarea.admin.circuit_breaker.break_for')
30
+ = link_to '#circuit-break-for', data: { tooltip: '' } do
31
+ = inline_svg('workarea/admin/icons/help.svg', class: 'svg-icon svg-icon--small svg-icon--blue', title: t('workarea.admin.circuit_breaker.break_for'))
32
+ #circuit-break-for.tooltip-content
33
+ %p= t('workarea.admin.circuit_breaker.help.break_for')
34
+ %th= t('workarea.admin.circuit_breaker.current_fails')
35
+ %th= t('workarea.admin.circuit_breaker.timeline')
36
+ %th= t('workarea.admin.circuit_breaker.broken_until')
37
+ %th
38
+ %tbody
39
+ - @circuits.each do |circuit|
40
+ %tr
41
+ %td= circuit.name
42
+ %td= circuit.max_fails
43
+ %td= circuit.window.inspect
44
+ %td= circuit.break_for.inspect
45
+ %td= circuit.current_fails
46
+ %td
47
+ - if circuit.timeline.present?
48
+ %ul.list-reset
49
+ - circuit.timeline.each do |failure_message|
50
+ %li
51
+ = failure_message.timestamp.to_s(:time_only)
52
+ - if failure_message.event_id.present?
53
+ %strong= t('workarea.admin.circuit_breaker.sentry')
54
+ = failure_message.event_id
55
+ %strong= t('workarea.admin.circuit_breaker.message')
56
+ = failure_message.message
57
+ %td
58
+ - if circuit.broken_until.present?
59
+ = circuit.broken_until.to_s(:time_only)
60
+ - if circuit.healthy?
61
+ %td
62
+ = inline_svg('workarea/admin/valid.svg', class: 'svg-icon svg-icon--green')
63
+ = link_to t('workarea.admin.circuit_breaker.break'), disable_circuit_path(circuit.slug), data: { method: 'post' }
64
+ - else
65
+ %td
66
+ = inline_svg('workarea/admin/invalid.svg', class: 'svg-icon svg-icon--red')
67
+ = link_to t('workarea.admin.circuit_breaker.enable'), enable_circuit_path(circuit.slug), data: { method: 'post' }
68
+ - else
69
+ %p= t('workarea.admin.circuit_breaker.no_circuits')
@@ -0,0 +1 @@
1
+ %li{ class: "primary-nav__item" }= link_to t('workarea.admin.shared.primary_nav.circuit_breaker_title'), circuits_path, class: navigation_link_classes(circuits_path)
data/bin/rails ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" with Rails gems
3
+ # installed from the root of your application.
4
+
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/workarea/circuit_breaker/engine', __dir__)
7
+ APP_PATH = File.expand_path('../test/dummy/config/application', __dir__)
8
+
9
+ # Set up gems listed in the Gemfile.
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
11
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
12
+
13
+ require "rails"
14
+ # Pick the frameworks you want:
15
+ require "active_model/railtie"
16
+ require "active_job/railtie"
17
+ # require "active_record/railtie"
18
+ # require "active_storage/engine"
19
+ require "action_controller/railtie"
20
+ require "action_mailer/railtie"
21
+ require "action_view/railtie"
22
+ # require "action_cable/engine"
23
+ require "sprockets/railtie"
24
+ require "rails/test_unit/railtie"
25
+ require 'rails/engine/commands'
@@ -0,0 +1,4 @@
1
+ Workarea.append_partials(
2
+ "admin.settings_menu",
3
+ "workarea/admin/shared/circuit_breaker_link"
4
+ )
@@ -0,0 +1,11 @@
1
+ Workarea.configure do |config|
2
+ config.circuit_breaker = ActiveSupport::Configurable::Configuration.new
3
+
4
+ config.circuit_breaker.circuits ||= {}
5
+ config.circuit_breaker.redis ||= {}
6
+ config.circuit_breaker.circuit_defaults ||= {
7
+ max_fails: 3,
8
+ window: 5.minutes,
9
+ break_for: 5.minutes
10
+ }
11
+ end
@@ -0,0 +1,29 @@
1
+ en:
2
+ workarea:
3
+ admin:
4
+ shared:
5
+ primary_nav:
6
+ circuit_breaker_title: Circuit Breakers
7
+ circuit_breaker:
8
+ flash_messages:
9
+ turned_on: "%{circuit} has been enabled"
10
+ turned_off: "%{circuit} has been disabled for %{break_for}"
11
+ break: Break
12
+ break_for: Break For
13
+ broken_until: Broken Until
14
+ circuit: Circuit
15
+ current_fails: Current Fails
16
+ enable: Enable
17
+ max_fails: Max Fails
18
+ message: Message
19
+ sentry: Sentry
20
+ timeline: Timeline
21
+ title: Circuit Breakers
22
+ window: Window
23
+ manually_broke_circuit: "%{user} manually broke circuit"
24
+ no_circuits: There are currently no circuits defined.
25
+ help_text: Circuits are developer configured code blocks that allow for processes to execute a defined fallback when a service fails within a given set of parameters.
26
+ help:
27
+ max_fails: Maximum times this circuit can fail before shutting off.
28
+ window: Timeframe in which a Max fails would trigger a circuit to shut off. Eg. five failures (max fails) in one minute (window) will trigger the circuit to shut off.
29
+ break_for: How long to turn the circuit off for when the max failures limit is reached within the window duration.
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ Workarea::Admin::Engine.routes.draw do
2
+ scope '(:locale)', constraints: Workarea::I18n.routes_constraint do
3
+ resources :circuits, only: :index do
4
+ member do
5
+ post :enable
6
+ post :disable
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,96 @@
1
+ require 'connection_pool'
2
+
3
+ require 'workarea'
4
+ require 'workarea/storefront'
5
+ require 'workarea/admin'
6
+
7
+ require 'workarea/circuit_breaker/engine'
8
+ require 'workarea/circuit_breaker/version'
9
+ require 'workarea/circuit_breaker/circuit'
10
+ require 'workarea/circuit_breaker/failure_message'
11
+
12
+ module Workarea
13
+ module CircuitBreaker
14
+ class CircuitBreakerError < RuntimeError; end
15
+ module ConnectionPoolLike
16
+ def with
17
+ yield self
18
+ end
19
+ end
20
+
21
+ unless ::Redis.instance_methods.include?(:with)
22
+ ::Redis.include(ConnectionPoolLike)
23
+ end
24
+
25
+ CIRCUIT_KEY_BASE = "workarea_circuits"
26
+
27
+ def self.config
28
+ Workarea.config.circuit_breaker
29
+ end
30
+
31
+ def self.circuit_defaults
32
+ config.circuit_defaults
33
+ end
34
+
35
+ def self.add_service(name, **options)
36
+ options = options.symbolize_keys
37
+ overwrite = options.delete(:overwrite) || false
38
+
39
+ if !overwrite && config.circuits.has_key?(name)
40
+ Rails.logger.warn "Overwriting existing service #{name} in CircuitBreaker"
41
+ end
42
+
43
+ config.circuits[name.to_s] = options
44
+ end
45
+
46
+ def self.[](circuit_name)
47
+ circuit_name = circuit_name.to_s
48
+ options = config.circuits[circuit_name] || {}
49
+ Circuit.new(circuit_name, **options)
50
+ end
51
+
52
+ def self.redis
53
+ @redis ||= if pool_options.present?
54
+ ConnectionPool.new(pool_options) do
55
+ Redis.new(url: Workarea::Configuration::Redis.persistent.to_url)
56
+ end
57
+ else
58
+ Redis.new(url: Workarea::Configuration::Redis.persistent.to_url)
59
+ end
60
+ end
61
+
62
+ # Capture errors thrown by code that is running in the circuit
63
+ # breaker. When `Sentry::Raven` is installed, send the error over to
64
+ # Sentry and return the event ID. Otherwise, just return `nil`. This
65
+ # will also return `nil` when Sentry is not configured to capture
66
+ # exceptions in the environment, such as when tests are running.
67
+ #
68
+ # @param [Exception] exception - Error thrown by the application
69
+ # @return [String] Event ID or `nil` when the exception was not
70
+ # captured by Sentry.
71
+ def self.capture_exception(exception)
72
+ event = if defined?(::Raven)
73
+ Raven.capture_exception(exception) || nil
74
+ else
75
+ Rails.logger.warn exception
76
+ nil
77
+ end
78
+
79
+ return if event.blank?
80
+
81
+ event.id
82
+ end
83
+
84
+ def self.freeze_config!
85
+ Workarea.config.circuit_breaker.freeze
86
+ Workarea.config.circuit_breaker.circuits.freeze
87
+ Workarea.config.circuit_breaker.circuit_defaults.freeze
88
+ end
89
+
90
+ private
91
+
92
+ def self.pool_options
93
+ config.redis.fetch(:pool, {})
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,137 @@
1
+ module Workarea
2
+ module CircuitBreaker
3
+ class Circuit
4
+ attr_reader :name, :options
5
+
6
+ def initialize(name, **options)
7
+ @name = name.to_s
8
+ @options = CircuitBreaker.circuit_defaults.merge(options).symbolize_keys
9
+ end
10
+
11
+ # Checks if this circuit is currently, defaults to true
12
+ #
13
+ # @return [Boolean]
14
+ def healthy?
15
+ broken = CircuitBreaker.redis.with do |connection|
16
+ connection.get(redis_broken_key)
17
+ end
18
+
19
+ broken.nil?
20
+ rescue Timeout::Error => error
21
+ CircuitBreaker.capture_exception(error)
22
+ true
23
+ end
24
+
25
+ # Set the circuit to be healthy
26
+ #
27
+ # @return [nil]
28
+ def set_healthy!
29
+ CircuitBreaker.redis.with do |connection|
30
+ connection.pipelined do
31
+ connection.del(redis_broken_key)
32
+ connection.del(redis_failure_set_key)
33
+ end
34
+ end
35
+ nil
36
+ rescue Timeout::Error => error
37
+ CircuitBreaker.capture_exception(error)
38
+ nil
39
+ end
40
+
41
+ # Break this circuit for the configured break_for duration of this service
42
+ #
43
+ # @return nil
44
+ def break!
45
+ CircuitBreaker.redis.with do |connection|
46
+ connection.setex(redis_broken_key, break_for, break_for.from_now.to_i)
47
+ end
48
+ nil
49
+ rescue Timeout::Error => error
50
+ CircuitBreaker.capture_exception(error)
51
+ nil
52
+ end
53
+
54
+ # Wraps the black around a circuit. If the circuit is healthy the block
55
+ # is executed and it's return value is returned. If the black raises an error
56
+ # an event is added to the circuit timeline. If a fallback is provided the
57
+ # fallback is invoked otherwise an error is raised
58
+ #
59
+ # @param [Symbol, Object] either a symbol of a method defined on the blocks binding receiver or anything that responds to call
60
+ def wrap(fallback: nil, &block)
61
+ if healthy?
62
+ begin
63
+ block.call
64
+ rescue => error
65
+ event_id = CircuitBreaker.capture_exception(error)
66
+ add_failure(event_id: event_id, message: "#{error.class}: #{error.message}")
67
+ fallback_or_error(block.binding.receiver, fallback: fallback, error: error)
68
+ end
69
+ else
70
+ fallback_or_error(block.binding.receiver, fallback: fallback, error: CircuitBreakerError)
71
+ end
72
+ end
73
+
74
+ def window
75
+ options[:window]
76
+ end
77
+
78
+ def break_for
79
+ options[:break_for]
80
+ end
81
+
82
+ def max_fails
83
+ options[:max_fails]
84
+ end
85
+
86
+ # @param [String] event_id Raven::Event#id
87
+ # @param [String] message - normally an error message
88
+ #
89
+ # @return [Int] number of events in the timeline
90
+ def add_failure(message:, event_id: nil)
91
+ CircuitBreaker.redis.with do |connection|
92
+ failure_message = FailureMessage.new(event_id: event_id, message: message)
93
+
94
+ _deleted, _added, length, _expiry_set = connection.pipelined do
95
+ connection.zremrangebyscore(redis_failure_set_key, 0, break_for.ago.to_i)
96
+ connection.zadd(redis_failure_set_key, Time.current.to_i, failure_message.to_s)
97
+ connection.zcard(redis_failure_set_key)
98
+ # expire as the time a circuit could be broken so info is avaiable whole length
99
+ # of broken time
100
+ connection.expire(redis_failure_set_key, break_for.to_i)
101
+ end
102
+
103
+ break! if length >= max_fails
104
+
105
+ length
106
+ end
107
+ rescue Timeout::Error => error
108
+ CircuitBreaker.capture_exception(error)
109
+ 0
110
+ end
111
+
112
+ def redis_broken_key
113
+ "#{redis_key_base}:broken"
114
+ end
115
+
116
+ def redis_failure_set_key
117
+ "#{redis_key_base}:failures"
118
+ end
119
+
120
+ private
121
+
122
+ def fallback_or_error(receiver, fallback: nil, error: nil)
123
+ if fallback.blank?
124
+ raise error
125
+ elsif fallback.respond_to?(:call)
126
+ fallback.call
127
+ elsif receiver.respond_to?(fallback, true)
128
+ receiver.send(fallback)
129
+ end
130
+ end
131
+
132
+ def redis_key_base
133
+ "#{CircuitBreaker::CIRCUIT_KEY_BASE}:#{name.to_s.systemize}"
134
+ end
135
+ end
136
+ end
137
+ end