stoplight 5.6.0 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/lib/stoplight/admin/dependencies.rb +1 -1
  3. data/lib/stoplight/admin/helpers.rb +10 -5
  4. data/lib/stoplight/admin/lights_repository.rb +18 -15
  5. data/lib/stoplight/admin.rb +2 -1
  6. data/lib/stoplight/common/deprecations.rb +11 -0
  7. data/lib/stoplight/domain/config.rb +5 -1
  8. data/lib/stoplight/domain/data_store.rb +17 -1
  9. data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
  10. data/lib/stoplight/domain/light.rb +31 -20
  11. data/lib/stoplight/domain/metrics.rb +6 -27
  12. data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
  13. data/lib/stoplight/domain/storage/metrics.rb +42 -0
  14. data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
  15. data/lib/stoplight/domain/storage/state.rb +87 -0
  16. data/lib/stoplight/domain/strategies/run_strategy.rb +0 -5
  17. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +58 -32
  18. data/lib/stoplight/domain/tracker/base.rb +0 -29
  19. data/lib/stoplight/domain/tracker/recovery_probe.rb +23 -22
  20. data/lib/stoplight/domain/tracker/request.rb +23 -19
  21. data/lib/stoplight/domain/traffic_recovery/base.rb +1 -2
  22. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +2 -8
  23. data/lib/stoplight/domain/traffic_recovery.rb +0 -1
  24. data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
  25. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
  26. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
  27. data/lib/stoplight/infrastructure/data_store/memory.rb +61 -32
  28. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
  29. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
  30. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
  31. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
  32. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
  33. data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
  34. data/lib/stoplight/infrastructure/data_store/redis.rb +133 -162
  35. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
  36. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
  37. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
  38. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
  39. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
  40. data/lib/stoplight/version.rb +1 -1
  41. data/lib/stoplight/wiring/data_store/base.rb +11 -0
  42. data/lib/stoplight/wiring/data_store/memory.rb +10 -0
  43. data/lib/stoplight/wiring/data_store/redis.rb +25 -0
  44. data/lib/stoplight/wiring/default.rb +1 -1
  45. data/lib/stoplight/wiring/default_configuration.rb +1 -1
  46. data/lib/stoplight/wiring/default_factory_builder.rb +1 -1
  47. data/lib/stoplight/wiring/light_builder.rb +185 -0
  48. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
  49. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
  50. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
  51. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  52. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  53. data/lib/stoplight/wiring/light_factory.rb +45 -132
  54. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  55. data/lib/stoplight/wiring/public_api.rb +3 -2
  56. data/lib/stoplight.rb +18 -3
  57. metadata +50 -15
  58. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
  59. data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
  60. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
  61. data/lib/stoplight/wiring/container.rb +0 -80
  62. data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -147
  63. data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
  64. data/lib/stoplight/wiring/system_container.rb +0 -9
  65. data/lib/stoplight/wiring/system_light_factory.rb +0 -17
  66. /data/lib/stoplight/infrastructure/data_store/redis/{get_metrics.lua → lua_scripts/get_metrics.lua} +0 -0
  67. /data/lib/stoplight/infrastructure/data_store/redis/{record_failure.lua → lua_scripts/record_failure.lua} +0 -0
  68. /data/lib/stoplight/infrastructure/data_store/redis/{record_success.lua → lua_scripts/record_success.lua} +0 -0
  69. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_green.lua → lua_scripts/transition_to_green.lua} +0 -0
  70. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_red.lua → lua_scripts/transition_to_red.lua} +0 -0
  71. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_yellow.lua → lua_scripts/transition_to_yellow.lua} +0 -0
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Stoplight
6
+ module Infrastructure
7
+ module DataStore
8
+ # A wrapper around a data store that provides fail-safe mechanisms using a
9
+ # circuit breaker. It ensures that operations on the data store can gracefully
10
+ # handle failures by falling back to default values when necessary.
11
+ #
12
+ # @api private
13
+ class FailSafe < Domain::DataStore
14
+ # @!attribute data_store
15
+ # @return [Stoplight::DataStore::Base] The underlying primary data store being used
16
+ attr_reader :data_store
17
+
18
+ # @!attribute error_notifier
19
+ # @return [Proc]
20
+ attr_reader :error_notifier
21
+
22
+ # @!attribute failover_data_store
23
+ # @return [Stoplight::DataStore::Base] The fallback data store used when the primary fails.
24
+ attr_reader :failover_data_store
25
+
26
+ # @!attribute circuit_breaker
27
+ # @return [Stoplight::Light] The circuit breaker used to handle data store failures.
28
+ private attr_reader :circuit_breaker
29
+
30
+ # @param data_store [Stoplight::Domain::DataStore]
31
+ # @param error_notifier [Proc]
32
+ # @param failover_data_store [Stoplight::Domain::DataStore]
33
+ # @param circuit_breaker [Stoplight::Domain::Light]
34
+ def initialize(data_store:, error_notifier:, failover_data_store:, circuit_breaker:)
35
+ @data_store = data_store
36
+ @error_notifier = error_notifier
37
+ @failover_data_store = failover_data_store
38
+ @circuit_breaker = circuit_breaker
39
+ end
40
+
41
+ def names
42
+ with_fallback(:names) do
43
+ data_store.names
44
+ end
45
+ end
46
+
47
+ def get_metrics(config, *args, **kwargs)
48
+ with_fallback(:get_metrics, config, *args, **kwargs) do
49
+ data_store.get_metrics(config, *args, **kwargs)
50
+ end
51
+ end
52
+
53
+ def get_recovery_metrics(config, *args, **kwargs)
54
+ with_fallback(:get_recovery_metrics, config, *args, **kwargs) do
55
+ data_store.get_recovery_metrics(config, *args, **kwargs)
56
+ end
57
+ end
58
+
59
+ def get_state_snapshot(config)
60
+ with_fallback(:get_state_snapshot, config) do
61
+ data_store.get_state_snapshot(config)
62
+ end
63
+ end
64
+
65
+ def clear_metrics(config)
66
+ with_fallback(:clear_metrics, config) do
67
+ data_store.clear_metrics(config)
68
+ end
69
+ end
70
+
71
+ def clear_recovery_metrics(config)
72
+ with_fallback(:clear_recovery_metrics, config) do
73
+ data_store.clear_recovery_metrics(config)
74
+ end
75
+ end
76
+
77
+ def record_failure(config, *args, **kwargs)
78
+ with_fallback(:record_failure, config, *args, **kwargs) do
79
+ data_store.record_failure(config, *args, **kwargs)
80
+ end
81
+ end
82
+
83
+ def record_success(config, *args, **kwargs)
84
+ with_fallback(:record_success, config, *args, **kwargs) do
85
+ data_store.record_success(config, *args, **kwargs)
86
+ end
87
+ end
88
+
89
+ def record_recovery_probe_success(config, *args, **kwargs)
90
+ with_fallback(:record_recovery_probe_success, config, *args, **kwargs) do
91
+ data_store.record_recovery_probe_success(config, *args, **kwargs)
92
+ end
93
+ end
94
+
95
+ def record_recovery_probe_failure(config, *args, **kwargs)
96
+ with_fallback(:record_recovery_probe_failure, config, *args, **kwargs) do
97
+ data_store.record_recovery_probe_failure(config, *args, **kwargs)
98
+ end
99
+ end
100
+
101
+ def set_state(config, *args, **kwargs)
102
+ with_fallback(:set_state, config, *args, **kwargs) do
103
+ data_store.set_state(config, *args, **kwargs)
104
+ end
105
+ end
106
+
107
+ def transition_to_color(config, *args, **kwargs)
108
+ with_fallback(:transition_to_color, config, *args, **kwargs) do
109
+ data_store.transition_to_color(config, *args, **kwargs)
110
+ end
111
+ end
112
+
113
+ def delete_light(config, *args, **kwargs)
114
+ with_fallback(:delete_light, config, *args, **kwargs) do
115
+ data_store.delete_light(config, *args, **kwargs)
116
+ end
117
+ end
118
+
119
+ # @param config [Stoplight::Domain::Config]
120
+ def acquire_recovery_lock(config)
121
+ with_fallback(:acquire_recovery_lock, config) do
122
+ data_store.acquire_recovery_lock(config)
123
+ end
124
+ end
125
+
126
+ # Routes release to correct store based on token type.
127
+ # Redis tokens release via primary (with error notification on failure).
128
+ # Memory tokens release via failover directly.
129
+ #
130
+ # @param recovery_lock_token [Stoplight::Domain::RecoveryLockToken]
131
+ def release_recovery_lock(recovery_lock_token)
132
+ case recovery_lock_token
133
+ in Redis::RecoveryLockToken
134
+ fallback = proc do |error|
135
+ error_notifier.call(error) if error
136
+ end
137
+
138
+ circuit_breaker.run(fallback) do
139
+ data_store.release_recovery_lock(recovery_lock_token)
140
+ end
141
+ in Memory::RecoveryLockToken
142
+ failover_data_store.release_recovery_lock(recovery_lock_token)
143
+ end
144
+ end
145
+
146
+ def ==(other)
147
+ other.is_a?(self.class) && other.data_store == data_store && other.error_notifier == error_notifier &&
148
+ other.failover_data_store == failover_data_store
149
+ end
150
+
151
+ # @param method_name [Symbol] protected method name
152
+ private def with_fallback(method_name, *args, **kwargs, &code)
153
+ fallback = proc do |error|
154
+ config = args.first
155
+ error_notifier.call(error) if config && error
156
+ @failover_data_store.public_send(method_name, *args, **kwargs)
157
+ end
158
+
159
+ circuit_breaker.run(fallback, &code)
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ module Stoplight
6
+ module Infrastructure
7
+ module DataStore
8
+ class Memory
9
+ # Process-local recovery lock using Ruby's Thread::Mutex.
10
+ #
11
+ # This only serializes recovery within a single Ruby process.
12
+ # Multiple processes/servers will NOT coordinate - each process
13
+ # can send probes independently.
14
+ #
15
+ # Mutex Lifecycle:
16
+ # - One mutex created per unique light_name (lazily)
17
+ # - Mutexes persist for process lifetime (never GC'd)
18
+ #
19
+ class RecoveryLockStore
20
+ # @!attribute locks
21
+ # Stores one mutex per unique light_name for the lifetime of the process.
22
+ # Mutexes are never garbage collected.
23
+ # @return [Concurrent::Map<Thread::Mutex>]
24
+ private attr_reader :locks
25
+
26
+ def initialize
27
+ @locks = Concurrent::Map.new
28
+ end
29
+
30
+ # @param light_name [String]
31
+ # @return [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockToken, nil]
32
+ def acquire_lock(light_name)
33
+ lock = lock_for(light_name)
34
+ RecoveryLockToken.new(light_name:) if lock.try_lock
35
+ end
36
+
37
+ # @param recovery_lock_token [Stoplight::Infrastructure::DataStore::Memory::RecoveryLockToken]
38
+ # @return [void]
39
+ def release_lock(recovery_lock_token)
40
+ lock_for(recovery_lock_token.light_name).unlock
41
+ end
42
+
43
+ # @param light_name [String]
44
+ # @return [Thread::Mutex]
45
+ private def lock_for(light_name)
46
+ locks.compute_if_absent(light_name) do
47
+ Thread::Mutex.new
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module DataStore
6
+ class Memory
7
+ class RecoveryLockToken < Domain::RecoveryLockToken
8
+ # @!attribute light_name
9
+ # @return [String]
10
+ attr_reader :light_name
11
+
12
+ # @param light_name [String]
13
+ def initialize(light_name:)
14
+ @light_name = light_name
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -11,22 +11,28 @@ module Stoplight
11
11
 
12
12
  KEY_SEPARATOR = ":"
13
13
 
14
- def initialize
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
15
22
  @errors = Hash.new { |errors, light_name| errors[light_name] = SlidingWindow.new }
16
23
  @successes = Hash.new { |successes, light_name| successes[light_name] = SlidingWindow.new }
24
+ @metrics = Hash.new { |metrics, light_name| metrics[light_name] = Metrics.new }
17
25
 
18
- @recovery_probe_errors = Hash.new { |recovery_probe_errors, light_name| recovery_probe_errors[light_name] = SlidingWindow.new }
19
- @recovery_probe_successes = Hash.new { |recovery_probe_successes, light_name| recovery_probe_successes[light_name] = SlidingWindow.new }
26
+ @recovery_metrics = Hash.new { |metrics, light_name| metrics[light_name] = Metrics.new }
20
27
 
21
28
  @states = Hash.new { |states, light_name| states[light_name] = State.new }
22
- @metrics = Hash.new { |metrics, light_name| metrics[light_name] = Metrics.new }
23
29
 
24
- super # MonitorMixin
30
+ super() # MonitorMixin
25
31
  end
26
32
 
27
33
  # @return [Array<String>]
28
34
  def names
29
- synchronize { @metrics.keys | @states.keys }
35
+ synchronize { @metrics.keys | @states.keys | @recovery_metrics.keys }
30
36
  end
31
37
 
32
38
  # @param config [Stoplight::Domain::Config]
@@ -46,12 +52,14 @@ module Stoplight
46
52
 
47
53
  errors = @errors[light_name].sum_in_window(window_start) if config.window_size
48
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
49
57
 
50
58
  Domain::Metrics.new(
51
59
  errors:,
52
60
  successes:,
53
- total_consecutive_errors: metrics.consecutive_errors,
54
- total_consecutive_successes: metrics.consecutive_successes,
61
+ consecutive_errors:,
62
+ consecutive_successes:,
55
63
  last_error: metrics.last_error,
56
64
  last_success_at: metrics.last_success_at
57
65
  )
@@ -63,21 +71,12 @@ module Stoplight
63
71
  light_name = config.name
64
72
 
65
73
  synchronize do
66
- current_time = self.current_time
67
- recovery_window_start = (current_time - config.cool_off_time)
68
- if config.window_size
69
- (current_time - config.window_size)
70
- else
71
- current_time
72
- end
73
-
74
- metrics = @metrics[light_name]
74
+ metrics = @recovery_metrics[light_name]
75
75
 
76
76
  Domain::Metrics.new(
77
- errors: @recovery_probe_errors[light_name].sum_in_window(recovery_window_start),
78
- successes: @recovery_probe_successes[light_name].sum_in_window(recovery_window_start),
79
- total_consecutive_errors: metrics.consecutive_errors,
80
- total_consecutive_successes: metrics.consecutive_successes,
77
+ errors: nil, successes: nil,
78
+ consecutive_errors: metrics.consecutive_errors,
79
+ consecutive_successes: metrics.consecutive_successes,
81
80
  last_error: metrics.last_error,
82
81
  last_success_at: metrics.last_success_at
83
82
  )
@@ -121,12 +120,20 @@ module Stoplight
121
120
  end
122
121
  end
123
122
 
124
- def clear_windowed_metrics(config)
125
- if config.window_size
126
- synchronize do
127
- @errors[config.name] = SlidingWindow.new
128
- @successes[config.name] = SlidingWindow.new
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
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
130
137
  end
131
138
  end
132
139
 
@@ -159,9 +166,7 @@ module Stoplight
159
166
  failure = Domain::Failure.from_error(exception, time: current_time)
160
167
 
161
168
  synchronize do
162
- @recovery_probe_errors[light_name].increment
163
-
164
- metrics = @metrics[light_name]
169
+ metrics = @recovery_metrics[light_name]
165
170
 
166
171
  if metrics.last_error_at.nil? || failure.occurred_at > metrics.last_error_at
167
172
  metrics.last_error = failure
@@ -179,9 +184,7 @@ module Stoplight
179
184
  current_time = self.current_time
180
185
 
181
186
  synchronize do
182
- @recovery_probe_successes[light_name].increment
183
-
184
- metrics = @metrics[light_name]
187
+ metrics = @recovery_metrics[light_name]
185
188
  if metrics.last_success_at.nil? || current_time > metrics.last_success_at
186
189
  metrics.last_success_at = current_time
187
190
  end
@@ -208,6 +211,20 @@ module Stoplight
208
211
  "#<#{self.class.name}>"
209
212
  end
210
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
+
211
228
  # Combined method that performs the state transition based on color
212
229
  #
213
230
  # @param config [Stoplight::Domain::Config] The light configuration
@@ -226,6 +243,18 @@ module Stoplight
226
243
  end
227
244
  end
228
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
+
229
258
  # Transitions to GREEN state and ensures only one notification
230
259
  #
231
260
  # @param config [Stoplight::Domain::Config] The light configuration
@@ -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