stoplight 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/stoplight/admin/actions/remove.rb +23 -0
  4. data/lib/stoplight/admin/dependencies.rb +6 -1
  5. data/lib/stoplight/admin/helpers.rb +10 -5
  6. data/lib/stoplight/admin/lights_repository.rb +26 -14
  7. data/lib/stoplight/admin/views/_card.erb +13 -1
  8. data/lib/stoplight/admin.rb +9 -0
  9. data/lib/stoplight/common/deprecations.rb +11 -0
  10. data/lib/stoplight/domain/config.rb +5 -1
  11. data/lib/stoplight/domain/data_store.rb +58 -6
  12. data/lib/stoplight/domain/failure.rb +2 -0
  13. data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
  14. data/lib/stoplight/domain/light.rb +34 -24
  15. data/lib/stoplight/domain/metrics.rb +64 -0
  16. data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
  17. data/lib/stoplight/domain/{metadata.rb → state_snapshot.rb} +29 -37
  18. data/lib/stoplight/domain/storage/metrics.rb +42 -0
  19. data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
  20. data/lib/stoplight/domain/storage/state.rb +87 -0
  21. data/lib/stoplight/domain/strategies/green_run_strategy.rb +2 -2
  22. data/lib/stoplight/domain/strategies/red_run_strategy.rb +3 -3
  23. data/lib/stoplight/domain/strategies/run_strategy.rb +2 -7
  24. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +63 -36
  25. data/lib/stoplight/domain/tracker/base.rb +0 -29
  26. data/lib/stoplight/domain/tracker/recovery_probe.rb +26 -22
  27. data/lib/stoplight/domain/tracker/request.rb +26 -21
  28. data/lib/stoplight/domain/traffic_control/base.rb +5 -5
  29. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +3 -7
  30. data/lib/stoplight/domain/traffic_control/error_rate.rb +3 -3
  31. data/lib/stoplight/domain/traffic_recovery/base.rb +5 -5
  32. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +4 -8
  33. data/lib/stoplight/domain/traffic_recovery.rb +0 -1
  34. data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
  35. data/lib/stoplight/infrastructure/data_store/memory/metrics.rb +27 -0
  36. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
  37. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
  38. data/lib/stoplight/infrastructure/data_store/memory/state.rb +21 -0
  39. data/lib/stoplight/infrastructure/data_store/memory.rb +163 -132
  40. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/get_metrics.lua +26 -0
  41. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
  42. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
  43. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
  44. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
  45. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
  46. data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
  47. data/lib/stoplight/infrastructure/data_store/redis.rb +211 -165
  48. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
  49. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
  50. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
  51. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
  52. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
  53. data/lib/stoplight/version.rb +1 -1
  54. data/lib/stoplight/wiring/data_store/base.rb +11 -0
  55. data/lib/stoplight/wiring/data_store/memory.rb +10 -0
  56. data/lib/stoplight/wiring/data_store/redis.rb +25 -0
  57. data/lib/stoplight/wiring/default.rb +1 -1
  58. data/lib/stoplight/wiring/default_configuration.rb +1 -1
  59. data/lib/stoplight/wiring/default_factory_builder.rb +1 -1
  60. data/lib/stoplight/wiring/light_builder.rb +185 -0
  61. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
  62. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
  63. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
  64. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  65. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  66. data/lib/stoplight/wiring/light_factory.rb +45 -132
  67. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  68. data/lib/stoplight/wiring/public_api.rb +3 -2
  69. data/lib/stoplight.rb +18 -3
  70. metadata +55 -16
  71. data/lib/stoplight/infrastructure/data_store/redis/get_metadata.lua +0 -38
  72. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
  73. data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
  74. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
  75. data/lib/stoplight/wiring/container.rb +0 -80
  76. data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -123
  77. data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
  78. data/lib/stoplight/wiring/system_container.rb +0 -9
  79. data/lib/stoplight/wiring/system_light_factory.rb +0 -17
  80. /data/lib/stoplight/infrastructure/data_store/redis/{record_failure.lua → lua_scripts/record_failure.lua} +0 -0
  81. /data/lib/stoplight/infrastructure/data_store/redis/{record_success.lua → lua_scripts/record_success.lua} +0 -0
  82. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_green.lua → lua_scripts/transition_to_green.lua} +0 -0
  83. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_red.lua → lua_scripts/transition_to_red.lua} +0 -0
  84. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_yellow.lua → lua_scripts/transition_to_yellow.lua} +0 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "forwardable"
5
+
6
+ module Stoplight
7
+ 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]
18
+ attr_reader :light_name
19
+
20
+ # @!attribute token
21
+ # @return [String]
22
+ attr_reader :token
23
+
24
+ # @param light_name [String]
25
+ def initialize(light_name:)
26
+ @light_name = light_name
27
+ @token = SecureRandom.uuid
28
+ end
29
+
30
+ def lock_key = key(:locks, :recovery, light_name)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module DataStore
6
+ class Redis
7
+ # Manages Lua scripts for Redis operations.
8
+ #
9
+ # This class provides execution of Lua scripts by caching their SHA digests
10
+ # and automatically reloading scripts if they're evicted from Redis memory.
11
+ #
12
+ # @example
13
+ # script_manager = ScriptManager.new(redis: redis_client)
14
+ # script_manager.call(:increment_counter, keys: ["counter:1"], args: [5])
15
+ #
16
+ # @note Scripts are loaded lazily on first use and cached in memory
17
+ # @note Script files must be named `<script_name>.lua` and located in scripts_root
18
+ class Scripting
19
+ SCRIPTS_ROOT = File.join(__dir__, "lua_scripts")
20
+ # @!attribute scripts_root
21
+ # @return [String]
22
+ protected attr_reader :scripts_root
23
+
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
31
+
32
+ # @param redis [RedisClient | ConnectionPool]
33
+ # @param scripts_root [String]
34
+ def initialize(redis:, scripts_root: SCRIPTS_ROOT)
35
+ @scripts_root = scripts_root
36
+ @redis = redis
37
+ @shas = {}
38
+ end
39
+
40
+ def call(script_name, keys: [], args: [])
41
+ redis.then do |client|
42
+ client.evalsha(script_sha(script_name), keys: keys, argv: args)
43
+ end
44
+ rescue ::Redis::CommandError => error
45
+ if error.message.include?("NOSCRIPT")
46
+ reload_script(script_name)
47
+ retry
48
+ else
49
+ raise
50
+ end
51
+ end
52
+
53
+ private def reload_script(script_name)
54
+ shas.delete(script_name)
55
+ script_sha(script_name)
56
+ end
57
+
58
+ private def script_sha(script_name)
59
+ if shas.key?(script_name)
60
+ shas[script_name]
61
+ else
62
+ script = File.read(File.join(scripts_root, "#{script_name}.lua"))
63
+
64
+ shas[script_name] = redis.then { |client| client.script("load", script) }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -74,30 +74,55 @@ module Stoplight
74
74
  KEY_SEPARATOR = ":"
75
75
  KEY_PREFIX = %w[stoplight v5].join(KEY_SEPARATOR)
76
76
 
77
+ # @!attribute recovery_lock_store
78
+ # @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
79
+ protected attr_reader :recovery_lock_store
80
+
81
+ # @!attribute scripting
82
+ # @return [Stoplight::Infrastructure::DataStore::Redis::Scripting]
83
+ protected attr_reader :scripting
84
+
85
+ # @!attribute redis
86
+ # @return [::Redis | ConnectionPool<::Redis>]
87
+ protected attr_reader :redis
88
+
89
+ # @!attribute warn_on_clock_skew
90
+ # @return [Boolean]
91
+ protected attr_reader :warn_on_clock_skew
92
+
77
93
  # @param redis [::Redis, ConnectionPool<::Redis>]
94
+ # @param recovery_lock_store [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
78
95
  # @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
96
+ # @param scripting [Stoplight::Infrastructure::DataStore::Redis::Scripting]
79
97
  # the application server
80
- def initialize(redis, warn_on_clock_skew: true)
98
+ def initialize(redis:, recovery_lock_store:, scripting:, warn_on_clock_skew: true)
81
99
  @warn_on_clock_skew = warn_on_clock_skew
82
100
  @redis = redis
101
+ @recovery_lock_store = recovery_lock_store
102
+ @scripting = scripting
83
103
  end
84
104
 
85
105
  def names
86
- pattern = key("metadata", "*")
87
- prefix_regex = /^#{key("metadata", "")}/
88
- @redis.then do |client|
89
- client.scan_each(match: pattern).to_a.map do |key|
90
- key.sub(prefix_regex, "")
106
+ metadata, recovery_metrics = @redis.then do |client|
107
+ [
108
+ [key("metadata", "*"), /^#{key("metadata", "")}/],
109
+ [key("recovery_metrics", "*"), /^#{key("recovery_metrics", "")}/]
110
+ ].map do |(pattern, prefix_regex)|
111
+ client.scan_each(match: pattern).to_a.map do |key|
112
+ key.sub(prefix_regex, "")
113
+ end
91
114
  end
92
115
  end
116
+ metadata + recovery_metrics
93
117
  end
94
118
 
95
- def get_metadata(config)
96
- detect_clock_skew
119
+ # @param config [Stoplight::Domain::Config]
120
+ # @return [Stoplight::Domain::Metrics]
121
+ def get_metrics(config)
122
+ config.name
97
123
 
98
124
  window_end_ts = current_time.to_f
99
- window_start_ts = window_end_ts - [config.window_size, METRICS_RETENTION_TIME].compact.min.to_f
100
- recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
125
+ window_start_ts = window_end_ts - config.window_size.to_i
101
126
 
102
127
  if config.window_size
103
128
  failure_keys = failure_bucket_keys(config, window_end: window_end_ts)
@@ -106,129 +131,172 @@ module Stoplight
106
131
  failure_keys = []
107
132
  success_keys = []
108
133
  end
109
- recovery_probe_failure_keys = recovery_probe_failure_bucket_keys(config, window_end: window_end_ts)
110
- recovery_probe_success_keys = recovery_probe_success_bucket_keys(config, window_end: window_end_ts)
111
-
112
- successes, errors, recovery_probe_successes, recovery_probe_errors, meta = @redis.with do |client|
113
- client.evalsha(
114
- get_metadata_sha,
115
- argv: [
116
- failure_keys.count,
117
- recovery_probe_failure_keys.count,
118
- window_start_ts,
119
- window_end_ts,
120
- recovery_window_start_ts
121
- ],
122
- keys: [
123
- metadata_key(config),
124
- *success_keys,
125
- *failure_keys,
126
- *recovery_probe_success_keys,
127
- *recovery_probe_failure_keys
128
- ]
134
+
135
+ successes, errors, last_success_at, last_error_json, consecutive_errors, consecutive_successes = scripting.call(
136
+ :get_metrics,
137
+ args: [
138
+ failure_keys.count,
139
+ window_start_ts,
140
+ window_end_ts,
141
+ "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
142
+ ],
143
+ keys: [
144
+ metadata_key(config),
145
+ *success_keys,
146
+ *failure_keys
147
+ ]
148
+ )
149
+ consecutive_errors = config.window_size ? [consecutive_errors.to_i, errors].min : consecutive_errors.to_i
150
+ consecutive_successes = config.window_size ? [consecutive_successes.to_i, successes].min : consecutive_successes.to_i
151
+
152
+ Domain::Metrics.new(
153
+ successes: (successes if config.window_size),
154
+ errors: (errors if config.window_size),
155
+ consecutive_errors:,
156
+ consecutive_successes:,
157
+ last_error: deserialize_failure(last_error_json),
158
+ last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
159
+ )
160
+ end
161
+
162
+ # @param config [Stoplight::Domain::Config]
163
+ # @return [Stoplight::Domain::Metrics]
164
+ def get_recovery_metrics(config)
165
+ last_success_at, last_error_json, consecutive_errors, consecutive_successes = @redis.with do |client|
166
+ client.hmget(
167
+ recovery_metrics_key(config),
168
+ "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
129
169
  )
130
170
  end
131
- meta_hash = meta.each_slice(2).to_h.transform_keys(&:to_sym)
132
- last_error_json = meta_hash.delete(:last_error_json)
133
- last_error = deserialize_failure(last_error_json) if last_error_json
134
-
135
- Domain::Metadata.new(
136
- current_time:,
137
- successes:,
138
- errors:,
139
- recovery_probe_successes:,
140
- recovery_probe_errors:,
141
- last_error:,
142
- last_error_at: (Time.at(meta_hash[:last_error_at].to_f) if meta_hash[:last_error_at]),
143
- last_success_at: (Time.at(meta_hash[:last_success_at].to_f) if meta_hash[:last_success_at]),
144
- consecutive_errors: meta_hash[:consecutive_errors].to_i,
145
- consecutive_successes: meta_hash[:consecutive_successes].to_i,
146
- breached_at: (Time.at(meta_hash[:breached_at].to_f) if meta_hash[:breached_at]),
147
- locked_state: meta_hash[:locked_state] || Domain::State::UNLOCKED,
148
- recovery_scheduled_after: (Time.at(meta_hash[:recovery_scheduled_after].to_f) if meta_hash[:recovery_scheduled_after]),
149
- recovery_started_at: (Time.at(meta_hash[:recovery_started_at].to_f) if meta_hash[:recovery_started_at]),
150
- recovered_at: (Time.at(meta_hash[:recovered_at].to_f) if meta_hash[:recovered_at])
171
+
172
+ Domain::Metrics.new(
173
+ successes: nil, errors: nil,
174
+ consecutive_errors: consecutive_errors.to_i,
175
+ consecutive_successes: consecutive_successes.to_i,
176
+ last_error: deserialize_failure(last_error_json),
177
+ last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
178
+ )
179
+ end
180
+
181
+ # @return [Stoplight::Domain::StateSnapshot]
182
+ def get_state_snapshot(config)
183
+ detect_clock_skew
184
+
185
+ breached_at_raw, locked_state, recovery_scheduled_after_raw, recovery_started_at_raw = @redis.with do |client|
186
+ client.hmget(metadata_key(config), :breached_at, :locked_state, :recovery_scheduled_after, :recovery_started_at)
187
+ end
188
+ breached_at = breached_at_raw&.to_f
189
+ recovery_scheduled_after = recovery_scheduled_after_raw&.to_f
190
+ recovery_started_at = recovery_started_at_raw&.to_f
191
+
192
+ Domain::StateSnapshot.new(
193
+ breached_at: (Time.at(breached_at) if breached_at),
194
+ locked_state: locked_state || Domain::State::UNLOCKED,
195
+ recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
196
+ recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
197
+ time: current_time
198
+ )
199
+ end
200
+
201
+ def clear_metrics(config)
202
+ if config.window_size
203
+ window_end_ts = current_time.to_i
204
+ @redis.with do |client|
205
+ client.multi do |tx|
206
+ tx.unlink(
207
+ *failure_bucket_keys(config, window_end: window_end_ts),
208
+ *success_bucket_keys(config, window_end: window_end_ts)
209
+ )
210
+ tx.hdel(metadata_key(config), "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes")
211
+ end
212
+ end
213
+ else
214
+ @redis.with do |client|
215
+ client.hdel(metadata_key(config), "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes")
216
+ end
217
+ end
218
+ end
219
+
220
+ def clear_recovery_metrics(config)
221
+ @redis.with do |client|
222
+ client.del(recovery_metrics_key(config))
223
+ end
224
+ end
225
+
226
+ private def state_snapshot_from_hash(data, time: current_time)
227
+ breached_at = data[:breached_at]&.to_f
228
+ recovery_scheduled_after = data[:recovery_scheduled_after]&.to_f
229
+ recovery_started_at = data[:recovery_started_at]&.to_f
230
+
231
+ Domain::StateSnapshot.new(
232
+ breached_at: (Time.at(breached_at) if breached_at),
233
+ locked_state: data[:locked_state] || Domain::State::UNLOCKED,
234
+ recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
235
+ recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
236
+ time:
151
237
  )
152
238
  end
153
239
 
154
240
  # @param config [Stoplight::Domain::Config] The light configuration.
155
241
  # @param exception [Exception]
156
- # @return [Stoplight::Domain::Metadata] The updated metadata after recording the failure.
242
+ # @return [void]
157
243
  def record_failure(config, exception)
158
244
  current_time = self.current_time
159
245
  current_ts = current_time.to_f
160
246
  failure = Domain::Failure.from_error(exception, time: current_time)
161
247
 
162
- @redis.then do |client|
163
- client.evalsha(
164
- record_failure_sha,
165
- argv: [current_ts, SecureRandom.hex(12), serialize_failure(failure), metrics_ttl, metadata_ttl],
166
- keys: [
167
- metadata_key(config),
168
- config.window_size && errors_key(config, time: current_ts)
169
- ].compact
170
- )
171
- end
172
- get_metadata(config)
248
+ scripting.call(
249
+ :record_failure,
250
+ args: [current_ts, SecureRandom.hex(12), serialize_failure(failure), metrics_ttl, metadata_ttl],
251
+ keys: [
252
+ metadata_key(config),
253
+ config.window_size && errors_key(config, time: current_ts)
254
+ ].compact
255
+ )
173
256
  end
174
257
 
175
258
  def record_success(config, request_id: SecureRandom.hex(12))
176
259
  current_ts = current_time.to_f
177
260
 
178
- @redis.then do |client|
179
- client.evalsha(
180
- record_success_sha,
181
- argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
182
- keys: [
183
- metadata_key(config),
184
- config.window_size && successes_key(config, time: current_ts)
185
- ].compact
186
- )
187
- end
261
+ scripting.call(
262
+ :record_success,
263
+ args: [current_ts, request_id, metrics_ttl, metadata_ttl],
264
+ keys: [
265
+ metadata_key(config),
266
+ config.window_size && successes_key(config, time: current_ts)
267
+ ].compact
268
+ )
188
269
  end
189
270
 
190
271
  # Records a failed recovery probe for a specific light configuration.
191
272
  #
192
273
  # @param config [Stoplight::Domain::Config] The light configuration.
193
274
  # @param exception [Exception]
194
- # @return [Stoplight::Domain::Metadata] The updated metadata after recording the failure.
275
+ # @return [void]
195
276
  def record_recovery_probe_failure(config, exception)
196
277
  current_time = self.current_time
197
278
  current_ts = current_time.to_f
198
279
  failure = Domain::Failure.from_error(exception, time: current_time)
199
280
 
200
- @redis.then do |client|
201
- client.evalsha(
202
- record_failure_sha,
203
- argv: [current_ts, SecureRandom.uuid, serialize_failure(failure), metrics_ttl, metrics_ttl],
204
- keys: [
205
- metadata_key(config),
206
- recovery_probe_errors_key(config, time: current_ts)
207
- ].compact
208
- )
209
- end
210
- get_metadata(config)
281
+ scripting.call(
282
+ :record_recovery_probe_failure,
283
+ args: [current_ts, serialize_failure(failure)],
284
+ keys: [recovery_metrics_key(config)]
285
+ )
211
286
  end
212
287
 
213
288
  # Records a successful recovery probe for a specific light configuration.
214
289
  #
215
290
  # @param config [Stoplight::Domain::Config] The light configuration.
216
- # @param request_id [String] The unique identifier for the request
217
- # @return [Stoplight::Domain::Metadata] The updated metadata after recording the success.
218
- def record_recovery_probe_success(config, request_id: SecureRandom.hex(12))
291
+ # @return [void]
292
+ def record_recovery_probe_success(config)
219
293
  current_ts = current_time.to_f
220
294
 
221
- @redis.then do |client|
222
- client.evalsha(
223
- record_success_sha,
224
- argv: [current_ts, request_id, metrics_ttl, metadata_ttl],
225
- keys: [
226
- metadata_key(config),
227
- recovery_probe_successes_key(config, time: current_ts)
228
- ].compact
229
- )
230
- end
231
- get_metadata(config)
295
+ scripting.call(
296
+ :record_recovery_probe_success,
297
+ args: [current_ts],
298
+ keys: [recovery_metrics_key(config)]
299
+ )
232
300
  end
233
301
 
234
302
  def set_state(config, state)
@@ -260,6 +328,18 @@ module Stoplight
260
328
  end
261
329
  end
262
330
 
331
+ # @param config [Stoplight::Domain::Config]
332
+ # @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken, nil]
333
+ def acquire_recovery_lock(config)
334
+ recovery_lock_store.acquire_lock(config.name)
335
+ end
336
+
337
+ # @param lock [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken]
338
+ # @return [void]
339
+ def release_recovery_lock(lock)
340
+ recovery_lock_store.release_lock(lock)
341
+ end
342
+
263
343
  # Transitions to GREEN state and ensures only one notification
264
344
  #
265
345
  # @param config [Stoplight::Domain::Config] The light configuration
@@ -268,13 +348,11 @@ module Stoplight
268
348
  current_ts = current_time.to_f
269
349
  meta_key = metadata_key(config)
270
350
 
271
- became_green = @redis.then do |client|
272
- client.evalsha(
273
- transition_to_green_sha,
274
- argv: [current_ts],
275
- keys: [meta_key]
276
- )
277
- end
351
+ became_green = scripting.call(
352
+ :transition_to_green,
353
+ args: [current_ts],
354
+ keys: [meta_key]
355
+ )
278
356
  became_green == 1
279
357
  end
280
358
 
@@ -283,16 +361,14 @@ module Stoplight
283
361
  # @param config [Stoplight::Domain::Config] The light configuration
284
362
  # @return [Boolean] true if this is the first instance to detect this transition
285
363
  private def transition_to_yellow(config)
286
- current_ts = current_time.to_i
364
+ current_ts = current_time.to_f
287
365
  meta_key = metadata_key(config)
288
366
 
289
- became_yellow = @redis.then do |client|
290
- client.evalsha(
291
- transition_to_yellow_sha,
292
- argv: [current_ts],
293
- keys: [meta_key]
294
- )
295
- end
367
+ became_yellow = scripting.call(
368
+ :transition_to_yellow,
369
+ args: [current_ts],
370
+ keys: [meta_key]
371
+ )
296
372
  became_yellow == 1
297
373
  end
298
374
 
@@ -301,24 +377,34 @@ module Stoplight
301
377
  # @param config [Stoplight::Domain::Config] The light configuration
302
378
  # @return [Boolean] true if this is the first instance to detect this transition
303
379
  private def transition_to_red(config)
304
- current_ts = current_time.to_i
380
+ current_ts = current_time.to_f
305
381
  meta_key = metadata_key(config)
306
382
  recovery_scheduled_after_ts = current_ts + config.cool_off_time
307
383
 
308
- became_red = @redis.then do |client|
309
- client.evalsha(
310
- transition_to_red_sha,
311
- argv: [current_ts, recovery_scheduled_after_ts],
312
- keys: [meta_key]
313
- )
314
- end
384
+ became_red = scripting.call(
385
+ :transition_to_red,
386
+ args: [current_ts, recovery_scheduled_after_ts],
387
+ keys: [meta_key]
388
+ )
315
389
 
316
390
  became_red == 1
317
391
  end
318
392
 
319
- # @param failure_json [String]
320
- # @return [Domain::Failure]
393
+ # Removes all traces of a light from Redis metadata (metrics will expire by TTL).
394
+ #
395
+ # @param config [Stoplight::Domain::Config] The light configuration.
396
+ # @return [void]
397
+ def delete_light(config)
398
+ @redis.then do |client|
399
+ client.del(metadata_key(config), recovery_metrics_key(config))
400
+ end
401
+ end
402
+
403
+ # @param failure_json [String, nil]
404
+ # @return [Domain::Failure, nil]
321
405
  private def deserialize_failure(failure_json)
406
+ return if failure_json.nil?
407
+
322
408
  object = JSON.parse(failure_json)
323
409
  error_object = object["error"]
324
410
 
@@ -389,12 +475,8 @@ module Stoplight
389
475
  self.class.bucket_key(config.name, metric: "failure", time:)
390
476
  end
391
477
 
392
- private def recovery_probe_successes_key(config, time:)
393
- self.class.bucket_key(config.name, metric: "recovery_probe_success", time:)
394
- end
395
-
396
- private def recovery_probe_errors_key(config, time:)
397
- self.class.bucket_key(config.name, metric: "recovery_probe_failure", time:)
478
+ private def recovery_metrics_key(config)
479
+ key("recovery_metrics", config.name)
398
480
  end
399
481
 
400
482
  private def metadata_key(config)
@@ -433,42 +515,6 @@ module Stoplight
433
515
  rand <= probability
434
516
  end
435
517
 
436
- private def record_success_sha
437
- @record_success_sha ||= @redis.then do |client|
438
- client.script("load", Lua::RECORD_SUCCESS)
439
- end
440
- end
441
-
442
- private def get_metadata_sha
443
- @get_metadata_sha ||= @redis.then do |client|
444
- client.script("load", Lua::GET_METADATA)
445
- end
446
- end
447
-
448
- private def transition_to_yellow_sha
449
- @transition_to_yellow_sha ||= @redis.then do |client|
450
- client.script("load", Lua::TRANSITION_TO_YELLOW)
451
- end
452
- end
453
-
454
- private def transition_to_red_sha
455
- @transition_to_red_sha ||= @redis.then do |client|
456
- client.script("load", Lua::TRANSITION_TO_RED)
457
- end
458
- end
459
-
460
- private def transition_to_green_sha
461
- @transition_to_green_sha ||= @redis.then do |client|
462
- client.script("load", Lua::TRANSITION_TO_GREEN)
463
- end
464
- end
465
-
466
- private def record_failure_sha
467
- @record_failure_sha ||= @redis.then do |client|
468
- client.script("load", Lua::RECORD_FAILURE)
469
- end
470
- end
471
-
472
518
  private def current_time
473
519
  Time.now
474
520
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Notifier
6
+ # A wrapper around a notifier that provides fail-safe mechanisms using a
7
+ # circuit breaker. It ensures that a notification can gracefully
8
+ # handle failures.
9
+ #
10
+ # @api private
11
+ class FailSafe < Domain::StateTransitionNotifier
12
+ # @!attribute [r] notifier
13
+ # @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
14
+ attr_reader :notifier
15
+
16
+ # @!attribute [r] error_notifier
17
+ # @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
18
+ attr_reader :error_notifier
19
+
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
24
+ def initialize(notifier:, error_notifier:)
25
+ @notifier = notifier
26
+ @error_notifier = error_notifier
27
+ end
28
+
29
+ # 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)
37
+ fallback = proc do |exception|
38
+ error_notifier.call(exception) if exception
39
+ nil
40
+ end
41
+
42
+ circuit_breaker.run(fallback) do
43
+ notifier.notify(config, from_color, to_color, error)
44
+ end
45
+ end
46
+
47
+ # @return [Boolean]
48
+ def ==(other)
49
+ other.is_a?(self.class) && notifier == other.notifier
50
+ end
51
+
52
+ # @return [Stoplight::Light] The circuit breaker used to handle failures.
53
+ private def circuit_breaker
54
+ @circuit_breaker ||= Stoplight.system_light(
55
+ "stoplight:notifier:fail_safe:#{notifier.class.name}",
56
+ notifiers: []
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end