stoplight 4.1.1 → 5.0.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +288 -354
  3. data/lib/stoplight/admin/actions/action.rb +24 -0
  4. data/lib/stoplight/admin/actions/lock.rb +23 -0
  5. data/lib/stoplight/admin/actions/lock_all_green.rb +18 -0
  6. data/lib/stoplight/admin/actions/lock_green.rb +23 -0
  7. data/lib/stoplight/admin/actions/lock_red.rb +23 -0
  8. data/lib/stoplight/admin/actions/stats.rb +27 -0
  9. data/lib/stoplight/admin/actions/unlock.rb +23 -0
  10. data/lib/stoplight/admin/dependencies.rb +50 -0
  11. data/lib/stoplight/admin/helpers.rb +27 -0
  12. data/lib/stoplight/admin/lights_repository/light.rb +155 -0
  13. data/lib/stoplight/admin/lights_repository.rb +74 -0
  14. data/lib/stoplight/admin/lights_stats.rb +77 -0
  15. data/lib/stoplight/admin/views/_card.erb +120 -0
  16. data/lib/stoplight/admin/views/index.erb +36 -0
  17. data/lib/stoplight/admin/views/layout.erb +68 -0
  18. data/lib/stoplight/admin.rb +68 -0
  19. data/lib/stoplight/color.rb +3 -3
  20. data/lib/stoplight/config/config_provider.rb +62 -0
  21. data/lib/stoplight/config/library_default_config.rb +29 -0
  22. data/lib/stoplight/config/user_default_config.rb +83 -0
  23. data/lib/stoplight/data_store/base.rb +59 -33
  24. data/lib/stoplight/data_store/fail_safe.rb +106 -0
  25. data/lib/stoplight/data_store/memory.rb +257 -50
  26. data/lib/stoplight/data_store/redis/get_metadata.lua +38 -0
  27. data/lib/stoplight/data_store/redis/lua.rb +23 -0
  28. data/lib/stoplight/data_store/redis/record_failure.lua +36 -0
  29. data/lib/stoplight/data_store/redis/record_success.lua +35 -0
  30. data/lib/stoplight/data_store/redis/transition_to_green.lua +10 -0
  31. data/lib/stoplight/data_store/redis/transition_to_red.lua +10 -0
  32. data/lib/stoplight/data_store/redis/transition_to_yellow.lua +9 -0
  33. data/lib/stoplight/data_store/redis.rb +363 -103
  34. data/lib/stoplight/default.rb +11 -9
  35. data/lib/stoplight/error.rb +1 -13
  36. data/lib/stoplight/failure.rb +14 -13
  37. data/lib/stoplight/light/config.rb +118 -0
  38. data/lib/stoplight/light/configuration_builder_interface.rb +128 -0
  39. data/lib/stoplight/light/green_run_strategy.rb +53 -0
  40. data/lib/stoplight/light/red_run_strategy.rb +26 -0
  41. data/lib/stoplight/light/run_strategy.rb +30 -0
  42. data/lib/stoplight/light/yellow_run_strategy.rb +78 -0
  43. data/lib/stoplight/light.rb +164 -84
  44. data/lib/stoplight/metadata.rb +71 -0
  45. data/lib/stoplight/notifier/base.rb +14 -7
  46. data/lib/stoplight/notifier/fail_safe.rb +67 -0
  47. data/lib/stoplight/notifier/generic.rb +54 -5
  48. data/lib/stoplight/rspec/generic_notifier.rb +11 -12
  49. data/lib/stoplight/rspec.rb +1 -1
  50. data/lib/stoplight/state.rb +3 -3
  51. data/lib/stoplight/traffic_control/base.rb +35 -0
  52. data/lib/stoplight/traffic_control/consecutive_failures.rb +43 -0
  53. data/lib/stoplight/traffic_recovery/base.rb +51 -0
  54. data/lib/stoplight/traffic_recovery/single_success.rb +35 -0
  55. data/lib/stoplight/version.rb +1 -1
  56. data/lib/stoplight.rb +111 -51
  57. metadata +49 -101
  58. data/lib/stoplight/builder.rb +0 -70
  59. data/lib/stoplight/circuit_breaker.rb +0 -102
  60. data/lib/stoplight/configurable.rb +0 -95
  61. data/lib/stoplight/configuration.rb +0 -126
  62. data/lib/stoplight/light/deprecated.rb +0 -44
  63. data/lib/stoplight/light/lockable.rb +0 -45
  64. data/lib/stoplight/light/runnable.rb +0 -127
  65. data/lib/stoplight/notifier.rb +0 -6
  66. data/spec/spec_helper.rb +0 -22
  67. data/spec/stoplight/builder_spec.rb +0 -165
  68. data/spec/stoplight/circuit_breaker_spec.rb +0 -43
  69. data/spec/stoplight/color_spec.rb +0 -39
  70. data/spec/stoplight/configurable_spec.rb +0 -25
  71. data/spec/stoplight/data_store/base_spec.rb +0 -71
  72. data/spec/stoplight/data_store/memory_spec.rb +0 -22
  73. data/spec/stoplight/data_store/redis_spec.rb +0 -45
  74. data/spec/stoplight/data_store_spec.rb +0 -9
  75. data/spec/stoplight/default_spec.rb +0 -80
  76. data/spec/stoplight/error_spec.rb +0 -39
  77. data/spec/stoplight/failure_spec.rb +0 -108
  78. data/spec/stoplight/light/lockable_spec.rb +0 -93
  79. data/spec/stoplight/light/runnable_spec.rb +0 -38
  80. data/spec/stoplight/light_spec.rb +0 -156
  81. data/spec/stoplight/notifier/base_spec.rb +0 -18
  82. data/spec/stoplight/notifier/generic_spec.rb +0 -50
  83. data/spec/stoplight/notifier/io_spec.rb +0 -41
  84. data/spec/stoplight/notifier/logger_spec.rb +0 -75
  85. data/spec/stoplight/notifier_spec.rb +0 -9
  86. data/spec/stoplight/state_spec.rb +0 -39
  87. data/spec/stoplight/version_spec.rb +0 -9
  88. data/spec/stoplight_spec.rb +0 -32
  89. data/spec/support/configurable.rb +0 -69
  90. data/spec/support/data_store/base/clear_failures.rb +0 -24
  91. data/spec/support/data_store/base/clear_state.rb +0 -20
  92. data/spec/support/data_store/base/get_all.rb +0 -44
  93. data/spec/support/data_store/base/get_failures.rb +0 -30
  94. data/spec/support/data_store/base/get_state.rb +0 -7
  95. data/spec/support/data_store/base/names.rb +0 -29
  96. data/spec/support/data_store/base/record_failures.rb +0 -70
  97. data/spec/support/data_store/base/set_state.rb +0 -15
  98. data/spec/support/data_store/base/with_notification_lock.rb +0 -27
  99. data/spec/support/data_store/base.rb +0 -21
  100. data/spec/support/database_cleaner.rb +0 -26
  101. data/spec/support/exception_helpers.rb +0 -9
  102. data/spec/support/light/runnable/color.rb +0 -79
  103. data/spec/support/light/runnable/run.rb +0 -247
  104. data/spec/support/light/runnable/state.rb +0 -31
  105. data/spec/support/light/runnable.rb +0 -5
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'redlock'
3
+ require "forwardable"
4
4
 
5
5
  module Stoplight
6
6
  module DataStore
@@ -12,172 +12,432 @@ module Stoplight
12
12
  # This data structure enables us to query errors that happened within a specific
13
13
  # period. We use this feature to support +window_size+ option.
14
14
  #
15
- # To avoid uncontrolled memory consumption, we keep at most +light.threshold+ number
16
- # of errors happened within last +light.window_size+ seconds (by default infinity).
15
+ # To avoid uncontrolled memory consumption, we keep at most +config.threshold+ number
16
+ # of errors happened within last +config.window_size+ seconds (by default infinity).
17
17
  #
18
18
  # @see Base
19
19
  class Redis < Base
20
- KEY_SEPARATOR = ':'
21
- KEY_PREFIX = %w[stoplight v4].join(KEY_SEPARATOR)
20
+ extend Forwardable
21
+
22
+ class << self
23
+ # Generates a Redis key by joining the prefix with the provided pieces.
24
+ #
25
+ # @param pieces [Array<String, Integer>] Parts of the key to be joined.
26
+ # @return [String] The generated Redis key.
27
+ # @api private
28
+ def key(*pieces)
29
+ [KEY_PREFIX, *pieces].join(KEY_SEPARATOR)
30
+ end
31
+
32
+ # Retrieves the list of Redis bucket keys required to cover a specific time window.
33
+ #
34
+ # @param light_name [String] The name of the light (used as part of the Redis key).
35
+ # @param metric [String] The metric type (e.g., "errors").
36
+ # @param window_end [Time, Numeric] The end time of the window (can be a Time object or a numeric timestamp).
37
+ # @param window_size [Numeric] The size of the time window in seconds.
38
+ # @return [Array<String>] A list of Redis keys for the buckets that cover the time window.
39
+ # @api private
40
+ def buckets_for_window(light_name, metric:, window_end:, window_size:)
41
+ window_end_ts = window_end.to_i
42
+ window_start_ts = window_end_ts - [window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
43
+
44
+ # Find bucket timestamps that contain any part of the window
45
+ start_bucket = (window_start_ts / bucket_size) * bucket_size
46
+
47
+ # End bucket is the last bucket that contains data within our window
48
+ end_bucket = ((window_end_ts - 1) / bucket_size) * bucket_size
49
+
50
+ (start_bucket..end_bucket).step(bucket_size).map do |bucket_start|
51
+ bucket_key(light_name, metric: metric, time: bucket_start)
52
+ end
53
+ end
54
+
55
+ # Generates a Redis key for a specific metric and time.
56
+ #
57
+ # @param light_name [String] The name of the light.
58
+ # @param metric [String] The metric type (e.g., "errors").
59
+ # @param time [Time, Numeric] The time for which to generate the key.
60
+ # @return [String] The generated Redis key.
61
+ def bucket_key(light_name, metric:, time:)
62
+ key("metrics", light_name, metric, (time.to_i / bucket_size) * bucket_size)
63
+ end
64
+
65
+ BUCKET_SIZE = 3600 # 1h
66
+ private_constant :BUCKET_SIZE
22
67
 
23
- # @param redis [::Redis]
24
- def initialize(redis, redlock: Redlock::Client.new([redis]))
68
+ private def bucket_size
69
+ BUCKET_SIZE
70
+ end
71
+ end
72
+
73
+ KEY_SEPARATOR = ":"
74
+ KEY_PREFIX = %w[stoplight v5].join(KEY_SEPARATOR)
75
+
76
+ # @param redis [::Redis, ConnectionPool<::Redis>]
77
+ # @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
78
+ # the application server
79
+ def initialize(redis, warn_on_clock_skew: true)
80
+ @warn_on_clock_skew = warn_on_clock_skew
25
81
  @redis = redis
26
- @redlock = redlock
27
82
  end
28
83
 
29
84
  def names
30
- state_names = @redis.hkeys(states_key)
31
-
32
- pattern = key('failures', '*')
33
- prefix_regex = /^#{key('failures', '')}/
34
- failure_names = @redis.scan_each(match: pattern).to_a.map do |key|
35
- key.sub(prefix_regex, '')
85
+ pattern = key("metadata", "*")
86
+ prefix_regex = /^#{key("metadata", "")}/
87
+ @redis.then do |client|
88
+ client.scan_each(match: pattern).to_a.map do |key|
89
+ key.sub(prefix_regex, "")
90
+ end
36
91
  end
92
+ end
37
93
 
38
- (state_names + failure_names).uniq
94
+ def get_metadata(config)
95
+ detect_clock_skew
96
+
97
+ window_end = Time.now
98
+ window_end_ts = window_end.to_i
99
+ window_start_ts = window_end_ts - [config.window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
100
+ recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
101
+
102
+ if config.window_size
103
+ failure_keys = failure_bucket_keys(config, window_end: window_end_ts)
104
+ success_keys = success_bucket_keys(config, window_end: window_end_ts)
105
+ else
106
+ failure_keys = []
107
+ success_keys = []
108
+ 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
+ ]
129
+ )
130
+ 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 = normalize_failure(last_error_json, config.error_notifier) if last_error_json
134
+
135
+ Metadata.new(
136
+ successes: successes,
137
+ errors: errors,
138
+ recovery_probe_successes: recovery_probe_successes,
139
+ recovery_probe_errors: recovery_probe_errors,
140
+ last_error:,
141
+ **meta_hash
142
+ )
39
143
  end
40
144
 
41
- def get_all(light)
42
- failures, state = @redis.multi do |transaction|
43
- query_failures(light, transaction: transaction)
44
- transaction.hget(states_key, light.name)
145
+ # @param config [Stoplight::Light::Config] The light configuration.
146
+ # @param failure [Stoplight::Failure] The failure to record.
147
+ # @return [Stoplight::Metadata] The updated metadata after recording the failure.
148
+ def record_failure(config, failure)
149
+ current_ts = failure.time.to_i
150
+ failure_json = failure.to_json
151
+
152
+ @redis.then do |client|
153
+ client.evalsha(
154
+ record_failure_sha,
155
+ argv: [current_ts, SecureRandom.hex(12), failure_json, metrics_ttl, metadata_ttl],
156
+ keys: [
157
+ metadata_key(config),
158
+ config.window_size && errors_key(config, time: current_ts)
159
+ ].compact
160
+ )
45
161
  end
162
+ get_metadata(config)
163
+ end
46
164
 
47
- [
48
- normalize_failures(failures, light.error_notifier),
49
- normalize_state(state)
50
- ]
165
+ def record_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
166
+ request_ts = request_time.to_i
167
+
168
+ @redis.then do |client|
169
+ client.evalsha(
170
+ record_success_sha,
171
+ argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
172
+ keys: [
173
+ metadata_key(config),
174
+ config.window_size && successes_key(config, time: request_ts)
175
+ ].compact
176
+ )
177
+ end
51
178
  end
52
179
 
53
- def get_failures(light)
54
- normalize_failures(query_failures(light), light.error_notifier)
180
+ # Records a failed recovery probe for a specific light configuration.
181
+ #
182
+ # @param config [Stoplight::Light::Config] The light configuration.
183
+ # @param failure [Failure] The failure to record.
184
+ # @return [Stoplight::Metadata] The updated metadata after recording the failure.
185
+ def record_recovery_probe_failure(config, failure)
186
+ current_ts = failure.time.to_i
187
+ failure_json = failure.to_json
188
+
189
+ @redis.then do |client|
190
+ client.evalsha(
191
+ record_failure_sha,
192
+ argv: [current_ts, SecureRandom.uuid, failure_json, metrics_ttl, metrics_ttl],
193
+ keys: [
194
+ metadata_key(config),
195
+ recovery_probe_errors_key(config, time: current_ts)
196
+ ].compact
197
+ )
198
+ end
199
+ get_metadata(config)
55
200
  end
56
201
 
57
- # Saves a new failure to the errors HSet and cleans up outdated errors.
58
- def record_failure(light, failure)
59
- *, size = @redis.multi do |transaction|
60
- failures_key = failures_key(light)
202
+ # Records a successful recovery probe for a specific light configuration.
203
+ #
204
+ # @param config [Stoplight::Light::Config] The light configuration.
205
+ # @param request_id [String] The unique identifier for the request
206
+ # @param request_time [Time] The time of the request
207
+ # @return [Stoplight::Metadata] The updated metadata after recording the success.
208
+ def record_recovery_probe_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
209
+ request_ts = request_time.to_i
210
+
211
+ @redis.then do |client|
212
+ client.evalsha(
213
+ record_success_sha,
214
+ argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
215
+ keys: [
216
+ metadata_key(config),
217
+ recovery_probe_successes_key(config, time: request_ts)
218
+ ].compact
219
+ )
220
+ end
221
+ get_metadata(config)
222
+ end
61
223
 
62
- transaction.zadd(failures_key, failure.time.to_i, failure.to_json)
63
- remove_outdated_failures(light, failure.time, transaction: transaction)
64
- transaction.zcard(failures_key)
224
+ def set_state(config, state)
225
+ @redis.then do |client|
226
+ client.hset(metadata_key(config), "locked_state", state)
65
227
  end
228
+ state
229
+ end
66
230
 
67
- size
231
+ # Combined method that performs the state transition based on color
232
+ #
233
+ # @param config [Stoplight::Light::Config] The light configuration
234
+ # @param color [String] The color to transition to ("green", "yellow", or "red")
235
+ # @param current_time [Time] Current timestamp
236
+ # @return [Boolean] true if this is the first instance to detect this transition
237
+ def transition_to_color(config, color, current_time: Time.now)
238
+ current_time.to_i
239
+
240
+ case color
241
+ when Color::GREEN
242
+ transition_to_green(config)
243
+ when Color::YELLOW
244
+ transition_to_yellow(config, current_time:)
245
+ when Color::RED
246
+ transition_to_red(config, current_time:)
247
+ else
248
+ raise ArgumentError, "Invalid color: #{color}"
249
+ end
68
250
  end
69
251
 
70
- def clear_failures(light)
71
- failures, = @redis.multi do |transaction|
72
- query_failures(light, transaction: transaction)
73
- transaction.del(failures_key(light))
252
+ # Transitions to GREEN state and ensures only one notification
253
+ #
254
+ # @param config [Stoplight::Light::Config] The light configuration
255
+ # @return [Boolean] true if this is the first instance to detect this transition
256
+ private def transition_to_green(config, current_time: Time.now)
257
+ current_ts = current_time.to_i
258
+ meta_key = metadata_key(config)
259
+
260
+ became_green = @redis.then do |client|
261
+ client.evalsha(
262
+ transition_to_green_sha,
263
+ argv: [current_ts],
264
+ keys: [meta_key]
265
+ )
74
266
  end
267
+ became_green == 1
268
+ end
75
269
 
76
- normalize_failures(failures, light.error_notifier)
270
+ # Transitions to YELLOW (recovery) state and ensures only one notification
271
+ #
272
+ # @param config [Stoplight::Light::Config] The light configuration
273
+ # @param current_time [Time] Current timestamp
274
+ # @return [Boolean] true if this is the first instance to detect this transition
275
+ private def transition_to_yellow(config, current_time: Time.now)
276
+ current_ts = current_time.to_i
277
+ meta_key = metadata_key(config)
278
+
279
+ became_yellow = @redis.then do |client|
280
+ client.evalsha(
281
+ transition_to_yellow_sha,
282
+ argv: [current_ts],
283
+ keys: [meta_key]
284
+ )
285
+ end
286
+ became_yellow == 1
77
287
  end
78
288
 
79
- def get_state(light)
80
- query_state(light) || State::UNLOCKED
289
+ # Transitions to RED state and ensures only one notification
290
+ #
291
+ # @param config [Stoplight::Light::Config] The light configuration
292
+ # @param current_time [Time] Current timestamp
293
+ # @return [Boolean] true if this is the first instance to detect this transition
294
+ private def transition_to_red(config, current_time: Time.now)
295
+ current_ts = current_time.to_i
296
+ meta_key = metadata_key(config)
297
+ recovery_scheduled_after_ts = current_ts + config.cool_off_time
298
+
299
+ became_red = @redis.then do |client|
300
+ client.evalsha(
301
+ transition_to_red_sha,
302
+ argv: [current_ts, recovery_scheduled_after_ts],
303
+ keys: [meta_key]
304
+ )
305
+ end
306
+
307
+ became_red == 1
81
308
  end
82
309
 
83
- def set_state(light, state)
84
- @redis.hset(states_key, light.name, state)
85
- state
310
+ private def normalize_failure(failure, error_notifier)
311
+ Failure.from_json(failure)
312
+ rescue => e
313
+ error_notifier.call(e)
314
+ Failure.from_error(e)
86
315
  end
87
316
 
88
- def clear_state(light)
89
- state, = @redis.multi do |transaction|
90
- query_state(light, transaction: transaction)
91
- transaction.hdel(states_key, light.name)
92
- end
317
+ def_delegator "self.class", :key
93
318
 
94
- normalize_state(state)
319
+ private def failure_bucket_keys(config, window_end:)
320
+ self.class.buckets_for_window(
321
+ config.name,
322
+ metric: "failure",
323
+ window_end: window_end,
324
+ window_size: config.window_size
325
+ )
95
326
  end
96
327
 
97
- LOCK_TTL = 2_000 # milliseconds
328
+ private def success_bucket_keys(config, window_end:)
329
+ self.class.buckets_for_window(
330
+ config.name,
331
+ metric: "success",
332
+ window_end: window_end,
333
+ window_size: config.window_size
334
+ )
335
+ end
98
336
 
99
- def with_notification_lock(light, from_color, to_color)
100
- @redlock.lock(notification_lock_key(light), LOCK_TTL) do
101
- if last_notification(light) != [from_color, to_color]
102
- set_last_notification(light, from_color, to_color)
337
+ private def recovery_probe_failure_bucket_keys(config, window_end:)
338
+ self.class.buckets_for_window(
339
+ config.name,
340
+ metric: "recovery_probe_failure",
341
+ window_end: window_end,
342
+ window_size: config.cool_off_time
343
+ )
344
+ end
103
345
 
104
- yield
105
- end
106
- end
346
+ private def recovery_probe_success_bucket_keys(config, window_end:)
347
+ self.class.buckets_for_window(
348
+ config.name,
349
+ metric: "recovery_probe_success",
350
+ window_end: window_end,
351
+ window_size: config.cool_off_time
352
+ )
107
353
  end
108
354
 
109
- private
355
+ private def successes_key(config, time:)
356
+ self.class.bucket_key(config.name, metric: "success", time:)
357
+ end
110
358
 
111
- # @param light [Stoplight::Light]
112
- # @param time [Time]
113
- def remove_outdated_failures(light, time, transaction: @redis)
114
- failures_key = failures_key(light)
359
+ private def errors_key(config, time:)
360
+ self.class.bucket_key(config.name, metric: "failure", time:)
361
+ end
115
362
 
116
- # Remove all errors happened before the window start
117
- transaction.zremrangebyscore(failures_key, 0, time.to_i - light.window_size)
118
- # Keep at most +light.threshold+ number of errors
119
- transaction.zremrangebyrank(failures_key, 0, -light.threshold - 1)
363
+ private def recovery_probe_successes_key(config, time:)
364
+ self.class.bucket_key(config.name, metric: "recovery_probe_success", time:)
120
365
  end
121
366
 
122
- # @param light [Stoplight::Light]
123
- # @return [Array, nil]
124
- def last_notification(light)
125
- @redis.get(last_notification_key(light))&.split('->')
367
+ private def recovery_probe_errors_key(config, time:)
368
+ self.class.bucket_key(config.name, metric: "recovery_probe_failure", time:)
126
369
  end
127
370
 
128
- # @param light [Stoplight::Light]
129
- # @param from_color [String]
130
- # @param to_color [String]
131
- # @return [void]
132
- def set_last_notification(light, from_color, to_color)
133
- @redis.set(last_notification_key(light), [from_color, to_color].join('->'))
371
+ private def metadata_key(config)
372
+ key("metadata", config.name)
134
373
  end
135
374
 
136
- def query_failures(light, transaction: @redis)
137
- window_start = Time.now.to_i - light.window_size
375
+ METRICS_TTL = 86400 # 1 day
376
+ private_constant :METRICS_TTL
138
377
 
139
- transaction.zrange(failures_key(light), Float::INFINITY, window_start, rev: true, by_score: true)
378
+ private def metrics_ttl
379
+ METRICS_TTL
140
380
  end
141
381
 
142
- def normalize_failures(failures, error_notifier)
143
- failures.map do |json|
144
- Failure.from_json(json)
145
- rescue StandardError => e
146
- error_notifier.call(e)
147
- Failure.from_error(e)
382
+ METADATA_TTL = 86400 * 7 # 7 days
383
+ private_constant :METADATA_TTL
384
+
385
+ private def metadata_ttl
386
+ METADATA_TTL
387
+ end
388
+
389
+ SKEW_TOLERANCE = 5 # seconds
390
+ private_constant :SKEW_TOLERANCE
391
+
392
+ private def detect_clock_skew
393
+ return unless @warn_on_clock_skew
394
+ return unless should_sample?(0.01) # 1% chance
395
+
396
+ redis_seconds, _redis_millis = @redis.then(&:time)
397
+ app_seconds = Time.now.to_i
398
+ if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
399
+ warn("Detected clock skew between Redis and the application server. Redis time: #{redis_seconds}, Application time: #{app_seconds}. See https://github.com/bolshakov/stoplight/wiki/Clock-Skew-and-Stoplight-Reliability")
148
400
  end
149
401
  end
150
402
 
151
- def query_state(light, transaction: @redis)
152
- transaction.hget(states_key, light.name)
403
+ private def should_sample?(probability)
404
+ rand <= probability
153
405
  end
154
406
 
155
- def normalize_state(state)
156
- state || State::UNLOCKED
407
+ private def record_success_sha
408
+ @record_success_sha ||= @redis.then do |client|
409
+ client.script("load", Lua::RECORD_SUCCESS)
410
+ end
157
411
  end
158
412
 
159
- # We store a list of failures happened in the +light+ in this key
160
- #
161
- # @param light [Stoplight::Light]
162
- # @return [String]
163
- def failures_key(light)
164
- key('failures', light.name)
413
+ private def get_metadata_sha
414
+ @get_metadata_sha ||= @redis.then do |client|
415
+ client.script("load", Lua::GET_METADATA)
416
+ end
165
417
  end
166
418
 
167
- def notification_lock_key(light)
168
- key('notification_lock', light.name)
419
+ private def transition_to_yellow_sha
420
+ @transition_to_yellow_sha ||= @redis.then do |client|
421
+ client.script("load", Lua::TRANSITION_TO_YELLOW)
422
+ end
169
423
  end
170
424
 
171
- def last_notification_key(light)
172
- key('last_notification', light.name)
425
+ private def transition_to_red_sha
426
+ @transition_to_red_sha ||= @redis.then do |client|
427
+ client.script("load", Lua::TRANSITION_TO_RED)
428
+ end
173
429
  end
174
430
 
175
- def states_key
176
- key('states')
431
+ private def transition_to_green_sha
432
+ @transition_to_green_sha ||= @redis.then do |client|
433
+ client.script("load", Lua::TRANSITION_TO_GREEN)
434
+ end
177
435
  end
178
436
 
179
- def key(*pieces)
180
- ([KEY_PREFIX] + pieces).join(KEY_SEPARATOR)
437
+ private def record_failure_sha
438
+ @record_failure_sha ||= @redis.then do |client|
439
+ client.script("load", Lua::RECORD_FAILURE)
440
+ end
181
441
  end
182
442
  end
183
443
  end
@@ -6,24 +6,26 @@ module Stoplight
6
6
 
7
7
  DATA_STORE = DataStore::Memory.new
8
8
 
9
- ERROR_HANDLER = ->(error, handler) { handler.call(error) }
10
-
11
9
  ERROR_NOTIFIER = ->(error) { warn error }
12
10
 
13
- FALLBACK = nil
14
-
15
11
  FORMATTER = lambda do |light, from_color, to_color, error|
16
- words = ['Switching', light.name, 'from', from_color, 'to', to_color]
17
- words += ['because', error.class, error.message] if error
18
- words.join(' ')
12
+ words = ["Switching", light.name, "from", from_color, "to", to_color]
13
+ words += ["because", error.class, error.message] if error
14
+ words.join(" ")
19
15
  end
20
16
 
21
17
  NOTIFIERS = [
22
- Notifier::IO.new($stderr)
18
+ Notifier::FailSafe.wrap(Notifier::IO.new($stderr))
23
19
  ].freeze
24
20
 
25
21
  THRESHOLD = 3
26
22
 
27
- WINDOW_SIZE = Float::INFINITY
23
+ WINDOW_SIZE = nil
24
+
25
+ TRACKED_ERRORS = [StandardError].freeze
26
+ SKIPPED_ERRORS = [].freeze
27
+
28
+ TRAFFIC_CONTROL = TrafficControl::ConsecutiveFailures.new
29
+ TRAFFIC_RECOVERY = TrafficRecovery::SingleSuccess.new
28
30
  end
29
31
  end
@@ -2,20 +2,8 @@
2
2
 
3
3
  module Stoplight
4
4
  module Error
5
- HANDLER = lambda do |error|
6
- raise error if AVOID_RESCUING.any? { |klass| error.is_a?(klass) }
7
- end
8
-
9
- AVOID_RESCUING = [
10
- NoMemoryError,
11
- ScriptError,
12
- SecurityError,
13
- SignalException,
14
- SystemExit,
15
- SystemStackError
16
- ].freeze
17
-
18
5
  Base = Class.new(StandardError)
6
+ ConfigurationError = Class.new(Base)
19
7
  IncorrectColor = Class.new(Base)
20
8
  RedLight = Class.new(Base)
21
9
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'time'
3
+ require "json"
4
+ require "time"
5
5
 
6
6
  module Stoplight
7
7
  class Failure # rubocop:disable Style/Documentation
8
- TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%N%:z'
8
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%N%:z"
9
9
 
10
10
  # @return [String]
11
11
  attr_reader :error_class
@@ -16,8 +16,8 @@ module Stoplight
16
16
 
17
17
  # @param error [Exception]
18
18
  # @return (see #initialize)
19
- def self.from_error(error)
20
- new(error.class.name, error.message, Time.now)
19
+ def self.from_error(error, time: Time.now)
20
+ new(error.class.name, error.message, time)
21
21
  end
22
22
 
23
23
  # @param json [String]
@@ -26,11 +26,11 @@ module Stoplight
26
26
  # @raise [ArgumentError]
27
27
  def self.from_json(json)
28
28
  object = JSON.parse(json)
29
- error_object = object['error']
29
+ error_object = object["error"]
30
30
 
31
- error_class = error_object['class']
32
- error_message = error_object['message']
33
- time = Time.parse(object['time'])
31
+ error_class = error_object["class"]
32
+ error_message = error_object["message"]
33
+ time = Time.at(object["time"])
34
34
 
35
35
  new(error_class, error_message, time)
36
36
  end
@@ -41,15 +41,16 @@ module Stoplight
41
41
  def initialize(error_class, error_message, time)
42
42
  @error_class = error_class
43
43
  @error_message = error_message
44
- @time = time
44
+ @time = Time.at(time.to_i) # truncate to seconds
45
45
  end
46
46
 
47
47
  # @param other [Failure]
48
48
  # @return [Boolean]
49
49
  def ==(other)
50
- error_class == other.error_class &&
50
+ other.is_a?(self.class) &&
51
+ error_class == other.error_class &&
51
52
  error_message == other.error_message &&
52
- time == other.time
53
+ time.to_i == other.time.to_i
53
54
  end
54
55
 
55
56
  # @param options [Object, nil]
@@ -61,7 +62,7 @@ module Stoplight
61
62
  class: error_class,
62
63
  message: error_message
63
64
  },
64
- time: time.strftime(TIME_FORMAT)
65
+ time: time.to_i
65
66
  },
66
67
  options
67
68
  )