stoplight 5.4.0 → 5.5.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 +4 -4
- data/README.md +1 -1
- data/lib/stoplight/admin/views/layout.erb +3 -3
- data/lib/stoplight/admin.rb +4 -4
- data/lib/stoplight/domain/color.rb +11 -0
- data/lib/stoplight/{config → domain}/compatibility_result.rb +1 -1
- data/lib/stoplight/domain/config.rb +55 -0
- data/lib/stoplight/{data_store/base.rb → domain/data_store.rb} +17 -15
- data/lib/stoplight/domain/error.rb +42 -0
- data/lib/stoplight/domain/failure.rb +42 -0
- data/lib/stoplight/domain/light/configuration_builder_interface.rb +130 -0
- data/lib/stoplight/domain/light.rb +198 -0
- data/lib/stoplight/domain/light_factory.rb +75 -0
- data/lib/stoplight/domain/metadata.rb +65 -0
- data/lib/stoplight/domain/state.rb +11 -0
- data/lib/stoplight/{notifier/base.rb → domain/state_transition_notifier.rb} +5 -4
- data/lib/stoplight/domain/strategies/green_run_strategy.rb +69 -0
- data/lib/stoplight/domain/strategies/red_run_strategy.rb +41 -0
- data/lib/stoplight/domain/strategies/run_strategy.rb +27 -0
- data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +98 -0
- data/lib/stoplight/domain/tracker/base.rb +41 -0
- data/lib/stoplight/domain/tracker/recovery_probe.rb +72 -0
- data/lib/stoplight/domain/tracker/request.rb +67 -0
- data/lib/stoplight/domain/traffic_control/base.rb +74 -0
- data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +57 -0
- data/lib/stoplight/domain/traffic_control/error_rate.rb +51 -0
- data/lib/stoplight/domain/traffic_recovery/base.rb +79 -0
- data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +70 -0
- data/lib/stoplight/domain/traffic_recovery.rb +13 -0
- data/lib/stoplight/infrastructure/data_store/memory/sliding_window.rb +79 -0
- data/lib/stoplight/infrastructure/data_store/memory.rb +307 -0
- data/lib/stoplight/infrastructure/data_store/redis/lua.rb +25 -0
- data/lib/stoplight/infrastructure/data_store/redis.rb +478 -0
- data/lib/stoplight/infrastructure/dependency_injection/container.rb +249 -0
- data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +13 -0
- data/lib/stoplight/infrastructure/notifier/generic.rb +90 -0
- data/lib/stoplight/infrastructure/notifier/io.rb +23 -0
- data/lib/stoplight/infrastructure/notifier/logger.rb +21 -0
- data/lib/stoplight/rspec/generic_notifier.rb +1 -1
- data/lib/stoplight/version.rb +1 -1
- data/lib/stoplight/wiring/container.rb +80 -0
- data/lib/stoplight/wiring/default.rb +28 -0
- data/lib/stoplight/{config/user_default_config.rb → wiring/default_configuration.rb} +24 -31
- data/lib/stoplight/wiring/default_factory_builder.rb +25 -0
- data/lib/stoplight/{data_store/fail_safe.rb → wiring/fail_safe_data_store.rb} +22 -11
- data/lib/stoplight/{notifier/fail_safe.rb → wiring/fail_safe_notifier.rb} +22 -13
- data/lib/stoplight/wiring/light/default_config.rb +18 -0
- data/lib/stoplight/wiring/light/system_config.rb +11 -0
- data/lib/stoplight/wiring/light_factory.rb +188 -0
- data/lib/stoplight/wiring/public_api.rb +28 -0
- data/lib/stoplight/wiring/system_container.rb +9 -0
- data/lib/stoplight/wiring/system_light_factory.rb +17 -0
- data/lib/stoplight.rb +38 -28
- metadata +53 -43
- data/lib/stoplight/color.rb +0 -9
- data/lib/stoplight/config/dsl.rb +0 -97
- data/lib/stoplight/config/library_default_config.rb +0 -21
- data/lib/stoplight/config/system_config.rb +0 -10
- data/lib/stoplight/data_store/memory/sliding_window.rb +0 -77
- data/lib/stoplight/data_store/memory.rb +0 -285
- data/lib/stoplight/data_store/redis/lua.rb +0 -23
- data/lib/stoplight/data_store/redis.rb +0 -446
- data/lib/stoplight/data_store.rb +0 -6
- data/lib/stoplight/default.rb +0 -30
- data/lib/stoplight/error.rb +0 -39
- data/lib/stoplight/failure.rb +0 -71
- data/lib/stoplight/light/config.rb +0 -112
- data/lib/stoplight/light/configuration_builder_interface.rb +0 -128
- data/lib/stoplight/light/green_run_strategy.rb +0 -54
- data/lib/stoplight/light/red_run_strategy.rb +0 -31
- data/lib/stoplight/light/run_strategy.rb +0 -32
- data/lib/stoplight/light/yellow_run_strategy.rb +0 -94
- data/lib/stoplight/light.rb +0 -191
- data/lib/stoplight/metadata.rb +0 -99
- data/lib/stoplight/notifier/generic.rb +0 -79
- data/lib/stoplight/notifier/io.rb +0 -21
- data/lib/stoplight/notifier/logger.rb +0 -19
- data/lib/stoplight/state.rb +0 -9
- data/lib/stoplight/traffic_control/base.rb +0 -70
- data/lib/stoplight/traffic_control/consecutive_errors.rb +0 -55
- data/lib/stoplight/traffic_control/error_rate.rb +0 -49
- data/lib/stoplight/traffic_recovery/base.rb +0 -75
- data/lib/stoplight/traffic_recovery/consecutive_successes.rb +0 -68
- data/lib/stoplight/traffic_recovery.rb +0 -11
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/get_metadata.lua +0 -0
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_failure.lua +0 -0
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_success.lua +0 -0
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_green.lua +0 -0
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_red.lua +0 -0
- /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_yellow.lua +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Domain
|
|
5
|
+
module TrafficControl
|
|
6
|
+
# A strategy that stops the traffic based on consecutive failures number.
|
|
7
|
+
#
|
|
8
|
+
# This strategy implements two distinct behaviors based on whether a window size
|
|
9
|
+
# is configured:
|
|
10
|
+
#
|
|
11
|
+
# 1. When window_size is set: The Stoplight turns red when the total number of
|
|
12
|
+
# failures within the window reaches the threshold.
|
|
13
|
+
#
|
|
14
|
+
# 2. When window_size is not set: The Stoplight turns red when consecutive failures
|
|
15
|
+
# reach the threshold.
|
|
16
|
+
#
|
|
17
|
+
# @example With window-based configuration
|
|
18
|
+
# traffic_control = Stoplight::Domain::TrafficControl::ConsecutiveErrors.new
|
|
19
|
+
# config = Stoplight::Domain::Config.new(threshold: 5, window_size: 60, traffic_control:)
|
|
20
|
+
#
|
|
21
|
+
# Will switch to red if 5 consecutive failures occur within the 60-second window
|
|
22
|
+
#
|
|
23
|
+
# @example With total number of consecutive failures configuration
|
|
24
|
+
# traffic_control = Stoplight::Domain::TrafficControl::ConsecutiveErrors.new
|
|
25
|
+
# config = Stoplight::Domain::Config.new(threshold: 5, window_size: nil, traffic_control:)
|
|
26
|
+
#
|
|
27
|
+
# Will switch to red only if 5 consecutive failures occur regardless of the time window
|
|
28
|
+
# @api private
|
|
29
|
+
class ConsecutiveErrors < Base
|
|
30
|
+
# @param config [Stoplight::Domain::Config]
|
|
31
|
+
# @return [Stoplight::Domain::CompatibilityResult]
|
|
32
|
+
def check_compatibility(config)
|
|
33
|
+
if config.threshold <= 0
|
|
34
|
+
incompatible("`threshold` should be bigger than 0")
|
|
35
|
+
elsif !config.threshold.is_a?(Integer)
|
|
36
|
+
incompatible("`threshold` should be an integer")
|
|
37
|
+
else
|
|
38
|
+
compatible
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Determines if traffic should be stopped based on failure counts.
|
|
43
|
+
#
|
|
44
|
+
# @param config [Stoplight::Domain::Config]
|
|
45
|
+
# @param metadata [Stoplight::Domain::Metadata]
|
|
46
|
+
# @return [Boolean] true if failures have reached the threshold, false otherwise
|
|
47
|
+
def stop_traffic?(config, metadata)
|
|
48
|
+
if config.window_size
|
|
49
|
+
[metadata.consecutive_errors, metadata.errors].min >= config.threshold
|
|
50
|
+
else
|
|
51
|
+
metadata.consecutive_errors >= config.threshold
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Domain
|
|
5
|
+
module TrafficControl
|
|
6
|
+
# A strategy that stops the traffic based on error rate.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# traffic_control = Stoplight::Domain::TrafficControl::ErrorRate.new
|
|
10
|
+
# config = Stoplight::Domain::Config.new(threshold: 0.6, window_size: 300, traffic_control:)
|
|
11
|
+
#
|
|
12
|
+
# Will switch to red if 60% error rate reached within the 5-minute (300 seconds) sliding window.
|
|
13
|
+
# By default this traffic control strategy starts evaluating only after 10 requests have been made. You can
|
|
14
|
+
# adjust this by passing a different value for `min_requests` when initializing the strategy.
|
|
15
|
+
#
|
|
16
|
+
# traffic_control = Stoplight::Domain::TrafficControl::ErrorRate.new(min_requests: 100)
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class ErrorRate < Base
|
|
20
|
+
# @!attribute min_requests
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
attr_reader :min_requests
|
|
23
|
+
|
|
24
|
+
# @param min_requests [Integer] Minimum number of requests before traffic control is applied.
|
|
25
|
+
# until this number of requests is reached, the error rate will not be considered.
|
|
26
|
+
def initialize(min_requests: 10)
|
|
27
|
+
@min_requests = min_requests
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param config [Stoplight::Domain::Config]
|
|
31
|
+
# @return [Stoplight::Domain::CompatibilityResult]
|
|
32
|
+
def check_compatibility(config)
|
|
33
|
+
if config.window_size.nil?
|
|
34
|
+
incompatible("`window_size` should be set")
|
|
35
|
+
elsif config.threshold < 0 || config.threshold > 1
|
|
36
|
+
incompatible("`threshold` should be between 0 and 1")
|
|
37
|
+
else
|
|
38
|
+
compatible
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param config [Stoplight::Domain::Config]
|
|
43
|
+
# @param metadata [Stoplight::Domain::Metadata]
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def stop_traffic?(config, metadata)
|
|
46
|
+
metadata.requests >= min_requests && metadata.error_rate >= config.threshold
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Domain
|
|
5
|
+
module TrafficRecovery
|
|
6
|
+
# Strategies for determining how to recover traffic flow through the Stoplight.
|
|
7
|
+
# These strategies evaluate recovery metrics to decide which color the Stoplight should
|
|
8
|
+
# transition to during the recovery process.
|
|
9
|
+
#
|
|
10
|
+
# @example Creating a custom traffic recovery strategy
|
|
11
|
+
# class GradualRecovery < Stoplight::Domain::TrafficRecovery::Base
|
|
12
|
+
# def initialize(min_success_rate: 0.8, min_samples: 100)
|
|
13
|
+
# @min_success_rate = min_success_rate
|
|
14
|
+
# @min_samples = min_samples
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# def determine_color(config, metadata)
|
|
18
|
+
# total_probes = metadata.recovery_probe_successes + metadata.recovery_probe_errors
|
|
19
|
+
#
|
|
20
|
+
# if total_probes < @min_samples
|
|
21
|
+
# return Color::YELLOW # Keep recovering, not enough samples
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# success_rate = metadata.recovery_probe_successes.fdiv(total_probes)
|
|
25
|
+
# if success_rate >= @min_success_rate
|
|
26
|
+
# Color::GREEN # Recovery successful
|
|
27
|
+
# elsif success_rate <= 0.2
|
|
28
|
+
# Color::RED # Recovery failed, too many errors
|
|
29
|
+
# else
|
|
30
|
+
# Color::YELLOW # Continue recovery
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @abstract
|
|
36
|
+
# @api private
|
|
37
|
+
class Base
|
|
38
|
+
# Checks if the strategy is compatible with the given Stoplight configuration.
|
|
39
|
+
#
|
|
40
|
+
# @param config [Stoplight::Domain::Config]
|
|
41
|
+
# @return [Stoplight::Domain::CompatibilityResult]
|
|
42
|
+
# :nocov:
|
|
43
|
+
def check_compatibility(config)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
# :nocov:
|
|
47
|
+
|
|
48
|
+
# Determines the appropriate recovery state based on the Stoplight's
|
|
49
|
+
# current metrics and recovery progress.
|
|
50
|
+
#
|
|
51
|
+
# @param config [Stoplight::Domain::Config]
|
|
52
|
+
# @param metadata [Stoplight::Domain::Metadata]
|
|
53
|
+
# @return [TrafficRecovery::Decision]
|
|
54
|
+
# :nocov:
|
|
55
|
+
def determine_color(config, metadata)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
# :nocov:
|
|
59
|
+
|
|
60
|
+
# @param other [any]
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def ==(other)
|
|
63
|
+
other.is_a?(self.class)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns a compatibility result indicating the strategy is compatible.
|
|
67
|
+
#
|
|
68
|
+
# @return [Stoplight::Domain::CompatibilityResult] A compatible result.
|
|
69
|
+
private def compatible = CompatibilityResult.compatible
|
|
70
|
+
|
|
71
|
+
# Returns a compatibility result indicating the strategy is incompatible.
|
|
72
|
+
#
|
|
73
|
+
# @param errors [Array<String>] The list of error messages describing incompatibility.
|
|
74
|
+
# @return [Stoplight::Domain::CompatibilityResult] An incompatible result.
|
|
75
|
+
private def incompatible(*errors) = CompatibilityResult.incompatible(*errors)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Domain
|
|
5
|
+
module TrafficRecovery
|
|
6
|
+
# A conservative strategy that requires multiple consecutive successful probes
|
|
7
|
+
# before resuming traffic flow.
|
|
8
|
+
#
|
|
9
|
+
# The strategy immediately returns to RED state if any failure occurs during
|
|
10
|
+
# the recovery process, ensuring that only truly stable services resume
|
|
11
|
+
# full traffic flow.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage with 3 consecutive successes required
|
|
14
|
+
# config = Stoplight::Domain::Config.new(
|
|
15
|
+
# cool_off_time: 60,
|
|
16
|
+
# recovery_threshold: 3
|
|
17
|
+
# )
|
|
18
|
+
# strategy = Stoplight::Domain::TrafficRecovery::ConsecutiveSuccesses.new
|
|
19
|
+
#
|
|
20
|
+
# Recovery behavior:
|
|
21
|
+
# - After cool-off period, Stoplight enters YELLOW (recovery) state
|
|
22
|
+
# - Requires 3 consecutive successful probes to transition to GREEN
|
|
23
|
+
# - Any failure during recovery immediately returns to RED state
|
|
24
|
+
# - Process repeats after another cool-off period
|
|
25
|
+
#
|
|
26
|
+
# Configuration requirements:
|
|
27
|
+
# - `recovery_threshold`: Integer > 0, specifies required consecutive successes
|
|
28
|
+
#
|
|
29
|
+
# Failure behavior:
|
|
30
|
+
# Unlike some circuit breaker implementations that tolerate occasional failures
|
|
31
|
+
# during recovery, this strategy takes a zero-tolerance approach: any failure
|
|
32
|
+
# during the recovery phase immediately transitions back to RED state. This
|
|
33
|
+
# conservative approach prioritizes stability over recovery speed.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
class ConsecutiveSuccesses < Base
|
|
37
|
+
# @param config [Stoplight::Domain::Config]
|
|
38
|
+
# @return [Stoplight::Domain::CompatibilityResult]
|
|
39
|
+
def check_compatibility(config)
|
|
40
|
+
if config.recovery_threshold <= 0
|
|
41
|
+
incompatible("`recovery_threshold` should be bigger than 0")
|
|
42
|
+
elsif !config.recovery_threshold.is_a?(Integer)
|
|
43
|
+
incompatible("`recovery_threshold` should be an integer")
|
|
44
|
+
else
|
|
45
|
+
compatible
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Determines if traffic should be resumed based on successes counts.
|
|
50
|
+
#
|
|
51
|
+
# @param config [Stoplight::Domain::Config]
|
|
52
|
+
# @param metadata [Stoplight::Domain::Metadata]
|
|
53
|
+
# @return [TrafficRecovery::Decision]
|
|
54
|
+
def determine_color(config, metadata)
|
|
55
|
+
return TrafficRecovery::PASS if metadata.color != Color::YELLOW
|
|
56
|
+
|
|
57
|
+
recovery_started_at = metadata.recovery_started_at || metadata.recovery_scheduled_after
|
|
58
|
+
|
|
59
|
+
if metadata.last_error_at && metadata.last_error_at >= recovery_started_at
|
|
60
|
+
TrafficRecovery::RED
|
|
61
|
+
elsif [metadata.consecutive_successes, metadata.recovery_probe_successes].min >= config.recovery_threshold
|
|
62
|
+
TrafficRecovery::GREEN
|
|
63
|
+
else
|
|
64
|
+
TrafficRecovery::YELLOW
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Domain
|
|
5
|
+
module TrafficRecovery
|
|
6
|
+
Decision = Data.define(:decision)
|
|
7
|
+
GREEN = Decision.new("green")
|
|
8
|
+
YELLOW = Decision.new("yellow")
|
|
9
|
+
RED = Decision.new("red")
|
|
10
|
+
PASS = Decision.new("pass")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Infrastructure
|
|
5
|
+
module DataStore
|
|
6
|
+
class Memory < Domain::DataStore
|
|
7
|
+
# Hash-based sliding window for O(1) amortized operations.
|
|
8
|
+
#
|
|
9
|
+
# Maintains a running sum and stores per-second counts in a Hash. Ruby's Hash
|
|
10
|
+
# preserves insertion order (FIFO), allowing efficient removal of expired
|
|
11
|
+
# buckets from the front via +Hash#shift+, with their counts subtracted from
|
|
12
|
+
# the running sum.
|
|
13
|
+
#
|
|
14
|
+
# Performance: O(1) amortized for both reads and writes
|
|
15
|
+
# Memory: Bounded to the number of buckets
|
|
16
|
+
#
|
|
17
|
+
# @note Not thread-safe; synchronization must be handled externally
|
|
18
|
+
# @api private
|
|
19
|
+
class SlidingWindow
|
|
20
|
+
# @!attribute buckets
|
|
21
|
+
# @return [Hash<Integer, Integer>] A hash mapping time buckets to their counts
|
|
22
|
+
private attr_reader :buckets
|
|
23
|
+
|
|
24
|
+
# @!attribute running_sum
|
|
25
|
+
# @return [Integer] The running sum of all increments in the current window
|
|
26
|
+
private attr_accessor :running_sum
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@buckets = Hash.new { |buckets, bucket| buckets[bucket] = 0 }
|
|
30
|
+
@running_sum = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Increment the count at a given timestamp
|
|
34
|
+
def increment
|
|
35
|
+
buckets[current_bucket] += 1
|
|
36
|
+
self.running_sum += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param window_start [Time]
|
|
40
|
+
# @return [Integer]
|
|
41
|
+
def sum_in_window(window_start)
|
|
42
|
+
slide_window!(window_start)
|
|
43
|
+
self.running_sum
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private def slide_window!(window_start)
|
|
47
|
+
window_start_ts = window_start.to_i
|
|
48
|
+
|
|
49
|
+
loop do
|
|
50
|
+
timestamp, sum = buckets.first
|
|
51
|
+
if timestamp.nil? || timestamp >= window_start_ts
|
|
52
|
+
break
|
|
53
|
+
else
|
|
54
|
+
self.running_sum -= sum
|
|
55
|
+
buckets.shift
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private def current_bucket
|
|
61
|
+
bucket_for_time(current_time)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private def bucket_for_time(time)
|
|
65
|
+
time.to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private def current_time
|
|
69
|
+
Time.now
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<#{self.class.name} #{buckets}>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Stoplight
|
|
6
|
+
module Infrastructure
|
|
7
|
+
module DataStore
|
|
8
|
+
# @see +Domain::DataStore+
|
|
9
|
+
class Memory < Domain::DataStore
|
|
10
|
+
include MonitorMixin
|
|
11
|
+
|
|
12
|
+
KEY_SEPARATOR = ":"
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@errors = Hash.new { |errors, light_name| errors[light_name] = SlidingWindow.new }
|
|
16
|
+
@successes = Hash.new { |successes, light_name| successes[light_name] = SlidingWindow.new }
|
|
17
|
+
|
|
18
|
+
@recovery_probe_errors = Hash.new { |recovery_probe_errors, light_name| recovery_probe_errors[light_name] = SlidingWindow.new }
|
|
19
|
+
@recovery_probe_successes = Hash.new { |recovery_probe_successes, light_name| recovery_probe_successes[light_name] = SlidingWindow.new }
|
|
20
|
+
|
|
21
|
+
@metadata = Hash.new do |metadata, light_name|
|
|
22
|
+
metadata[light_name] = Domain::Metadata.new(
|
|
23
|
+
current_time: Time.now,
|
|
24
|
+
successes: 0,
|
|
25
|
+
errors: 0,
|
|
26
|
+
recovery_probe_successes: 0,
|
|
27
|
+
recovery_probe_errors: 0,
|
|
28
|
+
last_error: nil,
|
|
29
|
+
last_error_at: nil,
|
|
30
|
+
last_success_at: nil,
|
|
31
|
+
consecutive_errors: 0,
|
|
32
|
+
consecutive_successes: 0,
|
|
33
|
+
breached_at: nil,
|
|
34
|
+
locked_state: Domain::State::UNLOCKED,
|
|
35
|
+
recovery_scheduled_after: nil,
|
|
36
|
+
recovery_started_at: nil,
|
|
37
|
+
recovered_at: nil
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
super # MonitorMixin
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>]
|
|
44
|
+
def names
|
|
45
|
+
synchronize { @metadata.keys }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param config [Stoplight::Domain::Config]
|
|
49
|
+
# @return [Stoplight::Domain::Metadata]
|
|
50
|
+
def get_metadata(config)
|
|
51
|
+
light_name = config.name
|
|
52
|
+
|
|
53
|
+
synchronize do
|
|
54
|
+
current_time = self.current_time
|
|
55
|
+
recovery_window_start = (current_time - config.cool_off_time)
|
|
56
|
+
recovered_at = @metadata[light_name].recovered_at
|
|
57
|
+
window_start = if config.window_size
|
|
58
|
+
[recovered_at, (current_time - config.window_size)].compact.max
|
|
59
|
+
else
|
|
60
|
+
current_time
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@metadata[light_name].with(
|
|
64
|
+
current_time:,
|
|
65
|
+
errors: @errors[config.name].sum_in_window(window_start),
|
|
66
|
+
successes: @successes[config.name].sum_in_window(window_start),
|
|
67
|
+
recovery_probe_errors: @recovery_probe_errors[config.name].sum_in_window(recovery_window_start),
|
|
68
|
+
recovery_probe_successes: @recovery_probe_successes[config.name].sum_in_window(recovery_window_start)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param config [Stoplight::Domain::Config]
|
|
74
|
+
# @param exception [Exception]
|
|
75
|
+
# @return [Stoplight::Domain::Metadata]
|
|
76
|
+
def record_failure(config, exception)
|
|
77
|
+
current_time = self.current_time
|
|
78
|
+
light_name = config.name
|
|
79
|
+
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
80
|
+
|
|
81
|
+
synchronize do
|
|
82
|
+
@errors[light_name].increment if config.window_size
|
|
83
|
+
|
|
84
|
+
metadata = @metadata[light_name]
|
|
85
|
+
@metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
|
|
86
|
+
metadata.with(
|
|
87
|
+
last_error_at: current_time,
|
|
88
|
+
last_error: failure,
|
|
89
|
+
consecutive_errors: metadata.consecutive_errors.succ,
|
|
90
|
+
consecutive_successes: 0
|
|
91
|
+
)
|
|
92
|
+
else
|
|
93
|
+
metadata.with(
|
|
94
|
+
consecutive_errors: metadata.consecutive_errors.succ,
|
|
95
|
+
consecutive_successes: 0
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
get_metadata(config)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param config [Stoplight::Domain::Config]
|
|
103
|
+
# @return [void]
|
|
104
|
+
def record_success(config)
|
|
105
|
+
light_name = config.name
|
|
106
|
+
current_time = self.current_time
|
|
107
|
+
|
|
108
|
+
synchronize do
|
|
109
|
+
@successes[light_name].increment if config.window_size
|
|
110
|
+
|
|
111
|
+
metadata = @metadata[light_name]
|
|
112
|
+
@metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
|
|
113
|
+
metadata.with(
|
|
114
|
+
last_success_at: current_time,
|
|
115
|
+
consecutive_errors: 0,
|
|
116
|
+
consecutive_successes: metadata.consecutive_successes.succ
|
|
117
|
+
)
|
|
118
|
+
else
|
|
119
|
+
metadata.with(
|
|
120
|
+
consecutive_errors: 0,
|
|
121
|
+
consecutive_successes: metadata.consecutive_successes.succ
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param config [Stoplight::Domain::Config]
|
|
128
|
+
# @param exception [Exception]
|
|
129
|
+
# @return [Stoplight::Domain::Metadata]
|
|
130
|
+
def record_recovery_probe_failure(config, exception)
|
|
131
|
+
light_name = config.name
|
|
132
|
+
current_time = self.current_time
|
|
133
|
+
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
134
|
+
|
|
135
|
+
synchronize do
|
|
136
|
+
@recovery_probe_errors[light_name].increment
|
|
137
|
+
|
|
138
|
+
metadata = @metadata[light_name]
|
|
139
|
+
@metadata[light_name] = if metadata.last_error_at.nil? || current_time > metadata.last_error_at
|
|
140
|
+
metadata.with(
|
|
141
|
+
last_error_at: current_time,
|
|
142
|
+
last_error: failure,
|
|
143
|
+
consecutive_errors: metadata.consecutive_errors.succ,
|
|
144
|
+
consecutive_successes: 0
|
|
145
|
+
)
|
|
146
|
+
else
|
|
147
|
+
metadata.with(
|
|
148
|
+
consecutive_errors: metadata.consecutive_errors.succ,
|
|
149
|
+
consecutive_successes: 0
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
get_metadata(config)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param config [Stoplight::Domain::Config]
|
|
157
|
+
# @return [Stoplight::Domain::Metadata]
|
|
158
|
+
def record_recovery_probe_success(config)
|
|
159
|
+
light_name = config.name
|
|
160
|
+
current_time = self.current_time
|
|
161
|
+
|
|
162
|
+
synchronize do
|
|
163
|
+
@recovery_probe_successes[light_name].increment
|
|
164
|
+
|
|
165
|
+
metadata = @metadata[light_name]
|
|
166
|
+
@metadata[light_name] = if metadata.last_success_at.nil? || current_time > metadata.last_success_at
|
|
167
|
+
metadata.with(
|
|
168
|
+
last_success_at: current_time,
|
|
169
|
+
consecutive_errors: 0,
|
|
170
|
+
consecutive_successes: metadata.consecutive_successes.succ
|
|
171
|
+
)
|
|
172
|
+
else
|
|
173
|
+
metadata.with(
|
|
174
|
+
consecutive_errors: 0,
|
|
175
|
+
consecutive_successes: metadata.consecutive_successes.succ
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
get_metadata(config)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @param config [Stoplight::Domain::Config]
|
|
183
|
+
# @param state [String]
|
|
184
|
+
# @return [String]
|
|
185
|
+
def set_state(config, state)
|
|
186
|
+
light_name = config.name
|
|
187
|
+
|
|
188
|
+
synchronize do
|
|
189
|
+
metadata = @metadata[light_name]
|
|
190
|
+
@metadata[light_name] = metadata.with(locked_state: state)
|
|
191
|
+
end
|
|
192
|
+
state
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [String]
|
|
196
|
+
def inspect
|
|
197
|
+
"#<#{self.class.name}>"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Combined method that performs the state transition based on color
|
|
201
|
+
#
|
|
202
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
203
|
+
# @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
|
|
204
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
205
|
+
def transition_to_color(config, color)
|
|
206
|
+
case color
|
|
207
|
+
when Domain::Color::GREEN
|
|
208
|
+
transition_to_green(config)
|
|
209
|
+
when Domain::Color::YELLOW
|
|
210
|
+
transition_to_yellow(config)
|
|
211
|
+
when Domain::Color::RED
|
|
212
|
+
transition_to_red(config)
|
|
213
|
+
else
|
|
214
|
+
raise ArgumentError, "Invalid color: #{color}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Transitions to GREEN state and ensures only one notification
|
|
219
|
+
#
|
|
220
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
221
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
222
|
+
private def transition_to_green(config)
|
|
223
|
+
light_name = config.name
|
|
224
|
+
current_time = self.current_time
|
|
225
|
+
|
|
226
|
+
synchronize do
|
|
227
|
+
metadata = @metadata[light_name]
|
|
228
|
+
if metadata.recovered_at
|
|
229
|
+
false
|
|
230
|
+
else
|
|
231
|
+
@metadata[light_name] = metadata.with(
|
|
232
|
+
recovered_at: current_time,
|
|
233
|
+
recovery_started_at: nil,
|
|
234
|
+
breached_at: nil,
|
|
235
|
+
recovery_scheduled_after: nil
|
|
236
|
+
)
|
|
237
|
+
true
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Transitions to YELLOW (recovery) state and ensures only one notification
|
|
243
|
+
#
|
|
244
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
245
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
246
|
+
private def transition_to_yellow(config)
|
|
247
|
+
light_name = config.name
|
|
248
|
+
current_time = self.current_time
|
|
249
|
+
|
|
250
|
+
synchronize do
|
|
251
|
+
metadata = @metadata[light_name]
|
|
252
|
+
if metadata.recovery_started_at.nil?
|
|
253
|
+
@metadata[light_name] = metadata.with(
|
|
254
|
+
recovery_started_at: current_time,
|
|
255
|
+
recovery_scheduled_after: nil,
|
|
256
|
+
recovered_at: nil,
|
|
257
|
+
breached_at: nil
|
|
258
|
+
)
|
|
259
|
+
true
|
|
260
|
+
else
|
|
261
|
+
@metadata[light_name] = metadata.with(
|
|
262
|
+
recovery_scheduled_after: nil,
|
|
263
|
+
recovered_at: nil,
|
|
264
|
+
breached_at: nil
|
|
265
|
+
)
|
|
266
|
+
false
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Transitions to RED state and ensures only one notification
|
|
272
|
+
#
|
|
273
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
274
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
275
|
+
private def transition_to_red(config)
|
|
276
|
+
light_name = config.name
|
|
277
|
+
current_time = self.current_time
|
|
278
|
+
recovery_scheduled_after = current_time + config.cool_off_time
|
|
279
|
+
|
|
280
|
+
synchronize do
|
|
281
|
+
metadata = @metadata[light_name]
|
|
282
|
+
if metadata.breached_at
|
|
283
|
+
@metadata[light_name] = metadata.with(
|
|
284
|
+
recovery_scheduled_after: recovery_scheduled_after,
|
|
285
|
+
recovery_started_at: nil,
|
|
286
|
+
recovered_at: nil
|
|
287
|
+
)
|
|
288
|
+
false
|
|
289
|
+
else
|
|
290
|
+
@metadata[light_name] = metadata.with(
|
|
291
|
+
breached_at: current_time,
|
|
292
|
+
recovery_scheduled_after: recovery_scheduled_after,
|
|
293
|
+
recovery_started_at: nil,
|
|
294
|
+
recovered_at: nil
|
|
295
|
+
)
|
|
296
|
+
true
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private def current_time
|
|
302
|
+
Time.now
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Infrastructure
|
|
5
|
+
module DataStore
|
|
6
|
+
class Redis
|
|
7
|
+
# @api private
|
|
8
|
+
module Lua
|
|
9
|
+
class << self
|
|
10
|
+
def read_lua_file(name_without_extension)
|
|
11
|
+
File.read(File.join(__dir__, "#{name_without_extension}.lua"))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
RECORD_FAILURE = read_lua_file("record_failure")
|
|
16
|
+
RECORD_SUCCESS = read_lua_file("record_success")
|
|
17
|
+
GET_METADATA = read_lua_file("get_metadata")
|
|
18
|
+
TRANSITION_TO_YELLOW = read_lua_file("transition_to_yellow")
|
|
19
|
+
TRANSITION_TO_RED = read_lua_file("transition_to_red")
|
|
20
|
+
TRANSITION_TO_GREEN = read_lua_file("transition_to_green")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|