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.
- checksums.yaml +4 -4
- data/lib/stoplight/admin/dependencies.rb +1 -1
- data/lib/stoplight/admin/helpers.rb +10 -5
- data/lib/stoplight/admin/lights_repository.rb +18 -15
- data/lib/stoplight/admin.rb +2 -1
- data/lib/stoplight/common/deprecations.rb +11 -0
- data/lib/stoplight/domain/config.rb +5 -1
- data/lib/stoplight/domain/data_store.rb +17 -1
- data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
- data/lib/stoplight/domain/light.rb +31 -20
- data/lib/stoplight/domain/metrics.rb +6 -27
- data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
- 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/run_strategy.rb +0 -5
- data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +58 -32
- data/lib/stoplight/domain/tracker/base.rb +0 -29
- data/lib/stoplight/domain/tracker/recovery_probe.rb +23 -22
- data/lib/stoplight/domain/tracker/request.rb +23 -19
- data/lib/stoplight/domain/traffic_recovery/base.rb +1 -2
- data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +2 -8
- data/lib/stoplight/domain/traffic_recovery.rb +0 -1
- data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -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.rb +61 -32
- 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 +133 -162
- data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -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/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 +1 -1
- data/lib/stoplight/wiring/default_configuration.rb +1 -1
- data/lib/stoplight/wiring/default_factory_builder.rb +1 -1
- 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 +45 -132
- data/lib/stoplight/wiring/notifier_factory.rb +26 -0
- data/lib/stoplight/wiring/public_api.rb +3 -2
- data/lib/stoplight.rb +18 -3
- metadata +50 -15
- data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
- data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
- data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
- data/lib/stoplight/wiring/container.rb +0 -80
- data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -147
- data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
- data/lib/stoplight/wiring/system_container.rb +0 -9
- data/lib/stoplight/wiring/system_light_factory.rb +0 -17
- /data/lib/stoplight/infrastructure/data_store/redis/{get_metrics.lua → lua_scripts/get_metrics.lua} +0 -0
- /data/lib/stoplight/infrastructure/data_store/redis/{record_failure.lua → lua_scripts/record_failure.lua} +0 -0
- /data/lib/stoplight/infrastructure/data_store/redis/{record_success.lua → lua_scripts/record_success.lua} +0 -0
- /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_green.lua → lua_scripts/transition_to_green.lua} +0 -0
- /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_red.lua → lua_scripts/transition_to_red.lua} +0 -0
- /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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
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
|