stoplight 5.6.0 → 5.8.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 (238) 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 +26 -5
  7. data/lib/stoplight/admin/lights_repository/light.rb +22 -6
  8. data/lib/stoplight/admin/lights_repository.rb +20 -16
  9. data/lib/stoplight/admin/views/_card.erb +8 -5
  10. data/lib/stoplight/admin.rb +2 -1
  11. data/lib/stoplight/color.rb +9 -0
  12. data/lib/stoplight/common/deprecations.rb +11 -0
  13. data/lib/stoplight/data_store.rb +28 -0
  14. data/lib/stoplight/domain/compatibility_result.rb +7 -7
  15. data/lib/stoplight/domain/config.rb +38 -35
  16. data/lib/stoplight/domain/error_tracking_policy.rb +27 -0
  17. data/lib/stoplight/domain/failure.rb +1 -1
  18. data/lib/stoplight/domain/light/configuration_builder_interface.rb +122 -16
  19. data/lib/stoplight/domain/light.rb +44 -64
  20. data/lib/stoplight/domain/light_info.rb +7 -0
  21. data/lib/stoplight/domain/metrics_snapshot.rb +58 -0
  22. data/lib/stoplight/domain/state_snapshot.rb +29 -23
  23. data/lib/stoplight/domain/storage/recovery_lock_token.rb +15 -0
  24. data/lib/stoplight/domain/strategies/green_run_strategy.rb +18 -26
  25. data/lib/stoplight/domain/strategies/red_run_strategy.rb +9 -12
  26. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +74 -58
  27. data/lib/stoplight/domain/tracker/recovery_probe.rb +27 -43
  28. data/lib/stoplight/domain/tracker/request.rb +24 -39
  29. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +8 -11
  30. data/lib/stoplight/domain/traffic_control/error_rate.rb +19 -15
  31. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +8 -18
  32. data/lib/stoplight/domain/traffic_recovery.rb +3 -5
  33. data/lib/stoplight/error.rb +46 -0
  34. data/lib/stoplight/infrastructure/fail_safe/data_store.rb +152 -0
  35. data/lib/stoplight/infrastructure/fail_safe/storage/metrics.rb +65 -0
  36. data/lib/stoplight/infrastructure/fail_safe/storage/recovery_lock.rb +69 -0
  37. data/lib/stoplight/infrastructure/fail_safe/storage/recovery_lock_token.rb +19 -0
  38. data/lib/stoplight/infrastructure/fail_safe/storage/state.rb +62 -0
  39. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/metrics.rb +2 -2
  40. data/lib/stoplight/infrastructure/memory/data_store/recovery_lock_store.rb +52 -0
  41. data/lib/stoplight/infrastructure/memory/data_store/recovery_lock_token.rb +17 -0
  42. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/sliding_window.rb +21 -26
  43. data/lib/stoplight/infrastructure/{data_store/memory → memory/data_store}/state.rb +3 -3
  44. data/lib/stoplight/infrastructure/{data_store/memory.rb → memory/data_store.rb} +90 -57
  45. data/lib/stoplight/infrastructure/memory/storage/recovery_lock.rb +35 -0
  46. data/lib/stoplight/infrastructure/memory/storage/recovery_metrics.rb +16 -0
  47. data/lib/stoplight/infrastructure/memory/storage/state.rb +155 -0
  48. data/lib/stoplight/infrastructure/memory/storage/unbounded_metrics.rb +103 -0
  49. data/lib/stoplight/infrastructure/memory/storage/window_metrics.rb +101 -0
  50. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +50 -0
  51. data/lib/stoplight/infrastructure/notifier/generic.rb +4 -14
  52. data/lib/stoplight/infrastructure/notifier/io.rb +1 -2
  53. data/lib/stoplight/infrastructure/notifier/logger.rb +1 -2
  54. data/lib/stoplight/infrastructure/redis/data_store/lua_scripts/record_recovery_probe_failure.lua +27 -0
  55. data/lib/stoplight/infrastructure/redis/data_store/lua_scripts/record_recovery_probe_success.lua +23 -0
  56. data/lib/stoplight/infrastructure/redis/data_store/lua_scripts/release_lock.lua +6 -0
  57. data/lib/stoplight/infrastructure/redis/data_store/recovery_lock_store.rb +60 -0
  58. data/lib/stoplight/infrastructure/redis/data_store/recovery_lock_token.rb +28 -0
  59. data/lib/stoplight/infrastructure/redis/data_store/scripting.rb +73 -0
  60. data/lib/stoplight/infrastructure/{data_store/redis.rb → redis/data_store.rb} +173 -210
  61. data/lib/stoplight/infrastructure/redis/storage/key_space.rb +51 -0
  62. data/lib/stoplight/infrastructure/redis/storage/metrics.rb +40 -0
  63. data/lib/stoplight/infrastructure/redis/storage/recovery_lock/release_lock.lua +6 -0
  64. data/lib/stoplight/infrastructure/redis/storage/recovery_lock.rb +64 -0
  65. data/lib/stoplight/infrastructure/redis/storage/recovery_metrics.rb +20 -0
  66. data/lib/stoplight/infrastructure/redis/storage/scripting.rb +18 -0
  67. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_green.lua +10 -0
  68. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_red.lua +10 -0
  69. data/lib/stoplight/infrastructure/redis/storage/state/transition_to_yellow.lua +9 -0
  70. data/lib/stoplight/infrastructure/redis/storage/state.rb +141 -0
  71. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics/record_failure.lua +28 -0
  72. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics/record_success.lua +26 -0
  73. data/lib/stoplight/infrastructure/redis/storage/unbounded_metrics.rb +123 -0
  74. data/lib/stoplight/infrastructure/redis/storage/window_metrics/metrics_snapshot.lua +26 -0
  75. data/lib/stoplight/infrastructure/redis/storage/window_metrics/record_failure.lua +36 -0
  76. data/lib/stoplight/infrastructure/redis/storage/window_metrics/record_success.lua +35 -0
  77. data/lib/stoplight/infrastructure/redis/storage/window_metrics.rb +174 -0
  78. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +41 -0
  79. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +33 -0
  80. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +47 -0
  81. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +44 -0
  82. data/lib/stoplight/infrastructure/system_clock.rb +16 -0
  83. data/lib/stoplight/notifier.rb +11 -0
  84. data/lib/stoplight/state.rb +9 -0
  85. data/lib/stoplight/types.rb +29 -0
  86. data/lib/stoplight/undefined.rb +16 -0
  87. data/lib/stoplight/version.rb +1 -1
  88. data/lib/stoplight/wiring/config_compatibility_validator.rb +54 -0
  89. data/lib/stoplight/wiring/configuration_dsl.rb +101 -0
  90. data/lib/stoplight/wiring/data_store_backend.rb +26 -0
  91. data/lib/stoplight/wiring/default.rb +2 -2
  92. data/lib/stoplight/wiring/default_config.rb +21 -0
  93. data/lib/stoplight/wiring/default_configuration.rb +70 -53
  94. data/lib/stoplight/wiring/light_builder.rb +198 -0
  95. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  96. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  97. data/lib/stoplight/wiring/light_factory.rb +74 -135
  98. data/lib/stoplight/wiring/memory/backend.rb +57 -0
  99. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  100. data/lib/stoplight/wiring/redis/backend.rb +116 -0
  101. data/lib/stoplight/wiring/storage_set.rb +12 -0
  102. data/lib/stoplight/wiring/storage_set_builder.rb +51 -0
  103. data/lib/stoplight/wiring/system/light_builder.rb +47 -0
  104. data/lib/stoplight/wiring/system/light_factory.rb +64 -0
  105. data/lib/stoplight/wiring/system.rb +129 -0
  106. data/lib/stoplight.rb +209 -23
  107. data/sig/_private/generators/stoplight/install/install_generator.rbs +22 -0
  108. data/sig/_private/stoplight/common/deprecations.rbs +9 -0
  109. data/sig/_private/stoplight/data_store.rbs +6 -0
  110. data/sig/_private/stoplight/domain/compatibility_result.rbs +18 -0
  111. data/sig/_private/stoplight/domain/config.rbs +65 -0
  112. data/sig/_private/stoplight/domain/error_tracking_policy.rbs +14 -0
  113. data/sig/_private/stoplight/domain/failure.rbs +16 -0
  114. data/sig/_private/stoplight/domain/light.rbs +25 -0
  115. data/sig/_private/stoplight/domain/light_info.rbs +19 -0
  116. data/sig/_private/stoplight/domain/metrics_snapshot.rbs +38 -0
  117. data/sig/_private/stoplight/domain/ports/clock.rbs +18 -0
  118. data/sig/_private/stoplight/domain/ports/data_store.rbs +76 -0
  119. data/{lib/stoplight/domain/light_factory.rb → sig/_private/stoplight/domain/ports/light_factory.rbs} +33 -28
  120. data/sig/_private/stoplight/domain/ports/metrics_store.rbs +29 -0
  121. data/sig/_private/stoplight/domain/ports/recovery_lock_store.rbs +52 -0
  122. data/sig/_private/stoplight/domain/ports/recovery_lock_token.rbs +6 -0
  123. data/sig/_private/stoplight/domain/ports/run_strategy.rbs +14 -0
  124. data/sig/_private/stoplight/domain/ports/state_store.rbs +79 -0
  125. data/sig/_private/stoplight/domain/ports/traffic_control.rbs +41 -0
  126. data/sig/_private/stoplight/domain/ports/traffic_recovery.rbs +47 -0
  127. data/sig/_private/stoplight/domain/state_snapshot.rbs +32 -0
  128. data/sig/_private/stoplight/domain/storage/recovery_lock_token.rbs +11 -0
  129. data/sig/_private/stoplight/domain/strategies/green_run_strategy.rbs +17 -0
  130. data/sig/_private/stoplight/domain/strategies/red_run_strategy.rbs +17 -0
  131. data/sig/_private/stoplight/domain/strategies/yellow_run_strategy.rbs +42 -0
  132. data/sig/_private/stoplight/domain/tracker/base.rbs +8 -0
  133. data/sig/_private/stoplight/domain/tracker/recovery_probe.rbs +25 -0
  134. data/sig/_private/stoplight/domain/tracker/request.rbs +26 -0
  135. data/sig/_private/stoplight/domain/traffic_control/consecutive_errors.rbs +9 -0
  136. data/sig/_private/stoplight/domain/traffic_control/error_rate.rbs +13 -0
  137. data/sig/_private/stoplight/domain/traffic_recovery/consecutive_successes.rbs +9 -0
  138. data/sig/_private/stoplight/domain/traffic_recovery.rbs +9 -0
  139. data/sig/_private/stoplight/infrastructure/fail_safe/data_store.rbs +26 -0
  140. data/sig/_private/stoplight/infrastructure/fail_safe/storage/metrics.rbs +25 -0
  141. data/sig/_private/stoplight/infrastructure/fail_safe/storage/recovery_lock.rbs +29 -0
  142. data/sig/_private/stoplight/infrastructure/fail_safe/storage/recovery_lock_token.rbs +19 -0
  143. data/sig/_private/stoplight/infrastructure/fail_safe/storage/state.rbs +25 -0
  144. data/sig/_private/stoplight/infrastructure/memory/data_store/metrics.rbs +25 -0
  145. data/sig/_private/stoplight/infrastructure/memory/data_store/recovery_lock_store.rbs +19 -0
  146. data/sig/_private/stoplight/infrastructure/memory/data_store/recovery_lock_token.rbs +17 -0
  147. data/sig/_private/stoplight/infrastructure/memory/data_store/sliding_window.rbs +27 -0
  148. data/sig/_private/stoplight/infrastructure/memory/data_store/state.rbs +17 -0
  149. data/sig/_private/stoplight/infrastructure/memory/data_store.rbs +30 -0
  150. data/sig/_private/stoplight/infrastructure/memory/storage/recovery_lock.rbs +15 -0
  151. data/sig/_private/stoplight/infrastructure/memory/storage/recovery_metrics.rbs +10 -0
  152. data/sig/_private/stoplight/infrastructure/memory/storage/state.rbs +28 -0
  153. data/sig/_private/stoplight/infrastructure/memory/storage/unbounded_metrics.rbs +25 -0
  154. data/sig/_private/stoplight/infrastructure/memory/storage/window_metrics.rbs +26 -0
  155. data/sig/_private/stoplight/infrastructure/notifier/fail_safe.rbs +17 -0
  156. data/sig/_private/stoplight/infrastructure/notifier/generic.rbs +18 -0
  157. data/sig/_private/stoplight/infrastructure/notifier/io.rbs +14 -0
  158. data/sig/_private/stoplight/infrastructure/notifier/logger.rbs +14 -0
  159. data/sig/_private/stoplight/infrastructure/redis/data_store/recovery_lock_store.rbs +24 -0
  160. data/sig/_private/stoplight/infrastructure/redis/data_store/recovery_lock_token.rbs +21 -0
  161. data/sig/_private/stoplight/infrastructure/redis/data_store/scripting.rbs +34 -0
  162. data/sig/_private/stoplight/infrastructure/redis/data_store.rbs +67 -0
  163. data/sig/_private/stoplight/infrastructure/redis/storage/key_space.rbs +19 -0
  164. data/sig/_private/stoplight/infrastructure/redis/storage/metrics.rbs +17 -0
  165. data/sig/_private/stoplight/infrastructure/redis/storage/recovery_lock.rbs +26 -0
  166. data/sig/_private/stoplight/infrastructure/redis/storage/recovery_metrics.rbs +10 -0
  167. data/sig/_private/stoplight/infrastructure/redis/storage/scripting.rbs +13 -0
  168. data/sig/_private/stoplight/infrastructure/redis/storage/state.rbs +32 -0
  169. data/sig/_private/stoplight/infrastructure/redis/storage/unbounded_metrics.rbs +21 -0
  170. data/sig/_private/stoplight/infrastructure/redis/storage/window_metrics.rbs +34 -0
  171. data/sig/_private/stoplight/infrastructure/storage/compatibility_metrics.rbs +17 -0
  172. data/sig/_private/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs +13 -0
  173. data/sig/_private/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs +14 -0
  174. data/sig/_private/stoplight/infrastructure/storage/compatibility_state.rbs +14 -0
  175. data/sig/_private/stoplight/infrastructure/system_clock.rbs +7 -0
  176. data/sig/_private/stoplight/system/light_builder.rbs +23 -0
  177. data/sig/_private/stoplight/system/light_factory.rbs +17 -0
  178. data/sig/_private/stoplight/types.rbs +6 -0
  179. data/sig/_private/stoplight/wiring/config_compatibility_validator.rbs +19 -0
  180. data/sig/_private/stoplight/wiring/configuration_dsl.rbs +43 -0
  181. data/sig/_private/stoplight/wiring/data_store_backend.rbs +11 -0
  182. data/sig/_private/stoplight/wiring/default.rbs +26 -0
  183. data/sig/_private/stoplight/wiring/default_config.rbs +7 -0
  184. data/sig/_private/stoplight/wiring/default_configuration.rbs +29 -0
  185. data/sig/_private/stoplight/wiring/light_builder.rbs +48 -0
  186. data/sig/_private/stoplight/wiring/light_factory/traffic_control_dsl.rbs +7 -0
  187. data/sig/_private/stoplight/wiring/light_factory/traffic_recovery_dsl.rbs +7 -0
  188. data/sig/_private/stoplight/wiring/light_factory.rbs +16 -0
  189. data/sig/_private/stoplight/wiring/memory/backend.rbs +26 -0
  190. data/sig/_private/stoplight/wiring/notifier_factory.rbs +10 -0
  191. data/sig/_private/stoplight/wiring/redis/backend.rbs +38 -0
  192. data/sig/_private/stoplight/wiring/storage_set.rbs +38 -0
  193. data/sig/_private/stoplight/wiring/storage_set_builder.rbs +15 -0
  194. data/sig/_private/stoplight/wiring/system.rbs +15 -0
  195. data/sig/_private/stoplight.rbs +48 -0
  196. data/sig/stoplight/color.rbs +7 -0
  197. data/sig/stoplight/data_store.rbs +19 -0
  198. data/sig/stoplight/error.rbs +20 -0
  199. data/sig/stoplight/notifier.rbs +11 -0
  200. data/sig/stoplight/ports/configuration.rbs +19 -0
  201. data/sig/stoplight/ports/exception_matcher.rbs +8 -0
  202. data/sig/stoplight/ports/light.rbs +12 -0
  203. data/sig/stoplight/ports/light_info.rbs +5 -0
  204. data/sig/stoplight/ports/state_transition_notifier.rbs +15 -0
  205. data/sig/stoplight/ports/system.rbs +21 -0
  206. data/sig/stoplight/state.rbs +7 -0
  207. data/sig/stoplight/undefined.rbs +9 -0
  208. data/sig/stoplight/version.rbs +3 -0
  209. data/sig/stoplight.rbs +66 -0
  210. metadata +199 -36
  211. data/lib/stoplight/domain/color.rb +0 -11
  212. data/lib/stoplight/domain/data_store.rb +0 -130
  213. data/lib/stoplight/domain/error.rb +0 -42
  214. data/lib/stoplight/domain/metrics.rb +0 -85
  215. data/lib/stoplight/domain/state.rb +0 -11
  216. data/lib/stoplight/domain/state_transition_notifier.rb +0 -25
  217. data/lib/stoplight/domain/strategies/run_strategy.rb +0 -27
  218. data/lib/stoplight/domain/tracker/base.rb +0 -41
  219. data/lib/stoplight/domain/traffic_control/base.rb +0 -74
  220. data/lib/stoplight/domain/traffic_recovery/base.rb +0 -80
  221. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
  222. data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
  223. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
  224. data/lib/stoplight/wiring/container.rb +0 -80
  225. data/lib/stoplight/wiring/default_factory_builder.rb +0 -25
  226. data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -147
  227. data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
  228. data/lib/stoplight/wiring/light/default_config.rb +0 -18
  229. data/lib/stoplight/wiring/light/system_config.rb +0 -11
  230. data/lib/stoplight/wiring/public_api.rb +0 -28
  231. data/lib/stoplight/wiring/system_container.rb +0 -9
  232. data/lib/stoplight/wiring/system_light_factory.rb +0 -17
  233. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/get_metrics.lua +0 -0
  234. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/record_failure.lua +0 -0
  235. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/record_success.lua +0 -0
  236. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/transition_to_green.lua +0 -0
  237. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/transition_to_red.lua +0 -0
  238. /data/lib/stoplight/infrastructure/{data_store/redis → redis/data_store/lua_scripts}/transition_to_yellow.lua +0 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ # Distributed storage for time-windowed light metrics using Redis.
8
+ #
9
+ # This class implements sliding window metrics using Redis sorted sets (ZSETs)
10
+ # for efficient time-range queries. Events are bucketed by hour to bound memory
11
+ # usage and enable automatic expiration via Redis TTLs.
12
+ #
13
+ # == Storage Structure
14
+ #
15
+ # Events are stored in hourly buckets as ZSETs:
16
+ # stoplight:{version}:{system}:{light}:window_metrics:success:1696154400
17
+ # stoplight:{version}:{system}:{light}:window_metrics:failure:1696154400
18
+ #
19
+ # Each ZSET member is a unique event ID with its timestamp as the score,
20
+ # enabling O(log N) range queries via ZCOUNT.
21
+ #
22
+ # Metadata (consecutive counters, last error) is stored in a hash:
23
+ # stoplight:{version}:{system}:{light}:window_metrics
24
+ #
25
+ # == Bucket Strategy
26
+ #
27
+ # Fixed 1-hour buckets provide a balance between:
28
+ # - Query efficiency: At most ~25 buckets for a 24-hour window
29
+ # - Memory efficiency: Natural expiration without manual cleanup
30
+ # - Precision: Sub-bucket accuracy via ZSET scores
31
+ #
32
+ # == Atomicity
33
+ #
34
+ # All operations use Lua scripts to ensure atomicity:
35
+ # - record_success: Increments counter and updates metadata in one round-trip
36
+ # - record_failure: Same, plus stores serialized error details
37
+ # - metrics_snapshot: Aggregates across buckets atomically
38
+ #
39
+ class WindowMetrics < Metrics
40
+ def initialize(redis:, scripting:, config:, clock:, key_space:)
41
+ @clock = clock
42
+ @scripting = scripting
43
+ @redis = redis
44
+ @config = config
45
+ @key_space = key_space
46
+ @metrics_key = key_space.key(:window_metrics)
47
+ @window_size = T.must(config.window_size).to_i
48
+ end
49
+
50
+ # Get metrics for the current light
51
+ #
52
+ # @return [Stoplight::Domain::Metrics]
53
+ def metrics_snapshot
54
+ window_end_ts = clock.current_time.to_f
55
+ window_start_ts = window_end_ts - @window_size
56
+ failure_keys = failure_bucket_keys(window_end_ts)
57
+ success_keys = success_bucket_keys(window_end_ts)
58
+
59
+ successes, errors, last_success_at, last_error_json, consecutive_errors, consecutive_successes = scripting.call(
60
+ :"window_metrics/metrics_snapshot",
61
+ args: [
62
+ failure_keys.count,
63
+ window_start_ts,
64
+ window_end_ts,
65
+ "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
66
+ ],
67
+ keys: [
68
+ metrics_key,
69
+ *success_keys,
70
+ *failure_keys
71
+ ]
72
+ )
73
+ Domain::MetricsSnapshot.new(
74
+ successes:, errors:,
75
+ consecutive_errors: [consecutive_errors.to_i, errors].min,
76
+ consecutive_successes: [consecutive_successes.to_i, successes].min,
77
+ last_error: deserialize_failure(last_error_json),
78
+ last_success_at: (clock.at(last_success_at.to_f) if last_success_at)
79
+ )
80
+ end
81
+
82
+ # Records successful circuit breaker execution
83
+ #
84
+ # @return [void]
85
+ def record_success
86
+ timestamp = clock.current_time.to_f
87
+
88
+ scripting.call(
89
+ :"window_metrics/record_success",
90
+ args: [timestamp, SecureRandom.hex(12), bucket_ttl, metrics_ttl],
91
+ keys: [
92
+ metrics_key,
93
+ successes_key(time: timestamp)
94
+ ]
95
+ )
96
+ end
97
+
98
+ # Records failed circuit breaker execution
99
+ #
100
+ # @param exception [StandardError]
101
+ # @return [void]
102
+ def record_failure(exception)
103
+ timestamp = clock.current_time.to_f
104
+
105
+ scripting.call(
106
+ :"window_metrics/record_failure",
107
+ args: [timestamp, SecureRandom.hex(12), serialize_exception(exception, timestamp:), bucket_ttl, metrics_ttl],
108
+ keys: [metrics_key, errors_key(time: timestamp)]
109
+ )
110
+ end
111
+
112
+ def clear
113
+ window_end_ts = clock.current_time.to_f
114
+ failure_keys = failure_bucket_keys(window_end_ts)
115
+ success_keys = success_bucket_keys(window_end_ts)
116
+
117
+ redis.with do |client|
118
+ client.del(metrics_key, *failure_keys, *success_keys)
119
+ end
120
+ end
121
+
122
+ # Generates a Redis key for a specific metric and time.
123
+ #
124
+ # @param metric [Symbol] The metric type (e.g., "errors").
125
+ # @param time [Time, Numeric] The time for which to generate the key.
126
+ # @return [String] The generated Redis key.
127
+ def bucket_key(metric:, time:)
128
+ key_space.key(:window_metrics, metric, (time.to_i / bucket_size) * bucket_size)
129
+ end
130
+
131
+ # Retrieves the list of Redis bucket keys required to cover a specific time window.
132
+ #
133
+ # @param metric The metric type (e.g., "errors").
134
+ # @param window_end The end time of the window (can be a Time object or a numeric timestamp).
135
+ # @return A list of Redis keys for the buckets that cover the time window.
136
+ # @api private
137
+ def buckets_for_window(metric:, window_end:)
138
+ window_end_ts = window_end.to_i
139
+ window_start_ts = window_end_ts - @window_size
140
+
141
+ # Find bucket timestamps that contain any part of the window
142
+ start_bucket = (window_start_ts / bucket_size) * bucket_size
143
+
144
+ # End bucket is the last bucket that contains data within our window
145
+ end_bucket = ((window_end_ts - 1) / bucket_size) * bucket_size
146
+
147
+ (start_bucket..end_bucket).step(bucket_size).map do |bucket_start|
148
+ bucket_key(metric: metric, time: bucket_start)
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ attr_reader :redis
155
+ attr_reader :scripting
156
+ attr_reader :metrics_key
157
+ attr_reader :clock
158
+ attr_reader :key_space
159
+
160
+ def bucket_size = 3600 # 1 hour
161
+ def bucket_ttl = @window_size + bucket_size
162
+
163
+ def successes_key(time:) = bucket_key(metric: :success, time:)
164
+
165
+ def errors_key(time:) = bucket_key(metric: :failure, time:)
166
+
167
+ def failure_bucket_keys(window_end) = buckets_for_window(metric: :failure, window_end:)
168
+
169
+ def success_bucket_keys(window_end) = buckets_for_window(metric: :success, window_end:)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges Domain::Storage::Metrics to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the metrics abstraction to be introduced
9
+ # without breaking existing data store implementations. It delegates all
10
+ # operations to the data store's original methods.
11
+ #
12
+ # This class will be removed in a future versions once all data stores
13
+ # have native metrics implementations.
14
+ #
15
+ # @example Creating metrics for a circuit
16
+ # metrics = CompatibilityMetrics.new(
17
+ # data_store: redis_store,
18
+ # config: config
19
+ # )
20
+ # metrics.record_success
21
+ #
22
+ class CompatibilityMetrics
23
+ attr_reader :data_store
24
+ attr_reader :config
25
+
26
+ def initialize(data_store:, config:)
27
+ @data_store = data_store
28
+ @config = config
29
+ end
30
+
31
+ def metrics_snapshot = data_store.get_metrics(config)
32
+
33
+ def record_success = data_store.record_success(config)
34
+
35
+ def record_failure(error) = data_store.record_failure(config, error)
36
+
37
+ def clear = data_store.clear_metrics(config)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges +Domain::Storage::RecoveryLock+ to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the recovery lock abstraction to be
9
+ # introduced without breaking existing data store implementations. It
10
+ # delegates all lock operations to the data store's original methods.
11
+ #
12
+ # This adapter will be removed in a future versions once all
13
+ # data stores have native recovery lock implementations.
14
+ #
15
+ # @see Stoplight::Domain::_RecoveryLockStore
16
+ class CompatibilityRecoveryLock
17
+ def initialize(data_store:, config:)
18
+ @data_store = data_store
19
+ @config = config
20
+ end
21
+
22
+ def acquire_lock = data_store.acquire_recovery_lock(config) #: Domain::Storage::RecoveryLockToken?
23
+
24
+ def release_lock(lock) = data_store.release_recovery_lock(lock)
25
+
26
+ private
27
+
28
+ attr_reader :data_store
29
+ attr_reader :config
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # When a circuit is RED (open), Stoplight periodically sends "recovery probes"
7
+ # to test whether the protected service has recovered. These test requests have
8
+ # different semantics than normal requests and their metrics are tracked separately.
9
+ #
10
+ # Like +CompatibilityMetrics+, this adapter will be replaced with purpose-built
11
+ # recovery metrics implementations (e.g., +ConsecutiveSuccessMetrics+) once the
12
+ # metrics extraction is complete.
13
+ #
14
+ # @example Recovery probe flow
15
+ # # Circuit is RED, start probing
16
+ # recovery_metrics = CompatibilityRecoveryMetrics.new(
17
+ # data_store: redis_store,
18
+ # config: circuit_config
19
+ # )
20
+ #
21
+ # recovery_metrics.record_success
22
+ # recovery_metrics.metrics_snapshot # => 1 success, 0 failures
23
+ #
24
+ class CompatibilityRecoveryMetrics
25
+ def initialize(data_store:, config:)
26
+ @data_store = data_store
27
+ @config = config
28
+ end
29
+
30
+ def metrics_snapshot = data_store.get_recovery_metrics(config)
31
+
32
+ # Tracks successful circuit breaker execution
33
+ def record_success = data_store.record_recovery_probe_success(config)
34
+
35
+ # Tracks failed circuit breaker execution
36
+ def record_failure(error) = data_store.record_recovery_probe_failure(config, error)
37
+
38
+ def clear = data_store.clear_recovery_metrics(config)
39
+
40
+ private
41
+
42
+ attr_reader :data_store
43
+ attr_reader :config
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges Domain::Storage::State to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the state abstraction to be introduced
9
+ # without breaking existing data store implementations. It delegates all
10
+ # state operations to the data store's original methods.
11
+ #
12
+ # This adapter will be removed in a future versions once all
13
+ # data stores have native state storage implementations.
14
+ #
15
+ # @example Creating state storage for a circuit
16
+ # state = CompatibilityState.new(
17
+ # data_store: redis_store,
18
+ # config: circuit_config
19
+ # )
20
+ # state.set_state(State::LOCKED_RED)
21
+ # snapshot = state.state_snapshot
22
+ #
23
+ class CompatibilityState
24
+ def initialize(data_store:, config:)
25
+ @data_store = data_store
26
+ @config = config
27
+ end
28
+
29
+ def state_snapshot = data_store.get_state_snapshot(config)
30
+
31
+ def set_state(state) = data_store.set_state(config, state)
32
+
33
+ def transition_to_color(color) = data_store.transition_to_color(config, color)
34
+
35
+ def clear = data_store.delete_light(config)
36
+
37
+ private
38
+
39
+ attr_reader :data_store
40
+ attr_reader :config
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ # Production clock implementation using Ruby's Time class.
6
+ #
7
+ # Default clock for all Stoplight time-dependent operations including
8
+ # bucket calculation, window boundaries, and state transition timestamps.
9
+ #
10
+ class SystemClock
11
+ def current_time = Time.now.utc
12
+
13
+ def at(timestamp) = Time.at(timestamp).utc
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Notifier
5
+ class Base
6
+ end
7
+ Generic = Infrastructure::Notifier::Generic
8
+ IO = Infrastructure::Notifier::IO
9
+ Logger = Infrastructure::Notifier::Logger
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module State
5
+ UNLOCKED = "unlocked"
6
+ LOCKED_GREEN = "locked_green"
7
+ LOCKED_RED = "locked_red"
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Stoplight
6
+ module Types
7
+ def self.undefined = Undefined.instance
8
+
9
+ # Asserts a value is non-nil, returning it with a narrowed type.
10
+ #
11
+ # Use this to satisfy Steep's flow typing when you know a nilable value
12
+ # must be present. Prefer this over type assertions (#: Type) since it
13
+ # provides runtime validation.
14
+ #
15
+ # @example Validating required configuration
16
+ # @window_size = T.must(config.window_size)
17
+ #
18
+ # @raise [TypeError] if value is nil
19
+ # @return [T] the non-nil value
20
+ #
21
+ def self.must(value)
22
+ if value.nil?
23
+ raise TypeError, "must not have nil value"
24
+ else
25
+ value
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ # Singleton representing an undefined/not-provided argument.
5
+ #
6
+ # Distinct from nil, which may be a valid configured value.
7
+ # Used with keyword arguments to detect when a parameter
8
+ # wasn't passed vs. explicitly set to nil.
9
+ # @api private
10
+ class Undefined
11
+ include Singleton
12
+
13
+ def inspect = "UNDEFINED"
14
+ alias_method :to_s, :inspect
15
+ end
16
+ end
@@ -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.8.0")
5
5
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ # Validates that traffic control and recovery strategies are
6
+ # compatible with the provided configuration.
7
+ #
8
+ # Different strategies have different configuration requirements:
9
+ # - ErrorRate requires window_size and threshold ∈ [0,1]
10
+ # - ConsecutiveErrors requires threshold > 0
11
+ # - ConsecutiveSuccesses requires recovery_threshold > 0
12
+ #
13
+ # @raise [Stoplight::Error::ConfigurationError] if incompatible
14
+ class ConfigCompatibilityValidator
15
+ private attr_reader :config
16
+
17
+ class << self
18
+ def call(config:) = new(config:).call
19
+ end
20
+
21
+ def initialize(config:)
22
+ @config = config
23
+ end
24
+
25
+ def call
26
+ validate_traffic_control!
27
+ validate_traffic_recovery!
28
+ config
29
+ end
30
+
31
+ private def validate_traffic_control!
32
+ traffic_control = config.traffic_control
33
+ traffic_control.check_compatibility(config).then do |compatibility_result|
34
+ if compatibility_result.incompatible?
35
+ raise Error::ConfigurationError,
36
+ "#{traffic_control} incompatible with config: #{compatibility_result.error_messages}",
37
+ caller(8)
38
+ end
39
+ end
40
+ end
41
+
42
+ def validate_traffic_recovery!
43
+ traffic_recovery = config.traffic_recovery
44
+ traffic_recovery.check_compatibility(config).then do |compatibility_result|
45
+ if compatibility_result.incompatible?
46
+ raise Error::ConfigurationError,
47
+ "#{traffic_recovery} incompatible with config: #{compatibility_result.error_messages}",
48
+ caller(8)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class ConfigurationDsl
6
+ def initialize(
7
+ name: T.undefined,
8
+ cool_off_time: T.undefined,
9
+ threshold: T.undefined,
10
+ recovery_threshold: T.undefined,
11
+ window_size: T.undefined,
12
+ tracked_errors: T.undefined,
13
+ skipped_errors: T.undefined,
14
+ data_store: T.undefined,
15
+ error_notifier: T.undefined,
16
+ notifiers: T.undefined,
17
+ traffic_control: T.undefined,
18
+ traffic_recovery: T.undefined
19
+ )
20
+ @name = name
21
+ @cool_off_time = cool_off_time
22
+ @threshold = threshold
23
+ @recovery_threshold = recovery_threshold
24
+ @window_size = window_size
25
+ @tracked_errors = tracked_errors
26
+ @skipped_errors = skipped_errors
27
+ @traffic_control = traffic_control
28
+ @traffic_recovery = traffic_recovery
29
+ @error_notifier = error_notifier
30
+ @data_store = data_store
31
+ @notifiers = notifiers
32
+ end
33
+
34
+ def configure!(default_config)
35
+ ConfigCompatibilityValidator.call(
36
+ config: default_config.with(
37
+ name:,
38
+ cool_off_time:,
39
+ threshold:,
40
+ recovery_threshold:,
41
+ window_size:,
42
+ tracked_errors:,
43
+ skipped_errors:,
44
+ traffic_control:,
45
+ traffic_recovery:,
46
+ error_notifier:,
47
+ data_store:,
48
+ notifiers:
49
+ )
50
+ )
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :name
56
+ attr_reader :cool_off_time
57
+ attr_reader :threshold
58
+ attr_reader :recovery_threshold
59
+ attr_reader :window_size
60
+ attr_reader :error_notifier
61
+ attr_reader :data_store
62
+ attr_reader :notifiers
63
+
64
+ def tracked_errors
65
+ value = @tracked_errors
66
+ if value.is_a?(Undefined)
67
+ value
68
+ else
69
+ Array(value)
70
+ end
71
+ end
72
+
73
+ def skipped_errors
74
+ value = @skipped_errors
75
+ if value.is_a?(Undefined)
76
+ value
77
+ else
78
+ Array(value)
79
+ end
80
+ end
81
+
82
+ def traffic_control
83
+ value = @traffic_control
84
+ if value.is_a?(Undefined)
85
+ value
86
+ else
87
+ LightFactory::TrafficControlDsl.call(value)
88
+ end
89
+ end
90
+
91
+ def traffic_recovery
92
+ value = @traffic_recovery
93
+ if value.is_a?(Undefined)
94
+ value
95
+ else
96
+ LightFactory::TrafficRecoveryDsl.call(value)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ # Abstract base class defining the storage backend interface.
6
+ #
7
+ # A backend encapsulates all storage construction for a specific data store type
8
+ # (Memory or Redis). Backends handle infrastructure concerns like connection
9
+ # management and failover wrapping, exposing a uniform interface to StorageSetBuilder.
10
+ #
11
+ # Each method returns a memoized storage instance. Backends are designed to be
12
+ # instantiated once per Light and reused.
13
+ #
14
+ # @abstract Subclass and implement all methods
15
+ # @see Memory::Backend
16
+ # @see Redis::Backend
17
+ # @api private
18
+ class DataStoreBackend
19
+ def state_store = raise ArgumentError
20
+ def recovery_lock_store = raise ArgumentError
21
+ def recovery_metrics_store = raise ArgumentError
22
+ def windowed_metrics_store = raise ArgumentError
23
+ def unbounded_metrics_store = raise ArgumentError
24
+ end
25
+ end
26
+ 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
 
@@ -19,7 +19,7 @@ module Stoplight
19
19
  WINDOW_SIZE = nil
20
20
 
21
21
  TRACKED_ERRORS = [StandardError].freeze
22
- SKIPPED_ERRORS = [].freeze
22
+ SKIPPED_ERRORS = [].freeze # steep:ignore
23
23
 
24
24
  TRAFFIC_CONTROL = Domain::TrafficControl::ConsecutiveErrors.new
25
25
  TRAFFIC_RECOVERY = Domain::TrafficRecovery::ConsecutiveSuccesses.new
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ # Provides default settings for the Stoplight library.
6
+ DefaultConfig = Domain::Config.new(
7
+ name: "DEFAULT_CONFIG",
8
+ cool_off_time: Default::COOL_OFF_TIME,
9
+ threshold: Default::THRESHOLD,
10
+ recovery_threshold: Default::RECOVERY_THRESHOLD,
11
+ window_size: Default::WINDOW_SIZE,
12
+ tracked_errors: Default::TRACKED_ERRORS,
13
+ skipped_errors: Default::SKIPPED_ERRORS,
14
+ traffic_control: Default::TRAFFIC_CONTROL,
15
+ traffic_recovery: Default::TRAFFIC_RECOVERY,
16
+ error_notifier: Default::ERROR_NOTIFIER,
17
+ notifiers: Default::NOTIFIERS,
18
+ data_store: Default::DATA_STORE
19
+ )
20
+ end
21
+ end