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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -2
  3. data/lib/generators/stoplight/install/templates/stoplight.rb.erb +2 -0
  4. data/lib/stoplight/admin/actions/remove.rb +23 -0
  5. data/lib/stoplight/admin/dependencies.rb +6 -1
  6. data/lib/stoplight/admin/helpers.rb +10 -5
  7. data/lib/stoplight/admin/lights_repository.rb +26 -14
  8. data/lib/stoplight/admin/views/_card.erb +13 -1
  9. data/lib/stoplight/admin/views/layout.erb +3 -3
  10. data/lib/stoplight/admin.rb +13 -4
  11. data/lib/stoplight/common/deprecations.rb +11 -0
  12. data/lib/stoplight/domain/color.rb +11 -0
  13. data/lib/stoplight/{config → domain}/compatibility_result.rb +1 -1
  14. data/lib/stoplight/domain/config.rb +59 -0
  15. data/lib/stoplight/{data_store/base.rb → domain/data_store.rb} +71 -17
  16. data/lib/stoplight/domain/error.rb +42 -0
  17. data/lib/stoplight/domain/failure.rb +44 -0
  18. data/lib/stoplight/domain/light/configuration_builder_interface.rb +234 -0
  19. data/lib/stoplight/domain/light.rb +208 -0
  20. data/lib/stoplight/domain/light_factory.rb +75 -0
  21. data/lib/stoplight/domain/metrics.rb +64 -0
  22. data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
  23. data/lib/stoplight/domain/state.rb +11 -0
  24. data/lib/stoplight/domain/state_snapshot.rb +57 -0
  25. data/lib/stoplight/{notifier/base.rb → domain/state_transition_notifier.rb} +5 -4
  26. data/lib/stoplight/domain/storage/metrics.rb +42 -0
  27. data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
  28. data/lib/stoplight/domain/storage/state.rb +87 -0
  29. data/lib/stoplight/domain/strategies/green_run_strategy.rb +69 -0
  30. data/lib/stoplight/domain/strategies/red_run_strategy.rb +41 -0
  31. data/lib/stoplight/domain/strategies/run_strategy.rb +22 -0
  32. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +125 -0
  33. data/lib/stoplight/domain/tracker/base.rb +12 -0
  34. data/lib/stoplight/domain/tracker/recovery_probe.rb +76 -0
  35. data/lib/stoplight/domain/tracker/request.rb +72 -0
  36. data/lib/stoplight/domain/traffic_control/base.rb +74 -0
  37. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +53 -0
  38. data/lib/stoplight/domain/traffic_control/error_rate.rb +51 -0
  39. data/lib/stoplight/domain/traffic_recovery/base.rb +79 -0
  40. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +66 -0
  41. data/lib/stoplight/domain/traffic_recovery.rb +12 -0
  42. data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
  43. data/lib/stoplight/infrastructure/data_store/memory/metrics.rb +27 -0
  44. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
  45. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
  46. data/lib/stoplight/infrastructure/data_store/memory/sliding_window.rb +79 -0
  47. data/lib/stoplight/infrastructure/data_store/memory/state.rb +21 -0
  48. data/lib/stoplight/infrastructure/data_store/memory.rb +338 -0
  49. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/get_metrics.lua +26 -0
  50. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
  51. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
  52. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
  53. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
  54. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
  55. data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
  56. data/lib/stoplight/infrastructure/data_store/redis.rb +524 -0
  57. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
  58. data/lib/stoplight/infrastructure/notifier/generic.rb +90 -0
  59. data/lib/stoplight/infrastructure/notifier/io.rb +23 -0
  60. data/lib/stoplight/infrastructure/notifier/logger.rb +21 -0
  61. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
  62. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
  63. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
  64. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
  65. data/lib/stoplight/rspec/generic_notifier.rb +1 -1
  66. data/lib/stoplight/version.rb +1 -1
  67. data/lib/stoplight/wiring/data_store/base.rb +11 -0
  68. data/lib/stoplight/wiring/data_store/memory.rb +10 -0
  69. data/lib/stoplight/wiring/data_store/redis.rb +25 -0
  70. data/lib/stoplight/wiring/default.rb +28 -0
  71. data/lib/stoplight/{config/user_default_config.rb → wiring/default_configuration.rb} +24 -31
  72. data/lib/stoplight/wiring/default_factory_builder.rb +25 -0
  73. data/lib/stoplight/wiring/light/default_config.rb +18 -0
  74. data/lib/stoplight/wiring/light/system_config.rb +11 -0
  75. data/lib/stoplight/wiring/light_builder.rb +185 -0
  76. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
  77. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
  78. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
  79. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  80. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  81. data/lib/stoplight/wiring/light_factory.rb +101 -0
  82. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  83. data/lib/stoplight/wiring/public_api.rb +29 -0
  84. data/lib/stoplight.rb +55 -30
  85. metadata +92 -42
  86. data/lib/stoplight/color.rb +0 -9
  87. data/lib/stoplight/config/dsl.rb +0 -97
  88. data/lib/stoplight/config/library_default_config.rb +0 -21
  89. data/lib/stoplight/config/system_config.rb +0 -7
  90. data/lib/stoplight/data_store/fail_safe.rb +0 -113
  91. data/lib/stoplight/data_store/memory.rb +0 -311
  92. data/lib/stoplight/data_store/redis/get_metadata.lua +0 -38
  93. data/lib/stoplight/data_store/redis/lua.rb +0 -23
  94. data/lib/stoplight/data_store/redis.rb +0 -449
  95. data/lib/stoplight/data_store.rb +0 -6
  96. data/lib/stoplight/default.rb +0 -30
  97. data/lib/stoplight/error.rb +0 -10
  98. data/lib/stoplight/failure.rb +0 -71
  99. data/lib/stoplight/light/config.rb +0 -111
  100. data/lib/stoplight/light/configuration_builder_interface.rb +0 -128
  101. data/lib/stoplight/light/green_run_strategy.rb +0 -54
  102. data/lib/stoplight/light/red_run_strategy.rb +0 -27
  103. data/lib/stoplight/light/run_strategy.rb +0 -32
  104. data/lib/stoplight/light/yellow_run_strategy.rb +0 -94
  105. data/lib/stoplight/light.rb +0 -191
  106. data/lib/stoplight/metadata.rb +0 -99
  107. data/lib/stoplight/notifier/fail_safe.rb +0 -70
  108. data/lib/stoplight/notifier/generic.rb +0 -79
  109. data/lib/stoplight/notifier/io.rb +0 -21
  110. data/lib/stoplight/notifier/logger.rb +0 -19
  111. data/lib/stoplight/state.rb +0 -9
  112. data/lib/stoplight/traffic_control/base.rb +0 -70
  113. data/lib/stoplight/traffic_control/consecutive_errors.rb +0 -55
  114. data/lib/stoplight/traffic_control/error_rate.rb +0 -49
  115. data/lib/stoplight/traffic_recovery/base.rb +0 -75
  116. data/lib/stoplight/traffic_recovery/consecutive_successes.rb +0 -68
  117. data/lib/stoplight/traffic_recovery.rb +0 -11
  118. /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/record_failure.lua +0 -0
  119. /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/record_success.lua +0 -0
  120. /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/transition_to_green.lua +0 -0
  121. /data/lib/stoplight/{data_store/redis → infrastructure/data_store/redis/lua_scripts}/transition_to_red.lua +0 -0
  122. /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)}
@@ -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
+
@@ -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,6 @@
1
+ local token = ARGV[1]
2
+ local lock_key = KEYS[1]
3
+
4
+ if redis.call("get", lock_key) == token then
5
+ return redis.call("del", lock_key)
6
+ 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