stoplight 5.7.0 → 5.8.2

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 (235) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/UPGRADING.md +303 -0
  4. data/lib/generators/stoplight/install/install_generator.rb +6 -1
  5. data/lib/stoplight/admin/dependencies.rb +1 -1
  6. data/lib/stoplight/admin/helpers.rb +20 -4
  7. data/lib/stoplight/admin/lights_repository/light.rb +22 -6
  8. data/lib/stoplight/admin/lights_repository.rb +6 -5
  9. data/lib/stoplight/admin/views/_card.erb +8 -5
  10. data/lib/stoplight/color.rb +9 -0
  11. data/lib/stoplight/data_store.rb +28 -0
  12. data/lib/stoplight/domain/compatibility_result.rb +7 -7
  13. data/lib/stoplight/domain/config.rb +38 -39
  14. data/lib/stoplight/domain/error_tracking_policy.rb +27 -0
  15. data/lib/stoplight/domain/failure.rb +1 -1
  16. data/lib/stoplight/domain/light/configuration_builder_interface.rb +2 -0
  17. data/lib/stoplight/domain/light.rb +15 -46
  18. data/lib/stoplight/domain/light_info.rb +7 -0
  19. data/lib/stoplight/domain/metrics_snapshot.rb +58 -0
  20. data/lib/stoplight/domain/state_snapshot.rb +29 -23
  21. data/lib/stoplight/domain/storage/recovery_lock_token.rb +15 -0
  22. data/lib/stoplight/domain/strategies/green_run_strategy.rb +18 -26
  23. data/lib/stoplight/domain/strategies/red_run_strategy.rb +9 -12
  24. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +41 -51
  25. data/lib/stoplight/domain/tracker/recovery_probe.rb +16 -33
  26. data/lib/stoplight/domain/tracker/request.rb +12 -31
  27. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +8 -11
  28. data/lib/stoplight/domain/traffic_control/error_rate.rb +19 -15
  29. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +6 -10
  30. data/lib/stoplight/domain/traffic_recovery.rb +3 -4
  31. data/lib/stoplight/error.rb +46 -0
  32. data/lib/stoplight/infrastructure/{data_store/fail_safe.rb → fail_safe/data_store.rb} +39 -51
  33. data/lib/stoplight/infrastructure/fail_safe/storage/metrics.rb +65 -0
  34. data/lib/stoplight/infrastructure/fail_safe/storage/recovery_lock.rb +69 -0
  35. data/lib/stoplight/infrastructure/fail_safe/storage/recovery_lock_token.rb +19 -0
  36. data/lib/stoplight/infrastructure/fail_safe/storage/state.rb +62 -0
  37. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/metrics.rb +2 -2
  38. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/recovery_lock_store.rb +10 -12
  39. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/recovery_lock_token.rb +3 -6
  40. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/sliding_window.rb +21 -26
  41. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/state.rb +3 -3
  42. data/lib/stoplight/infrastructure/{data_store/memory.rb → memory/data_store.rb} +36 -32
  43. data/lib/stoplight/infrastructure/memory/storage/recovery_lock.rb +35 -0
  44. data/lib/stoplight/infrastructure/memory/storage/recovery_metrics.rb +16 -0
  45. data/lib/stoplight/infrastructure/memory/storage/state.rb +155 -0
  46. data/lib/stoplight/infrastructure/memory/storage/unbounded_metrics.rb +103 -0
  47. data/lib/stoplight/infrastructure/memory/storage/window_metrics.rb +101 -0
  48. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +9 -21
  49. data/lib/stoplight/infrastructure/notifier/generic.rb +4 -14
  50. data/lib/stoplight/infrastructure/notifier/io.rb +1 -2
  51. data/lib/stoplight/infrastructure/notifier/logger.rb +1 -2
  52. data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/recovery_lock_store.rb +9 -22
  53. data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/recovery_lock_token.rb +7 -14
  54. data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/scripting.rb +22 -20
  55. data/lib/stoplight/infrastructure/{data_store/redis.rb → redis/data_store.rb} +47 -55
  56. data/lib/stoplight/infrastructure/redis/storage/key_space.rb +51 -0
  57. data/lib/stoplight/infrastructure/redis/storage/metrics.rb +40 -0
  58. data/lib/stoplight/infrastructure/redis/storage/recovery_lock/release_lock.lua +6 -0
  59. data/lib/stoplight/infrastructure/redis/storage/recovery_lock.rb +64 -0
  60. data/lib/stoplight/infrastructure/redis/storage/recovery_metrics.rb +20 -0
  61. data/lib/stoplight/infrastructure/redis/storage/scripting.rb +18 -0
  62. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_green.lua +10 -0
  63. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_red.lua +10 -0
  64. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_yellow.lua +9 -0
  65. data/lib/stoplight/infrastructure/redis/storage/state.rb +141 -0
  66. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics/record_failure.lua +28 -0
  67. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics/record_success.lua +26 -0
  68. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics.rb +123 -0
  69. data/lib/stoplight/infrastructure/redis/storage/window_metrics/metrics_snapshot.lua +26 -0
  70. data/lib/stoplight/infrastructure/redis/storage/window_metrics/record_failure.lua +36 -0
  71. data/lib/stoplight/infrastructure/redis/storage/window_metrics/record_success.lua +35 -0
  72. data/lib/stoplight/infrastructure/redis/storage/window_metrics.rb +174 -0
  73. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +3 -10
  74. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +8 -11
  75. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +6 -14
  76. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +6 -17
  77. data/lib/stoplight/infrastructure/system_clock.rb +16 -0
  78. data/lib/stoplight/notifier.rb +11 -0
  79. data/lib/stoplight/state.rb +9 -0
  80. data/lib/stoplight/types.rb +29 -0
  81. data/lib/stoplight/undefined.rb +16 -0
  82. data/lib/stoplight/version.rb +1 -1
  83. data/lib/stoplight/wiring/config_compatibility_validator.rb +54 -0
  84. data/lib/stoplight/wiring/configuration_dsl.rb +101 -0
  85. data/lib/stoplight/wiring/data_store_backend.rb +26 -0
  86. data/lib/stoplight/wiring/default.rb +1 -1
  87. data/lib/stoplight/wiring/default_config.rb +21 -0
  88. data/lib/stoplight/wiring/default_configuration.rb +70 -53
  89. data/lib/stoplight/wiring/light_builder.rb +76 -63
  90. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +3 -3
  91. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +4 -4
  92. data/lib/stoplight/wiring/light_factory.rb +78 -52
  93. data/lib/stoplight/wiring/memory/backend.rb +57 -0
  94. data/lib/stoplight/wiring/redis/backend.rb +116 -0
  95. data/lib/stoplight/wiring/storage_set.rb +12 -0
  96. data/lib/stoplight/wiring/storage_set_builder.rb +51 -0
  97. data/lib/stoplight/wiring/system/light_builder.rb +47 -0
  98. data/lib/stoplight/wiring/system/light_factory.rb +64 -0
  99. data/lib/stoplight/wiring/system.rb +129 -0
  100. data/lib/stoplight.rb +196 -25
  101. data/sig/_private/generators/stoplight/install/install_generator.rbs +22 -0
  102. data/sig/_private/stoplight/common/deprecations.rbs +9 -0
  103. data/sig/_private/stoplight/data_store.rbs +6 -0
  104. data/sig/_private/stoplight/domain/compatibility_result.rbs +18 -0
  105. data/sig/_private/stoplight/domain/config.rbs +65 -0
  106. data/sig/_private/stoplight/domain/error_tracking_policy.rbs +14 -0
  107. data/sig/_private/stoplight/domain/failure.rbs +16 -0
  108. data/sig/_private/stoplight/domain/light.rbs +25 -0
  109. data/sig/_private/stoplight/domain/light_info.rbs +19 -0
  110. data/sig/_private/stoplight/domain/metrics_snapshot.rbs +38 -0
  111. data/sig/_private/stoplight/domain/ports/clock.rbs +18 -0
  112. data/sig/_private/stoplight/domain/ports/data_store.rbs +76 -0
  113. data/{lib/stoplight/domain/light_factory.rb → sig/_private/stoplight/domain/ports/light_factory.rbs} +33 -28
  114. data/sig/_private/stoplight/domain/ports/metrics_store.rbs +29 -0
  115. data/sig/_private/stoplight/domain/ports/recovery_lock_store.rbs +52 -0
  116. data/sig/_private/stoplight/domain/ports/recovery_lock_token.rbs +6 -0
  117. data/sig/_private/stoplight/domain/ports/run_strategy.rbs +14 -0
  118. data/sig/_private/stoplight/domain/ports/state_store.rbs +79 -0
  119. data/sig/_private/stoplight/domain/ports/traffic_control.rbs +41 -0
  120. data/sig/_private/stoplight/domain/ports/traffic_recovery.rbs +47 -0
  121. data/sig/_private/stoplight/domain/state_snapshot.rbs +32 -0
  122. data/sig/_private/stoplight/domain/storage/recovery_lock_token.rbs +11 -0
  123. data/sig/_private/stoplight/domain/strategies/green_run_strategy.rbs +17 -0
  124. data/sig/_private/stoplight/domain/strategies/red_run_strategy.rbs +17 -0
  125. data/sig/_private/stoplight/domain/strategies/yellow_run_strategy.rbs +42 -0
  126. data/{lib/stoplight/domain/tracker/base.rb → sig/_private/stoplight/domain/tracker/base.rbs} +0 -4
  127. data/sig/_private/stoplight/domain/tracker/recovery_probe.rbs +25 -0
  128. data/sig/_private/stoplight/domain/tracker/request.rbs +26 -0
  129. data/sig/_private/stoplight/domain/traffic_control/consecutive_errors.rbs +9 -0
  130. data/sig/_private/stoplight/domain/traffic_control/error_rate.rbs +13 -0
  131. data/sig/_private/stoplight/domain/traffic_recovery/consecutive_successes.rbs +9 -0
  132. data/sig/_private/stoplight/domain/traffic_recovery.rbs +9 -0
  133. data/sig/_private/stoplight/infrastructure/fail_safe/data_store.rbs +26 -0
  134. data/sig/_private/stoplight/infrastructure/fail_safe/storage/metrics.rbs +25 -0
  135. data/sig/_private/stoplight/infrastructure/fail_safe/storage/recovery_lock.rbs +29 -0
  136. data/sig/_private/stoplight/infrastructure/fail_safe/storage/recovery_lock_token.rbs +19 -0
  137. data/sig/_private/stoplight/infrastructure/fail_safe/storage/state.rbs +25 -0
  138. data/sig/_private/stoplight/infrastructure/memory/data_store/metrics.rbs +25 -0
  139. data/sig/_private/stoplight/infrastructure/memory/data_store/recovery_lock_store.rbs +19 -0
  140. data/sig/_private/stoplight/infrastructure/memory/data_store/recovery_lock_token.rbs +17 -0
  141. data/sig/_private/stoplight/infrastructure/memory/data_store/sliding_window.rbs +27 -0
  142. data/sig/_private/stoplight/infrastructure/memory/data_store/state.rbs +17 -0
  143. data/sig/_private/stoplight/infrastructure/memory/data_store.rbs +30 -0
  144. data/sig/_private/stoplight/infrastructure/memory/storage/recovery_lock.rbs +15 -0
  145. data/sig/_private/stoplight/infrastructure/memory/storage/recovery_metrics.rbs +10 -0
  146. data/sig/_private/stoplight/infrastructure/memory/storage/state.rbs +28 -0
  147. data/sig/_private/stoplight/infrastructure/memory/storage/unbounded_metrics.rbs +25 -0
  148. data/sig/_private/stoplight/infrastructure/memory/storage/window_metrics.rbs +26 -0
  149. data/sig/_private/stoplight/infrastructure/notifier/fail_safe.rbs +17 -0
  150. data/sig/_private/stoplight/infrastructure/notifier/generic.rbs +18 -0
  151. data/sig/_private/stoplight/infrastructure/notifier/io.rbs +14 -0
  152. data/sig/_private/stoplight/infrastructure/notifier/logger.rbs +14 -0
  153. data/sig/_private/stoplight/infrastructure/redis/data_store/recovery_lock_store.rbs +24 -0
  154. data/sig/_private/stoplight/infrastructure/redis/data_store/recovery_lock_token.rbs +21 -0
  155. data/sig/_private/stoplight/infrastructure/redis/data_store/scripting.rbs +34 -0
  156. data/sig/_private/stoplight/infrastructure/redis/data_store.rbs +67 -0
  157. data/sig/_private/stoplight/infrastructure/redis/storage/key_space.rbs +19 -0
  158. data/sig/_private/stoplight/infrastructure/redis/storage/metrics.rbs +17 -0
  159. data/sig/_private/stoplight/infrastructure/redis/storage/recovery_lock.rbs +26 -0
  160. data/sig/_private/stoplight/infrastructure/redis/storage/recovery_metrics.rbs +10 -0
  161. data/sig/_private/stoplight/infrastructure/redis/storage/scripting.rbs +13 -0
  162. data/sig/_private/stoplight/infrastructure/redis/storage/state.rbs +32 -0
  163. data/sig/_private/stoplight/infrastructure/redis/storage/unbounded_metrics.rbs +21 -0
  164. data/sig/_private/stoplight/infrastructure/redis/storage/window_metrics.rbs +34 -0
  165. data/sig/_private/stoplight/infrastructure/storage/compatibility_metrics.rbs +17 -0
  166. data/sig/_private/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs +13 -0
  167. data/sig/_private/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs +14 -0
  168. data/sig/_private/stoplight/infrastructure/storage/compatibility_state.rbs +14 -0
  169. data/sig/_private/stoplight/infrastructure/system_clock.rbs +7 -0
  170. data/sig/_private/stoplight/system/light_builder.rbs +23 -0
  171. data/sig/_private/stoplight/system/light_factory.rbs +17 -0
  172. data/sig/_private/stoplight/types.rbs +6 -0
  173. data/sig/_private/stoplight/wiring/config_compatibility_validator.rbs +19 -0
  174. data/sig/_private/stoplight/wiring/configuration_dsl.rbs +43 -0
  175. data/sig/_private/stoplight/wiring/data_store_backend.rbs +11 -0
  176. data/sig/_private/stoplight/wiring/default.rbs +26 -0
  177. data/{lib/stoplight/wiring/data_store/memory.rb → sig/_private/stoplight/wiring/default_config.rbs} +1 -4
  178. data/sig/_private/stoplight/wiring/default_configuration.rbs +29 -0
  179. data/sig/_private/stoplight/wiring/light_builder.rbs +48 -0
  180. data/sig/_private/stoplight/wiring/light_factory/traffic_control_dsl.rbs +7 -0
  181. data/sig/_private/stoplight/wiring/light_factory/traffic_recovery_dsl.rbs +7 -0
  182. data/sig/_private/stoplight/wiring/light_factory.rbs +16 -0
  183. data/sig/_private/stoplight/wiring/memory/backend.rbs +26 -0
  184. data/sig/_private/stoplight/wiring/notifier_factory.rbs +10 -0
  185. data/sig/_private/stoplight/wiring/redis/backend.rbs +38 -0
  186. data/sig/_private/stoplight/wiring/storage_set.rbs +38 -0
  187. data/sig/_private/stoplight/wiring/storage_set_builder.rbs +15 -0
  188. data/sig/_private/stoplight/wiring/system.rbs +15 -0
  189. data/sig/_private/stoplight.rbs +48 -0
  190. data/sig/stoplight/color.rbs +7 -0
  191. data/sig/stoplight/data_store.rbs +19 -0
  192. data/sig/stoplight/error.rbs +20 -0
  193. data/sig/stoplight/notifier.rbs +11 -0
  194. data/sig/stoplight/ports/configuration.rbs +19 -0
  195. data/sig/stoplight/ports/exception_matcher.rbs +8 -0
  196. data/sig/stoplight/ports/light.rbs +12 -0
  197. data/sig/stoplight/ports/light_info.rbs +5 -0
  198. data/sig/stoplight/ports/state_transition_notifier.rbs +15 -0
  199. data/sig/stoplight/ports/system.rbs +21 -0
  200. data/sig/stoplight/state.rbs +7 -0
  201. data/sig/stoplight/undefined.rbs +9 -0
  202. data/sig/stoplight/version.rbs +3 -0
  203. data/sig/stoplight.rbs +66 -0
  204. metadata +175 -47
  205. data/lib/stoplight/domain/color.rb +0 -11
  206. data/lib/stoplight/domain/data_store.rb +0 -146
  207. data/lib/stoplight/domain/error.rb +0 -42
  208. data/lib/stoplight/domain/metrics.rb +0 -64
  209. data/lib/stoplight/domain/recovery_lock_token.rb +0 -15
  210. data/lib/stoplight/domain/state.rb +0 -11
  211. data/lib/stoplight/domain/state_transition_notifier.rb +0 -25
  212. data/lib/stoplight/domain/storage/metrics.rb +0 -42
  213. data/lib/stoplight/domain/storage/recovery_lock.rb +0 -56
  214. data/lib/stoplight/domain/storage/state.rb +0 -87
  215. data/lib/stoplight/domain/strategies/run_strategy.rb +0 -22
  216. data/lib/stoplight/domain/traffic_control/base.rb +0 -74
  217. data/lib/stoplight/domain/traffic_recovery/base.rb +0 -79
  218. data/lib/stoplight/wiring/data_store/base.rb +0 -11
  219. data/lib/stoplight/wiring/data_store/redis.rb +0 -25
  220. data/lib/stoplight/wiring/default_factory_builder.rb +0 -25
  221. data/lib/stoplight/wiring/light/default_config.rb +0 -18
  222. data/lib/stoplight/wiring/light/system_config.rb +0 -11
  223. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +0 -55
  224. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +0 -71
  225. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +0 -72
  226. data/lib/stoplight/wiring/public_api.rb +0 -29
  227. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/get_metrics.lua +0 -0
  228. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/record_failure.lua +0 -0
  229. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/record_recovery_probe_failure.lua +0 -0
  230. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/record_recovery_probe_success.lua +0 -0
  231. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/record_success.lua +0 -0
  232. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/release_lock.lua +0 -0
  233. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/transition_to_green.lua +0 -0
  234. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/transition_to_red.lua +0 -0
  235. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store}/lua_scripts/transition_to_yellow.lua +0 -0
@@ -4,7 +4,7 @@ require "forwardable"
4
4
 
5
5
  module Stoplight
6
6
  module Infrastructure
7
- module DataStore
7
+ module Redis
8
8
  # == Errors
9
9
  # All errors are stored in the sorted set where keys are serialized errors and
10
10
  # values (Redis uses "score" term) contain integer representations of the time
@@ -17,7 +17,8 @@ module Stoplight
17
17
  # of errors happened within last +config.window_size+ seconds (by default infinity).
18
18
  #
19
19
  # @see Base
20
- class Redis < Domain::DataStore
20
+ # steep:ignore:start
21
+ class DataStore
21
22
  extend Forwardable
22
23
 
23
24
  class << self
@@ -30,6 +31,8 @@ module Stoplight
30
31
  [KEY_PREFIX, *pieces].join(KEY_SEPARATOR)
31
32
  end
32
33
 
34
+ METRICS_RETENTION_TIME = 60 * 60 * 24 # 1 day
35
+
33
36
  # Retrieves the list of Redis bucket keys required to cover a specific time window.
34
37
  #
35
38
  # @param light_name [String] The name of the light (used as part of the Redis key).
@@ -40,7 +43,7 @@ module Stoplight
40
43
  # @api private
41
44
  def buckets_for_window(light_name, metric:, window_end:, window_size:)
42
45
  window_end_ts = window_end.to_i
43
- window_start_ts = window_end_ts - [window_size, Domain::DataStore::METRICS_RETENTION_TIME].compact.min.to_i
46
+ window_start_ts = window_end_ts - [window_size, METRICS_RETENTION_TIME].compact.min.to_i
44
47
 
45
48
  # Find bucket timestamps that contain any part of the window
46
49
  start_bucket = (window_start_ts / bucket_size) * bucket_size
@@ -75,11 +78,11 @@ module Stoplight
75
78
  KEY_PREFIX = %w[stoplight v5].join(KEY_SEPARATOR)
76
79
 
77
80
  # @!attribute recovery_lock_store
78
- # @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
81
+ # @return [Stoplight::Infrastructure::Redis::DataStore::RecoveryLockStore]
79
82
  protected attr_reader :recovery_lock_store
80
83
 
81
84
  # @!attribute scripting
82
- # @return [Stoplight::Infrastructure::DataStore::Redis::Scripting]
85
+ # @return [Stoplight::Infrastructure::Redis::DataStore::Scripting]
83
86
  protected attr_reader :scripting
84
87
 
85
88
  # @!attribute redis
@@ -90,12 +93,18 @@ module Stoplight
90
93
  # @return [Boolean]
91
94
  protected attr_reader :warn_on_clock_skew
92
95
 
96
+ # @!attribute clock
97
+ # @return [Stoplight::Domain::_Clock]
98
+ private attr_reader :clock
99
+
93
100
  # @param redis [::Redis, ConnectionPool<::Redis>]
94
- # @param recovery_lock_store [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
101
+ # @param recovery_lock_store [Stoplight::Infrastructure::Redis::DataStore::RecoveryLockStore]
95
102
  # @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
96
- # @param scripting [Stoplight::Infrastructure::DataStore::Redis::Scripting]
103
+ # @param scripting [Stoplight::Infrastructure::Redis::DataStore::Scripting]
104
+ # @param clock [Stoplight::Domain::_Clock]
97
105
  # the application server
98
- def initialize(redis:, recovery_lock_store:, scripting:, warn_on_clock_skew: true)
106
+ def initialize(redis:, recovery_lock_store:, scripting:, clock:, warn_on_clock_skew: true)
107
+ @clock = clock
99
108
  @warn_on_clock_skew = warn_on_clock_skew
100
109
  @redis = redis
101
110
  @recovery_lock_store = recovery_lock_store
@@ -117,11 +126,11 @@ module Stoplight
117
126
  end
118
127
 
119
128
  # @param config [Stoplight::Domain::Config]
120
- # @return [Stoplight::Domain::Metrics]
129
+ # @return [Stoplight::Domain::MetricsSnapshot]
121
130
  def get_metrics(config)
122
131
  config.name
123
132
 
124
- window_end_ts = current_time.to_f
133
+ window_end_ts = clock.current_time.to_f
125
134
  window_start_ts = window_end_ts - config.window_size.to_i
126
135
 
127
136
  if config.window_size
@@ -149,18 +158,18 @@ module Stoplight
149
158
  consecutive_errors = config.window_size ? [consecutive_errors.to_i, errors].min : consecutive_errors.to_i
150
159
  consecutive_successes = config.window_size ? [consecutive_successes.to_i, successes].min : consecutive_successes.to_i
151
160
 
152
- Domain::Metrics.new(
161
+ Domain::MetricsSnapshot.new(
153
162
  successes: (successes if config.window_size),
154
163
  errors: (errors if config.window_size),
155
164
  consecutive_errors:,
156
165
  consecutive_successes:,
157
166
  last_error: deserialize_failure(last_error_json),
158
- last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
167
+ last_success_at: (clock.at(last_success_at.to_f) if last_success_at)
159
168
  )
160
169
  end
161
170
 
162
171
  # @param config [Stoplight::Domain::Config]
163
- # @return [Stoplight::Domain::Metrics]
172
+ # @return [Stoplight::Domain::MetricsSnapshot]
164
173
  def get_recovery_metrics(config)
165
174
  last_success_at, last_error_json, consecutive_errors, consecutive_successes = @redis.with do |client|
166
175
  client.hmget(
@@ -169,12 +178,12 @@ module Stoplight
169
178
  )
170
179
  end
171
180
 
172
- Domain::Metrics.new(
181
+ Domain::MetricsSnapshot.new(
173
182
  successes: nil, errors: nil,
174
183
  consecutive_errors: consecutive_errors.to_i,
175
184
  consecutive_successes: consecutive_successes.to_i,
176
185
  last_error: deserialize_failure(last_error_json),
177
- last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
186
+ last_success_at: (clock.at(last_success_at.to_f) if last_success_at)
178
187
  )
179
188
  end
180
189
 
@@ -190,17 +199,17 @@ module Stoplight
190
199
  recovery_started_at = recovery_started_at_raw&.to_f
191
200
 
192
201
  Domain::StateSnapshot.new(
193
- breached_at: (Time.at(breached_at) if breached_at),
194
- locked_state: locked_state || Domain::State::UNLOCKED,
195
- recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
196
- recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
197
- time: current_time
202
+ breached_at: (clock.at(breached_at) if breached_at),
203
+ locked_state: locked_state || State::UNLOCKED,
204
+ recovery_scheduled_after: (clock.at(recovery_scheduled_after) if recovery_scheduled_after),
205
+ recovery_started_at: (clock.at(recovery_started_at) if recovery_started_at),
206
+ time: clock.current_time
198
207
  )
199
208
  end
200
209
 
201
210
  def clear_metrics(config)
202
211
  if config.window_size
203
- window_end_ts = current_time.to_i
212
+ window_end_ts = clock.current_time.to_i
204
213
  @redis.with do |client|
205
214
  client.multi do |tx|
206
215
  tx.unlink(
@@ -223,26 +232,12 @@ module Stoplight
223
232
  end
224
233
  end
225
234
 
226
- private def state_snapshot_from_hash(data, time: current_time)
227
- breached_at = data[:breached_at]&.to_f
228
- recovery_scheduled_after = data[:recovery_scheduled_after]&.to_f
229
- recovery_started_at = data[:recovery_started_at]&.to_f
230
-
231
- Domain::StateSnapshot.new(
232
- breached_at: (Time.at(breached_at) if breached_at),
233
- locked_state: data[:locked_state] || Domain::State::UNLOCKED,
234
- recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
235
- recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
236
- time:
237
- )
238
- end
239
-
240
235
  # @param config [Stoplight::Domain::Config] The light configuration.
241
236
  # @param exception [Exception]
242
237
  # @return [void]
243
238
  def record_failure(config, exception)
244
- current_time = self.current_time
245
- current_ts = current_time.to_f
239
+ current_time = clock.current_time
240
+ current_ts = clock.current_time.to_f
246
241
  failure = Domain::Failure.from_error(exception, time: current_time)
247
242
 
248
243
  scripting.call(
@@ -256,7 +251,7 @@ module Stoplight
256
251
  end
257
252
 
258
253
  def record_success(config, request_id: SecureRandom.hex(12))
259
- current_ts = current_time.to_f
254
+ current_ts = clock.current_time.to_f
260
255
 
261
256
  scripting.call(
262
257
  :record_success,
@@ -274,8 +269,8 @@ module Stoplight
274
269
  # @param exception [Exception]
275
270
  # @return [void]
276
271
  def record_recovery_probe_failure(config, exception)
277
- current_time = self.current_time
278
- current_ts = current_time.to_f
272
+ current_time = clock.current_time
273
+ current_ts = clock.current_time.to_f
279
274
  failure = Domain::Failure.from_error(exception, time: current_time)
280
275
 
281
276
  scripting.call(
@@ -290,7 +285,7 @@ module Stoplight
290
285
  # @param config [Stoplight::Domain::Config] The light configuration.
291
286
  # @return [void]
292
287
  def record_recovery_probe_success(config)
293
- current_ts = current_time.to_f
288
+ current_ts = clock.current_time.to_f
294
289
 
295
290
  scripting.call(
296
291
  :record_recovery_probe_success,
@@ -317,11 +312,11 @@ module Stoplight
317
312
  # @return [Boolean] true if this is the first instance to detect this transition
318
313
  def transition_to_color(config, color)
319
314
  case color
320
- when Domain::Color::GREEN
315
+ when Color::GREEN
321
316
  transition_to_green(config)
322
- when Domain::Color::YELLOW
317
+ when Color::YELLOW
323
318
  transition_to_yellow(config)
324
- when Domain::Color::RED
319
+ when Color::RED
325
320
  transition_to_red(config)
326
321
  else
327
322
  raise ArgumentError, "Invalid color: #{color}"
@@ -329,12 +324,12 @@ module Stoplight
329
324
  end
330
325
 
331
326
  # @param config [Stoplight::Domain::Config]
332
- # @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken, nil]
327
+ # @return [Stoplight::Infrastructure::Redis::DataStore::RecoveryLockToken, nil]
333
328
  def acquire_recovery_lock(config)
334
329
  recovery_lock_store.acquire_lock(config.name)
335
330
  end
336
331
 
337
- # @param lock [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken]
332
+ # @param lock [Stoplight::Infrastructure::Redis::DataStore::RecoveryLockToken]
338
333
  # @return [void]
339
334
  def release_recovery_lock(lock)
340
335
  recovery_lock_store.release_lock(lock)
@@ -345,7 +340,7 @@ module Stoplight
345
340
  # @param config [Stoplight::Domain::Config] The light configuration
346
341
  # @return [Boolean] true if this is the first instance to detect this transition
347
342
  private def transition_to_green(config)
348
- current_ts = current_time.to_f
343
+ current_ts = clock.current_time.to_f
349
344
  meta_key = metadata_key(config)
350
345
 
351
346
  became_green = scripting.call(
@@ -361,7 +356,7 @@ module Stoplight
361
356
  # @param config [Stoplight::Domain::Config] The light configuration
362
357
  # @return [Boolean] true if this is the first instance to detect this transition
363
358
  private def transition_to_yellow(config)
364
- current_ts = current_time.to_f
359
+ current_ts = clock.current_time.to_f
365
360
  meta_key = metadata_key(config)
366
361
 
367
362
  became_yellow = scripting.call(
@@ -377,7 +372,7 @@ module Stoplight
377
372
  # @param config [Stoplight::Domain::Config] The light configuration
378
373
  # @return [Boolean] true if this is the first instance to detect this transition
379
374
  private def transition_to_red(config)
380
- current_ts = current_time.to_f
375
+ current_ts = clock.current_time.to_f
381
376
  meta_key = metadata_key(config)
382
377
  recovery_scheduled_after_ts = current_ts + config.cool_off_time
383
378
 
@@ -410,7 +405,7 @@ module Stoplight
410
405
 
411
406
  error_class = error_object["class"]
412
407
  error_message = error_object["message"]
413
- time = Time.at(object["time"])
408
+ time = clock.at(object["time"])
414
409
 
415
410
  Domain::Failure.new(error_class, error_message, time)
416
411
  end
@@ -505,7 +500,7 @@ module Stoplight
505
500
  return unless should_sample?(0.01) # 1% chance
506
501
 
507
502
  redis_seconds, _redis_millis = @redis.then(&:time)
508
- app_seconds = current_time.to_i
503
+ app_seconds = clock.current_time.to_i
509
504
  if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
510
505
  warn("Detected clock skew between Redis and the application server. Redis time: #{redis_seconds}, Application time: #{app_seconds}. See https://github.com/bolshakov/stoplight/wiki/Clock-Skew-and-Stoplight-Reliability")
511
506
  end
@@ -514,11 +509,8 @@ module Stoplight
514
509
  private def should_sample?(probability)
515
510
  rand <= probability
516
511
  end
517
-
518
- private def current_time
519
- Time.now
520
- end
521
512
  end
513
+ # steep:ignore:end
522
514
  end
523
515
  end
524
516
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Stoplight
6
+ module Infrastructure
7
+ module Redis
8
+ module Storage
9
+ # Immutable key namespace for a light within a system.
10
+ #
11
+ # Produces keys following entity-first structure:
12
+ # stoplight:v6:{system_id}:{light_id}:locks:recovery
13
+ # stoplight:v6:{system_id}:{light_id}:metrics:successes
14
+ # stoplight:v6:{system_id}:{light_id}:state
15
+ #
16
+ # Identifiers are derived from SHA-256 and truncated to 12 characters. Collisions are extremely
17
+ # unlikely at expected system scale.
18
+ #
19
+ # @example
20
+ # key_space = KeySpace.build(system_name: "payments", light_name: "stripe-api")
21
+ # key_space.key(:locks, :recovery) #=> "stoplight:v6:df384ae97c77:cfe6861fa39e:locks:recovery"
22
+ #
23
+ KeySpace = Data.define(:system_id, :light_id)
24
+
25
+ class KeySpace
26
+ # @!attribute system_id
27
+ # 12-char hex identifier for the system
28
+ #
29
+ # @!attribute light_id
30
+ # 12-char hex identifier for the light
31
+
32
+ class << self
33
+ def build(system_name:, light_name:) = new(
34
+ system_id: hash_name(system_name),
35
+ light_id: hash_name(light_name)
36
+ )
37
+
38
+ # Generates a truncated SHA256 hash for use in Redis keys.
39
+ def hash_name(name) = Digest::SHA256.hexdigest(name.to_s)[0, 12] #: String
40
+ end
41
+
42
+ # Builds a Redis key within this namespace.
43
+ #
44
+ # @param pieces Key segments to append
45
+ # @return Full Redis key
46
+ def key(*pieces) = [:stoplight, :v5, system_id, "{#{light_id}}", *pieces].join(":")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Stoplight
6
+ module Infrastructure
7
+ module Redis
8
+ module Storage
9
+ class Metrics
10
+ def serialize_exception(exception, timestamp:)
11
+ JSON.generate(
12
+ {
13
+ error: {
14
+ class: exception.class.name,
15
+ message: exception.message
16
+ },
17
+ time: timestamp
18
+ }
19
+ )
20
+ end
21
+
22
+ def deserialize_failure(failure_json)
23
+ return if failure_json.nil?
24
+
25
+ object = JSON.parse(failure_json)
26
+ error_object = object["error"]
27
+
28
+ error_class = error_object["class"]
29
+ error_message = error_object["message"]
30
+ time = Time.at(object["time"])
31
+
32
+ Domain::Failure.new(error_class, error_message, time)
33
+ end
34
+
35
+ private def metrics_ttl = 86400 * 7 # 7 days
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ local token = ARGV[1]
2
+ local lock_key = KEYS[1]
3
+
4
+ if redis.call("get", lock_key) == token then
5
+ return redis.call("del", lock_key)
6
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ # Distributed recovery lock using Redis SET NX (set-if-not-exists).
8
+ #
9
+ # Lock Acquisition:
10
+ # - Uses unique UUID token to prevent accidental release of others' locks
11
+ # - Atomic SET with NX flag ensures only one process acquires recovery_lock
12
+ # - TTL (px: lock_timeout) auto-releases recovery_lock if process crashes
13
+ #
14
+ # Lock Release:
15
+ # - Lua script ensures only token holder can release (token comparison)
16
+ # - Best-effort release; TTL cleanup handles failures
17
+ #
18
+ # Failure Modes:
19
+ # - Lock contention: Returns false, caller should skip probe
20
+ # - Redis unavailable: raises an error and let caller decide
21
+ # - Crashed holder: raises an error and let caller decide. Lock auto-expires after lock_timeout
22
+ # - Release failure: Lock auto-expires after lock_timeout
23
+ #
24
+ class RecoveryLock
25
+ def initialize(config:, redis:, scripting:, key_space:)
26
+ @config = config
27
+ @redis = redis
28
+ @scripting = scripting
29
+ @key_space = key_space
30
+ end
31
+
32
+ def acquire_lock
33
+ recovery_lock = Domain::Storage::RecoveryLockToken.new
34
+
35
+ acquired = redis.then do |client|
36
+ client.set(lock_key, recovery_lock.token, nx: true, px: lock_timeout)
37
+ end
38
+
39
+ recovery_lock if acquired
40
+ end
41
+
42
+ # @param recovery_lock [Stoplight::Infrastructure::Redis::DataStore::RecoveryLockToken]
43
+ # @return [void]
44
+ def release_lock(recovery_lock)
45
+ scripting.call(
46
+ :"recovery_lock/release_lock",
47
+ keys: [lock_key], args: [recovery_lock.token]
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :config
54
+ attr_reader :redis
55
+ attr_reader :scripting
56
+ attr_reader :key_space
57
+
58
+ def lock_key = key_space.key(:locks, :recovery)
59
+ def lock_timeout = config.cool_off_time_in_milliseconds
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ # When a circuit is RED (open), Stoplight periodically sends "recovery probes"
8
+ # to test whether the protected service has recovered. These test requests have
9
+ # different semantics than normal requests and their metrics are tracked separately.
10
+ #
11
+ class RecoveryMetrics < UnboundedMetrics
12
+ def initialize(redis:, scripting:, key_space:, clock:)
13
+ super
14
+ @metrics_key = key_space.key(:recovery_metrics)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ class Scripting < Infrastructure::Redis::DataStore::Scripting
8
+ SCRIPTS_ROOT = __dir__ => String
9
+ private_constant :SCRIPTS_ROOT
10
+
11
+ class << self
12
+ def default_scripts_root = SCRIPTS_ROOT
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+
4
+ -- 1 if the field is a new field in the hash and the value was set
5
+ local became_green = redis.call('HSETNX', meta_key, 'recovered_at', current_ts)
6
+
7
+ if became_green == 1 then
8
+ redis.call("HDEL", meta_key, 'recovery_started_at', 'recovery_scheduled_after', 'breached_at')
9
+ end
10
+ return became_green
@@ -0,0 +1,10 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+ local recovery_scheduled_after_ts = tonumber(ARGV[2])
4
+
5
+ -- 1 if the field is a new field in the hash and the value was set
6
+ local became_red = redis.call('HSETNX', meta_key, 'breached_at', current_ts)
7
+
8
+ redis.call('HSET', meta_key, 'recovery_scheduled_after', recovery_scheduled_after_ts)
9
+ redis.call("HDEL", meta_key, "recovery_started_at", "recovered_at")
10
+ return became_red
@@ -0,0 +1,9 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+
4
+ -- HSETNX returns 1 if field is new and was set, 0 if field already exists
5
+ local became_yellow = redis.call('HSETNX', meta_key, 'recovery_started_at', current_ts)
6
+ if became_yellow == 1 then
7
+ redis.call('HDEL', meta_key, 'recovery_scheduled_after', 'breached_at', 'recovered_at')
8
+ end
9
+ return became_yellow
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ # Redis-backed state storage for a single circuit breaker.
8
+ #
9
+ # Manages circuit breaker state transitions using Redis hashes and Lua scripts
10
+ # for atomic operations. Ensures notification deduplication across distributed
11
+ # processes - when multiple processes detect the same circuit condition,
12
+ # only one will receive +true+ from transition methods.
13
+ #
14
+ # All state is stored in a single Redis hash with fields:
15
+ # - +locked_state+: forced lock (UNLOCKED, LOCKED_GREEN, LOCKED_RED)
16
+ # - +breached_at+: timestamp (float) when circuit opened
17
+ # - +recovery_scheduled_after+: timestamp (float) when recovery probe allowed
18
+ # - +recovery_started_at+: timestamp (float) when recovery probe began
19
+ #
20
+ # @example Basic usage
21
+ # state = State.new(
22
+ # clock: SystemClock.new,
23
+ # redis: Redis.new,
24
+ # scripting: Scripting.new(redis:),
25
+ # key_space: KeySpace.build(light_name: "payments", system_name: "main"),
26
+ # cool_off_time: 60
27
+ # )
28
+ #
29
+ # # Multiple processes may call this concurrently
30
+ # if state.transition_to_color(Color::RED)
31
+ # # Only one process reaches here - send notification
32
+ # notifier.notify("payments", :opened)
33
+ # end
34
+ #
35
+ # @note Thread safety is guaranteed by Redis's single-threaded execution model
36
+ # and the use of Lua scripts for atomic multistep operations.
37
+ #
38
+ # @see Stoplight::Memory::State for the in-memory equivalent
39
+ # @see Stoplight::Domain::StateSnapshot for the structure of state snapshots
40
+ #
41
+ class State
42
+ def initialize(clock:, redis:, scripting:, key_space:, cool_off_time:)
43
+ @redis = redis
44
+ @scripting = scripting
45
+ @key_space = key_space
46
+ @clock = clock
47
+ @cool_off_time = cool_off_time
48
+
49
+ @state_key = key_space.key(:state)
50
+ end
51
+
52
+ def set_state(state)
53
+ redis.with do |client|
54
+ client.hset(state_key, "locked_state", state)
55
+ end
56
+ state
57
+ end
58
+
59
+ def state_snapshot
60
+ breached_at_raw, locked_state, recovery_scheduled_after_raw, recovery_started_at_raw = redis.with do |client|
61
+ client.hmget(state_key, :breached_at, :locked_state, :recovery_scheduled_after, :recovery_started_at)
62
+ end
63
+
64
+ Domain::StateSnapshot.new(
65
+ breached_at: breached_at_raw && clock.at(breached_at_raw.to_f),
66
+ locked_state: locked_state || Stoplight::State::UNLOCKED,
67
+ recovery_scheduled_after: recovery_scheduled_after_raw && clock.at(recovery_scheduled_after_raw.to_f),
68
+ recovery_started_at: recovery_started_at_raw && clock.at(recovery_started_at_raw.to_f),
69
+ time: clock.current_time
70
+ )
71
+ end
72
+
73
+ def clear
74
+ redis.with do |client|
75
+ client.del(state_key)
76
+ end
77
+ end
78
+
79
+ def transition_to_color(color)
80
+ case color
81
+ when Color::GREEN
82
+ transition_to_green
83
+ when Color::YELLOW
84
+ transition_to_yellow
85
+ when Color::RED
86
+ transition_to_red
87
+ else
88
+ raise ArgumentError, "Invalid color: #{color}"
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :redis
95
+ attr_reader :scripting
96
+ attr_reader :key_space
97
+ attr_reader :clock
98
+ attr_reader :cool_off_time
99
+ attr_reader :state_key
100
+
101
+ # Transitions to GREEN state and ensures only one notification
102
+ #
103
+ def transition_to_green
104
+ became_green = scripting.call(
105
+ :"state/transition_to_green",
106
+ args: [clock.current_time.to_f],
107
+ keys: [state_key]
108
+ )
109
+ became_green == 1
110
+ end
111
+
112
+ # Transitions to YELLOW (recovery) state and ensures only one notification
113
+ #
114
+ def transition_to_yellow
115
+ became_yellow = scripting.call(
116
+ :"state/transition_to_yellow",
117
+ args: [clock.current_time.to_f],
118
+ keys: [state_key]
119
+ )
120
+ became_yellow == 1
121
+ end
122
+
123
+ # Transitions to RED state and ensures only one notification
124
+ #
125
+ def transition_to_red
126
+ current_ts = clock.current_time.to_f
127
+ recovery_scheduled_after_ts = current_ts + cool_off_time
128
+
129
+ became_red = scripting.call(
130
+ :"state/transition_to_red",
131
+ args: [current_ts, recovery_scheduled_after_ts],
132
+ keys: [state_key]
133
+ )
134
+
135
+ became_red == 1
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end