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,93 +1,300 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'monitor'
3
+ require "monitor"
4
4
 
5
5
  module Stoplight
6
6
  module DataStore
7
7
  # @see Base
8
8
  class Memory < Base
9
9
  include MonitorMixin
10
- KEY_SEPARATOR = ':'
10
+ KEY_SEPARATOR = ":"
11
11
 
12
12
  def initialize
13
- @failures = Hash.new { |h, k| h[k] = [] }
14
- @states = Hash.new { |h, k| h[k] = State::UNLOCKED }
15
- @last_notifications = {}
16
- super() # MonitorMixin
13
+ @errors = Hash.new { |h, k| h[k] = [] }
14
+ @successes = Hash.new { |h, k| h[k] = [] }
15
+
16
+ @recovery_probe_errors = Hash.new { |h, k| h[k] = [] }
17
+ @recovery_probe_successes = Hash.new { |h, k| h[k] = [] }
18
+
19
+ @metadata = Hash.new { |h, k| h[k] = Metadata.new }
20
+ super # MonitorMixin
17
21
  end
18
22
 
23
+ # @return [Array<String>]
19
24
  def names
20
- synchronize { @failures.keys | @states.keys }
25
+ synchronize { @metadata.keys }
21
26
  end
22
27
 
23
- def get_all(light)
24
- synchronize { [query_failures(light), @states[light.name]] }
28
+ # @param config [Stoplight::Light::Config]
29
+ # @return [Stoplight::Metadata]
30
+ def get_metadata(config)
31
+ light_name = config.name
32
+ window_end = Time.now
33
+ recovery_window = (window_end - config.cool_off_time + 1)..window_end
34
+
35
+ synchronize do
36
+ recovered_at = @metadata[light_name].recovered_at
37
+ window = if config.window_size
38
+ window_start = [recovered_at, (window_end - config.window_size + 1)].compact.max
39
+ (window_start..window_end)
40
+ else
41
+ (..window_end)
42
+ end
43
+
44
+ errors = @errors[config.name].count do |request_time|
45
+ window.cover?(request_time)
46
+ end
47
+
48
+ successes = @successes[config.name].count do |request_time|
49
+ window.cover?(request_time)
50
+ end
51
+
52
+ recovery_probe_errors = @recovery_probe_errors[config.name].count do |request_time|
53
+ recovery_window.cover?(request_time)
54
+ end
55
+ recovery_probe_successes = @recovery_probe_successes[config.name].count do |request_time|
56
+ recovery_window.cover?(request_time)
57
+ end
58
+
59
+ @metadata[light_name].with(
60
+ errors:,
61
+ successes:,
62
+ recovery_probe_errors:,
63
+ recovery_probe_successes:
64
+ )
65
+ end
25
66
  end
26
67
 
27
- def get_failures(light)
28
- synchronize { query_failures(light) }
68
+ # @param metrics [<Time>]
69
+ # @param window_size [Numeric, nil]
70
+ # @return [void]
71
+ def cleanup(metrics, window_size:)
72
+ min_age = Time.now - [window_size&.*(3), METRICS_RETENTION_TIME].compact.min
73
+
74
+ metrics.reject! { _1 < min_age }
29
75
  end
30
76
 
31
- def record_failure(light, failure)
77
+ # @param config [Stoplight::Light::Config]
78
+ # @param failure [Stoplight::Failure]
79
+ # @return [Stoplight::Metadata]
80
+ def record_failure(config, failure)
81
+ light_name = config.name
82
+
32
83
  synchronize do
33
- light_name = light.name
34
-
35
- # Keep at most +light.threshold+ number of errors
36
- @failures[light_name] = @failures[light_name].first(light.threshold - 1)
37
- @failures[light_name].unshift(failure)
38
- # Remove all errors happened before the window start
39
- @failures[light_name] = query_failures(light, failure.time)
40
- @failures[light_name].size
84
+ @errors[light_name].unshift(failure.time) if config.window_size
85
+
86
+ cleanup(@errors[light_name], window_size: config.window_size)
87
+
88
+ metadata = @metadata[light_name]
89
+ @metadata[light_name] = if metadata.last_error_at.nil? || failure.time > metadata.last_error_at
90
+ metadata.with(
91
+ last_error_at: failure.time,
92
+ last_error: failure,
93
+ consecutive_errors: metadata.consecutive_errors.succ,
94
+ consecutive_successes: 0
95
+ )
96
+ else
97
+ metadata.with(
98
+ consecutive_errors: metadata.consecutive_errors.succ,
99
+ consecutive_successes: 0
100
+ )
101
+ end
102
+ get_metadata(config)
41
103
  end
42
104
  end
43
105
 
44
- def clear_failures(light)
45
- synchronize { @failures.delete(light.name) }
46
- end
106
+ # @param config [Stoplight::Light::Config]
107
+ # @param request_id [String]
108
+ # @param request_time [Time]
109
+ # @return [void]
110
+ def record_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
111
+ light_name = config.name
47
112
 
48
- def get_state(light)
49
- synchronize { @states[light.name] }
50
- end
113
+ synchronize do
114
+ @successes[light_name].unshift(request_time) if config.window_size
115
+ cleanup(@successes[light_name], window_size: config.window_size)
51
116
 
52
- def set_state(light, state)
53
- synchronize { @states[light.name] = state }
117
+ metadata = @metadata[light_name]
118
+ @metadata[light_name] = if metadata.last_success_at.nil? || request_time > metadata.last_success_at
119
+ metadata.with(
120
+ last_success_at: request_time,
121
+ consecutive_errors: 0,
122
+ consecutive_successes: metadata.consecutive_successes.succ
123
+ )
124
+ else
125
+ metadata.with(
126
+ consecutive_errors: 0,
127
+ consecutive_successes: metadata.consecutive_successes.succ
128
+ )
129
+ end
130
+ end
54
131
  end
55
132
 
56
- def clear_state(light)
57
- synchronize { @states.delete(light.name) }
133
+ # @param config [Stoplight::Light::Config]
134
+ # @param failure [Stoplight::Failure]
135
+ # @return [Stoplight::Metadata]
136
+ def record_recovery_probe_failure(config, failure)
137
+ light_name = config.name
138
+
139
+ synchronize do
140
+ @recovery_probe_errors[light_name].unshift(failure.time)
141
+ cleanup(@recovery_probe_errors[light_name], window_size: config.cool_off_time)
142
+
143
+ metadata = @metadata[light_name]
144
+ @metadata[light_name] = if metadata.last_error_at.nil? || failure.time > metadata.last_error_at
145
+ metadata.with(
146
+ last_error_at: failure.time,
147
+ last_error: failure,
148
+ consecutive_errors: metadata.consecutive_errors.succ,
149
+ consecutive_successes: 0
150
+ )
151
+ else
152
+ metadata.with(
153
+ consecutive_errors: metadata.consecutive_errors.succ,
154
+ consecutive_successes: 0
155
+ )
156
+ end
157
+ get_metadata(config)
158
+ end
58
159
  end
59
160
 
60
- def with_notification_lock(light, from_color, to_color)
161
+ # @param config [Stoplight::Light::Config]
162
+ # @param request_id [String]
163
+ # @param request_time [Time]
164
+ # @return [Stoplight::Metadata]
165
+ def record_recovery_probe_success(config, request_time: Time.now, request_id: SecureRandom.hex(12))
166
+ light_name = config.name
167
+
61
168
  synchronize do
62
- if last_notification(light) != [from_color, to_color]
63
- set_last_notification(light, from_color, to_color)
169
+ @recovery_probe_successes[light_name].unshift(request_time)
170
+ cleanup(@recovery_probe_successes[light_name], window_size: config.cool_off_time)
64
171
 
65
- yield
172
+ metadata = @metadata[light_name]
173
+ recovery_started_at = metadata.recovery_started_at || request_time
174
+ @metadata[light_name] = if metadata.last_success_at.nil? || request_time > metadata.last_success_at
175
+ metadata.with(
176
+ last_success_at: request_time,
177
+ recovery_started_at:,
178
+ consecutive_errors: 0,
179
+ consecutive_successes: metadata.consecutive_successes.succ
180
+ )
181
+ else
182
+ metadata.with(
183
+ recovery_started_at:,
184
+ consecutive_errors: 0,
185
+ consecutive_successes: metadata.consecutive_successes.succ
186
+ )
66
187
  end
188
+ get_metadata(config)
67
189
  end
68
190
  end
69
191
 
70
- private
192
+ # @param config [Stoplight::Light::Config]
193
+ # @param state [String]
194
+ # @return [String]
195
+ def set_state(config, state)
196
+ light_name = config.name
71
197
 
72
- # @param light [Stoplight::Light]
73
- # @return [Array, nil]
74
- def last_notification(light)
75
- @last_notifications[light.name]
198
+ synchronize do
199
+ metadata = @metadata[light_name]
200
+ @metadata[light_name] = metadata.with(locked_state: state)
201
+ end
202
+ state
76
203
  end
77
204
 
78
- # @param light [Stoplight::Light]
79
- # @param from_color [String]
80
- # @param to_color [String]
81
- # @return [void]
82
- def set_last_notification(light, from_color, to_color)
83
- @last_notifications[light.name] = [from_color, to_color]
205
+ # Combined method that performs the state transition based on color
206
+ #
207
+ # @param config [Stoplight::Light::Config] The light configuration
208
+ # @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
209
+ # @param current_time [Time]
210
+ # @return [Boolean] true if this is the first instance to detect this transition
211
+ def transition_to_color(config, color, current_time: Time.now)
212
+ case color
213
+ when Color::GREEN
214
+ transition_to_green(config)
215
+ when Color::YELLOW
216
+ transition_to_yellow(config, current_time:)
217
+ when Color::RED
218
+ transition_to_red(config, current_time:)
219
+ else
220
+ raise ArgumentError, "Invalid color: #{color}"
221
+ end
84
222
  end
85
223
 
86
- # @param light [Stoplight::Light]
87
- # @return [<Stoplight::Failure>]
88
- def query_failures(light, time = Time.now)
89
- @failures[light.name].select do |failure|
90
- failure.time.to_i > time.to_i - light.window_size
224
+ # Transitions to GREEN state and ensures only one notification
225
+ #
226
+ # @param config [Stoplight::Light::Config] The light configuration
227
+ # @return [Boolean] true if this is the first instance to detect this transition
228
+ private def transition_to_green(config, current_time: Time.now)
229
+ light_name = config.name
230
+
231
+ synchronize do
232
+ metadata = @metadata[light_name]
233
+ if metadata.recovered_at
234
+ false
235
+ else
236
+ @metadata[light_name] = metadata.with(
237
+ recovered_at: current_time,
238
+ recovery_started_at: nil,
239
+ breached_at: nil,
240
+ recovery_scheduled_after: nil
241
+ )
242
+ true
243
+ end
244
+ end
245
+ end
246
+
247
+ # Transitions to YELLOW (recovery) state and ensures only one notification
248
+ #
249
+ # @param config [Stoplight::Light::Config] The light configuration
250
+ # @param current_time [Time]
251
+ # @return [Boolean] true if this is the first instance to detect this transition
252
+ private def transition_to_yellow(config, current_time: Time.now)
253
+ light_name = config.name
254
+
255
+ synchronize do
256
+ metadata = @metadata[light_name]
257
+ if metadata.recovery_started_at
258
+ false
259
+ else
260
+ @metadata[light_name] = metadata.with(
261
+ recovery_started_at: current_time,
262
+ recovery_scheduled_after: nil,
263
+ recovered_at: nil,
264
+ breached_at: nil
265
+ )
266
+ true
267
+ end
268
+ end
269
+ end
270
+
271
+ # Transitions to RED state and ensures only one notification
272
+ #
273
+ # @param config [Stoplight::Light::Config] The light configuration
274
+ # @param current_time [Time]
275
+ # @return [Boolean] true if this is the first instance to detect this transition
276
+ private def transition_to_red(config, current_time: Time.now)
277
+ light_name = config.name
278
+ recovery_scheduled_after = current_time + config.cool_off_time
279
+
280
+ synchronize do
281
+ metadata = @metadata[light_name]
282
+ if metadata.breached_at
283
+ @metadata[light_name] = metadata.with(
284
+ recovery_scheduled_after: recovery_scheduled_after,
285
+ recovery_started_at: nil,
286
+ recovered_at: nil
287
+ )
288
+ false
289
+ else
290
+ @metadata[light_name] = metadata.with(
291
+ breached_at: current_time,
292
+ recovery_scheduled_after: recovery_scheduled_after,
293
+ recovery_started_at: nil,
294
+ recovered_at: nil
295
+ )
296
+ true
297
+ end
91
298
  end
92
299
  end
93
300
  end
@@ -0,0 +1,38 @@
1
+ local number_of_metric_buckets = tonumber(ARGV[1])
2
+ local number_of_recovery_buckets = tonumber(ARGV[2])
3
+ local window_start_ts = tonumber(ARGV[3])
4
+ local window_end_ts = tonumber(ARGV[4])
5
+ local recovery_window_start_ts = tonumber(ARGV[5])
6
+
7
+ local metadata_key = KEYS[1]
8
+
9
+ -- It possible that after a successful recovery, Stoplight still see metrics
10
+ -- that are older than the recovery window. To prevent this from happening,
11
+ -- we need to limit the start time of the window to the time of the last recovery.
12
+ local recovered_at = redis.call('HGET', metadata_key, "recovered_at")
13
+ if recovered_at then
14
+ window_start_ts = math.max(window_start_ts, recovered_at)
15
+ end
16
+
17
+ local function count_events(start_idx, bucket_count, start_ts)
18
+ local total = 0
19
+ for idx = start_idx, start_idx + bucket_count - 1 do
20
+ total = total + tonumber(redis.call('ZCOUNT', KEYS[idx], start_ts, window_end_ts))
21
+ end
22
+ return total
23
+ end
24
+
25
+ local offset = 2
26
+ local successes = count_events(2, number_of_metric_buckets, window_start_ts)
27
+
28
+ offset = offset + number_of_metric_buckets
29
+ local errors = count_events(offset, number_of_metric_buckets, window_start_ts)
30
+
31
+ offset = offset + number_of_metric_buckets
32
+ local recovery_probe_successes = count_events(offset, number_of_recovery_buckets, recovery_window_start_ts)
33
+
34
+ offset = offset + number_of_recovery_buckets
35
+ local recovery_probe_errors = count_events(offset, number_of_recovery_buckets, recovery_window_start_ts)
36
+
37
+ local metadata = redis.call('HGETALL', metadata_key)
38
+ return {successes, errors, recovery_probe_successes, recovery_probe_errors, metadata}
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module DataStore
5
+ class Redis
6
+ # @api private
7
+ module Lua
8
+ class << self
9
+ def read_lua_file(name_without_extension)
10
+ File.read(File.join(__dir__, "#{name_without_extension}.lua"))
11
+ end
12
+ end
13
+
14
+ RECORD_FAILURE = read_lua_file("record_failure")
15
+ RECORD_SUCCESS = read_lua_file("record_success")
16
+ GET_METADATA = read_lua_file("get_metadata")
17
+ TRANSITION_TO_YELLOW = read_lua_file("transition_to_yellow")
18
+ TRANSITION_TO_RED = read_lua_file("transition_to_red")
19
+ TRANSITION_TO_GREEN = read_lua_file("transition_to_green")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -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 metadata_key = KEYS[1]
8
+ local errors_key = KEYS[2]
9
+
10
+ -- Record failure
11
+ if errors_key ~= nil then
12
+ redis.call('ZADD', errors_key, failure_ts, failure_id)
13
+ redis.call('EXPIRE', errors_key, bucket_ttl) -- Not supported in Redis 6.2:, 'NX')
14
+ end
15
+
16
+ -- Update metadata
17
+ local meta = redis.call('HMGET', metadata_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', metadata_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', metadata_key,
32
+ 'consecutive_errors', (prev_consecutive_errors or 0) + 1,
33
+ 'consecutive_successes', 0
34
+ )
35
+ end
36
+ redis.call('EXPIRE', metadata_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 metadata_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', metadata_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', metadata_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', metadata_key,
30
+ 'consecutive_errors', 0,
31
+ 'consecutive_successes', (prev_consecutive_successes or 0) + 1
32
+ )
33
+ end
34
+
35
+ redis.call('EXPIRE', metadata_key, metadata_ttl) -- Not supported in Redis 6.2:, 'GT')
@@ -0,0 +1,10 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+
4
+ -- 1 if the field is a new field in the hash and the value was set
5
+ local became_green = redis.call('HSETNX', meta_key, 'recovered_at', current_ts)
6
+
7
+ if became_green == 1 then
8
+ redis.call("HDEL", meta_key, 'recovery_started_at', 'recovery_scheduled_after', 'breached_at')
9
+ end
10
+ return became_green
@@ -0,0 +1,10 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+ local recovery_scheduled_after_ts = tonumber(ARGV[2])
4
+
5
+ -- 1 if the field is a new field in the hash and the value was set
6
+ local became_red = redis.call('HSETNX', meta_key, 'breached_at', current_ts)
7
+
8
+ redis.call('HSET', meta_key, 'recovery_scheduled_after', recovery_scheduled_after_ts)
9
+ redis.call("HDEL", meta_key, "recovery_started_at", "recovered_at")
10
+ return became_red
@@ -0,0 +1,9 @@
1
+ local meta_key = KEYS[1]
2
+ local current_ts = tonumber(ARGV[1])
3
+
4
+ -- HSETNX returns 1 if field is new and was set, 0 if field already exists
5
+ local became_yellow = redis.call('HSETNX', meta_key, 'recovery_started_at', current_ts)
6
+ if became_yellow == 1 then
7
+ redis.call('HDEL', meta_key, 'recovery_scheduled_after', 'breached_at', 'recovered_at')
8
+ end
9
+ return became_yellow