Skip to content

Commit 00c9d2d

Browse files
add lumping_interval feature in Semian Circuit Breaker
fix notation for index for SlidingWindow and edge case for empty array
1 parent f82188e commit 00c9d2d

File tree

3 files changed

+123
-2
lines changed

3 files changed

+123
-2
lines changed

lib/semian.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@ def create_circuit_breaker(name, **options)
310310
else
311311
options[:error_threshold_timeout_enabled]
312312
end,
313+
lumping_interval: if options[:lumping_interval].nil?
314+
0
315+
else
316+
options[:lumping_interval]
317+
end,
313318
exceptions: Array(exceptions) + [::Semian::BaseError],
314319
half_open_resource_timeout: options[:half_open_resource_timeout],
315320
implementation: implementation(**options),

lib/semian/circuit_breaker.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ class CircuitBreaker # :nodoc:
1717

1818
def initialize(name, exceptions:, success_threshold:, error_threshold:,
1919
error_timeout:, implementation:, half_open_resource_timeout: nil,
20-
error_threshold_timeout: nil, error_threshold_timeout_enabled: true)
20+
error_threshold_timeout: nil, error_threshold_timeout_enabled: true,
21+
lumping_interval: 0)
2122
@name = name.to_sym
2223
@success_count_threshold = success_threshold
2324
@error_count_threshold = error_threshold
@@ -26,6 +27,11 @@ def initialize(name, exceptions:, success_threshold:, error_threshold:,
2627
@error_timeout = error_timeout
2728
@exceptions = exceptions
2829
@half_open_resource_timeout = half_open_resource_timeout
30+
@lumping_interval = lumping_interval
31+
32+
if @lumping_interval > @error_threshold_timeout
33+
raise ArgumentError, "lumping_interval (#{@lumping_interval}) must be less than error_threshold_timeout (#{@error_threshold_timeout})"
34+
end
2935

3036
@errors = implementation::SlidingWindow.new(max_size: @error_count_threshold)
3137
@successes = implementation::Integer.new
@@ -137,11 +143,14 @@ def push_error(error)
137143

138144
def push_time
139145
time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146+
140147
if error_threshold_timeout_enabled
141148
@errors.reject! { |err_time| err_time + @error_threshold_timeout < time }
142149
end
143150

144-
@errors << time
151+
if @errors.empty? || @errors.last <= time - @lumping_interval
152+
@errors << time
153+
end
145154
end
146155

147156
def log_state_transition(new_state)

test/circuit_breaker_test.rb

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,4 +600,111 @@ def test_notify_state_transition
600600
ensure
601601
Semian.unsubscribe(:test_notify_state_transition)
602602
end
603+
604+
def test_lumping_interval_prevents_rapid_error_accumulation
605+
resource = Semian.register(
606+
:lumping_test,
607+
bulkhead: false,
608+
exceptions: [SomeError],
609+
error_threshold: 3,
610+
error_timeout: 10,
611+
success_threshold: 1,
612+
lumping_interval: 2,
613+
)
614+
615+
# Trigger an arbitrary number of errors within lumping interval
616+
6.times do
617+
trigger_error!(resource)
618+
end
619+
620+
# Should not open circuit since errors are lumped
621+
assert_circuit_closed(resource)
622+
623+
time_travel(3) do
624+
6.times do
625+
trigger_error!(resource)
626+
end
627+
628+
assert_circuit_closed(resource)
629+
630+
time_travel(3) do
631+
# A single error should open circuit because we have reached the error threshold
632+
trigger_error!(resource)
633+
634+
assert_circuit_opened(resource)
635+
end
636+
end
637+
ensure
638+
Semian.destroy(:lumping_test)
639+
end
640+
641+
def test_lumping_interval_respects_error_threshold
642+
resource = Semian.register(
643+
:lumping_threshold_test,
644+
bulkhead: false,
645+
exceptions: [SomeError],
646+
error_threshold: 2,
647+
error_timeout: 5,
648+
success_threshold: 1,
649+
lumping_interval: 1,
650+
)
651+
652+
# First error
653+
trigger_error!(resource)
654+
655+
assert_circuit_closed(resource)
656+
657+
# Wait past lumping interval
658+
time_travel(2) do
659+
# Second error should open circuit
660+
trigger_error!(resource)
661+
662+
assert_circuit_opened(resource)
663+
end
664+
ensure
665+
Semian.destroy(:lumping_threshold_test)
666+
end
667+
668+
def test_lumping_interval_with_zero_value
669+
resource = Semian.register(
670+
:lumping_zero_test,
671+
bulkhead: false,
672+
exceptions: [SomeError],
673+
error_threshold: 2,
674+
error_timeout: 5,
675+
success_threshold: 1,
676+
lumping_interval: 0,
677+
)
678+
679+
# First error
680+
trigger_error!(resource)
681+
682+
assert_circuit_closed(resource)
683+
684+
# Second error should open circuit immediately
685+
trigger_error!(resource)
686+
687+
assert_circuit_opened(resource)
688+
ensure
689+
Semian.destroy(:lumping_zero_test)
690+
end
691+
692+
def test_lumping_interval_cannot_be_greater_than_error_threshold_timeout
693+
error = assert_raises(ArgumentError) do
694+
Semian.register(
695+
:lumping_validation_test,
696+
bulkhead: false,
697+
exceptions: [SomeError],
698+
error_threshold: 2,
699+
error_timeout: 5,
700+
error_threshold_timeout: 3,
701+
success_threshold: 1,
702+
lumping_interval: 4,
703+
)
704+
end
705+
706+
assert_match(/lumping_interval \(4\) must be less than error_threshold_timeout \(3\)/, error.message)
707+
ensure
708+
Semian.destroy(:lumping_validation_test)
709+
end
603710
end

0 commit comments

Comments
 (0)