From 0b48f6653b1dd735242ff7a4bbfce7d91fd682d6 Mon Sep 17 00:00:00 2001 From: Madelein van Niekerk <mvanniekerk@gitlab.com> Date: Fri, 24 Nov 2023 15:28:34 +0000 Subject: [PATCH] Introduce Gitlab::CircuitBreaker Changelog: added --- .rubocop_todo/rspec/named_subject.yml | 1 - .../style/inline_disable_annotation.yml | 1 - config/initializers/circuitbox.rb | 8 +- ee/lib/gitlab/llm/concerns/circuit_breaker.rb | 36 ------ .../llm/concerns/exponential_backoff.rb | 9 +- .../llm/concerns/circuit_breaker_spec.rb | 103 --------------- .../llm/circuit_breaker_shared_examples.rb | 2 +- lib/gitlab/circuit_breaker.rb | 36 ++++++ .../gitlab/circuit_breaker/notifier.rb | 2 +- .../gitlab/circuit_breaker/store.rb | 6 - rubocop/rubocop-code_reuse.yml | 1 + spec/initializers/circuitbox_spec.rb | 7 +- .../gitlab/circuit_breaker/notifier_spec.rb | 12 +- .../lib/gitlab/circuit_breaker/store_spec.rb | 0 spec/lib/gitlab/circuit_breaker_spec.rb | 120 ++++++++++++++++++ 15 files changed, 175 insertions(+), 169 deletions(-) delete mode 100644 ee/lib/gitlab/llm/concerns/circuit_breaker.rb delete mode 100644 ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb create mode 100644 lib/gitlab/circuit_breaker.rb rename {ee/lib => lib}/gitlab/circuit_breaker/notifier.rb (93%) rename {ee/lib => lib}/gitlab/circuit_breaker/store.rb (79%) rename {ee/spec => spec}/lib/gitlab/circuit_breaker/notifier_spec.rb (64%) rename {ee/spec => spec}/lib/gitlab/circuit_breaker/store_spec.rb (100%) create mode 100644 spec/lib/gitlab/circuit_breaker_spec.rb diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml index 960b5bb5a05a8..75b566464c281 100644 --- a/.rubocop_todo/rspec/named_subject.yml +++ b/.rubocop_todo/rspec/named_subject.yml @@ -473,7 +473,6 @@ RSpec/NamedSubject: - 'ee/spec/lib/gitlab/llm/chat_message_spec.rb' - 'ee/spec/lib/gitlab/llm/chat_storage_spec.rb' - 'ee/spec/lib/gitlab/llm/completions/chat_spec.rb' - - 'ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb' - 'ee/spec/lib/gitlab/llm/concerns/exponential_backoff_spec.rb' - 'ee/spec/lib/gitlab/llm/graphql_subscription_response_service_spec.rb' - 'ee/spec/lib/gitlab/llm/templates/categorize_question_spec.rb' diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml index 37c6c6b93b422..aeefb9ceda364 100644 --- a/.rubocop_todo/style/inline_disable_annotation.yml +++ b/.rubocop_todo/style/inline_disable_annotation.yml @@ -2085,7 +2085,6 @@ Style/InlineDisableAnnotation: - 'ee/spec/lib/gitlab/import_export/project/relation_factory_spec.rb' - 'ee/spec/lib/gitlab/llm/chain/agents/zero_shot/executor_real_requests_spec.rb' - 'ee/spec/lib/gitlab/llm/chain/tools/epic_identifier/executor_spec.rb' - - 'ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb' - 'ee/spec/lib/gitlab/mirror_spec.rb' - 'ee/spec/lib/gitlab/patch/database_config_spec.rb' - 'ee/spec/lib/gitlab/sitemaps/sitemap_file_spec.rb' diff --git a/config/initializers/circuitbox.rb b/config/initializers/circuitbox.rb index 966320265aa5e..55c06cf868b91 100644 --- a/config/initializers/circuitbox.rb +++ b/config/initializers/circuitbox.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -Gitlab.ee do - Circuitbox.configure do |config| - config.default_circuit_store = Gitlab::CircuitBreaker::Store.new - config.default_notifier = Gitlab::CircuitBreaker::Notifier.new - end +Circuitbox.configure do |config| + config.default_circuit_store = Gitlab::CircuitBreaker::Store.new + config.default_notifier = Gitlab::CircuitBreaker::Notifier.new end diff --git a/ee/lib/gitlab/llm/concerns/circuit_breaker.rb b/ee/lib/gitlab/llm/concerns/circuit_breaker.rb deleted file mode 100644 index d72514eeaff27..0000000000000 --- a/ee/lib/gitlab/llm/concerns/circuit_breaker.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Llm - module Concerns - module CircuitBreaker - extend ActiveSupport::Concern - - InternalServerError = Class.new(StandardError) - - ERROR_THRESHOLD = 50 - VOLUME_THRESHOLD = 10 - - included do - def circuit - @circuit ||= Circuitbox.circuit(service_name, { - exceptions: [InternalServerError], - error_threshold: ERROR_THRESHOLD, - volume_threshold: VOLUME_THRESHOLD - }) - end - end - - def run_with_circuit(&block) - circuit.run(exception: false, &block) - end - - private - - def service_name - self.class.name - end - end - end - end -end diff --git a/ee/lib/gitlab/llm/concerns/exponential_backoff.rb b/ee/lib/gitlab/llm/concerns/exponential_backoff.rb index 72c86f8b1dfc0..8f9dc6c90d4b3 100644 --- a/ee/lib/gitlab/llm/concerns/exponential_backoff.rb +++ b/ee/lib/gitlab/llm/concerns/exponential_backoff.rb @@ -5,7 +5,6 @@ module Llm module Concerns module ExponentialBackoff extend ActiveSupport::Concern - include ::Gitlab::Llm::Concerns::CircuitBreaker INITIAL_DELAY = 1.second EXPONENTIAL_BASE = 2 @@ -18,7 +17,7 @@ def self.included(base) end def retry_with_exponential_backoff(&block) - run_with_circuit do + Gitlab::CircuitBreaker.run_with_circuit(service_name) do retry_with_monitored_exponential_backoff(&block) end end @@ -46,7 +45,7 @@ def run_retry_with_exponential_backoff http_response = response.response return if http_response.nil? || http_response.body.blank? - raise Gitlab::Llm::Concerns::CircuitBreaker::InternalServerError if response.server_error? + raise Gitlab::CircuitBreaker::InternalServerError if response.server_error? return response unless response.too_many_requests? || retry_immediately?(response) @@ -65,6 +64,10 @@ def run_retry_with_exponential_backoff def retry_immediately?(_response) false end + + def service_name + self.class.name + end end end end diff --git a/ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb b/ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb deleted file mode 100644 index 90e8d243eace5..0000000000000 --- a/ee/spec/lib/gitlab/llm/concerns/circuit_breaker_spec.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Llm::Concerns::CircuitBreaker, :clean_gitlab_redis_rate_limiting, feature_category: :ai_abstraction_layer do - let(:dummy_class) do - Class.new do - include ::Gitlab::Llm::Concerns::CircuitBreaker - - def dummy_method - run_with_circuit do - raise Gitlab::Llm::Concerns::CircuitBreaker::InternalServerError - end - end - - def another_dummy_method - run_with_circuit do - # Do nothing but successful. - end - end - end - end - - subject { dummy_class.new } - - before do - stub_const('DummyService', dummy_class) - end - - describe '#circuit' do - it 'returns nil value' do - expect(Circuitbox).to receive(:circuit).with('DummyService', anything).and_call_original - expect(subject.dummy_method).to be_nil - end - - it 'does not raise an error' do - expect(Circuitbox).to receive(:circuit).with('DummyService', anything).and_call_original - expect { subject.dummy_method }.not_to raise_error - end - - context 'when failed multiple times below volume threshold' do - it 'does not open the circuit' do - (described_class::VOLUME_THRESHOLD - 1).times.each do - subject.dummy_method - end - - expect(subject.circuit).not_to be_open - end - end - - context 'when failed multiple times over volume threshold' do - it 'opens the circuit' do - (described_class::VOLUME_THRESHOLD + 1).times.each do - subject.dummy_method - end - - expect(subject.circuit).to be_open - end - end - - context 'when circuit is previously open' do - before do - # Opens the circuit - (described_class::VOLUME_THRESHOLD + 1).times.each do - subject.dummy_method - end - - # Deletes the open key - subject.circuit.try_close_next_time - end - - context 'when does not fail again' do - it 'closes the circuit' do - subject.another_dummy_method - - expect(subject.circuit).not_to be_open - end - end - - context 'when fails again' do - it 'opens the circuit' do - subject.dummy_method - - expect(subject.circuit).to be_open - end - end - end - end - - describe '#run_with_circuit' do - let(:circuit) { double('circuit') } # rubocop: disable RSpec/VerifiedDoubles - let(:block) { proc {} } - - before do - allow(subject).to receive(:circuit).and_return(circuit) - end - - it 'runs the code block within the Circuitbox circuit' do - expect(circuit).to receive(:run).with(exception: false, &block) - subject.run_with_circuit(&block) - end - end -end diff --git a/ee/spec/support/shared_examples/lib/gitlab/llm/circuit_breaker_shared_examples.rb b/ee/spec/support/shared_examples/lib/gitlab/llm/circuit_breaker_shared_examples.rb index f4fe78106b292..19b08c42b85a6 100644 --- a/ee/spec/support/shared_examples/lib/gitlab/llm/circuit_breaker_shared_examples.rb +++ b/ee/spec/support/shared_examples/lib/gitlab/llm/circuit_breaker_shared_examples.rb @@ -3,7 +3,7 @@ RSpec.shared_examples 'has circuit breaker' do describe '#call_external_service' do it 'runs the code block within the circuit breaker' do - expect(service).to receive(:run_with_circuit) + expect(Gitlab::CircuitBreaker).to receive(:run_with_circuit) subject end end diff --git a/lib/gitlab/circuit_breaker.rb b/lib/gitlab/circuit_breaker.rb new file mode 100644 index 0000000000000..2b3a6187f273b --- /dev/null +++ b/lib/gitlab/circuit_breaker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# A configurable circuit breaker to protect the application from external service failures. +# The circuit measures the amount of failures and if the threshold is exceeded, stops sending requests. +module Gitlab + module CircuitBreaker + InternalServerError = Class.new(StandardError) + + DEFAULT_ERROR_THRESHOLD = 50 + DEFAULT_VOLUME_THRESHOLD = 10 + + class << self + include ::Gitlab::Utils::StrongMemoize + + # @param [String] unique name for the circuit + # @param options [Hash] an options hash setting optional values per circuit + def run_with_circuit(service_name, options = {}, &block) + circuit(service_name, options).run(exception: false, &block) + end + + private + + def circuit(service_name, options) + strong_memoize_with(:circuit, service_name, options) do + circuit_options = { + exceptions: [InternalServerError], + error_threshold: DEFAULT_ERROR_THRESHOLD, + volume_threshold: DEFAULT_VOLUME_THRESHOLD + }.merge(options) + + Circuitbox.circuit(service_name, circuit_options) + end + end + end + end +end diff --git a/ee/lib/gitlab/circuit_breaker/notifier.rb b/lib/gitlab/circuit_breaker/notifier.rb similarity index 93% rename from ee/lib/gitlab/circuit_breaker/notifier.rb rename to lib/gitlab/circuit_breaker/notifier.rb index aaa4616696328..b555158ee48b6 100644 --- a/ee/lib/gitlab/circuit_breaker/notifier.rb +++ b/lib/gitlab/circuit_breaker/notifier.rb @@ -14,7 +14,7 @@ def notify(service_name, event) Gitlab::ErrorTracking.track_exception(exception) end - def notify_warning(service_name, message) + def notify_warning(_service_name, _message) # no-op end diff --git a/ee/lib/gitlab/circuit_breaker/store.rb b/lib/gitlab/circuit_breaker/store.rb similarity index 79% rename from ee/lib/gitlab/circuit_breaker/store.rb rename to lib/gitlab/circuit_breaker/store.rb index 56e66624d3adf..0ba4f08d5e17a 100644 --- a/ee/lib/gitlab/circuit_breaker/store.rb +++ b/lib/gitlab/circuit_breaker/store.rb @@ -4,9 +4,7 @@ module Gitlab module CircuitBreaker class Store def key?(key) - # rubocop: disable CodeReuse/ActiveRecord with { |redis| redis.exists?(key) } - # rubocop: enable CodeReuse/ActiveRecord end def store(key, value, opts = {}) @@ -19,14 +17,12 @@ def store(key, value, opts = {}) def increment(key, amount = 1, opts = {}) expires = opts[:expires] - # rubocop: disable CodeReuse/ActiveRecord with do |redis| redis.multi do |multi| multi.incrby(key, amount) multi.expire(key, expires) if expires end end - # rubocop: enable CodeReuse/ActiveRecord end def load(key, _opts = {}) @@ -44,9 +40,7 @@ def delete(key) private def with(&block) - # rubocop: disable CodeReuse/ActiveRecord Gitlab::Redis::RateLimiting.with(&block) - # rubocop: enable CodeReuse/ActiveRecord rescue ::Redis::BaseConnectionError # Do not raise an error if we cannot connect to Redis. If # Redis::RateLimiting is unavailable it should not take the site down. diff --git a/rubocop/rubocop-code_reuse.yml b/rubocop/rubocop-code_reuse.yml index 6f9d7902dc681..cf81aa2db5e6d 100644 --- a/rubocop/rubocop-code_reuse.yml +++ b/rubocop/rubocop-code_reuse.yml @@ -26,6 +26,7 @@ CodeReuse/ActiveRecord: - lib/banzai/**/*.rb - lib/click_house/migration_support/**/*.rb - lib/gitlab/background_migration/**/*.rb + - lib/gitlab/circuit_breaker/store.rb - lib/gitlab/cycle_analytics/**/*.rb - lib/gitlab/counters/**/*.rb - lib/gitlab/database/**/*.rb diff --git a/spec/initializers/circuitbox_spec.rb b/spec/initializers/circuitbox_spec.rb index 64e384e4d2240..d0a6d6c9f9152 100644 --- a/spec/initializers/circuitbox_spec.rb +++ b/spec/initializers/circuitbox_spec.rb @@ -3,12 +3,7 @@ require 'spec_helper' RSpec.describe 'circuitbox', feature_category: :shared do - it 'does not configure Circuitbox', unless: Gitlab.ee? do - expect(Circuitbox.default_circuit_store).to be_a(Circuitbox::MemoryStore) - expect(Circuitbox.default_notifier).to be_a(Circuitbox::Notifier::ActiveSupport) - end - - it 'configures Circuitbox', if: Gitlab.ee? do + it 'configures Circuitbox' do expect(Circuitbox.default_circuit_store).to be_a(Gitlab::CircuitBreaker::Store) expect(Circuitbox.default_notifier).to be_a(Gitlab::CircuitBreaker::Notifier) end diff --git a/ee/spec/lib/gitlab/circuit_breaker/notifier_spec.rb b/spec/lib/gitlab/circuit_breaker/notifier_spec.rb similarity index 64% rename from ee/spec/lib/gitlab/circuit_breaker/notifier_spec.rb rename to spec/lib/gitlab/circuit_breaker/notifier_spec.rb index 3d222c299ff6f..1640ebb99f967 100644 --- a/ee/spec/lib/gitlab/circuit_breaker/notifier_spec.rb +++ b/spec/lib/gitlab/circuit_breaker/notifier_spec.rb @@ -2,15 +2,15 @@ require 'spec_helper' -RSpec.describe Gitlab::CircuitBreaker::Notifier, feature_category: :ai_abstraction_layer do - subject { described_class.new } +RSpec.describe Gitlab::CircuitBreaker::Notifier, feature_category: :shared do + subject(:instance) { described_class.new } describe '#notify' do context 'when event is failure' do it 'sends an exception to Gitlab::ErrorTracking' do expect(Gitlab::ErrorTracking).to receive(:track_exception) - subject.notify('test_service', 'failure') + instance.notify('test_service', 'failure') end end @@ -18,20 +18,20 @@ it 'does not send an exception to Gitlab::ErrorTracking' do expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - subject.notify('test_service', 'test_event') + instance.notify('test_service', 'test_event') end end end describe '#notify_warning' do it do - expect { subject.notify_warning('test_service', 'test_message') }.not_to raise_error + expect { instance.notify_warning('test_service', 'test_message') }.not_to raise_error end end describe '#notify_run' do it do - expect { subject.notify_run('test_service') { puts 'test block' } }.not_to raise_error + expect { instance.notify_run('test_service') { puts 'test block' } }.not_to raise_error end end end diff --git a/ee/spec/lib/gitlab/circuit_breaker/store_spec.rb b/spec/lib/gitlab/circuit_breaker/store_spec.rb similarity index 100% rename from ee/spec/lib/gitlab/circuit_breaker/store_spec.rb rename to spec/lib/gitlab/circuit_breaker/store_spec.rb diff --git a/spec/lib/gitlab/circuit_breaker_spec.rb b/spec/lib/gitlab/circuit_breaker_spec.rb new file mode 100644 index 0000000000000..4cd2f41869eee --- /dev/null +++ b/spec/lib/gitlab/circuit_breaker_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::CircuitBreaker, :clean_gitlab_redis_rate_limiting, feature_category: :shared do + let(:service_name) { 'DummyService' } + let(:volume_threshold) { 5 } + let(:circuit) do + Circuitbox.circuit(service_name, + { volume_threshold: volume_threshold, exceptions: [Gitlab::CircuitBreaker::InternalServerError] }) + end + + let(:dummy_class) do + Class.new do + def dummy_method + Gitlab::CircuitBreaker.run_with_circuit('DummyService') do + raise Gitlab::CircuitBreaker::InternalServerError + end + end + + def another_dummy_method + Gitlab::CircuitBreaker.run_with_circuit('DummyService') do + # Do nothing but successful. + end + end + end + end + + subject(:instance) { dummy_class.new } + + before do + stub_const(service_name, dummy_class) + allow(Circuitbox).to receive(:circuit).and_return(circuit) + end + + # rubocop: disable RSpec/AnyInstanceOf -- the instance is defined by an initializer + describe '#circuit' do + it 'returns nil value' do + expect(instance.dummy_method).to be_nil + end + + it 'does not raise an error' do + expect { instance.dummy_method }.not_to raise_error + end + + context 'when failed multiple times below volume threshold' do + it 'does not open the circuit' do + expect_any_instance_of(Gitlab::CircuitBreaker::Notifier).to receive(:notify) + .with(anything, 'failure') + .exactly(4).times + + 4.times do + instance.dummy_method + end + + expect(circuit).not_to be_open + end + end + + context 'when failed multiple times over volume threshold' do + it 'allows the call 5 times, then opens the circuit and skips subsequent calls' do + expect_any_instance_of(Gitlab::CircuitBreaker::Notifier).to receive(:notify) + .with(anything, 'failure') + .exactly(5).times + + expect_any_instance_of(Gitlab::CircuitBreaker::Notifier).to receive(:notify) + .with(anything, 'open') + .once + + expect_any_instance_of(Gitlab::CircuitBreaker::Notifier).to receive(:notify) + .with(anything, 'skipped') + .once + + 6.times do + instance.dummy_method + end + + expect(circuit).to be_open + end + end + + context 'when circuit is previously open' do + before do + # Opens the circuit + 6.times do + instance.dummy_method + end + + # Deletes the open key + circuit.try_close_next_time + end + + context 'when does not fail again' do + it 'closes the circuit' do + instance.another_dummy_method + + expect(circuit).not_to be_open + end + end + + context 'when fails again' do + it 'opens the circuit' do + instance.dummy_method + + expect(circuit).to be_open + end + end + end + end + # rubocop: enable RSpec/AnyInstanceOf + + describe '#run_with_circuit' do + let(:block) { proc {} } + + it 'runs the code block within the Circuitbox circuit' do + expect(circuit).to receive(:run).with(exception: false, &block) + described_class.run_with_circuit('service', &block) + end + end +end -- GitLab