stoplight 5.7.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 (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} +48 -56
  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
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Memory
6
+ module Storage
7
+ # Thread-safe in-memory state storage for a single light.
8
+ #
9
+ # Manages light state transitions and ensures notification
10
+ # deduplication through atomic transition detection. Each transition
11
+ # method returns whether the calling thread was first to trigger that
12
+ # transition, enabling exactly-once notification semantics.
13
+ #
14
+ # @example Basic usage
15
+ # state = State.new(clock: SystemClock.new, cool_off_time: 60)
16
+ #
17
+ # # Multiple threads may call this concurrently
18
+ # if state.transition_to_color(Color::RED)
19
+ # # Only one thread reaches here - send notification
20
+ # notifier.notify(circuit_name, :opened)
21
+ # end
22
+ #
23
+ # @example Inspecting current state
24
+ # snapshot = state.state_snapshot
25
+ # snapshot.locked_state # => "unlocked"
26
+ # snapshot.breached_at # => 2025-01-15 10:30:00 UTC
27
+ # snapshot.recovery_scheduled_after # => 2025-01-15 10:31:00 UTC
28
+ #
29
+ # @see Stoplight::Domain::StateSnapshot for the structure of state snapshots
30
+ #
31
+ class State
32
+ def initialize(clock:, cool_off_time:)
33
+ @locked_state = Stoplight::State::UNLOCKED
34
+ @mutex = Thread::Mutex.new
35
+ @clock = clock
36
+ @cool_off_time = cool_off_time
37
+ end
38
+
39
+ def set_state(state)
40
+ mutex.synchronize do
41
+ self.locked_state = state
42
+ end
43
+ end
44
+
45
+ def state_snapshot
46
+ mutex.synchronize do
47
+ Domain::StateSnapshot.new(
48
+ time: clock.current_time,
49
+ locked_state:,
50
+ recovery_scheduled_after:,
51
+ recovery_started_at:,
52
+ breached_at:
53
+ )
54
+ end
55
+ end
56
+
57
+ # Combined method that performs the state transition based on color
58
+ #
59
+ # @param color The color to transition to ("GREEN", "YELLOW", or "RED")
60
+ # @return true if this is the first instance to detect this transition
61
+ def transition_to_color(color)
62
+ case color
63
+ when Color::GREEN
64
+ transition_to_green
65
+ when Color::YELLOW
66
+ transition_to_yellow
67
+ when Color::RED
68
+ transition_to_red
69
+ else
70
+ raise ArgumentError, "Invalid color: #{color}"
71
+ end
72
+ end
73
+
74
+ def clear
75
+ mutex.synchronize do
76
+ self.locked_state = Stoplight::State::UNLOCKED
77
+ self.recovered_at = nil
78
+ self.recovery_scheduled_after = nil
79
+ self.breached_at = nil
80
+ self.recovery_started_at = nil
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ attr_accessor :locked_state
87
+ attr_accessor :recovered_at
88
+ attr_accessor :recovery_scheduled_after
89
+ attr_accessor :recovery_started_at
90
+ attr_accessor :breached_at
91
+ attr_reader :mutex
92
+ attr_reader :clock
93
+ attr_reader :cool_off_time
94
+
95
+ # Transitions to GREEN state and ensures only one notification
96
+ #
97
+ # @return true if this is the first instance to detect this transition
98
+ def transition_to_green
99
+ mutex.synchronize do
100
+ if recovered_at
101
+ false
102
+ else
103
+ self.recovered_at = clock.current_time
104
+ self.recovery_started_at = nil
105
+ self.breached_at = nil
106
+ self.recovery_scheduled_after = nil
107
+ true
108
+ end
109
+ end
110
+ end
111
+
112
+ # Transitions to YELLOW (recovery) state and ensures only one notification
113
+ #
114
+ # @return true if this is the first instance to detect this transition
115
+ def transition_to_yellow
116
+ mutex.synchronize do
117
+ if recovery_started_at.nil?
118
+ self.recovery_started_at = clock.current_time
119
+ self.recovery_scheduled_after = nil
120
+ self.recovered_at = nil
121
+ self.breached_at = nil
122
+ true
123
+ else
124
+ self.recovery_scheduled_after = nil
125
+ self.recovered_at = nil
126
+ self.breached_at = nil
127
+ false
128
+ end
129
+ end
130
+ end
131
+
132
+ # Transitions to RED state and ensures only one notification
133
+ #
134
+ # @return true if this is the first instance to detect this transition
135
+ def transition_to_red
136
+ mutex.synchronize do
137
+ current_time = clock.current_time
138
+
139
+ self.recovery_scheduled_after = current_time + cool_off_time
140
+ self.recovery_started_at = nil
141
+ self.recovered_at = nil
142
+
143
+ if breached_at
144
+ false
145
+ else
146
+ self.breached_at = current_time
147
+ true
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Memory
6
+ module Storage
7
+ # Thread-safe metrics storage for consecutive-error light strategies.
8
+ #
9
+ # Unlike +WindowMetrics+, this class does not track event counts within
10
+ # a time window. It only maintains:
11
+ # - Consecutive success/failure counters (reset on opposite outcome)
12
+ # - Most recent error with timestamp
13
+ # - Most recent success timestamp
14
+ #
15
+ # This is appropriate for circuit breakers using threshold-based strategies
16
+ # (e.g., "open after 5 consecutive failures") rather than rate-based
17
+ # strategies (e.g., "open when error rate exceeds 50%").
18
+ #
19
+ # @note The +#errors+ and +#successes+ fields in the returned +Stoplight::Domain::Metrics+
20
+ # are always +nil+ since totals aren't tracked.
21
+ #
22
+ class UnboundedMetrics
23
+ def initialize(clock:)
24
+ initialize_metrics
25
+ @clock = clock
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # Get metrics for the current light
30
+ def metrics_snapshot
31
+ mutex.synchronize do
32
+ Domain::MetricsSnapshot.new(
33
+ errors: nil,
34
+ successes: nil,
35
+ consecutive_errors: consecutive_errors.to_i,
36
+ consecutive_successes: consecutive_successes.to_i,
37
+ last_error: last_error,
38
+ last_success_at: last_success_at
39
+ )
40
+ end
41
+ end
42
+
43
+ # Records successful circuit breaker execution
44
+ def record_success
45
+ current_time = clock.current_time
46
+
47
+ mutex.synchronize do
48
+ if last_success_at.nil? || current_time > last_success_at
49
+ self.last_success_at = current_time
50
+ end
51
+
52
+ self.consecutive_errors = 0
53
+ self.consecutive_successes += 1
54
+ end
55
+ end
56
+
57
+ # Records failed circuit breaker execution
58
+ def record_failure(exception)
59
+ current_time = clock.current_time
60
+ failure = Domain::Failure.from_error(exception, time: current_time)
61
+ last_error_at = self.last_error_at
62
+
63
+ mutex.synchronize do
64
+ if last_error_at.nil? || failure.occurred_at > last_error_at
65
+ self.last_error = failure
66
+ end
67
+
68
+ self.consecutive_errors += 1
69
+ self.consecutive_successes = 0
70
+ end
71
+ end
72
+
73
+ def clear
74
+ mutex.synchronize do
75
+ initialize_metrics
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ attr_accessor :consecutive_errors
82
+ attr_accessor :consecutive_successes
83
+ attr_accessor :last_error
84
+ attr_accessor :last_success_at
85
+
86
+ attr_reader :mutex
87
+ attr_reader :clock
88
+
89
+ def initialize_metrics
90
+ @consecutive_errors = 0
91
+ @consecutive_successes = 0
92
+ @last_error = nil
93
+ @last_success_at = nil
94
+ end
95
+
96
+ def last_error_at
97
+ last_error&.occurred_at
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Memory
6
+ module Storage
7
+ # Thread-safe in-memory storage for time-windowed light @
8
+ #
9
+ # This class tracks success and failure counts within a sliding time window,
10
+ # along with consecutive counters and the most recent error. It's designed
11
+ # for single-process deployments where distributed coordination isn't needed.
12
+ #
13
+ # The sliding window approach provides more accurate error rate calculations
14
+ # than consecutive-error counting, as it considers the full picture of
15
+ # recent traffic rather than just the most recent streak.
16
+ #
17
+ # @note All public methods are synchronized via mutex to ensure thread safety.
18
+ #
19
+ class WindowMetrics
20
+ def initialize(window_size:, clock:)
21
+ @clock = clock
22
+ @mutex = Mutex.new
23
+ @window_size = window_size
24
+
25
+ initialize_metrics
26
+ end
27
+
28
+ # Get metrics for the current light
29
+ def metrics_snapshot
30
+ mutex.synchronize do
31
+ window_start = (clock.current_time - @window_size)
32
+ errors = @errors.sum_in_window(window_start)
33
+ successes = @successes.sum_in_window(window_start)
34
+
35
+ Domain::MetricsSnapshot.new(
36
+ errors:,
37
+ successes:,
38
+ consecutive_errors: [@consecutive_errors, errors].min,
39
+ consecutive_successes: [@consecutive_successes, successes].min,
40
+ last_error: @last_error,
41
+ last_success_at: @last_success_at
42
+ )
43
+ end
44
+ end
45
+
46
+ # Records successful circuit breaker execution
47
+ def record_success
48
+ mutex.synchronize do
49
+ current_time = clock.current_time
50
+ @successes.increment
51
+
52
+ if @last_success_at.nil? || current_time > T.must(@last_success_at)
53
+ @last_success_at = current_time
54
+ end
55
+
56
+ @consecutive_errors = 0
57
+ @consecutive_successes += 1
58
+ end
59
+ end
60
+
61
+ # Records failed circuit breaker execution
62
+ def record_failure(exception)
63
+ mutex.synchronize do
64
+ @errors.increment
65
+
66
+ failure = Domain::Failure.from_error(exception, time: clock.current_time)
67
+ last_error_at = @last_error&.occurred_at
68
+
69
+ if last_error_at.nil? || failure.occurred_at > last_error_at
70
+ @last_error = failure
71
+ end
72
+
73
+ @consecutive_errors += 1
74
+ @consecutive_successes = 0
75
+ end
76
+ end
77
+
78
+ def clear
79
+ mutex.synchronize do
80
+ initialize_metrics
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :mutex
87
+ attr_reader :clock
88
+
89
+ def initialize_metrics
90
+ @consecutive_errors = 0
91
+ @consecutive_successes = 0
92
+ @last_error = nil
93
+ @last_success_at = nil
94
+ @successes = Infrastructure::Memory::DataStore::SlidingWindow.new(clock:)
95
+ @errors = Infrastructure::Memory::DataStore::SlidingWindow.new(clock:)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -8,40 +8,29 @@ module Stoplight
8
8
  # handle failures.
9
9
  #
10
10
  # @api private
11
- class FailSafe < Domain::StateTransitionNotifier
12
- # @!attribute [r] notifier
13
- # @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
11
+ class FailSafe
12
+ # The underlying notifier being wrapped.
14
13
  attr_reader :notifier
15
-
16
- # @!attribute [r] error_notifier
17
- # @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
14
+ # The underlying notifier being wrapped.
18
15
  attr_reader :error_notifier
19
16
 
20
- # Initializes a new instance of the +FailSafe+ class.
21
- #
22
- # @param notifier [Stoplight::Domain::StateTransitionNotifier] The notifier to wrap.
23
- # @param error_notifier [Proc] called when wrapped data store fails
17
+ # @param notifier The notifier to wrap.
18
+ # @param error_notifier called when wrapped data store fails
24
19
  def initialize(notifier:, error_notifier:)
25
20
  @notifier = notifier
26
21
  @error_notifier = error_notifier
27
22
  end
28
23
 
29
24
  # Sends a notification using the wrapped notifier with fail-safe mechanisms.
30
- #
31
- # @param config [Stoplight::Domain::Config] The light configuration.
32
- # @param from_color [String] The initial color of the light.
33
- # @param to_color [String] The target color of the light.
34
- # @param error [Exception, nil] An optional error to include in the notification.
35
- # @return [void]
36
- def notify(config, from_color, to_color, error = nil)
25
+ def notify(info, from_color, to_color, error = nil)
37
26
  fallback = proc do |exception|
38
27
  error_notifier.call(exception) if exception
39
28
  nil
40
- end
29
+ end #: ^(StandardError?) -> void
41
30
 
42
31
  circuit_breaker.run(fallback) do
43
- notifier.notify(config, from_color, to_color, error)
44
- end
32
+ notifier.notify(info, from_color, to_color, error)
33
+ end #: void
45
34
  end
46
35
 
47
36
  # @return [Boolean]
@@ -49,7 +38,6 @@ module Stoplight
49
38
  other.is_a?(self.class) && notifier == other.notifier
50
39
  end
51
40
 
52
- # @return [Stoplight::Light] The circuit breaker used to handle failures.
53
41
  private def circuit_breaker
54
42
  @circuit_breaker ||= Stoplight.system_light(
55
43
  "stoplight:notifier:fail_safe:#{notifier.class.name}",
@@ -39,10 +39,8 @@ module Stoplight
39
39
  # light.run { raise 'Simulated failure' } rescue nil
40
40
  # light.run { raise 'Simulated failure' } rescue nil
41
41
  #
42
- module Generic # rubocop:disable Style/Documentation
43
- # @!attribute [r] formatter
44
- # @return [Proc] The formatter used to generate notification messages.
45
- # @see Stoplight::Default::FORMATTER
42
+ module Generic
43
+ # The formatter used to generate notification messages.
46
44
  attr_reader :formatter
47
45
 
48
46
  DEFAULT_FORMATTER = lambda do |light, from_color, to_color, error|
@@ -52,8 +50,8 @@ module Stoplight
52
50
  end
53
51
  public_constant :DEFAULT_FORMATTER
54
52
 
55
- # @param object [Object] The object used by the notifier (e.g., a logger or external service).
56
- # @param formatter [Proc, nil] A custom formatter for generating notification messages.
53
+ # @param object The object used by the notifier (e.g., a logger or external service).
54
+ # @param formatter A custom formatter for generating notification messages.
57
55
  # If no formatter is provided, the default formatter is used.
58
56
  def initialize(object, formatter = nil)
59
57
  @object = object
@@ -61,12 +59,6 @@ module Stoplight
61
59
  end
62
60
 
63
61
  # Sends a notification when a Stoplight changes state.
64
- #
65
- # @param light [Light] The Stoplight instance triggering the notification.
66
- # @param from_color [String] The previous state color of the Stoplight.
67
- # @param to_color [String] The new state color of the Stoplight.
68
- # @param error [Exception, nil] The error (if any) that caused the state change.
69
- # @return [String] The formatted notification message.
70
62
  def notify(light, from_color, to_color, error)
71
63
  message = formatter.call(light, from_color, to_color, error)
72
64
  put(message)
@@ -77,8 +69,6 @@ module Stoplight
77
69
 
78
70
  # Processes the notification message.
79
71
  #
80
- # @param message [String] The notification message to be processed.
81
- # @raise [NotImplementedError] If the method is not implemented in a subclass.
82
72
  # :nocov:
83
73
  def put(message)
84
74
  raise NotImplementedError
@@ -4,10 +4,9 @@ module Stoplight
4
4
  module Infrastructure
5
5
  module Notifier
6
6
  # @see Base
7
- class IO < Domain::StateTransitionNotifier
7
+ class IO
8
8
  include Generic
9
9
 
10
- # @return [::IO]
11
10
  def io
12
11
  @object
13
12
  end
@@ -4,10 +4,9 @@ module Stoplight
4
4
  module Infrastructure
5
5
  module Notifier
6
6
  # @see Base
7
- class Logger < Domain::StateTransitionNotifier
7
+ class Logger
8
8
  include Generic
9
9
 
10
- # @return [::Logger]
11
10
  def logger
12
11
  @object
13
12
  end
@@ -5,9 +5,9 @@ require "forwardable"
5
5
 
6
6
  module Stoplight
7
7
  module Infrastructure
8
- module DataStore
9
- class Redis
10
- # Distributed recovery recovery_lock using Redis SET NX (set-if-not-exists).
8
+ module Redis
9
+ class DataStore
10
+ # Distributed recovery lock using Redis SET NX (set-if-not-exists).
11
11
  #
12
12
  # Lock Acquisition:
13
13
  # - Uses unique UUID token to prevent accidental release of others' locks
@@ -25,29 +25,12 @@ module Stoplight
25
25
  # - Release failure: Lock auto-expires after lock_timeout
26
26
  #
27
27
  class RecoveryLockStore
28
- # @!attribute redis
29
- # @return [RedisClient]
30
- protected attr_reader :redis
31
-
32
- # @!attribute lock_timeout
33
- # @return [Integer]
34
- protected attr_reader :lock_timeout
35
-
36
- # @!attribute scripting
37
- # @return [Stoplight::Infrastructure::DataStore::Redis::Scripting]
38
- protected attr_reader :scripting
39
-
40
- # @param redis [RedisClient | ConnectionPool]
41
- # @param lock_timeout [Integer] recovery_lock timeout in milliseconds
42
- # @param scripting [Stoplight::Infrastructure::DataStore::Redis::Scripting]
43
28
  def initialize(redis:, lock_timeout:, scripting:)
44
29
  @redis = redis
45
30
  @lock_timeout = lock_timeout
46
31
  @scripting = scripting
47
32
  end
48
33
 
49
- # @param light_name [String]
50
- # @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken, nil]
51
34
  def acquire_lock(light_name)
52
35
  recovery_lock = RecoveryLockToken.new(light_name:)
53
36
 
@@ -58,14 +41,18 @@ module Stoplight
58
41
  recovery_lock if acquired
59
42
  end
60
43
 
61
- # @param recovery_lock [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken]
62
- # @return [void]
63
44
  def release_lock(recovery_lock)
64
45
  scripting.call(
65
46
  :release_lock,
66
47
  keys: [recovery_lock.lock_key], args: [recovery_lock.token]
67
48
  )
68
49
  end
50
+
51
+ protected
52
+
53
+ attr_reader :redis
54
+ attr_reader :lock_timeout
55
+ attr_reader :scripting
69
56
  end
70
57
  end
71
58
  end
@@ -1,24 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "forwardable"
5
4
 
6
5
  module Stoplight
7
6
  module Infrastructure
8
- module DataStore
9
- class Redis
10
- class RecoveryLockToken < Domain::RecoveryLockToken
11
- extend Forwardable
12
-
13
- def_delegator "Stoplight::Infrastructure::DataStore::Redis", :key
14
- private :key
15
-
16
- # @!attribute light_name
17
- # @return [String]
7
+ module Redis
8
+ class DataStore
9
+ class RecoveryLockToken
18
10
  attr_reader :light_name
19
-
20
- # @!attribute token
21
- # @return [String]
22
11
  attr_reader :token
23
12
 
24
13
  # @param light_name [String]
@@ -28,6 +17,10 @@ module Stoplight
28
17
  end
29
18
 
30
19
  def lock_key = key(:locks, :recovery, light_name)
20
+
21
+ private def key(*parts)
22
+ Stoplight::Infrastructure::Redis::DataStore.key(*parts)
23
+ end
31
24
  end
32
25
  end
33
26
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Stoplight
4
4
  module Infrastructure
5
- module DataStore
6
- class Redis
5
+ module Redis
6
+ class DataStore
7
7
  # Manages Lua scripts for Redis operations.
8
8
  #
9
9
  # This class provides execution of Lua scripts by caching their SHA digests
@@ -16,22 +16,15 @@ module Stoplight
16
16
  # @note Scripts are loaded lazily on first use and cached in memory
17
17
  # @note Script files must be named `<script_name>.lua` and located in scripts_root
18
18
  class Scripting
19
- SCRIPTS_ROOT = File.join(__dir__, "lua_scripts")
20
- # @!attribute scripts_root
21
- # @return [String]
22
- protected attr_reader :scripts_root
19
+ current_dir = __dir__ or raise "Cannot determine script directory"
20
+ SCRIPTS_ROOT = File.join(current_dir, "lua_scripts")
21
+ private_constant :SCRIPTS_ROOT
23
22
 
24
- # @!attribute shas
25
- # @return [Hash{Symbol, String}]
26
- private attr_reader :shas
27
-
28
- # @!attribute redis
29
- # @return [RedisClient | ConnectionPool]
30
- protected attr_reader :redis
23
+ class << self
24
+ def default_scripts_root = SCRIPTS_ROOT
25
+ end
31
26
 
32
- # @param redis [RedisClient | ConnectionPool]
33
- # @param scripts_root [String]
34
- def initialize(redis:, scripts_root: SCRIPTS_ROOT)
27
+ def initialize(redis:, scripts_root: self.class.default_scripts_root)
35
28
  @scripts_root = scripts_root
36
29
  @redis = redis
37
30
  @shas = {}
@@ -39,7 +32,7 @@ module Stoplight
39
32
 
40
33
  def call(script_name, keys: [], args: [])
41
34
  redis.then do |client|
42
- client.evalsha(script_sha(script_name), keys: keys, argv: args)
35
+ client.evalsha(script_sha(script_name), keys: keys.map(&:to_s), argv: args.map(&:to_s))
43
36
  end
44
37
  rescue ::Redis::CommandError => error
45
38
  if error.message.include?("NOSCRIPT")
@@ -50,18 +43,27 @@ module Stoplight
50
43
  end
51
44
  end
52
45
 
53
- private def reload_script(script_name)
46
+ protected
47
+
48
+ attr_reader :scripts_root
49
+ attr_reader :redis
50
+
51
+ private
52
+
53
+ attr_reader :shas
54
+
55
+ def reload_script(script_name)
54
56
  shas.delete(script_name)
55
57
  script_sha(script_name)
56
58
  end
57
59
 
58
- private def script_sha(script_name)
60
+ def script_sha(script_name)
59
61
  if shas.key?(script_name)
60
62
  shas[script_name]
61
63
  else
62
64
  script = File.read(File.join(scripts_root, "#{script_name}.lua"))
63
65
 
64
- shas[script_name] = redis.then { |client| client.script("load", script) }
66
+ shas[script_name] = redis.then { |client| client.script(:load, script) }
65
67
  end
66
68
  end
67
69
  end