stoplight 4.1.0 → 5.0.1

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 +289 -350
  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 +66 -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 +105 -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 +345 -106
  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 -98
  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 -18
  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,411 @@ 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)
22
-
23
- # @param redis [::Redis]
24
- def initialize(redis, redlock: Redlock::Client.new([redis]))
25
- @redis = redis
26
- @redlock = redlock
27
- end
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
28
31
 
29
- def names
30
- state_names = @redis.hkeys(states_key)
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
31
54
 
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, '')
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)
36
63
  end
37
64
 
38
- (state_names + failure_names).uniq
39
- end
65
+ BUCKET_SIZE = 3600 # 1h
66
+ private_constant :BUCKET_SIZE
40
67
 
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)
68
+ private def bucket_size
69
+ BUCKET_SIZE
45
70
  end
71
+ end
46
72
 
47
- [
48
- normalize_failures(failures, light.error_notifier),
49
- normalize_state(state)
50
- ]
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
81
+ @redis = redis
82
+ @redis.then do |client|
83
+ @record_failure_sha,
84
+ @record_success_sha,
85
+ @get_metadata_sha,
86
+ @transition_to_yellow_sha,
87
+ @transition_to_red_sha,
88
+ @transition_to_green_sha = client.pipelined do |pipeline|
89
+ pipeline.script("load", Lua::RECORD_FAILURE)
90
+ pipeline.script("load", Lua::RECORD_SUCCESS)
91
+ pipeline.script("load", Lua::GET_METADATA)
92
+ pipeline.script("load", Lua::TRANSITION_TO_YELLOW)
93
+ pipeline.script("load", Lua::TRANSITION_TO_RED)
94
+ pipeline.script("load", Lua::TRANSITION_TO_GREEN)
95
+ end
96
+ end
51
97
  end
52
98
 
53
- def get_failures(light)
54
- normalize_failures(query_failures(light), light.error_notifier)
99
+ def names
100
+ pattern = key("metadata", "*")
101
+ prefix_regex = /^#{key("metadata", "")}/
102
+ @redis.then do |client|
103
+ client.scan_each(match: pattern).to_a.map do |key|
104
+ key.sub(prefix_regex, "")
105
+ end
106
+ end
55
107
  end
56
108
 
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)
109
+ def get_metadata(config)
110
+ detect_clock_skew
111
+
112
+ window_end = Time.now
113
+ window_end_ts = window_end.to_i
114
+ window_start_ts = window_end_ts - [config.window_size, Base::METRICS_RETENTION_TIME].compact.min.to_i
115
+ recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
61
116
 
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)
117
+ if config.window_size
118
+ failure_keys = failure_bucket_keys(config, window_end: window_end_ts)
119
+ success_keys = success_bucket_keys(config, window_end: window_end_ts)
120
+ else
121
+ failure_keys = []
122
+ success_keys = []
123
+ end
124
+ recovery_probe_failure_keys = recovery_probe_failure_bucket_keys(config, window_end: window_end_ts)
125
+ recovery_probe_success_keys = recovery_probe_success_bucket_keys(config, window_end: window_end_ts)
126
+
127
+ successes, errors, recovery_probe_successes, recovery_probe_errors, meta = @redis.with do |client|
128
+ client.evalsha(
129
+ @get_metadata_sha,
130
+ argv: [
131
+ failure_keys.count,
132
+ recovery_probe_failure_keys.count,
133
+ window_start_ts,
134
+ window_end_ts,
135
+ recovery_window_start_ts
136
+ ],
137
+ keys: [
138
+ metadata_key(config),
139
+ *success_keys,
140
+ *failure_keys,
141
+ *recovery_probe_success_keys,
142
+ *recovery_probe_failure_keys
143
+ ]
144
+ )
65
145
  end
146
+ meta_hash = meta.each_slice(2).to_h.transform_keys(&:to_sym)
147
+ last_error_json = meta_hash.delete(:last_error_json)
148
+ last_error = normalize_failure(last_error_json, config.error_notifier) if last_error_json
149
+
150
+ Metadata.new(
151
+ successes: successes,
152
+ errors: errors,
153
+ recovery_probe_successes: recovery_probe_successes,
154
+ recovery_probe_errors: recovery_probe_errors,
155
+ last_error:,
156
+ **meta_hash
157
+ )
158
+ end
66
159
 
67
- size
160
+ # @param config [Stoplight::Light::Config] The light configuration.
161
+ # @param failure [Stoplight::Failure] The failure to record.
162
+ # @return [Stoplight::Metadata] The updated metadata after recording the failure.
163
+ def record_failure(config, failure)
164
+ current_ts = failure.time.to_i
165
+ failure_json = failure.to_json
166
+
167
+ @redis.then do |client|
168
+ client.evalsha(
169
+ @record_failure_sha,
170
+ argv: [current_ts, SecureRandom.hex(12), failure_json, metrics_ttl, metadata_ttl],
171
+ keys: [
172
+ metadata_key(config),
173
+ config.window_size && errors_key(config, time: current_ts)
174
+ ].compact
175
+ )
176
+ end
177
+ get_metadata(config)
68
178
  end
69
179
 
70
- def clear_failures(light)
71
- failures, = @redis.multi do |transaction|
72
- query_failures(light, transaction: transaction)
73
- transaction.del(failures_key(light))
180
+ def record_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
181
+ request_ts = request_time.to_i
182
+
183
+ @redis.then do |client|
184
+ client.evalsha(
185
+ @record_success_sha,
186
+ argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
187
+ keys: [
188
+ metadata_key(config),
189
+ config.window_size && successes_key(config, time: request_ts)
190
+ ].compact
191
+ )
74
192
  end
193
+ end
75
194
 
76
- normalize_failures(failures, light.error_notifier)
195
+ # Records a failed recovery probe for a specific light configuration.
196
+ #
197
+ # @param config [Stoplight::Light::Config] The light configuration.
198
+ # @param failure [Failure] The failure to record.
199
+ # @return [Stoplight::Metadata] The updated metadata after recording the failure.
200
+ def record_recovery_probe_failure(config, failure)
201
+ current_ts = failure.time.to_i
202
+ failure_json = failure.to_json
203
+
204
+ @redis.then do |client|
205
+ client.evalsha(
206
+ @record_failure_sha,
207
+ argv: [current_ts, SecureRandom.uuid, failure_json, metrics_ttl, metrics_ttl],
208
+ keys: [
209
+ metadata_key(config),
210
+ recovery_probe_errors_key(config, time: current_ts)
211
+ ].compact
212
+ )
213
+ end
214
+ get_metadata(config)
77
215
  end
78
216
 
79
- def get_state(light)
80
- query_state(light) || State::UNLOCKED
217
+ # Records a successful recovery probe for a specific light configuration.
218
+ #
219
+ # @param config [Stoplight::Light::Config] The light configuration.
220
+ # @param request_id [String] The unique identifier for the request
221
+ # @param request_time [Time] The time of the request
222
+ # @return [Stoplight::Metadata] The updated metadata after recording the success.
223
+ def record_recovery_probe_success(config, request_id: SecureRandom.hex(12), request_time: Time.now)
224
+ request_ts = request_time.to_i
225
+
226
+ @redis.then do |client|
227
+ client.evalsha(
228
+ @record_success_sha,
229
+ argv: [request_ts, request_id, metrics_ttl, metadata_ttl],
230
+ keys: [
231
+ metadata_key(config),
232
+ recovery_probe_successes_key(config, time: request_ts)
233
+ ].compact
234
+ )
235
+ end
236
+ get_metadata(config)
81
237
  end
82
238
 
83
- def set_state(light, state)
84
- @redis.hset(states_key, light.name, state)
239
+ def set_state(config, state)
240
+ @redis.then do |client|
241
+ client.hset(metadata_key(config), "locked_state", state)
242
+ end
85
243
  state
86
244
  end
87
245
 
88
- def clear_state(light)
89
- state, = @redis.multi do |transaction|
90
- query_state(light, transaction: transaction)
91
- transaction.hdel(states_key, light.name)
246
+ # Combined method that performs the state transition based on color
247
+ #
248
+ # @param config [Stoplight::Light::Config] The light configuration
249
+ # @param color [String] The color to transition to ("green", "yellow", or "red")
250
+ # @param current_time [Time] Current timestamp
251
+ # @return [Boolean] true if this is the first instance to detect this transition
252
+ def transition_to_color(config, color, current_time: Time.now)
253
+ current_time.to_i
254
+
255
+ case color
256
+ when Color::GREEN
257
+ transition_to_green(config)
258
+ when Color::YELLOW
259
+ transition_to_yellow(config, current_time:)
260
+ when Color::RED
261
+ transition_to_red(config, current_time:)
262
+ else
263
+ raise ArgumentError, "Invalid color: #{color}"
92
264
  end
93
-
94
- normalize_state(state)
95
265
  end
96
266
 
97
- LOCK_TTL = 2_000 # milliseconds
267
+ # Transitions to GREEN state and ensures only one notification
268
+ #
269
+ # @param config [Stoplight::Light::Config] The light configuration
270
+ # @return [Boolean] true if this is the first instance to detect this transition
271
+ private def transition_to_green(config, current_time: Time.now)
272
+ current_ts = current_time.to_i
273
+ meta_key = metadata_key(config)
274
+
275
+ became_green = @redis.then do |client|
276
+ client.evalsha(
277
+ @transition_to_green_sha,
278
+ argv: [current_ts],
279
+ keys: [meta_key]
280
+ )
281
+ end
282
+ became_green == 1
283
+ end
98
284
 
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)
285
+ # Transitions to YELLOW (recovery) state and ensures only one notification
286
+ #
287
+ # @param config [Stoplight::Light::Config] The light configuration
288
+ # @param current_time [Time] Current timestamp
289
+ # @return [Boolean] true if this is the first instance to detect this transition
290
+ private def transition_to_yellow(config, current_time: Time.now)
291
+ current_ts = current_time.to_i
292
+ meta_key = metadata_key(config)
293
+
294
+ became_yellow = @redis.then do |client|
295
+ client.evalsha(
296
+ @transition_to_yellow_sha,
297
+ argv: [current_ts],
298
+ keys: [meta_key]
299
+ )
300
+ end
301
+ became_yellow == 1
302
+ end
103
303
 
104
- yield
105
- end
304
+ # Transitions to RED state and ensures only one notification
305
+ #
306
+ # @param config [Stoplight::Light::Config] The light configuration
307
+ # @param current_time [Time] Current timestamp
308
+ # @return [Boolean] true if this is the first instance to detect this transition
309
+ private def transition_to_red(config, current_time: Time.now)
310
+ current_ts = current_time.to_i
311
+ meta_key = metadata_key(config)
312
+ recovery_scheduled_after_ts = current_ts + config.cool_off_time
313
+
314
+ became_red = @redis.then do |client|
315
+ client.evalsha(
316
+ @transition_to_red_sha,
317
+ argv: [current_ts, recovery_scheduled_after_ts],
318
+ keys: [meta_key]
319
+ )
106
320
  end
321
+
322
+ became_red == 1
107
323
  end
108
324
 
109
- private
325
+ private def normalize_failure(failure, error_notifier)
326
+ Failure.from_json(failure)
327
+ rescue => e
328
+ error_notifier.call(e)
329
+ Failure.from_error(e)
330
+ end
110
331
 
111
- # @param light [Stoplight::Light]
112
- # @param time [Time]
113
- def remove_outdated_failures(light, time, transaction: @redis)
114
- failures_key = failures_key(light)
332
+ def_delegator "self.class", :key
115
333
 
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)
334
+ private def failure_bucket_keys(config, window_end:)
335
+ self.class.buckets_for_window(
336
+ config.name,
337
+ metric: "failure",
338
+ window_end: window_end,
339
+ window_size: config.window_size
340
+ )
120
341
  end
121
342
 
122
- # @param light [Stoplight::Light]
123
- # @return [Array, nil]
124
- def last_notification(light)
125
- @redis.get(last_notification_key(light))&.split('->')
343
+ private def success_bucket_keys(config, window_end:)
344
+ self.class.buckets_for_window(
345
+ config.name,
346
+ metric: "success",
347
+ window_end: window_end,
348
+ window_size: config.window_size
349
+ )
126
350
  end
127
351
 
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('->'))
352
+ private def recovery_probe_failure_bucket_keys(config, window_end:)
353
+ self.class.buckets_for_window(
354
+ config.name,
355
+ metric: "recovery_probe_failure",
356
+ window_end: window_end,
357
+ window_size: config.cool_off_time
358
+ )
134
359
  end
135
360
 
136
- def query_failures(light, transaction: @redis)
137
- window_start = Time.now.to_i - light.window_size
361
+ private def recovery_probe_success_bucket_keys(config, window_end:)
362
+ self.class.buckets_for_window(
363
+ config.name,
364
+ metric: "recovery_probe_success",
365
+ window_end: window_end,
366
+ window_size: config.cool_off_time
367
+ )
368
+ end
138
369
 
139
- transaction.zrange(failures_key(light), Float::INFINITY, window_start, rev: true, by_score: true)
370
+ private def successes_key(config, time:)
371
+ self.class.bucket_key(config.name, metric: "success", time:)
140
372
  end
141
373
 
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)
148
- end
374
+ private def errors_key(config, time:)
375
+ self.class.bucket_key(config.name, metric: "failure", time:)
149
376
  end
150
377
 
151
- def query_state(light, transaction: @redis)
152
- transaction.hget(states_key, light.name)
378
+ private def recovery_probe_successes_key(config, time:)
379
+ self.class.bucket_key(config.name, metric: "recovery_probe_success", time:)
153
380
  end
154
381
 
155
- def normalize_state(state)
156
- state || State::UNLOCKED
382
+ private def recovery_probe_errors_key(config, time:)
383
+ self.class.bucket_key(config.name, metric: "recovery_probe_failure", time:)
157
384
  end
158
385
 
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)
386
+ private def metadata_key(config)
387
+ key("metadata", config.name)
165
388
  end
166
389
 
167
- def notification_lock_key(light)
168
- key('notification_lock', light.name)
390
+ METRICS_TTL = 86400 # 1 day
391
+ private_constant :METRICS_TTL
392
+
393
+ private def metrics_ttl
394
+ METRICS_TTL
169
395
  end
170
396
 
171
- def last_notification_key(light)
172
- key('last_notification', light.name)
397
+ METADATA_TTL = 86400 * 7 # 7 days
398
+ private_constant :METADATA_TTL
399
+
400
+ private def metadata_ttl
401
+ METADATA_TTL
173
402
  end
174
403
 
175
- def states_key
176
- key('states')
404
+ SKEW_TOLERANCE = 5 # seconds
405
+ private_constant :SKEW_TOLERANCE
406
+
407
+ private def detect_clock_skew
408
+ return unless @warn_on_clock_skew
409
+ return unless should_sample?(0.01) # 1% chance
410
+
411
+ redis_seconds, _redis_millis = @redis.then(&:time)
412
+ app_seconds = Time.now.to_i
413
+ if (redis_seconds - app_seconds).abs > SKEW_TOLERANCE
414
+ 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")
415
+ end
177
416
  end
178
417
 
179
- def key(*pieces)
180
- ([KEY_PREFIX] + pieces).join(KEY_SEPARATOR)
418
+ private def should_sample?(probability)
419
+ rand <= probability
181
420
  end
182
421
  end
183
422
  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
  )