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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/stoplight/admin/views/layout.erb +3 -3
  4. data/lib/stoplight/admin.rb +4 -4
  5. data/lib/stoplight/domain/color.rb +11 -0
  6. data/lib/stoplight/{config → domain}/compatibility_result.rb +1 -1
  7. data/lib/stoplight/domain/config.rb +55 -0
  8. data/lib/stoplight/{data_store/base.rb → domain/data_store.rb} +17 -15
  9. data/lib/stoplight/domain/error.rb +42 -0
  10. data/lib/stoplight/domain/failure.rb +42 -0
  11. data/lib/stoplight/domain/light/configuration_builder_interface.rb +130 -0
  12. data/lib/stoplight/domain/light.rb +198 -0
  13. data/lib/stoplight/domain/light_factory.rb +75 -0
  14. data/lib/stoplight/domain/metadata.rb +65 -0
  15. data/lib/stoplight/domain/state.rb +11 -0
  16. data/lib/stoplight/{notifier/base.rb → domain/state_transition_notifier.rb} +5 -4
  17. data/lib/stoplight/domain/strategies/green_run_strategy.rb +69 -0
  18. data/lib/stoplight/domain/strategies/red_run_strategy.rb +41 -0
  19. data/lib/stoplight/domain/strategies/run_strategy.rb +27 -0
  20. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +98 -0
  21. data/lib/stoplight/domain/tracker/base.rb +41 -0
  22. data/lib/stoplight/domain/tracker/recovery_probe.rb +72 -0
  23. data/lib/stoplight/domain/tracker/request.rb +67 -0
  24. data/lib/stoplight/domain/traffic_control/base.rb +74 -0
  25. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +57 -0
  26. data/lib/stoplight/domain/traffic_control/error_rate.rb +51 -0
  27. data/lib/stoplight/domain/traffic_recovery/base.rb +79 -0
  28. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +70 -0
  29. data/lib/stoplight/domain/traffic_recovery.rb +13 -0
  30. data/lib/stoplight/infrastructure/data_store/memory/sliding_window.rb +79 -0
  31. data/lib/stoplight/infrastructure/data_store/memory.rb +307 -0
  32. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +25 -0
  33. data/lib/stoplight/infrastructure/data_store/redis.rb +478 -0
  34. data/lib/stoplight/infrastructure/dependency_injection/container.rb +249 -0
  35. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +13 -0
  36. data/lib/stoplight/infrastructure/notifier/generic.rb +90 -0
  37. data/lib/stoplight/infrastructure/notifier/io.rb +23 -0
  38. data/lib/stoplight/infrastructure/notifier/logger.rb +21 -0
  39. data/lib/stoplight/rspec/generic_notifier.rb +1 -1
  40. data/lib/stoplight/version.rb +1 -1
  41. data/lib/stoplight/wiring/container.rb +80 -0
  42. data/lib/stoplight/wiring/default.rb +28 -0
  43. data/lib/stoplight/{config/user_default_config.rb → wiring/default_configuration.rb} +24 -31
  44. data/lib/stoplight/wiring/default_factory_builder.rb +25 -0
  45. data/lib/stoplight/{data_store/fail_safe.rb → wiring/fail_safe_data_store.rb} +22 -11
  46. data/lib/stoplight/{notifier/fail_safe.rb → wiring/fail_safe_notifier.rb} +22 -13
  47. data/lib/stoplight/wiring/light/default_config.rb +18 -0
  48. data/lib/stoplight/wiring/light/system_config.rb +11 -0
  49. data/lib/stoplight/wiring/light_factory.rb +188 -0
  50. data/lib/stoplight/wiring/public_api.rb +28 -0
  51. data/lib/stoplight/wiring/system_container.rb +9 -0
  52. data/lib/stoplight/wiring/system_light_factory.rb +17 -0
  53. data/lib/stoplight.rb +38 -28
  54. metadata +53 -43
  55. data/lib/stoplight/color.rb +0 -9
  56. data/lib/stoplight/config/dsl.rb +0 -97
  57. data/lib/stoplight/config/library_default_config.rb +0 -21
  58. data/lib/stoplight/config/system_config.rb +0 -10
  59. data/lib/stoplight/data_store/memory/sliding_window.rb +0 -77
  60. data/lib/stoplight/data_store/memory.rb +0 -285
  61. data/lib/stoplight/data_store/redis/lua.rb +0 -23
  62. data/lib/stoplight/data_store/redis.rb +0 -446
  63. data/lib/stoplight/data_store.rb +0 -6
  64. data/lib/stoplight/default.rb +0 -30
  65. data/lib/stoplight/error.rb +0 -39
  66. data/lib/stoplight/failure.rb +0 -71
  67. data/lib/stoplight/light/config.rb +0 -112
  68. data/lib/stoplight/light/configuration_builder_interface.rb +0 -128
  69. data/lib/stoplight/light/green_run_strategy.rb +0 -54
  70. data/lib/stoplight/light/red_run_strategy.rb +0 -31
  71. data/lib/stoplight/light/run_strategy.rb +0 -32
  72. data/lib/stoplight/light/yellow_run_strategy.rb +0 -94
  73. data/lib/stoplight/light.rb +0 -191
  74. data/lib/stoplight/metadata.rb +0 -99
  75. data/lib/stoplight/notifier/generic.rb +0 -79
  76. data/lib/stoplight/notifier/io.rb +0 -21
  77. data/lib/stoplight/notifier/logger.rb +0 -19
  78. data/lib/stoplight/state.rb +0 -9
  79. data/lib/stoplight/traffic_control/base.rb +0 -70
  80. data/lib/stoplight/traffic_control/consecutive_errors.rb +0 -55
  81. data/lib/stoplight/traffic_control/error_rate.rb +0 -49
  82. data/lib/stoplight/traffic_recovery/base.rb +0 -75
  83. data/lib/stoplight/traffic_recovery/consecutive_successes.rb +0 -68
  84. data/lib/stoplight/traffic_recovery.rb +0 -11
  85. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/get_metadata.lua +0 -0
  86. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_failure.lua +0 -0
  87. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_success.lua +0 -0
  88. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_green.lua +0 -0
  89. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_red.lua +0 -0
  90. /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