stoplight 5.3.8 → 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.
- checksums.yaml +4 -4
- data/README.md +17 -2
- data/lib/generators/stoplight/install/templates/stoplight.rb.erb +2 -0
- data/lib/stoplight/admin/actions/remove.rb +23 -0
- data/lib/stoplight/admin/dependencies.rb +6 -1
- data/lib/stoplight/admin/helpers.rb +10 -5
- data/lib/stoplight/admin/lights_repository.rb +26 -14
- data/lib/stoplight/admin/views/_card.erb +13 -1
- data/lib/stoplight/admin/views/layout.erb +3 -3
- data/lib/stoplight/admin.rb +13 -4
- data/lib/stoplight/common/deprecations.rb +11 -0
- data/lib/stoplight/domain/color.rb +11 -0
- data/lib/stoplight/{config → domain}/compatibility_result.rb +1 -1
- data/lib/stoplight/domain/config.rb +59 -0
- data/lib/stoplight/{data_store/base.rb → domain/data_store.rb} +71 -17
- data/lib/stoplight/domain/error.rb +42 -0
- data/lib/stoplight/domain/failure.rb +44 -0
- data/lib/stoplight/domain/light/configuration_builder_interface.rb +234 -0
- data/lib/stoplight/domain/light.rb +208 -0
- data/lib/stoplight/domain/light_factory.rb +75 -0
- data/lib/stoplight/domain/metrics.rb +64 -0
- data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
- data/lib/stoplight/domain/state.rb +11 -0
- data/lib/stoplight/domain/state_snapshot.rb +57 -0
- data/lib/stoplight/{notifier/base.rb → domain/state_transition_notifier.rb} +5 -4
- data/lib/stoplight/domain/storage/metrics.rb +42 -0
- data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
- data/lib/stoplight/domain/storage/state.rb +87 -0
- data/lib/stoplight/domain/strategies/green_run_strategy.rb +69 -0
- data/lib/stoplight/domain/strategies/red_run_strategy.rb +41 -0
- data/lib/stoplight/domain/strategies/run_strategy.rb +22 -0
- data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +125 -0
- data/lib/stoplight/domain/tracker/base.rb +12 -0
- data/lib/stoplight/domain/tracker/recovery_probe.rb +76 -0
- data/lib/stoplight/domain/tracker/request.rb +72 -0
- data/lib/stoplight/domain/traffic_control/base.rb +74 -0
- data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +53 -0
- data/lib/stoplight/domain/traffic_control/error_rate.rb +51 -0
- data/lib/stoplight/domain/traffic_recovery/base.rb +79 -0
- data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +66 -0
- data/lib/stoplight/domain/traffic_recovery.rb +12 -0
- data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
- data/lib/stoplight/infrastructure/data_store/memory/metrics.rb +27 -0
- data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
- data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
- data/lib/stoplight/infrastructure/data_store/memory/sliding_window.rb +79 -0
- data/lib/stoplight/infrastructure/data_store/memory/state.rb +21 -0
- data/lib/stoplight/infrastructure/data_store/memory.rb +338 -0
- data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/get_metrics.lua +26 -0
- data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
- data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
- data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
- data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
- data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
- data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
- data/lib/stoplight/infrastructure/data_store/redis.rb +524 -0
- data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
- data/lib/stoplight/infrastructure/notifier/generic.rb +90 -0
- data/lib/stoplight/infrastructure/notifier/io.rb +23 -0
- data/lib/stoplight/infrastructure/notifier/logger.rb +21 -0
- data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
- data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
- data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
- data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
- data/lib/stoplight/rspec/generic_notifier.rb +1 -1
- data/lib/stoplight/version.rb +1 -1
- data/lib/stoplight/wiring/data_store/base.rb +11 -0
- data/lib/stoplight/wiring/data_store/memory.rb +10 -0
- data/lib/stoplight/wiring/data_store/redis.rb +25 -0
- data/lib/stoplight/wiring/default.rb +28 -0
- data/lib/stoplight/{config/user_default_config.rb → wiring/default_configuration.rb} +24 -31
- data/lib/stoplight/wiring/default_factory_builder.rb +25 -0
- data/lib/stoplight/wiring/light/default_config.rb +18 -0
- data/lib/stoplight/wiring/light/system_config.rb +11 -0
- data/lib/stoplight/wiring/light_builder.rb +185 -0
- data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
- data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
- data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
- data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
- data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
- data/lib/stoplight/wiring/light_factory.rb +101 -0
- data/lib/stoplight/wiring/notifier_factory.rb +26 -0
- data/lib/stoplight/wiring/public_api.rb +29 -0
- data/lib/stoplight.rb +55 -30
- metadata +92 -42
- data/lib/stoplight/color.rb +0 -9
- data/lib/stoplight/config/dsl.rb +0 -97
- data/lib/stoplight/config/library_default_config.rb +0 -21
- data/lib/stoplight/config/system_config.rb +0 -7
- data/lib/stoplight/data_store/fail_safe.rb +0 -113
- data/lib/stoplight/data_store/memory.rb +0 -311
- data/lib/stoplight/data_store/redis/get_metadata.lua +0 -38
- data/lib/stoplight/data_store/redis/lua.rb +0 -23
- data/lib/stoplight/data_store/redis.rb +0 -449
- data/lib/stoplight/data_store.rb +0 -6
- data/lib/stoplight/default.rb +0 -30
- data/lib/stoplight/error.rb +0 -10
- data/lib/stoplight/failure.rb +0 -71
- data/lib/stoplight/light/config.rb +0 -111
- data/lib/stoplight/light/configuration_builder_interface.rb +0 -128
- data/lib/stoplight/light/green_run_strategy.rb +0 -54
- data/lib/stoplight/light/red_run_strategy.rb +0 -27
- data/lib/stoplight/light/run_strategy.rb +0 -32
- data/lib/stoplight/light/yellow_run_strategy.rb +0 -94
- data/lib/stoplight/light.rb +0 -191
- data/lib/stoplight/metadata.rb +0 -99
- data/lib/stoplight/notifier/fail_safe.rb +0 -70
- data/lib/stoplight/notifier/generic.rb +0 -79
- data/lib/stoplight/notifier/io.rb +0 -21
- data/lib/stoplight/notifier/logger.rb +0 -19
- data/lib/stoplight/state.rb +0 -9
- data/lib/stoplight/traffic_control/base.rb +0 -70
- data/lib/stoplight/traffic_control/consecutive_errors.rb +0 -55
- data/lib/stoplight/traffic_control/error_rate.rb +0 -49
- data/lib/stoplight/traffic_recovery/base.rb +0 -75
- data/lib/stoplight/traffic_recovery/consecutive_successes.rb +0 -68
- data/lib/stoplight/traffic_recovery.rb +0 -11
- /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/record_failure.lua +0 -0
- /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/record_success.lua +0 -0
- /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/transition_to_green.lua +0 -0
- /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/transition_to_red.lua +0 -0
- /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/transition_to_yellow.lua +0 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Stoplight
|
|
6
|
+
module Infrastructure
|
|
7
|
+
module DataStore
|
|
8
|
+
# @see +Domain::DataStore+
|
|
9
|
+
class Memory < Domain::DataStore
|
|
10
|
+
include MonitorMixin
|
|
11
|
+
|
|
12
|
+
KEY_SEPARATOR = ":"
|
|
13
|
+
|
|
14
|
+
# @!attribute recovery_lock_store
|
|
15
|
+
# @return [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockStore]
|
|
16
|
+
# @api private
|
|
17
|
+
private attr_reader :recovery_lock_store
|
|
18
|
+
|
|
19
|
+
# @param recovery_lock_store [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockStore]
|
|
20
|
+
def initialize(recovery_lock_store:)
|
|
21
|
+
@recovery_lock_store = recovery_lock_store
|
|
22
|
+
@errors = Hash.new { |errors, light_name| errors[light_name] = SlidingWindow.new }
|
|
23
|
+
@successes = Hash.new { |successes, light_name| successes[light_name] = SlidingWindow.new }
|
|
24
|
+
@metrics = Hash.new { |metrics, light_name| metrics[light_name] = Metrics.new }
|
|
25
|
+
|
|
26
|
+
@recovery_metrics = Hash.new { |metrics, light_name| metrics[light_name] = Metrics.new }
|
|
27
|
+
|
|
28
|
+
@states = Hash.new { |states, light_name| states[light_name] = State.new }
|
|
29
|
+
|
|
30
|
+
super() # MonitorMixin
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>]
|
|
34
|
+
def names
|
|
35
|
+
synchronize { @metrics.keys | @states.keys | @recovery_metrics.keys }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param config [Stoplight::Domain::Config]
|
|
39
|
+
# @return [Stoplight::Domain::Metrics]
|
|
40
|
+
def get_metrics(config)
|
|
41
|
+
light_name = config.name
|
|
42
|
+
|
|
43
|
+
synchronize do
|
|
44
|
+
current_time = self.current_time
|
|
45
|
+
window_start = if config.window_size
|
|
46
|
+
(current_time - config.window_size)
|
|
47
|
+
else
|
|
48
|
+
current_time
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
metrics = @metrics[light_name]
|
|
52
|
+
|
|
53
|
+
errors = @errors[light_name].sum_in_window(window_start) if config.window_size
|
|
54
|
+
successes = @successes[light_name].sum_in_window(window_start) if config.window_size
|
|
55
|
+
consecutive_errors = config.window_size ? [metrics.consecutive_errors, errors].min : metrics.consecutive_errors
|
|
56
|
+
consecutive_successes = config.window_size ? [metrics.consecutive_successes.to_i, successes].min : metrics.consecutive_successes.to_i
|
|
57
|
+
|
|
58
|
+
Domain::Metrics.new(
|
|
59
|
+
errors:,
|
|
60
|
+
successes:,
|
|
61
|
+
consecutive_errors:,
|
|
62
|
+
consecutive_successes:,
|
|
63
|
+
last_error: metrics.last_error,
|
|
64
|
+
last_success_at: metrics.last_success_at
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Stoplight::Domain::Metrics]
|
|
70
|
+
def get_recovery_metrics(config)
|
|
71
|
+
light_name = config.name
|
|
72
|
+
|
|
73
|
+
synchronize do
|
|
74
|
+
metrics = @recovery_metrics[light_name]
|
|
75
|
+
|
|
76
|
+
Domain::Metrics.new(
|
|
77
|
+
errors: nil, successes: nil,
|
|
78
|
+
consecutive_errors: metrics.consecutive_errors,
|
|
79
|
+
consecutive_successes: metrics.consecutive_successes,
|
|
80
|
+
last_error: metrics.last_error,
|
|
81
|
+
last_success_at: metrics.last_success_at
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Stoplight::Domain::StateSnapshot]
|
|
87
|
+
def get_state_snapshot(config)
|
|
88
|
+
time, state = synchronize do
|
|
89
|
+
[current_time, @states[config.name]]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Domain::StateSnapshot.new(
|
|
93
|
+
time:,
|
|
94
|
+
locked_state: state.locked_state,
|
|
95
|
+
recovery_scheduled_after: state.recovery_scheduled_after,
|
|
96
|
+
recovery_started_at: state.recovery_started_at,
|
|
97
|
+
breached_at: state.breached_at
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param config [Stoplight::Domain::Config]
|
|
102
|
+
# @param exception [Exception]
|
|
103
|
+
# @return [void]
|
|
104
|
+
def record_failure(config, exception)
|
|
105
|
+
current_time = self.current_time
|
|
106
|
+
light_name = config.name
|
|
107
|
+
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
108
|
+
|
|
109
|
+
synchronize do
|
|
110
|
+
@errors[light_name].increment if config.window_size
|
|
111
|
+
|
|
112
|
+
metrics = @metrics[light_name]
|
|
113
|
+
|
|
114
|
+
if metrics.last_error_at.nil? || failure.occurred_at > metrics.last_error_at
|
|
115
|
+
metrics.last_error = failure
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
metrics.consecutive_errors += 1
|
|
119
|
+
metrics.consecutive_successes = 0
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def clear_metrics(config)
|
|
124
|
+
light_name = config.name
|
|
125
|
+
synchronize do
|
|
126
|
+
if config.window_size
|
|
127
|
+
@errors[light_name] = SlidingWindow.new
|
|
128
|
+
@successes[light_name] = SlidingWindow.new
|
|
129
|
+
end
|
|
130
|
+
@metrics[light_name] = Metrics.new
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def clear_recovery_metrics(config)
|
|
135
|
+
synchronize do
|
|
136
|
+
@recovery_metrics[config.name] = Metrics.new
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param config [Stoplight::Domain::Config]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def record_success(config)
|
|
143
|
+
light_name = config.name
|
|
144
|
+
current_time = self.current_time
|
|
145
|
+
|
|
146
|
+
synchronize do
|
|
147
|
+
@successes[light_name].increment if config.window_size
|
|
148
|
+
|
|
149
|
+
metrics = @metrics[light_name]
|
|
150
|
+
|
|
151
|
+
if metrics.last_success_at.nil? || current_time > metrics.last_success_at
|
|
152
|
+
metrics.last_success_at = current_time
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
metrics.consecutive_errors = 0
|
|
156
|
+
metrics.consecutive_successes += 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param config [Stoplight::Domain::Config]
|
|
161
|
+
# @param exception [Exception]
|
|
162
|
+
# @return [void]
|
|
163
|
+
def record_recovery_probe_failure(config, exception)
|
|
164
|
+
light_name = config.name
|
|
165
|
+
current_time = self.current_time
|
|
166
|
+
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
167
|
+
|
|
168
|
+
synchronize do
|
|
169
|
+
metrics = @recovery_metrics[light_name]
|
|
170
|
+
|
|
171
|
+
if metrics.last_error_at.nil? || failure.occurred_at > metrics.last_error_at
|
|
172
|
+
metrics.last_error = failure
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
metrics.consecutive_errors += 1
|
|
176
|
+
metrics.consecutive_successes = 0
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param config [Stoplight::Domain::Config]
|
|
181
|
+
# @return [void]
|
|
182
|
+
def record_recovery_probe_success(config)
|
|
183
|
+
light_name = config.name
|
|
184
|
+
current_time = self.current_time
|
|
185
|
+
|
|
186
|
+
synchronize do
|
|
187
|
+
metrics = @recovery_metrics[light_name]
|
|
188
|
+
if metrics.last_success_at.nil? || current_time > metrics.last_success_at
|
|
189
|
+
metrics.last_success_at = current_time
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
metrics.consecutive_errors = 0
|
|
193
|
+
metrics.consecutive_successes += 1
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param config [Stoplight::Domain::Config]
|
|
198
|
+
# @param state [String]
|
|
199
|
+
# @return [String]
|
|
200
|
+
def set_state(config, state)
|
|
201
|
+
light_name = config.name
|
|
202
|
+
|
|
203
|
+
synchronize do
|
|
204
|
+
@states[light_name].locked_state = state
|
|
205
|
+
end
|
|
206
|
+
state
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @return [String]
|
|
210
|
+
def inspect
|
|
211
|
+
"#<#{self.class.name}>"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @param config [Stoplight::Domain::Config]
|
|
215
|
+
# @return [void]
|
|
216
|
+
def delete_light(config)
|
|
217
|
+
light_name = config.name
|
|
218
|
+
|
|
219
|
+
synchronize do
|
|
220
|
+
@states.delete(light_name)
|
|
221
|
+
@recovery_metrics.delete(light_name)
|
|
222
|
+
@metrics.delete(light_name)
|
|
223
|
+
@errors.delete(light_name)
|
|
224
|
+
@successes.delete(light_name)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Combined method that performs the state transition based on color
|
|
229
|
+
#
|
|
230
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
231
|
+
# @param color [String] The color to transition to ("GREEN", "YELLOW", or "RED")
|
|
232
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
233
|
+
def transition_to_color(config, color)
|
|
234
|
+
case color
|
|
235
|
+
when Domain::Color::GREEN
|
|
236
|
+
transition_to_green(config)
|
|
237
|
+
when Domain::Color::YELLOW
|
|
238
|
+
transition_to_yellow(config)
|
|
239
|
+
when Domain::Color::RED
|
|
240
|
+
transition_to_red(config)
|
|
241
|
+
else
|
|
242
|
+
raise ArgumentError, "Invalid color: #{color}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @param config [Stoplight::Domain::Config]
|
|
247
|
+
# @return [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockToken, nil]
|
|
248
|
+
def acquire_recovery_lock(config)
|
|
249
|
+
recovery_lock_store.acquire_lock(config.name)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @param lock [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockToken]
|
|
253
|
+
# @return [void]
|
|
254
|
+
def release_recovery_lock(lock)
|
|
255
|
+
recovery_lock_store.release_lock(lock)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Transitions to GREEN state and ensures only one notification
|
|
259
|
+
#
|
|
260
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
261
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
262
|
+
private def transition_to_green(config)
|
|
263
|
+
light_name = config.name
|
|
264
|
+
current_time = self.current_time
|
|
265
|
+
|
|
266
|
+
synchronize do
|
|
267
|
+
state = @states[light_name]
|
|
268
|
+
|
|
269
|
+
if state.recovered_at
|
|
270
|
+
false
|
|
271
|
+
else
|
|
272
|
+
state.recovered_at = current_time
|
|
273
|
+
state.recovery_started_at = nil
|
|
274
|
+
state.breached_at = nil
|
|
275
|
+
state.recovery_scheduled_after = nil
|
|
276
|
+
true
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Transitions to YELLOW (recovery) state and ensures only one notification
|
|
282
|
+
#
|
|
283
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
284
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
285
|
+
private def transition_to_yellow(config)
|
|
286
|
+
light_name = config.name
|
|
287
|
+
current_time = self.current_time
|
|
288
|
+
|
|
289
|
+
synchronize do
|
|
290
|
+
state = @states[light_name]
|
|
291
|
+
if state.recovery_started_at.nil?
|
|
292
|
+
state.recovery_started_at = current_time
|
|
293
|
+
state.recovery_scheduled_after = nil
|
|
294
|
+
state.recovered_at = nil
|
|
295
|
+
state.breached_at = nil
|
|
296
|
+
true
|
|
297
|
+
else
|
|
298
|
+
state.recovery_scheduled_after = nil
|
|
299
|
+
state.recovered_at = nil
|
|
300
|
+
state.breached_at = nil
|
|
301
|
+
false
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Transitions to RED state and ensures only one notification
|
|
307
|
+
#
|
|
308
|
+
# @param config [Stoplight::Domain::Config] The light configuration
|
|
309
|
+
# @return [Boolean] true if this is the first instance to detect this transition
|
|
310
|
+
private def transition_to_red(config)
|
|
311
|
+
light_name = config.name
|
|
312
|
+
current_time = self.current_time
|
|
313
|
+
recovery_scheduled_after = current_time + config.cool_off_time
|
|
314
|
+
|
|
315
|
+
synchronize do
|
|
316
|
+
state = @states[light_name]
|
|
317
|
+
if state.breached_at
|
|
318
|
+
state.recovery_scheduled_after = recovery_scheduled_after
|
|
319
|
+
state.recovery_started_at = nil
|
|
320
|
+
state.recovered_at = nil
|
|
321
|
+
false
|
|
322
|
+
else
|
|
323
|
+
state.breached_at = current_time
|
|
324
|
+
state.recovery_scheduled_after = recovery_scheduled_after
|
|
325
|
+
state.recovery_started_at = nil
|
|
326
|
+
state.recovered_at = nil
|
|
327
|
+
true
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private def current_time
|
|
333
|
+
Time.now
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
local number_of_metric_buckets = tonumber(ARGV[1])
|
|
2
|
+
local window_start_ts = tonumber(ARGV[2])
|
|
3
|
+
local window_end_ts = tonumber(ARGV[3])
|
|
4
|
+
local metrics_keys = {}
|
|
5
|
+
for idx = 4, #ARGV do
|
|
6
|
+
table.insert(metrics_keys, ARGV[idx])
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
local metadata_key = KEYS[1]
|
|
10
|
+
|
|
11
|
+
local function count_events(start_idx, bucket_count, start_ts)
|
|
12
|
+
local total = 0
|
|
13
|
+
for idx = start_idx, start_idx + bucket_count - 1 do
|
|
14
|
+
total = total + tonumber(redis.call('ZCOUNT', KEYS[idx], start_ts, window_end_ts))
|
|
15
|
+
end
|
|
16
|
+
return total
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local offset = 2
|
|
20
|
+
local successes = count_events(2, number_of_metric_buckets, window_start_ts)
|
|
21
|
+
|
|
22
|
+
offset = offset + number_of_metric_buckets
|
|
23
|
+
local errors = count_events(offset, number_of_metric_buckets, window_start_ts)
|
|
24
|
+
|
|
25
|
+
local metrics = redis.call('HMGET', metadata_key, unpack(metrics_keys))
|
|
26
|
+
return {successes, errors, unpack(metrics)}
|
data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
local failure_ts = tonumber(ARGV[1])
|
|
2
|
+
local failure_json = ARGV[2]
|
|
3
|
+
|
|
4
|
+
local metadata_key = KEYS[1]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
-- Update metadata
|
|
8
|
+
local meta = redis.call('HMGET', metadata_key, 'last_error_at', 'consecutive_errors')
|
|
9
|
+
local prev_failure_ts = tonumber(meta[1])
|
|
10
|
+
local prev_consecutive_errors = tonumber(meta[2])
|
|
11
|
+
|
|
12
|
+
if not prev_failure_ts or failure_ts > prev_failure_ts then
|
|
13
|
+
redis.call(
|
|
14
|
+
'HSET', metadata_key,
|
|
15
|
+
'last_error_at', failure_ts,
|
|
16
|
+
'last_error_json', failure_json,
|
|
17
|
+
'consecutive_errors', (prev_consecutive_errors or 0) + 1,
|
|
18
|
+
'consecutive_successes', 0
|
|
19
|
+
)
|
|
20
|
+
else
|
|
21
|
+
redis.call(
|
|
22
|
+
'HSET', metadata_key,
|
|
23
|
+
'consecutive_errors', (prev_consecutive_errors or 0) + 1,
|
|
24
|
+
'consecutive_successes', 0
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
local request_ts = tonumber(ARGV[1])
|
|
2
|
+
|
|
3
|
+
local metadata_key = KEYS[1]
|
|
4
|
+
|
|
5
|
+
-- Update metadata
|
|
6
|
+
local meta = redis.call('HMGET', metadata_key, 'last_success_at', 'consecutive_successes')
|
|
7
|
+
local prev_success_ts = tonumber(meta[1])
|
|
8
|
+
local prev_consecutive_successes = tonumber(meta[2])
|
|
9
|
+
|
|
10
|
+
if not prev_success_ts or request_ts > prev_success_ts then
|
|
11
|
+
redis.call(
|
|
12
|
+
'HSET', metadata_key,
|
|
13
|
+
'last_success_at', request_ts,
|
|
14
|
+
'consecutive_errors', 0,
|
|
15
|
+
'consecutive_successes', (prev_consecutive_successes or 0) + 1
|
|
16
|
+
)
|
|
17
|
+
else
|
|
18
|
+
redis.call(
|
|
19
|
+
'HSET', metadata_key,
|
|
20
|
+
'consecutive_errors', 0,
|
|
21
|
+
'consecutive_successes', (prev_consecutive_successes or 0) + 1
|
|
22
|
+
)
|
|
23
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
# Distributed recovery recovery_lock using Redis SET NX (set-if-not-exists).
|
|
11
|
+
#
|
|
12
|
+
# Lock Acquisition:
|
|
13
|
+
# - Uses unique UUID token to prevent accidental release of others' locks
|
|
14
|
+
# - Atomic SET with NX flag ensures only one process acquires recovery_lock
|
|
15
|
+
# - TTL (px: lock_timeout) auto-releases recovery_lock if process crashes
|
|
16
|
+
#
|
|
17
|
+
# Lock Release:
|
|
18
|
+
# - Lua script ensures only token holder can release (token comparison)
|
|
19
|
+
# - Best-effort release; TTL cleanup handles failures
|
|
20
|
+
#
|
|
21
|
+
# Failure Modes:
|
|
22
|
+
# - Lock contention: Returns false, caller should skip probe
|
|
23
|
+
# - Redis unavailable: raises an error and let caller decide
|
|
24
|
+
# - Crashed holder: raises an error and let caller decide. Lock auto-expires after lock_timeout
|
|
25
|
+
# - Release failure: Lock auto-expires after lock_timeout
|
|
26
|
+
#
|
|
27
|
+
class RecoveryLockStore
|
|
28
|
+
# @!attribute redis
|
|
29
|
+
# @return [RedisClient]
|
|
30
|
+
protected attr_reader :redis
|
|
31
|
+
|
|
32
|
+
# @!attribute lock_timeout
|
|
33
|
+
# @return [Integer]
|
|
34
|
+
protected attr_reader :lock_timeout
|
|
35
|
+
|
|
36
|
+
# @!attribute scripting
|
|
37
|
+
# @return [Stoplight::Infrastructure::DataStore::Redis::Scripting]
|
|
38
|
+
protected attr_reader :scripting
|
|
39
|
+
|
|
40
|
+
# @param redis [RedisClient | ConnectionPool]
|
|
41
|
+
# @param lock_timeout [Integer] recovery_lock timeout in milliseconds
|
|
42
|
+
# @param scripting [Stoplight::Infrastructure::DataStore::Redis::Scripting]
|
|
43
|
+
def initialize(redis:, lock_timeout:, scripting:)
|
|
44
|
+
@redis = redis
|
|
45
|
+
@lock_timeout = lock_timeout
|
|
46
|
+
@scripting = scripting
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param light_name [String]
|
|
50
|
+
# @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken, nil]
|
|
51
|
+
def acquire_lock(light_name)
|
|
52
|
+
recovery_lock = RecoveryLockToken.new(light_name:)
|
|
53
|
+
|
|
54
|
+
acquired = !!redis.then do |client|
|
|
55
|
+
client.set(recovery_lock.lock_key, recovery_lock.token, nx: true, px: lock_timeout)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
recovery_lock if acquired
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param recovery_lock [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def release_lock(recovery_lock)
|
|
64
|
+
scripting.call(
|
|
65
|
+
:release_lock,
|
|
66
|
+
keys: [recovery_lock.lock_key], args: [recovery_lock.token]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|