stoplight 5.6.0 → 5.7.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/lib/stoplight/admin/dependencies.rb +1 -1
  3. data/lib/stoplight/admin/helpers.rb +10 -5
  4. data/lib/stoplight/admin/lights_repository.rb +18 -15
  5. data/lib/stoplight/admin.rb +2 -1
  6. data/lib/stoplight/common/deprecations.rb +11 -0
  7. data/lib/stoplight/domain/config.rb +5 -1
  8. data/lib/stoplight/domain/data_store.rb +17 -1
  9. data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
  10. data/lib/stoplight/domain/light.rb +31 -20
  11. data/lib/stoplight/domain/metrics.rb +6 -27
  12. data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
  13. data/lib/stoplight/domain/storage/metrics.rb +42 -0
  14. data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
  15. data/lib/stoplight/domain/storage/state.rb +87 -0
  16. data/lib/stoplight/domain/strategies/run_strategy.rb +0 -5
  17. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +58 -32
  18. data/lib/stoplight/domain/tracker/base.rb +0 -29
  19. data/lib/stoplight/domain/tracker/recovery_probe.rb +23 -22
  20. data/lib/stoplight/domain/tracker/request.rb +23 -19
  21. data/lib/stoplight/domain/traffic_recovery/base.rb +1 -2
  22. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +2 -8
  23. data/lib/stoplight/domain/traffic_recovery.rb +0 -1
  24. data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
  25. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
  26. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
  27. data/lib/stoplight/infrastructure/data_store/memory.rb +61 -32
  28. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
  29. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
  30. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
  31. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
  32. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
  33. data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
  34. data/lib/stoplight/infrastructure/data_store/redis.rb +133 -162
  35. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
  36. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
  37. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
  38. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
  39. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
  40. data/lib/stoplight/version.rb +1 -1
  41. data/lib/stoplight/wiring/data_store/base.rb +11 -0
  42. data/lib/stoplight/wiring/data_store/memory.rb +10 -0
  43. data/lib/stoplight/wiring/data_store/redis.rb +25 -0
  44. data/lib/stoplight/wiring/default.rb +1 -1
  45. data/lib/stoplight/wiring/default_configuration.rb +1 -1
  46. data/lib/stoplight/wiring/default_factory_builder.rb +1 -1
  47. data/lib/stoplight/wiring/light_builder.rb +185 -0
  48. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
  49. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
  50. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
  51. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  52. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  53. data/lib/stoplight/wiring/light_factory.rb +45 -132
  54. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  55. data/lib/stoplight/wiring/public_api.rb +3 -2
  56. data/lib/stoplight.rb +18 -3
  57. metadata +50 -15
  58. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
  59. data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
  60. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
  61. data/lib/stoplight/wiring/container.rb +0 -80
  62. data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -147
  63. data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
  64. data/lib/stoplight/wiring/system_container.rb +0 -9
  65. data/lib/stoplight/wiring/system_light_factory.rb +0 -17
  66. /data/lib/stoplight/infrastructure/data_store/redis/{get_metrics.lua → lua_scripts/get_metrics.lua} +0 -0
  67. /data/lib/stoplight/infrastructure/data_store/redis/{record_failure.lua → lua_scripts/record_failure.lua} +0 -0
  68. /data/lib/stoplight/infrastructure/data_store/redis/{record_success.lua → lua_scripts/record_success.lua} +0 -0
  69. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_green.lua → lua_scripts/transition_to_green.lua} +0 -0
  70. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_red.lua → lua_scripts/transition_to_red.lua} +0 -0
  71. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_yellow.lua → lua_scripts/transition_to_yellow.lua} +0 -0
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("5.6.0")
4
+ VERSION = Gem::Version.new("5.7.0")
5
5
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ # @abstract
7
+ class Base
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ class Memory < Base
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ class Redis < Base
7
+ # @!attribute redis
8
+ # @return [::Redis, ConnectionPool<::Redis>]
9
+ attr_reader :redis
10
+
11
+ # @!attribute warn_on_clock_skew
12
+ # @return [Boolean]
13
+ attr_reader :warn_on_clock_skew
14
+
15
+ # @param redis [::Redis, ConnectionPool<::Redis>]
16
+ # @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
17
+ # the application server
18
+ def initialize(redis, warn_on_clock_skew: true)
19
+ @warn_on_clock_skew = warn_on_clock_skew
20
+ @redis = redis
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -5,7 +5,7 @@ module Stoplight
5
5
  module Default
6
6
  COOL_OFF_TIME = 60.0
7
7
 
8
- DATA_STORE = Infrastructure::DataStore::Memory.new
8
+ DATA_STORE = Stoplight::DataStore::Memory.new
9
9
 
10
10
  ERROR_NOTIFIER = ->(error) { warn error }
11
11
 
@@ -37,7 +37,7 @@ module Stoplight
37
37
  attr_accessor :notifiers
38
38
 
39
39
  # @!attribute [rw] data_store
40
- # @return [Stoplight::Domain::DataStore] The default data store instance.
40
+ # @return [Stoplight::Wiring::DataStore::Base] The default data store instance.
41
41
  attr_accessor :data_store
42
42
 
43
43
  # @!attribute [w] traffic_control
@@ -18,7 +18,7 @@ module Stoplight
18
18
  # @return [Stoplight::Wiring::LightFactory]
19
19
  # @api private the method is used internally by Stoplight
20
20
  def build
21
- LightFactory.new(Wiring::Container).with(**configuration.to_h)
21
+ LightFactory.new(configuration.to_h)
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ module Stoplight
6
+ module Wiring
7
+ # Constructs a fully-wired Light instance from validated configuration.
8
+ #
9
+ # LightBuilder is the final assembly step in the Light creation pipeline.
10
+ # It receives validated config and dependencies from ConfigurationPipeline
11
+ # and wires together all infrastructure components (data stores, trackers,
12
+ # strategies) needed for a functioning circuit breaker.
13
+ #
14
+ # LightBuilder maintains a global registry (MEMORY_REGISTRY) that ensures
15
+ # the same Memory data store config object always produces the same
16
+ # data store instance:
17
+ #
18
+ # data_store = Stoplight::DataStore::Memory.new
19
+ # light1 = Stoplight("foo", data_store: data_store)
20
+ # light2 = Stoplight("bar", data_store: data_store)
21
+ # # light1 and light2 share the same underlying memory store
22
+ #
23
+ # light3 = Stoplight("baz", data_store: Stoplight::DataStore::Memory.new)
24
+ # # light3 has its own independent store
25
+ #
26
+ # This singleton behavior is keyed by config object identity (object_id),
27
+ # not by value equality.
28
+ #
29
+ # @api private
30
+ class LightBuilder
31
+ FAILOVER_DATA_STORE_CONFIG = Stoplight::DataStore::Memory.new
32
+ private_constant :FAILOVER_DATA_STORE_CONFIG
33
+
34
+ MEMORY_REGISTRY = Concurrent::Map.new
35
+ private_constant :MEMORY_REGISTRY
36
+
37
+ # @!attribute data_store_config
38
+ # @return [Stoplight::DataStore::Bose]
39
+ private attr_reader :data_store_config
40
+
41
+ # @!attribute error_notifier
42
+ # @return [Proc]
43
+ private attr_reader :error_notifier
44
+
45
+ # @!attribute traffic_recovery
46
+ # @return [Stoplight::Domain::TrafficRecovery::Base]
47
+ private attr_reader :traffic_recovery
48
+
49
+ # @!attribute traffic_control
50
+ # @return [Stoplight::Domain::TrafficControl::Base]
51
+ private attr_reader :traffic_control
52
+
53
+ # @!attribute config
54
+ # @return [Stoplight::Domain::Config]
55
+ private attr_reader :config
56
+
57
+ # @!attribute factory
58
+ # @return [Stoplight::Domain::LightFactory]
59
+ private attr_reader :factory
60
+
61
+ def initialize(settings)
62
+ @notifiers = settings[:notifiers]
63
+ @data_store_config = settings[:data_store]
64
+ @error_notifier = settings[:error_notifier]
65
+ @traffic_recovery = settings[:traffic_recovery]
66
+ @traffic_control = settings[:traffic_control]
67
+ @config = settings[:config]
68
+ @factory = settings[:factory]
69
+ end
70
+
71
+ def build
72
+ Stoplight::Domain::Light.new(
73
+ config,
74
+ state_store:,
75
+ green_run_strategy:,
76
+ yellow_run_strategy:,
77
+ red_run_strategy:,
78
+ factory:
79
+ )
80
+ end
81
+
82
+ private def state_store = Stoplight::Infrastructure::Storage::CompatibilityState.new(config:, data_store:)
83
+
84
+ # @return [<Stoplight::Notifier::Base>]
85
+ private def notifiers
86
+ Array(@notifiers).map do |notifier|
87
+ Infrastructure::Notifier::FailSafe.new(notifier:, error_notifier:)
88
+ end
89
+ end
90
+
91
+ private def redis_recovery_lock_store
92
+ Infrastructure::DataStore::Redis::RecoveryLockStore.new(
93
+ redis: data_store_config.redis,
94
+ lock_timeout: config.cool_off_time_in_milliseconds,
95
+ scripting:
96
+ )
97
+ end
98
+
99
+ private def scripting = Infrastructure::DataStore::Redis::Scripting.new(redis: data_store_config.redis)
100
+
101
+ private def memory_recovery_lock_store
102
+ Infrastructure::DataStore::Memory::RecoveryLockStore.new
103
+ end
104
+
105
+ private def failover_data_store
106
+ create_data_store(FAILOVER_DATA_STORE_CONFIG)
107
+ end
108
+
109
+ private def data_store
110
+ create_data_store(data_store_config)
111
+ end
112
+
113
+ private def metrics_store
114
+ Stoplight::Infrastructure::Storage::CompatibilityMetrics.new(config:, data_store:)
115
+ end
116
+
117
+ private def recovery_lock_store
118
+ Stoplight::Infrastructure::Storage::CompatibilityRecoveryLock.new(config:, data_store:)
119
+ end
120
+
121
+ private def request_tracker
122
+ Domain::Tracker::Request.new(traffic_control:, notifiers:, config:, metrics_store:, state_store:)
123
+ end
124
+
125
+ private def recovery_probe_tracker
126
+ Domain::Tracker::RecoveryProbe.new(
127
+ traffic_recovery:,
128
+ notifiers:,
129
+ config:,
130
+ metrics_store: recovery_metrics_store,
131
+ state_store:
132
+ )
133
+ end
134
+
135
+ private def recovery_metrics_store
136
+ Stoplight::Infrastructure::Storage::CompatibilityRecoveryMetrics.new(config:, data_store:)
137
+ end
138
+
139
+ private def green_run_strategy
140
+ Domain::Strategies::GreenRunStrategy.new(config:, request_tracker:)
141
+ end
142
+
143
+ private def yellow_run_strategy
144
+ Domain::Strategies::YellowRunStrategy.new(
145
+ config:,
146
+ notifiers:,
147
+ request_tracker: recovery_probe_tracker,
148
+ red_run_strategy:,
149
+ state_store:,
150
+ metrics_store:,
151
+ recovery_lock_store:
152
+ )
153
+ end
154
+
155
+ private def red_run_strategy
156
+ Domain::Strategies::RedRunStrategy.new(config:)
157
+ end
158
+
159
+ private def create_data_store(data_store_config)
160
+ case data_store_config
161
+ in Stoplight::DataStore::Memory
162
+ memory_registry.compute_if_absent(data_store_config.object_id) do
163
+ Infrastructure::DataStore::Memory.new(
164
+ recovery_lock_store: memory_recovery_lock_store
165
+ )
166
+ end
167
+ in Stoplight::DataStore::Redis
168
+ Infrastructure::DataStore::FailSafe.new(
169
+ data_store: Stoplight::Infrastructure::DataStore::Redis.new(
170
+ redis: data_store_config.redis,
171
+ warn_on_clock_skew: data_store_config.warn_on_clock_skew,
172
+ recovery_lock_store: redis_recovery_lock_store,
173
+ scripting:
174
+ ),
175
+ error_notifier:,
176
+ failover_data_store:,
177
+ circuit_breaker: Stoplight.system_light("data_store:fail_safe:redis")
178
+ )
179
+ end
180
+ end
181
+
182
+ private def memory_registry = MEMORY_REGISTRY
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Validates that traffic control and recovery strategies are
7
+ # compatible with the provided configuration.
8
+ #
9
+ # Different strategies have different configuration requirements:
10
+ # - ErrorRate requires window_size and threshold ∈ [0,1]
11
+ # - ConsecutiveErrors requires threshold > 0
12
+ # - ConsecutiveSuccesses requires recovery_threshold > 0
13
+ #
14
+ # @raise [Stoplight::Error::ConfigurationError] if incompatible
15
+ class CompatibilityValidator
16
+ private attr_reader :dependencies
17
+ private attr_reader :config
18
+
19
+ class << self
20
+ def call(config, dependencies) = new(config, dependencies).call
21
+ end
22
+
23
+ def initialize(config, dependencies)
24
+ @config = config
25
+ @dependencies = dependencies
26
+ end
27
+
28
+ def call
29
+ validate_traffic_control!
30
+ validate_traffic_recovery!
31
+ end
32
+
33
+ private def validate_traffic_control!
34
+ traffic_control = dependencies.fetch(:traffic_control)
35
+ traffic_control.check_compatibility(config).then do |compatibility_result|
36
+ if compatibility_result.incompatible?
37
+ raise Domain::Error::ConfigurationError,
38
+ "#{traffic_control.class.name} incompatible with config: #{compatibility_result.error_messages}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def validate_traffic_recovery!
44
+ traffic_recovery = dependencies.fetch(:traffic_recovery)
45
+ traffic_recovery.check_compatibility(config).then do |compatibility_result|
46
+ if compatibility_result.incompatible?
47
+ raise Domain::Error::ConfigurationError,
48
+ "#{traffic_recovery.class.name} incompatible with config: #{compatibility_result.error_messages}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Normalizes user-provided configuration values into canonical forms.
7
+ #
8
+ # Handles common user convenience patterns:
9
+ # - Single error class → Array of error classes
10
+ # - Numeric cool_off_time → Integer
11
+ #
12
+ # @example
13
+ # config = Config.empty.with(
14
+ # tracked_errors: StandardError, # Single class
15
+ # cool_off_time: 30.5 # Float
16
+ # )
17
+ #
18
+ # normalized = ConfigNormalizer.call(config)
19
+ # normalized.tracked_errors #=> [StandardError] # Array
20
+ # normalized.cool_off_time #=> 30 # Integer
21
+ #
22
+ # @api private
23
+ class ConfigNormalizer
24
+ class << self
25
+ def call(config) = new(config).call
26
+ end
27
+
28
+ # @!attribute config
29
+ # @return [Stoplight::Domain::Config]
30
+ private attr_reader :config
31
+
32
+ # @param [Stoplight::Domain::Config]
33
+ def initialize(config)
34
+ @config = config
35
+ end
36
+
37
+ # @return [Stoplight::Domain::Config]
38
+ def call
39
+ config
40
+ .then { |c| normalize_tracked_errors(c) }
41
+ .then { |c| normalize_skipped_errors(c) }
42
+ .then { |c| normalize_cool_off_time(c) }
43
+ end
44
+
45
+ private def normalize_tracked_errors(config)
46
+ if config.tracked_errors.is_a?(Array)
47
+ config
48
+ else
49
+ config.with(tracked_errors: Array(config.tracked_errors))
50
+ end
51
+ end
52
+
53
+ private def normalize_skipped_errors(config)
54
+ if config.skipped_errors.is_a?(Array)
55
+ config
56
+ else
57
+ config.with(skipped_errors: Array(config.skipped_errors))
58
+ end
59
+ end
60
+
61
+ private def normalize_cool_off_time(config)
62
+ if config.cool_off_time.is_a?(Integer)
63
+ config
64
+ else
65
+ config.with(cool_off_time: config.cool_off_time.to_i)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Orchestrates DSL interpretation, normalization, and validation.
7
+ #
8
+ # ConfigurationPipeline is the entry point for transforming raw user settings
9
+ # into validated domain objects. It coordinates three steps:
10
+ #
11
+ # 1. Normalization - Convert user-friendly values to canonical forms
12
+ # 2. DSL Interpretation - Transform symbols/hashes into strategy objects
13
+ # 3. Validation - Ensure strategies are compatible with configuration
14
+ #
15
+ # @api private
16
+ class ConfigurationPipeline
17
+ BASE_DEPENDENCIES = {
18
+ data_store: Default::DATA_STORE,
19
+ traffic_recovery: Default::TRAFFIC_RECOVERY,
20
+ traffic_control: Default::TRAFFIC_CONTROL,
21
+ notifiers: Default::NOTIFIERS,
22
+ error_notifier: Default::ERROR_NOTIFIER
23
+ }.freeze
24
+ private_constant :BASE_DEPENDENCIES
25
+
26
+ private attr_reader :dependency_settings
27
+ private attr_reader :config_settings
28
+
29
+ def self.process(config_settings, dependency_settings)
30
+ new(config_settings, dependency_settings).process
31
+ end
32
+
33
+ def initialize(config_settings, dependency_settings)
34
+ @config_settings = config_settings
35
+ @dependency_settings = dependency_settings
36
+ end
37
+
38
+ def process
39
+ config = build_config
40
+ dependencies = build_dependencies
41
+
42
+ CompatibilityValidator.call(config, dependencies)
43
+
44
+ [config, dependencies]
45
+ end
46
+
47
+ def build_config
48
+ base_config
49
+ .with(**config_settings)
50
+ .then { |cfg| ConfigNormalizer.call(cfg) }
51
+ end
52
+
53
+ def build_dependencies
54
+ base_dependencies
55
+ .merge(dependency_settings)
56
+ .then { |deps| interpret_dsl(deps) }
57
+ end
58
+
59
+ def interpret_dsl(dependencies)
60
+ dependencies.merge(
61
+ traffic_control: TrafficControlDsl.call(dependencies.fetch(:traffic_control)),
62
+ traffic_recovery: TrafficRecoveryDsl.call(dependencies.fetch(:traffic_recovery))
63
+ )
64
+ end
65
+
66
+ def base_config = Light::DefaultConfig
67
+
68
+ def base_dependencies = BASE_DEPENDENCIES
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ TrafficControlDsl = proc do |value|
7
+ case value
8
+ in Domain::TrafficControl::Base
9
+ value
10
+ in :consecutive_errors
11
+ Domain::TrafficControl::ConsecutiveErrors.new
12
+ in :error_rate
13
+ Domain::TrafficControl::ErrorRate.new
14
+ in {error_rate: error_rate_settings}
15
+ Domain::TrafficControl::ErrorRate.new(**error_rate_settings)
16
+ else
17
+ raise Stoplight::Error::ConfigurationError, <<~ERROR
18
+ unsupported traffic_control strategy provided (`#{value}`). Supported options:
19
+ * :consecutive_errors
20
+ * :error_rate
21
+ ERROR
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ TrafficRecoveryDsl = proc do |value|
7
+ case value
8
+ in Domain::TrafficRecovery::Base
9
+ value
10
+ in :consecutive_successes
11
+ Domain::TrafficRecovery::ConsecutiveSuccesses.new
12
+ else
13
+ raise Domain::Error::ConfigurationError, <<~ERROR
14
+ unsupported traffic_recovery strategy provided (`#{value}`). Supported options:
15
+ * :consecutive_successes
16
+ ERROR
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end