stoplight 5.3.8 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -1
  3. data/lib/generators/stoplight/install/templates/stoplight.rb.erb +2 -0
  4. data/lib/stoplight/admin/views/layout.erb +3 -3
  5. data/lib/stoplight/admin.rb +4 -4
  6. data/lib/stoplight/domain/color.rb +11 -0
  7. data/lib/stoplight/{config → domain}/compatibility_result.rb +1 -1
  8. data/lib/stoplight/domain/config.rb +55 -0
  9. data/lib/stoplight/{data_store/base.rb → domain/data_store.rb} +17 -15
  10. data/lib/stoplight/domain/error.rb +42 -0
  11. data/lib/stoplight/domain/failure.rb +42 -0
  12. data/lib/stoplight/domain/light/configuration_builder_interface.rb +130 -0
  13. data/lib/stoplight/domain/light.rb +198 -0
  14. data/lib/stoplight/domain/light_factory.rb +75 -0
  15. data/lib/stoplight/domain/metadata.rb +65 -0
  16. data/lib/stoplight/domain/state.rb +11 -0
  17. data/lib/stoplight/{notifier/base.rb → domain/state_transition_notifier.rb} +5 -4
  18. data/lib/stoplight/domain/strategies/green_run_strategy.rb +69 -0
  19. data/lib/stoplight/domain/strategies/red_run_strategy.rb +41 -0
  20. data/lib/stoplight/domain/strategies/run_strategy.rb +27 -0
  21. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +98 -0
  22. data/lib/stoplight/domain/tracker/base.rb +41 -0
  23. data/lib/stoplight/domain/tracker/recovery_probe.rb +72 -0
  24. data/lib/stoplight/domain/tracker/request.rb +67 -0
  25. data/lib/stoplight/domain/traffic_control/base.rb +74 -0
  26. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +57 -0
  27. data/lib/stoplight/domain/traffic_control/error_rate.rb +51 -0
  28. data/lib/stoplight/domain/traffic_recovery/base.rb +79 -0
  29. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +70 -0
  30. data/lib/stoplight/domain/traffic_recovery.rb +13 -0
  31. data/lib/stoplight/infrastructure/data_store/memory/sliding_window.rb +79 -0
  32. data/lib/stoplight/infrastructure/data_store/memory.rb +307 -0
  33. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +25 -0
  34. data/lib/stoplight/infrastructure/data_store/redis.rb +478 -0
  35. data/lib/stoplight/infrastructure/dependency_injection/container.rb +249 -0
  36. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +13 -0
  37. data/lib/stoplight/infrastructure/notifier/generic.rb +90 -0
  38. data/lib/stoplight/infrastructure/notifier/io.rb +23 -0
  39. data/lib/stoplight/infrastructure/notifier/logger.rb +21 -0
  40. data/lib/stoplight/rspec/generic_notifier.rb +1 -1
  41. data/lib/stoplight/version.rb +1 -1
  42. data/lib/stoplight/wiring/container.rb +80 -0
  43. data/lib/stoplight/wiring/default.rb +28 -0
  44. data/lib/stoplight/{config/user_default_config.rb → wiring/default_configuration.rb} +24 -31
  45. data/lib/stoplight/wiring/default_factory_builder.rb +25 -0
  46. data/lib/stoplight/wiring/fail_safe_data_store.rb +123 -0
  47. data/lib/stoplight/{notifier/fail_safe.rb → wiring/fail_safe_notifier.rb} +22 -13
  48. data/lib/stoplight/wiring/light/default_config.rb +18 -0
  49. data/lib/stoplight/wiring/light/system_config.rb +11 -0
  50. data/lib/stoplight/wiring/light_factory.rb +188 -0
  51. data/lib/stoplight/wiring/public_api.rb +28 -0
  52. data/lib/stoplight/wiring/system_container.rb +9 -0
  53. data/lib/stoplight/wiring/system_light_factory.rb +17 -0
  54. data/lib/stoplight.rb +38 -28
  55. metadata +53 -42
  56. data/lib/stoplight/color.rb +0 -9
  57. data/lib/stoplight/config/dsl.rb +0 -97
  58. data/lib/stoplight/config/library_default_config.rb +0 -21
  59. data/lib/stoplight/config/system_config.rb +0 -7
  60. data/lib/stoplight/data_store/fail_safe.rb +0 -113
  61. data/lib/stoplight/data_store/memory.rb +0 -311
  62. data/lib/stoplight/data_store/redis/lua.rb +0 -23
  63. data/lib/stoplight/data_store/redis.rb +0 -449
  64. data/lib/stoplight/data_store.rb +0 -6
  65. data/lib/stoplight/default.rb +0 -30
  66. data/lib/stoplight/error.rb +0 -10
  67. data/lib/stoplight/failure.rb +0 -71
  68. data/lib/stoplight/light/config.rb +0 -111
  69. data/lib/stoplight/light/configuration_builder_interface.rb +0 -128
  70. data/lib/stoplight/light/green_run_strategy.rb +0 -54
  71. data/lib/stoplight/light/red_run_strategy.rb +0 -27
  72. data/lib/stoplight/light/run_strategy.rb +0 -32
  73. data/lib/stoplight/light/yellow_run_strategy.rb +0 -94
  74. data/lib/stoplight/light.rb +0 -191
  75. data/lib/stoplight/metadata.rb +0 -99
  76. data/lib/stoplight/notifier/generic.rb +0 -79
  77. data/lib/stoplight/notifier/io.rb +0 -21
  78. data/lib/stoplight/notifier/logger.rb +0 -19
  79. data/lib/stoplight/state.rb +0 -9
  80. data/lib/stoplight/traffic_control/base.rb +0 -70
  81. data/lib/stoplight/traffic_control/consecutive_errors.rb +0 -55
  82. data/lib/stoplight/traffic_control/error_rate.rb +0 -49
  83. data/lib/stoplight/traffic_recovery/base.rb +0 -75
  84. data/lib/stoplight/traffic_recovery/consecutive_successes.rb +0 -68
  85. data/lib/stoplight/traffic_recovery.rb +0 -11
  86. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/get_metadata.lua +0 -0
  87. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_failure.lua +0 -0
  88. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/record_success.lua +0 -0
  89. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_green.lua +0 -0
  90. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_red.lua +0 -0
  91. /data/lib/stoplight/{data_store → infrastructure/data_store}/redis/transition_to_yellow.lua +0 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Domain
5
+ module Tracker
6
+ class RecoveryProbe < Base
7
+ # @!attribute [r] data_store
8
+ # @return [Stoplight::DataStore::Base] The data store associated with the light.
9
+ protected attr_reader :data_store
10
+
11
+ # @!attribute [r] traffic_recovery
12
+ # @return [Stoplight::Domain::TrafficRecovery::Base]
13
+ protected attr_reader :traffic_recovery
14
+
15
+ # @!attribute [r] traffic_control
16
+ # @return [Stoplight::Domain::TrafficControl::Base]
17
+ protected attr_reader :notifiers
18
+
19
+ # @!attribute [r] config
20
+ # @return [Stoplight::Domain::Config] The configuration for the light.
21
+ protected attr_reader :config
22
+
23
+ # @param data_store [Stoplight::Domain::DataStore]
24
+ # @param traffic_recovery [Stoplight::Domain::TrafficRecovery::Base]
25
+ # @param notifiers [<Stoplight::Domain::StateTransitionNotifier>]
26
+ # @param config [Stoplight::Domain::Config]
27
+ def initialize(data_store:, traffic_recovery:, notifiers:, config:)
28
+ @data_store = data_store
29
+ @traffic_recovery = traffic_recovery
30
+ @notifiers = notifiers
31
+ @config = config
32
+ end
33
+
34
+ # @param exception [Exception]
35
+ def record_failure(exception)
36
+ metadata = data_store.record_recovery_probe_failure(config, exception)
37
+
38
+ recover(metadata)
39
+ end
40
+
41
+ def record_success
42
+ metadata = data_store.record_recovery_probe_success(config)
43
+
44
+ recover(metadata)
45
+ end
46
+ RECOVERY_TRANSITIONS = {
47
+ TrafficRecovery::GREEN => [Color::YELLOW, Color::GREEN],
48
+ TrafficRecovery::YELLOW => [Color::RED, Color::YELLOW],
49
+ TrafficRecovery::RED => [Color::YELLOW, Color::RED]
50
+ }.freeze
51
+
52
+ private def recover(metadata)
53
+ recovery_result = traffic_recovery.determine_color(config, metadata)
54
+
55
+ return if recovery_result == TrafficRecovery::PASS
56
+
57
+ from_color, to_color = RECOVERY_TRANSITIONS.fetch(recovery_result) do
58
+ raise "recovery strategy returned unexpected color: #{recovery_result}"
59
+ end
60
+
61
+ transition_and_notify(from_color, to_color, nil)
62
+ end
63
+
64
+ # @param other [any]
65
+ # @return [bool]
66
+ def ==(other)
67
+ super && traffic_recovery == other.traffic_recovery
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Domain
5
+ module Tracker
6
+ # Tracks request outcomes (success/failure) and manages state transitions
7
+ # for normal traffic.
8
+ #
9
+ # Used by +GreenRunStrategy+ to track failures and potentially open the circuit.
10
+ #
11
+ # @api private
12
+ class Request < Base
13
+ # @!attribute [r] data_store
14
+ # @return [Stoplight::DataStore::Base] The data store associated with the light.
15
+ protected attr_reader :data_store
16
+
17
+ # @!attribute [r] traffic_control
18
+ # @return [Stoplight::Domain::TrafficControl::Base]
19
+ protected attr_reader :traffic_control
20
+
21
+ # @!attribute [r] traffic_control
22
+ # @return [Stoplight::Domain::TrafficControl::Base]
23
+ protected attr_reader :notifiers
24
+
25
+ # @!attribute [r] config
26
+ # @return [Stoplight::Domain::Config] The configuration for the light.
27
+ protected attr_reader :config
28
+
29
+ # @param data_store [Stoplight::Domain::DataStore]
30
+ # @param traffic_control [Stoplight::Domain::TrafficControl::Base]
31
+ # @param notifiers [<Stoplight::Domain::StateTransitionNotifier>]
32
+ # @param config [Stoplight::Domain::Config]
33
+ def initialize(data_store:, traffic_control:, notifiers:, config:)
34
+ @data_store = data_store
35
+ @traffic_control = traffic_control
36
+ @notifiers = notifiers
37
+ @config = config
38
+ end
39
+
40
+ # @param exception [Exception]
41
+ # @return [void]
42
+ def record_failure(exception)
43
+ metadata = data_store.record_failure(config, exception)
44
+
45
+ transition_to_red(exception, metadata:)
46
+ end
47
+
48
+ # @return [void]
49
+ def record_success
50
+ data_store.record_success(config)
51
+ end
52
+
53
+ private def transition_to_red(exception, metadata:)
54
+ if traffic_control.stop_traffic?(config, metadata)
55
+ transition_and_notify(Color::GREEN, Color::RED, exception)
56
+ end
57
+ end
58
+
59
+ # @param other [any]
60
+ # @return [bool]
61
+ def ==(other)
62
+ super && traffic_control == other.traffic_control
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Domain
5
+ module TrafficControl
6
+ # Strategies for determining when a Stoplight should change color to red.
7
+ #
8
+ # These strategies evaluate the current state and metrics of a Stoplight to decide
9
+ # if traffic should be stopped (i.e., if the light should turn RED).
10
+ #
11
+ # @example Creating a custom strategy
12
+ # class ErrorRateStrategy < Stoplight::Domain::TrafficControl::Base
13
+ # def check_compatibility(config)
14
+ # if config.window_size.nil?
15
+ # incompatible("`window_size` should be set")
16
+ # else
17
+ # compatible
18
+ # end
19
+ # end
20
+ #
21
+ # def stop_traffic?(config, metadata)
22
+ # total = metadata.successes + metadata.failures
23
+ # return false if total < 10 # Minimum sample size
24
+ #
25
+ # error_rate = metadata.failures.fdiv(total)
26
+ # error_rate >= 0.5 # Stop traffic when error rate reaches 50%
27
+ # end
28
+ # end
29
+ #
30
+ # @abstract
31
+ # @api private
32
+ class Base
33
+ # Checks if the strategy is compatible with the given Stoplight configuration.
34
+ #
35
+ # @param config [Stoplight::Domain::Config]
36
+ # @return [Stoplight::Domain::CompatibilityResult]
37
+ # :nocov:
38
+ def check_compatibility(config)
39
+ raise NotImplementedError
40
+ end
41
+ # :nocov:
42
+
43
+ # Determines whether traffic should be stopped based on the Stoplight's
44
+ # current state and metrics.
45
+ #
46
+ # @param config [Stoplight::Domain::Config]
47
+ # @param metadata [Stoplight::Domain::Metadata]
48
+ # @return [Boolean] true if traffic should be stopped (rec), false otherwise (green)
49
+ # :nocov:
50
+ def stop_traffic?(config, metadata)
51
+ raise NotImplementedError
52
+ end
53
+ # :nocov:
54
+
55
+ # @param other [any]
56
+ # @return [Boolean]
57
+ def ==(other)
58
+ other.is_a?(self.class)
59
+ end
60
+
61
+ # Returns a compatibility result indicating the strategy is compatible.
62
+ #
63
+ # @return [Stoplight::Domain::CompatibilityResult] A compatible result.
64
+ private def compatible = CompatibilityResult.compatible
65
+
66
+ # Returns a compatibility result indicating the strategy is incompatible.
67
+ #
68
+ # @param errors [Array<String>] The list of error messages describing incompatibility.
69
+ # @return [Stoplight::Domain::CompatibilityResult] An incompatible result.
70
+ private def incompatible(*errors) = CompatibilityResult.incompatible(*errors)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -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