stoplight 5.5.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/README.md +1 -1
- 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.rb +9 -0
- data/lib/stoplight/common/deprecations.rb +11 -0
- data/lib/stoplight/domain/config.rb +5 -1
- data/lib/stoplight/domain/data_store.rb +58 -6
- data/lib/stoplight/domain/failure.rb +2 -0
- data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
- data/lib/stoplight/domain/light.rb +34 -24
- data/lib/stoplight/domain/metrics.rb +64 -0
- data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
- data/lib/stoplight/domain/{metadata.rb → state_snapshot.rb} +29 -37
- 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 +2 -2
- data/lib/stoplight/domain/strategies/red_run_strategy.rb +3 -3
- data/lib/stoplight/domain/strategies/run_strategy.rb +2 -7
- data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +63 -36
- data/lib/stoplight/domain/tracker/base.rb +0 -29
- data/lib/stoplight/domain/tracker/recovery_probe.rb +26 -22
- data/lib/stoplight/domain/tracker/request.rb +26 -21
- data/lib/stoplight/domain/traffic_control/base.rb +5 -5
- data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +3 -7
- data/lib/stoplight/domain/traffic_control/error_rate.rb +3 -3
- data/lib/stoplight/domain/traffic_recovery/base.rb +5 -5
- data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +4 -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/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/state.rb +21 -0
- data/lib/stoplight/infrastructure/data_store/memory.rb +163 -132
- 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 +211 -165
- 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 +55 -16
- data/lib/stoplight/infrastructure/data_store/redis/get_metadata.lua +0 -38
- 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 -123
- 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/{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,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
|
|
@@ -74,30 +74,55 @@ module Stoplight
|
|
|
74
74
|
KEY_SEPARATOR = ":"
|
|
75
75
|
KEY_PREFIX = %w[stoplight v5].join(KEY_SEPARATOR)
|
|
76
76
|
|
|
77
|
+
# @!attribute recovery_lock_store
|
|
78
|
+
# @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
|
|
79
|
+
protected attr_reader :recovery_lock_store
|
|
80
|
+
|
|
81
|
+
# @!attribute scripting
|
|
82
|
+
# @return [Stoplight::Infrastructure::DataStore::Redis::Scripting]
|
|
83
|
+
protected attr_reader :scripting
|
|
84
|
+
|
|
85
|
+
# @!attribute redis
|
|
86
|
+
# @return [::Redis | ConnectionPool<::Redis>]
|
|
87
|
+
protected attr_reader :redis
|
|
88
|
+
|
|
89
|
+
# @!attribute warn_on_clock_skew
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
protected attr_reader :warn_on_clock_skew
|
|
92
|
+
|
|
77
93
|
# @param redis [::Redis, ConnectionPool<::Redis>]
|
|
94
|
+
# @param recovery_lock_store [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockStore]
|
|
78
95
|
# @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
|
|
96
|
+
# @param scripting [Stoplight::Infrastructure::DataStore::Redis::Scripting]
|
|
79
97
|
# the application server
|
|
80
|
-
def initialize(redis
|
|
98
|
+
def initialize(redis:, recovery_lock_store:, scripting:, warn_on_clock_skew: true)
|
|
81
99
|
@warn_on_clock_skew = warn_on_clock_skew
|
|
82
100
|
@redis = redis
|
|
101
|
+
@recovery_lock_store = recovery_lock_store
|
|
102
|
+
@scripting = scripting
|
|
83
103
|
end
|
|
84
104
|
|
|
85
105
|
def names
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
metadata, recovery_metrics = @redis.then do |client|
|
|
107
|
+
[
|
|
108
|
+
[key("metadata", "*"), /^#{key("metadata", "")}/],
|
|
109
|
+
[key("recovery_metrics", "*"), /^#{key("recovery_metrics", "")}/]
|
|
110
|
+
].map do |(pattern, prefix_regex)|
|
|
111
|
+
client.scan_each(match: pattern).to_a.map do |key|
|
|
112
|
+
key.sub(prefix_regex, "")
|
|
113
|
+
end
|
|
91
114
|
end
|
|
92
115
|
end
|
|
116
|
+
metadata + recovery_metrics
|
|
93
117
|
end
|
|
94
118
|
|
|
95
|
-
|
|
96
|
-
|
|
119
|
+
# @param config [Stoplight::Domain::Config]
|
|
120
|
+
# @return [Stoplight::Domain::Metrics]
|
|
121
|
+
def get_metrics(config)
|
|
122
|
+
config.name
|
|
97
123
|
|
|
98
124
|
window_end_ts = current_time.to_f
|
|
99
|
-
window_start_ts = window_end_ts -
|
|
100
|
-
recovery_window_start_ts = window_end_ts - config.cool_off_time.to_i
|
|
125
|
+
window_start_ts = window_end_ts - config.window_size.to_i
|
|
101
126
|
|
|
102
127
|
if config.window_size
|
|
103
128
|
failure_keys = failure_bucket_keys(config, window_end: window_end_ts)
|
|
@@ -106,129 +131,172 @@ module Stoplight
|
|
|
106
131
|
failure_keys = []
|
|
107
132
|
success_keys = []
|
|
108
133
|
end
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
|
|
135
|
+
successes, errors, last_success_at, last_error_json, consecutive_errors, consecutive_successes = scripting.call(
|
|
136
|
+
:get_metrics,
|
|
137
|
+
args: [
|
|
138
|
+
failure_keys.count,
|
|
139
|
+
window_start_ts,
|
|
140
|
+
window_end_ts,
|
|
141
|
+
"last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
|
|
142
|
+
],
|
|
143
|
+
keys: [
|
|
144
|
+
metadata_key(config),
|
|
145
|
+
*success_keys,
|
|
146
|
+
*failure_keys
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
consecutive_errors = config.window_size ? [consecutive_errors.to_i, errors].min : consecutive_errors.to_i
|
|
150
|
+
consecutive_successes = config.window_size ? [consecutive_successes.to_i, successes].min : consecutive_successes.to_i
|
|
151
|
+
|
|
152
|
+
Domain::Metrics.new(
|
|
153
|
+
successes: (successes if config.window_size),
|
|
154
|
+
errors: (errors if config.window_size),
|
|
155
|
+
consecutive_errors:,
|
|
156
|
+
consecutive_successes:,
|
|
157
|
+
last_error: deserialize_failure(last_error_json),
|
|
158
|
+
last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @param config [Stoplight::Domain::Config]
|
|
163
|
+
# @return [Stoplight::Domain::Metrics]
|
|
164
|
+
def get_recovery_metrics(config)
|
|
165
|
+
last_success_at, last_error_json, consecutive_errors, consecutive_successes = @redis.with do |client|
|
|
166
|
+
client.hmget(
|
|
167
|
+
recovery_metrics_key(config),
|
|
168
|
+
"last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes"
|
|
129
169
|
)
|
|
130
170
|
end
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
171
|
+
|
|
172
|
+
Domain::Metrics.new(
|
|
173
|
+
successes: nil, errors: nil,
|
|
174
|
+
consecutive_errors: consecutive_errors.to_i,
|
|
175
|
+
consecutive_successes: consecutive_successes.to_i,
|
|
176
|
+
last_error: deserialize_failure(last_error_json),
|
|
177
|
+
last_success_at: (Time.at(last_success_at.to_f) if last_success_at)
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [Stoplight::Domain::StateSnapshot]
|
|
182
|
+
def get_state_snapshot(config)
|
|
183
|
+
detect_clock_skew
|
|
184
|
+
|
|
185
|
+
breached_at_raw, locked_state, recovery_scheduled_after_raw, recovery_started_at_raw = @redis.with do |client|
|
|
186
|
+
client.hmget(metadata_key(config), :breached_at, :locked_state, :recovery_scheduled_after, :recovery_started_at)
|
|
187
|
+
end
|
|
188
|
+
breached_at = breached_at_raw&.to_f
|
|
189
|
+
recovery_scheduled_after = recovery_scheduled_after_raw&.to_f
|
|
190
|
+
recovery_started_at = recovery_started_at_raw&.to_f
|
|
191
|
+
|
|
192
|
+
Domain::StateSnapshot.new(
|
|
193
|
+
breached_at: (Time.at(breached_at) if breached_at),
|
|
194
|
+
locked_state: locked_state || Domain::State::UNLOCKED,
|
|
195
|
+
recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
|
|
196
|
+
recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
|
|
197
|
+
time: current_time
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def clear_metrics(config)
|
|
202
|
+
if config.window_size
|
|
203
|
+
window_end_ts = current_time.to_i
|
|
204
|
+
@redis.with do |client|
|
|
205
|
+
client.multi do |tx|
|
|
206
|
+
tx.unlink(
|
|
207
|
+
*failure_bucket_keys(config, window_end: window_end_ts),
|
|
208
|
+
*success_bucket_keys(config, window_end: window_end_ts)
|
|
209
|
+
)
|
|
210
|
+
tx.hdel(metadata_key(config), "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
else
|
|
214
|
+
@redis.with do |client|
|
|
215
|
+
client.hdel(metadata_key(config), "last_success_at", "last_error_json", "consecutive_errors", "consecutive_successes")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def clear_recovery_metrics(config)
|
|
221
|
+
@redis.with do |client|
|
|
222
|
+
client.del(recovery_metrics_key(config))
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private def state_snapshot_from_hash(data, time: current_time)
|
|
227
|
+
breached_at = data[:breached_at]&.to_f
|
|
228
|
+
recovery_scheduled_after = data[:recovery_scheduled_after]&.to_f
|
|
229
|
+
recovery_started_at = data[:recovery_started_at]&.to_f
|
|
230
|
+
|
|
231
|
+
Domain::StateSnapshot.new(
|
|
232
|
+
breached_at: (Time.at(breached_at) if breached_at),
|
|
233
|
+
locked_state: data[:locked_state] || Domain::State::UNLOCKED,
|
|
234
|
+
recovery_scheduled_after: (Time.at(recovery_scheduled_after) if recovery_scheduled_after),
|
|
235
|
+
recovery_started_at: (Time.at(recovery_started_at) if recovery_started_at),
|
|
236
|
+
time:
|
|
151
237
|
)
|
|
152
238
|
end
|
|
153
239
|
|
|
154
240
|
# @param config [Stoplight::Domain::Config] The light configuration.
|
|
155
241
|
# @param exception [Exception]
|
|
156
|
-
# @return [
|
|
242
|
+
# @return [void]
|
|
157
243
|
def record_failure(config, exception)
|
|
158
244
|
current_time = self.current_time
|
|
159
245
|
current_ts = current_time.to_f
|
|
160
246
|
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
161
247
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
end
|
|
172
|
-
get_metadata(config)
|
|
248
|
+
scripting.call(
|
|
249
|
+
:record_failure,
|
|
250
|
+
args: [current_ts, SecureRandom.hex(12), serialize_failure(failure), metrics_ttl, metadata_ttl],
|
|
251
|
+
keys: [
|
|
252
|
+
metadata_key(config),
|
|
253
|
+
config.window_size && errors_key(config, time: current_ts)
|
|
254
|
+
].compact
|
|
255
|
+
)
|
|
173
256
|
end
|
|
174
257
|
|
|
175
258
|
def record_success(config, request_id: SecureRandom.hex(12))
|
|
176
259
|
current_ts = current_time.to_f
|
|
177
260
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
)
|
|
187
|
-
end
|
|
261
|
+
scripting.call(
|
|
262
|
+
:record_success,
|
|
263
|
+
args: [current_ts, request_id, metrics_ttl, metadata_ttl],
|
|
264
|
+
keys: [
|
|
265
|
+
metadata_key(config),
|
|
266
|
+
config.window_size && successes_key(config, time: current_ts)
|
|
267
|
+
].compact
|
|
268
|
+
)
|
|
188
269
|
end
|
|
189
270
|
|
|
190
271
|
# Records a failed recovery probe for a specific light configuration.
|
|
191
272
|
#
|
|
192
273
|
# @param config [Stoplight::Domain::Config] The light configuration.
|
|
193
274
|
# @param exception [Exception]
|
|
194
|
-
# @return [
|
|
275
|
+
# @return [void]
|
|
195
276
|
def record_recovery_probe_failure(config, exception)
|
|
196
277
|
current_time = self.current_time
|
|
197
278
|
current_ts = current_time.to_f
|
|
198
279
|
failure = Domain::Failure.from_error(exception, time: current_time)
|
|
199
280
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
metadata_key(config),
|
|
206
|
-
recovery_probe_errors_key(config, time: current_ts)
|
|
207
|
-
].compact
|
|
208
|
-
)
|
|
209
|
-
end
|
|
210
|
-
get_metadata(config)
|
|
281
|
+
scripting.call(
|
|
282
|
+
:record_recovery_probe_failure,
|
|
283
|
+
args: [current_ts, serialize_failure(failure)],
|
|
284
|
+
keys: [recovery_metrics_key(config)]
|
|
285
|
+
)
|
|
211
286
|
end
|
|
212
287
|
|
|
213
288
|
# Records a successful recovery probe for a specific light configuration.
|
|
214
289
|
#
|
|
215
290
|
# @param config [Stoplight::Domain::Config] The light configuration.
|
|
216
|
-
# @
|
|
217
|
-
|
|
218
|
-
def record_recovery_probe_success(config, request_id: SecureRandom.hex(12))
|
|
291
|
+
# @return [void]
|
|
292
|
+
def record_recovery_probe_success(config)
|
|
219
293
|
current_ts = current_time.to_f
|
|
220
294
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
metadata_key(config),
|
|
227
|
-
recovery_probe_successes_key(config, time: current_ts)
|
|
228
|
-
].compact
|
|
229
|
-
)
|
|
230
|
-
end
|
|
231
|
-
get_metadata(config)
|
|
295
|
+
scripting.call(
|
|
296
|
+
:record_recovery_probe_success,
|
|
297
|
+
args: [current_ts],
|
|
298
|
+
keys: [recovery_metrics_key(config)]
|
|
299
|
+
)
|
|
232
300
|
end
|
|
233
301
|
|
|
234
302
|
def set_state(config, state)
|
|
@@ -260,6 +328,18 @@ module Stoplight
|
|
|
260
328
|
end
|
|
261
329
|
end
|
|
262
330
|
|
|
331
|
+
# @param config [Stoplight::Domain::Config]
|
|
332
|
+
# @return [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken, nil]
|
|
333
|
+
def acquire_recovery_lock(config)
|
|
334
|
+
recovery_lock_store.acquire_lock(config.name)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# @param lock [Stoplight::Infrastructure::DataStore::Redis::RecoveryLockToken]
|
|
338
|
+
# @return [void]
|
|
339
|
+
def release_recovery_lock(lock)
|
|
340
|
+
recovery_lock_store.release_lock(lock)
|
|
341
|
+
end
|
|
342
|
+
|
|
263
343
|
# Transitions to GREEN state and ensures only one notification
|
|
264
344
|
#
|
|
265
345
|
# @param config [Stoplight::Domain::Config] The light configuration
|
|
@@ -268,13 +348,11 @@ module Stoplight
|
|
|
268
348
|
current_ts = current_time.to_f
|
|
269
349
|
meta_key = metadata_key(config)
|
|
270
350
|
|
|
271
|
-
became_green =
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
)
|
|
277
|
-
end
|
|
351
|
+
became_green = scripting.call(
|
|
352
|
+
:transition_to_green,
|
|
353
|
+
args: [current_ts],
|
|
354
|
+
keys: [meta_key]
|
|
355
|
+
)
|
|
278
356
|
became_green == 1
|
|
279
357
|
end
|
|
280
358
|
|
|
@@ -283,16 +361,14 @@ module Stoplight
|
|
|
283
361
|
# @param config [Stoplight::Domain::Config] The light configuration
|
|
284
362
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
285
363
|
private def transition_to_yellow(config)
|
|
286
|
-
current_ts = current_time.
|
|
364
|
+
current_ts = current_time.to_f
|
|
287
365
|
meta_key = metadata_key(config)
|
|
288
366
|
|
|
289
|
-
became_yellow =
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)
|
|
295
|
-
end
|
|
367
|
+
became_yellow = scripting.call(
|
|
368
|
+
:transition_to_yellow,
|
|
369
|
+
args: [current_ts],
|
|
370
|
+
keys: [meta_key]
|
|
371
|
+
)
|
|
296
372
|
became_yellow == 1
|
|
297
373
|
end
|
|
298
374
|
|
|
@@ -301,24 +377,34 @@ module Stoplight
|
|
|
301
377
|
# @param config [Stoplight::Domain::Config] The light configuration
|
|
302
378
|
# @return [Boolean] true if this is the first instance to detect this transition
|
|
303
379
|
private def transition_to_red(config)
|
|
304
|
-
current_ts = current_time.
|
|
380
|
+
current_ts = current_time.to_f
|
|
305
381
|
meta_key = metadata_key(config)
|
|
306
382
|
recovery_scheduled_after_ts = current_ts + config.cool_off_time
|
|
307
383
|
|
|
308
|
-
became_red =
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
end
|
|
384
|
+
became_red = scripting.call(
|
|
385
|
+
:transition_to_red,
|
|
386
|
+
args: [current_ts, recovery_scheduled_after_ts],
|
|
387
|
+
keys: [meta_key]
|
|
388
|
+
)
|
|
315
389
|
|
|
316
390
|
became_red == 1
|
|
317
391
|
end
|
|
318
392
|
|
|
319
|
-
#
|
|
320
|
-
#
|
|
393
|
+
# Removes all traces of a light from Redis metadata (metrics will expire by TTL).
|
|
394
|
+
#
|
|
395
|
+
# @param config [Stoplight::Domain::Config] The light configuration.
|
|
396
|
+
# @return [void]
|
|
397
|
+
def delete_light(config)
|
|
398
|
+
@redis.then do |client|
|
|
399
|
+
client.del(metadata_key(config), recovery_metrics_key(config))
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# @param failure_json [String, nil]
|
|
404
|
+
# @return [Domain::Failure, nil]
|
|
321
405
|
private def deserialize_failure(failure_json)
|
|
406
|
+
return if failure_json.nil?
|
|
407
|
+
|
|
322
408
|
object = JSON.parse(failure_json)
|
|
323
409
|
error_object = object["error"]
|
|
324
410
|
|
|
@@ -389,12 +475,8 @@ module Stoplight
|
|
|
389
475
|
self.class.bucket_key(config.name, metric: "failure", time:)
|
|
390
476
|
end
|
|
391
477
|
|
|
392
|
-
private def
|
|
393
|
-
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
private def recovery_probe_errors_key(config, time:)
|
|
397
|
-
self.class.bucket_key(config.name, metric: "recovery_probe_failure", time:)
|
|
478
|
+
private def recovery_metrics_key(config)
|
|
479
|
+
key("recovery_metrics", config.name)
|
|
398
480
|
end
|
|
399
481
|
|
|
400
482
|
private def metadata_key(config)
|
|
@@ -433,42 +515,6 @@ module Stoplight
|
|
|
433
515
|
rand <= probability
|
|
434
516
|
end
|
|
435
517
|
|
|
436
|
-
private def record_success_sha
|
|
437
|
-
@record_success_sha ||= @redis.then do |client|
|
|
438
|
-
client.script("load", Lua::RECORD_SUCCESS)
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
private def get_metadata_sha
|
|
443
|
-
@get_metadata_sha ||= @redis.then do |client|
|
|
444
|
-
client.script("load", Lua::GET_METADATA)
|
|
445
|
-
end
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
private def transition_to_yellow_sha
|
|
449
|
-
@transition_to_yellow_sha ||= @redis.then do |client|
|
|
450
|
-
client.script("load", Lua::TRANSITION_TO_YELLOW)
|
|
451
|
-
end
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
private def transition_to_red_sha
|
|
455
|
-
@transition_to_red_sha ||= @redis.then do |client|
|
|
456
|
-
client.script("load", Lua::TRANSITION_TO_RED)
|
|
457
|
-
end
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
private def transition_to_green_sha
|
|
461
|
-
@transition_to_green_sha ||= @redis.then do |client|
|
|
462
|
-
client.script("load", Lua::TRANSITION_TO_GREEN)
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
private def record_failure_sha
|
|
467
|
-
@record_failure_sha ||= @redis.then do |client|
|
|
468
|
-
client.script("load", Lua::RECORD_FAILURE)
|
|
469
|
-
end
|
|
470
|
-
end
|
|
471
|
-
|
|
472
518
|
private def current_time
|
|
473
519
|
Time.now
|
|
474
520
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stoplight
|
|
4
|
+
module Infrastructure
|
|
5
|
+
module Notifier
|
|
6
|
+
# A wrapper around a notifier that provides fail-safe mechanisms using a
|
|
7
|
+
# circuit breaker. It ensures that a notification can gracefully
|
|
8
|
+
# handle failures.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class FailSafe < Domain::StateTransitionNotifier
|
|
12
|
+
# @!attribute [r] notifier
|
|
13
|
+
# @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
|
|
14
|
+
attr_reader :notifier
|
|
15
|
+
|
|
16
|
+
# @!attribute [r] error_notifier
|
|
17
|
+
# @return [Stoplight::Domain::StateTransitionNotifier] The underlying notifier being wrapped.
|
|
18
|
+
attr_reader :error_notifier
|
|
19
|
+
|
|
20
|
+
# Initializes a new instance of the +FailSafe+ class.
|
|
21
|
+
#
|
|
22
|
+
# @param notifier [Stoplight::Domain::StateTransitionNotifier] The notifier to wrap.
|
|
23
|
+
# @param error_notifier [Proc] called when wrapped data store fails
|
|
24
|
+
def initialize(notifier:, error_notifier:)
|
|
25
|
+
@notifier = notifier
|
|
26
|
+
@error_notifier = error_notifier
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Sends a notification using the wrapped notifier with fail-safe mechanisms.
|
|
30
|
+
#
|
|
31
|
+
# @param config [Stoplight::Domain::Config] The light configuration.
|
|
32
|
+
# @param from_color [String] The initial color of the light.
|
|
33
|
+
# @param to_color [String] The target color of the light.
|
|
34
|
+
# @param error [Exception, nil] An optional error to include in the notification.
|
|
35
|
+
# @return [void]
|
|
36
|
+
def notify(config, from_color, to_color, error = nil)
|
|
37
|
+
fallback = proc do |exception|
|
|
38
|
+
error_notifier.call(exception) if exception
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
circuit_breaker.run(fallback) do
|
|
43
|
+
notifier.notify(config, from_color, to_color, error)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def ==(other)
|
|
49
|
+
other.is_a?(self.class) && notifier == other.notifier
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Stoplight::Light] The circuit breaker used to handle failures.
|
|
53
|
+
private def circuit_breaker
|
|
54
|
+
@circuit_breaker ||= Stoplight.system_light(
|
|
55
|
+
"stoplight:notifier:fail_safe:#{notifier.class.name}",
|
|
56
|
+
notifiers: []
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|