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
@@ -0,0 +1,28 @@
1
+ local failure_ts = tonumber(ARGV[1])
2
+ local failure_json = ARGV[2]
3
+ local metrics_ttl = tonumber(ARGV[3])
4
+
5
+ local metrics_key = KEYS[1]
6
+
7
+ -- Update metadata
8
+ local meta = redis.call('HMGET', metrics_key, 'last_error_at', 'consecutive_errors')
9
+ local prev_failure_ts = tonumber(meta[1])
10
+ local prev_consecutive_errors = tonumber(meta[2])
11
+
12
+ if not prev_failure_ts or failure_ts > prev_failure_ts then
13
+ redis.call(
14
+ 'HSET', metrics_key,
15
+ 'last_error_at', failure_ts,
16
+ 'last_error_json', failure_json,
17
+ 'consecutive_errors', (prev_consecutive_errors or 0) + 1,
18
+ 'consecutive_successes', 0
19
+ )
20
+ else
21
+ redis.call(
22
+ 'HSET', metrics_key,
23
+ 'consecutive_errors', (prev_consecutive_errors or 0) + 1,
24
+ 'consecutive_successes', 0
25
+ )
26
+ end
27
+
28
+ redis.call('EXPIRE', metrics_key, metrics_ttl)
@@ -0,0 +1,26 @@
1
+ local request_ts = tonumber(ARGV[1])
2
+ local metrics_ttl = tonumber(ARGV[2])
3
+
4
+ local metrics_key = KEYS[1]
5
+
6
+ -- Update metadata
7
+ local meta = redis.call('HMGET', metrics_key, 'last_success_at', 'consecutive_successes')
8
+ local prev_success_ts = tonumber(meta[1])
9
+ local prev_consecutive_successes = tonumber(meta[2])
10
+
11
+ if not prev_success_ts or request_ts > prev_success_ts then
12
+ redis.call(
13
+ 'HSET', metrics_key,
14
+ 'last_success_at', request_ts,
15
+ 'consecutive_errors', 0,
16
+ 'consecutive_successes', (prev_consecutive_successes or 0) + 1
17
+ )
18
+ else
19
+ redis.call(
20
+ 'HSET', metrics_key,
21
+ 'consecutive_errors', 0,
22
+ 'consecutive_successes', (prev_consecutive_successes or 0) + 1
23
+ )
24
+ end
25
+
26
+ redis.call('EXPIRE', metrics_key, metrics_ttl)
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Redis
6
+ module Storage
7
+ # Distributed metrics storage for consecutive-error light strategies.
8
+ #
9
+ # This class provides a lightweight alternative to +WindowMetrics+ for circuit
10
+ # breakers that don't need time-windowed rate calculations. It tracks only:
11
+ # - Consecutive success/failure counters (reset on opposite outcome)
12
+ # - Most recent error with timestamp and serialized details
13
+ # - Most recent success timestamp
14
+ #
15
+ # == Storage Structure
16
+ #
17
+ # All data is stored in a single Redis hash:
18
+ # stoplight:{version}:{system}:{light}:metrics
19
+ #
20
+ # Hash fields:
21
+ # - +last_success_at+: Unix timestamp (float) of most recent success
22
+ # - +last_error_at+: Unix timestamp (float) of most recent failure
23
+ # - +last_error_json+: Serialized {Domain::Failure} for error details
24
+ # - +consecutive_successes+: Integer counter, reset to 0 on failure
25
+ # - +consecutive_errors+: Integer counter, reset to 0 on success
26
+ #
27
+ # == Performance Characteristics
28
+ #
29
+ # This implementation is optimized for minimal Redis overhead:
30
+ # - Single hash key per circuit (vs. multiple ZSETs for +WindowMetrics+)
31
+ # - O(1) reads and writes
32
+ # - No time-range queries or bucket management
33
+ # - Low memory footprint
34
+ #
35
+ # == Atomicity
36
+ #
37
+ # Record operations use Lua scripts to ensure atomic read-modify-write:
38
+ # - Consecutive counters are incremented and reset in one round-trip
39
+ # - "Last" timestamps only update if the new event is more recent,
40
+ # preventing out-of-order writes from corrupting state
41
+ #
42
+ # == When to Use
43
+ #
44
+ # Choose UnboundedMetrics when your circuit breaker strategy is based on
45
+ # consecutive failures (e.g., "open after 5 failures in a row"). Choose
46
+ # {WindowMetrics} when you need error rate calculations (e.g., "open when
47
+ # error rate exceeds 50% over 5 minutes").
48
+ #
49
+ # @note The +errors+ and +successes+ fields in the returned +Stoplight::Domain::Metrics+
50
+ # are always +nil+ since this class doesn't track windowed totals.
51
+ #
52
+ # @note The metrics hash TTL is refreshed on every write operation. Circuits
53
+ # with no activity will have their metrics expire, which is generally
54
+ # desirable for ephemeral or decommissioned lights.
55
+ #
56
+ class UnboundedMetrics < Metrics
57
+ def initialize(redis:, scripting:, key_space:, clock:)
58
+ @clock = clock
59
+ @scripting = scripting
60
+ @redis = redis
61
+ @metrics_key = key_space.key(:metrics)
62
+ end
63
+
64
+ # Get metrics for the current light
65
+ #
66
+ def metrics_snapshot
67
+ last_success_at, last_error_json, consecutive_errors, consecutive_successes = redis.with do |client|
68
+ client.hmget(
69
+ metrics_key,
70
+ "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
71
+ )
72
+ end
73
+
74
+ Domain::MetricsSnapshot.new(
75
+ successes: nil, errors: nil,
76
+ consecutive_errors: consecutive_errors.to_i,
77
+ consecutive_successes: consecutive_successes.to_i,
78
+ last_error: deserialize_failure(last_error_json),
79
+ last_success_at: (clock.at(last_success_at.to_f) if last_success_at)
80
+ )
81
+ end
82
+
83
+ # Records successful circuit breaker execution
84
+ #
85
+ def record_success
86
+ timestamp = clock.current_time.to_f
87
+
88
+ scripting.call(
89
+ :"unbounded_metrics/record_success",
90
+ args: [timestamp, metrics_ttl],
91
+ keys: [metrics_key]
92
+ )
93
+ end
94
+
95
+ # Records failed circuit breaker execution
96
+ #
97
+ def record_failure(exception)
98
+ timestamp = clock.current_time.to_f
99
+
100
+ scripting.call(
101
+ :"unbounded_metrics/record_failure",
102
+ args: [timestamp, serialize_exception(exception, timestamp:), metrics_ttl],
103
+ keys: [metrics_key]
104
+ )
105
+ end
106
+
107
+ def clear
108
+ redis.with do |client|
109
+ client.hdel(metrics_key, "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes")
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :redis
116
+ attr_reader :scripting
117
+ attr_reader :metrics_key
118
+ attr_reader :clock
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,26 @@
1
+ local number_of_metric_buckets = tonumber(ARGV[1])
2
+ local window_start_ts = tonumber(ARGV[2])
3
+ local window_end_ts = tonumber(ARGV[3])
4
+ local metrics_fields = {}
5
+ for idx = 4, #ARGV do
6
+ table.insert(metrics_fields, ARGV[idx])
7
+ end
8
+
9
+ local metrics_key = KEYS[1]
10
+
11
+ local function count_events(start_idx, bucket_count, start_ts)
12
+ local total = 0
13
+ for idx = start_idx, start_idx + bucket_count - 1 do
14
+ total = total + tonumber(redis.call('ZCOUNT', KEYS[idx], start_ts, window_end_ts))
15
+ end
16
+ return total
17
+ end
18
+
19
+ local offset = 2
20
+ local successes = count_events(2, number_of_metric_buckets, window_start_ts)
21
+
22
+ offset = offset + number_of_metric_buckets
23
+ local errors = count_events(offset, number_of_metric_buckets, window_start_ts)
24
+
25
+ local metrics = redis.call('HMGET', metrics_key, unpack(metrics_fields))
26
+ return {successes, errors, unpack(metrics)}
@@ -0,0 +1,36 @@
1
+ local failure_ts = tonumber(ARGV[1])
2
+ local failure_id = ARGV[2]
3
+ local failure_json = ARGV[3]
4
+ local bucket_ttl = tonumber(ARGV[4])
5
+ local metadata_ttl = tonumber(ARGV[5])
6
+
7
+ local metrics_key = KEYS[1]
8
+ local failures_key = KEYS[2]
9
+
10
+ -- Record failure
11
+ if failures_key ~= nil then
12
+ redis.call('ZADD', failures_key, failure_ts, failure_id)
13
+ redis.call('EXPIRE', failures_key, bucket_ttl) -- Not supported in Redis 6.2:, 'NX')
14
+ end
15
+
16
+ -- Update metadata
17
+ local meta = redis.call('HMGET', metrics_key, 'last_error_at', 'consecutive_errors')
18
+ local prev_failure_ts = tonumber(meta[1])
19
+ local prev_consecutive_errors = tonumber(meta[2])
20
+
21
+ if not prev_failure_ts or failure_ts > prev_failure_ts then
22
+ redis.call(
23
+ 'HSET', metrics_key,
24
+ 'last_error_at', failure_ts,
25
+ 'last_error_json', failure_json,
26
+ 'consecutive_errors', (prev_consecutive_errors or 0) + 1,
27
+ 'consecutive_successes', 0
28
+ )
29
+ else
30
+ redis.call(
31
+ 'HSET', metrics_key,
32
+ 'consecutive_errors', (prev_consecutive_errors or 0) + 1,
33
+ 'consecutive_successes', 0
34
+ )
35
+ end
36
+ redis.call('EXPIRE', metrics_key, metadata_ttl) -- Not supported in Redis 6.2:, 'GT')
@@ -0,0 +1,35 @@
1
+ local request_ts = tonumber(ARGV[1])
2
+ local request_id = ARGV[2]
3
+ local bucket_ttl = tonumber(ARGV[3])
4
+ local metadata_ttl = tonumber(ARGV[4])
5
+
6
+ local metrics_key = KEYS[1]
7
+ local successes_key = KEYS[2]
8
+
9
+ -- Record success
10
+ if successes_key ~= nil then
11
+ redis.call('ZADD', successes_key, request_ts, request_id)
12
+ redis.call('EXPIRE', successes_key, bucket_ttl) -- Not supported in Redis 6.2:, 'NX')
13
+ end
14
+
15
+ -- Update metadata
16
+ local meta = redis.call('HMGET', metrics_key, 'last_success_at', 'consecutive_successes')
17
+ local prev_success_ts = tonumber(meta[1])
18
+ local prev_consecutive_successes = tonumber(meta[2])
19
+
20
+ if not prev_success_ts or request_ts > prev_success_ts then
21
+ redis.call(
22
+ 'HSET', metrics_key,
23
+ 'last_success_at', request_ts,
24
+ 'consecutive_errors', 0,
25
+ 'consecutive_successes', (prev_consecutive_successes or 0) + 1
26
+ )
27
+ else
28
+ redis.call(
29
+ 'HSET', metrics_key,
30
+ 'consecutive_errors', 0,
31
+ 'consecutive_successes', (prev_consecutive_successes or 0) + 1
32
+ )
33
+ end
34
+
35
+ redis.call('EXPIRE', metrics_key, metadata_ttl) -- Not supported in Redis 6.2:, 'GT')
@@ -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
@@ -19,13 +19,10 @@ module Stoplight
19
19
  # )
20
20
  # metrics.record_success
21
21
  #
22
- # @see Stoplight::Domain::Storage::Metrics
23
- class CompatibilityMetrics < Domain::Storage::Metrics
24
- private attr_reader :data_store
25
- private attr_reader :config
22
+ class CompatibilityMetrics
23
+ attr_reader :data_store
24
+ attr_reader :config
26
25
 
27
- # @param data_store [Stoplight::Domain::DataStore]
28
- # @param config [Stoplight::Domain::Config]
29
26
  def initialize(data_store:, config:)
30
27
  @data_store = data_store
31
28
  @config = config
@@ -33,14 +30,10 @@ module Stoplight
33
30
 
34
31
  def metrics_snapshot = data_store.get_metrics(config)
35
32
 
36
- # @return [void]
37
33
  def record_success = data_store.record_success(config)
38
34
 
39
- # @param error [StandardError]
40
- # @return [void]
41
35
  def record_failure(error) = data_store.record_failure(config, error)
42
36
 
43
- # @return [void]
44
37
  def clear = data_store.clear_metrics(config)
45
38
  end
46
39
  end
@@ -12,24 +12,21 @@ module Stoplight
12
12
  # This adapter will be removed in a future versions once all
13
13
  # data stores have native recovery lock implementations.
14
14
  #
15
- # @see Stoplight::Domain::Storage::RecoveryLock
16
- class CompatibilityRecoveryLock < Domain::Storage::RecoveryLock
17
- private attr_reader :data_store
18
- private attr_reader :config
19
-
20
- # @param data_store [Stoplight::Domain::DataStore]
21
- # @param config [Stoplight::Domain::Config]
15
+ # @see Stoplight::Domain::_RecoveryLockStore
16
+ class CompatibilityRecoveryLock
22
17
  def initialize(data_store:, config:)
23
18
  @data_store = data_store
24
19
  @config = config
25
20
  end
26
21
 
27
- # @return [Stoplight::Domain::RecoveryLockToken, nil]
28
- def acquire_lock = data_store.acquire_recovery_lock(config)
22
+ def acquire_lock = data_store.acquire_recovery_lock(config) #: Domain::Storage::RecoveryLockToken?
29
23
 
30
- # @param lock [Stoplight::Domain::LockToken]
31
- # @return [void]
32
24
  def release_lock(lock) = data_store.release_recovery_lock(lock)
25
+
26
+ private
27
+
28
+ attr_reader :data_store
29
+ attr_reader :config
33
30
  end
34
31
  end
35
32
  end
@@ -21,13 +21,7 @@ module Stoplight
21
21
  # recovery_metrics.record_success
22
22
  # recovery_metrics.metrics_snapshot # => 1 success, 0 failures
23
23
  #
24
- # @see Stoplight::Domain::Storage::Metrics
25
- class CompatibilityRecoveryMetrics < Domain::Storage::Metrics
26
- private attr_reader :data_store
27
- private attr_reader :config
28
-
29
- # @param data_store [Stoplight::Domain::DataStore]
30
- # @param config [Stoplight::Domain::Config]
24
+ class CompatibilityRecoveryMetrics
31
25
  def initialize(data_store:, config:)
32
26
  @data_store = data_store
33
27
  @config = config
@@ -36,19 +30,17 @@ module Stoplight
36
30
  def metrics_snapshot = data_store.get_recovery_metrics(config)
37
31
 
38
32
  # Tracks successful circuit breaker execution
39
- #
40
- # @return [void]
41
33
  def record_success = data_store.record_recovery_probe_success(config)
42
34
 
43
35
  # Tracks failed circuit breaker execution
44
- #
45
- # @param error [StandardError]
46
- # @return [void]
47
36
  def record_failure(error) = data_store.record_recovery_probe_failure(config, error)
48
37
 
49
- # Clears metrics
50
- # @return [void]
51
38
  def clear = data_store.clear_recovery_metrics(config)
39
+
40
+ private
41
+
42
+ attr_reader :data_store
43
+ attr_reader :config
52
44
  end
53
45
  end
54
46
  end
@@ -20,35 +20,24 @@ module Stoplight
20
20
  # state.set_state(State::LOCKED_RED)
21
21
  # snapshot = state.state_snapshot
22
22
  #
23
- class CompatibilityState < Domain::Storage::State
24
- # @!attribute data_store
25
- # @return [Stoplight::Domain::DataStore]
26
- private attr_reader :data_store
27
-
28
- # @!attribute config
29
- # @return [Stoplight::Domain::Config]
30
- private attr_reader :config
31
-
32
- # @param data_store [Stoplight::Domain::DataStore]
33
- # @param config [Stoplight::Domain::Config]
23
+ class CompatibilityState
34
24
  def initialize(data_store:, config:)
35
25
  @data_store = data_store
36
26
  @config = config
37
27
  end
38
28
 
39
- # @return [Stoplight::Domain::StateSnapshot]
40
29
  def state_snapshot = data_store.get_state_snapshot(config)
41
30
 
42
- # @param state [String]
43
- # @return [String]
44
31
  def set_state(state) = data_store.set_state(config, state)
45
32
 
46
- # @param color [String]
47
- # @return [Boolean]
48
33
  def transition_to_color(color) = data_store.transition_to_color(config, color)
49
34
 
50
- # @return [void]
51
35
  def clear = data_store.delete_light(config)
36
+
37
+ private
38
+
39
+ attr_reader :data_store
40
+ attr_reader :config
52
41
  end
53
42
  end
54
43
  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