minitest-distributed 0.2.12 → 0.3.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 +30 -0
- data/lib/minitest/distributed/configuration.rb +46 -0
- data/lib/minitest/distributed/coordinators/coordinator_interface.rb +6 -0
- data/lib/minitest/distributed/coordinators/memory_coordinator.rb +10 -0
- data/lib/minitest/distributed/coordinators/redis_coordinator.rb +2157 -202
- data/lib/minitest/distributed/enqueued_runnable.rb +2 -1
- data/lib/minitest/distributed/reporters/distributed_summary_reporter.rb +81 -5
- data/lib/minitest/distributed/version.rb +1 -1
- data/sorbet/config +2 -0
- data/sorbet/rbi/minitest.rbi +5 -4
- data/sorbet/rbi/shims/prism.rbi +13 -0
- data/sorbet/typed_overrides.yaml +21 -0
- metadata +4 -2
|
@@ -44,6 +44,27 @@ module Minitest
|
|
|
44
44
|
extend T::Sig
|
|
45
45
|
include CoordinatorInterface
|
|
46
46
|
|
|
47
|
+
class CoordinatorStateError < StandardError; end
|
|
48
|
+
private_constant :CoordinatorStateError
|
|
49
|
+
|
|
50
|
+
class StallProbe < T::Struct
|
|
51
|
+
const :acks, T.nilable(Integer)
|
|
52
|
+
const :size, T.nilable(Integer)
|
|
53
|
+
const :pending_count, Integer
|
|
54
|
+
const :lag, T.nilable(Integer)
|
|
55
|
+
const :production_complete, T::Boolean
|
|
56
|
+
const :production_heartbeat, T.nilable(Integer)
|
|
57
|
+
const :invalid_values, T::Array[String]
|
|
58
|
+
end
|
|
59
|
+
private_constant :StallProbe
|
|
60
|
+
|
|
61
|
+
class HeartbeatControl < T::Struct
|
|
62
|
+
const :mutex, Mutex, factory: -> { Mutex.new }
|
|
63
|
+
const :condition, ConditionVariable, factory: -> { ConditionVariable.new }
|
|
64
|
+
prop :stop_requested, T::Boolean, default: false
|
|
65
|
+
end
|
|
66
|
+
private_constant :HeartbeatControl
|
|
67
|
+
|
|
47
68
|
sig { returns(Configuration) }
|
|
48
69
|
attr_reader :configuration
|
|
49
70
|
|
|
@@ -62,60 +83,84 @@ module Minitest
|
|
|
62
83
|
sig { returns(T::Set[EnqueuedRunnable]) }
|
|
63
84
|
attr_reader :reclaimed_failed_tests
|
|
64
85
|
|
|
86
|
+
sig { returns(T.nilable(String)) }
|
|
87
|
+
attr_reader :stall_diagnostic
|
|
88
|
+
|
|
65
89
|
sig { params(configuration: Configuration).void }
|
|
66
90
|
def initialize(configuration:)
|
|
67
91
|
@configuration = configuration
|
|
68
92
|
|
|
69
93
|
@redis = T.let(nil, T.nilable(Redis))
|
|
94
|
+
@register_consumergroup_script = T.let(nil, T.nilable(String))
|
|
95
|
+
@read_results_script = T.let(nil, T.nilable(String))
|
|
96
|
+
@reset_results_script = T.let(nil, T.nilable(String))
|
|
97
|
+
@commit_results_script = T.let(nil, T.nilable(String))
|
|
98
|
+
@cleanup_script = T.let(nil, T.nilable(String))
|
|
99
|
+
@abort_script = T.let(nil, T.nilable(String))
|
|
100
|
+
@adjust_results_script = T.let(nil, T.nilable(String))
|
|
101
|
+
@publish_tests_script = T.let(nil, T.nilable(String))
|
|
102
|
+
@heartbeat_script = T.let(nil, T.nilable(String))
|
|
103
|
+
@mark_attempt_state_script = T.let(nil, T.nilable(String))
|
|
70
104
|
@stream_key = T.let(key("queue"), String)
|
|
71
|
-
@group_name = T.let(
|
|
105
|
+
@group_name = T.let(BASE_GROUP_NAME, String)
|
|
106
|
+
@attempt_generation = T.let(nil, T.nilable(String))
|
|
107
|
+
@production_heartbeat_error = T.let(nil, T.nilable(StandardError))
|
|
108
|
+
@production_heartbeat_control = T.let(nil, T.nilable(HeartbeatControl))
|
|
109
|
+
@mutating_script_mutex = T.let(Mutex.new, Mutex)
|
|
72
110
|
@local_results = T.let(ResultAggregate.new, ResultAggregate)
|
|
73
111
|
@combined_results = T.let(nil, T.nilable(ResultAggregate))
|
|
112
|
+
@combined_results_production_complete = T.let(nil, T.nilable(T::Boolean))
|
|
113
|
+
@attempt_truncated = T.let(false, T::Boolean)
|
|
114
|
+
@truncated_follower = T.let(false, T::Boolean)
|
|
115
|
+
@retry_refused_due_to_truncation = T.let(false, T::Boolean)
|
|
116
|
+
@truncation_state_invalid = T.let(false, T::Boolean)
|
|
117
|
+
@registration_rejected = T.let(false, T::Boolean)
|
|
118
|
+
@superseded = T.let(false, T::Boolean)
|
|
74
119
|
@reclaimed_timeout_tests = T.let(Set.new, T::Set[EnqueuedRunnable])
|
|
75
120
|
@reclaimed_failed_tests = T.let(Set.new, T::Set[EnqueuedRunnable])
|
|
76
121
|
@aborted = T.let(false, T::Boolean)
|
|
122
|
+
@stall_diagnostic = T.let(nil, T.nilable(String))
|
|
123
|
+
@output = T.let(nil, T.untyped)
|
|
77
124
|
end
|
|
78
125
|
|
|
79
126
|
sig { override.params(reporter: Minitest::CompositeReporter, options: T::Hash[Symbol, T.untyped]).void }
|
|
80
127
|
def register_reporters(reporter:, options:)
|
|
128
|
+
@output = options[:io]
|
|
81
129
|
reporter << Reporters::RedisCoordinatorWarningsReporter.new(options[:io], options)
|
|
82
130
|
end
|
|
83
131
|
|
|
84
132
|
sig { override.returns(ResultAggregate) }
|
|
85
133
|
def combined_results
|
|
86
134
|
@combined_results ||= begin
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
key("errors"),
|
|
93
|
-
key("skips"),
|
|
94
|
-
key("requeues"),
|
|
95
|
-
key("discards"),
|
|
96
|
-
key("acks"),
|
|
97
|
-
key("size"),
|
|
135
|
+
keys = STATS_KEY_NAMES.map { |name| key(name) }
|
|
136
|
+
keys << key("production_complete")
|
|
137
|
+
response = T.cast(
|
|
138
|
+
execute_script(script_name: :read_results, keys: keys, argv: []),
|
|
139
|
+
T::Array[T.untyped],
|
|
98
140
|
)
|
|
141
|
+
@combined_results_production_complete = response.fetch(0) == 1
|
|
142
|
+
stats_as_string = response.drop(1)
|
|
99
143
|
|
|
100
144
|
ResultAggregate.new(
|
|
101
145
|
max_failures: configuration.max_failures,
|
|
102
146
|
|
|
103
|
-
runs: Integer(stats_as_string.fetch(0)
|
|
104
|
-
assertions: Integer(stats_as_string.fetch(1)
|
|
105
|
-
passes: Integer(stats_as_string.fetch(2)
|
|
106
|
-
failures: Integer(stats_as_string.fetch(3)
|
|
107
|
-
errors: Integer(stats_as_string.fetch(4)
|
|
108
|
-
skips: Integer(stats_as_string.fetch(5)
|
|
109
|
-
requeues: Integer(stats_as_string.fetch(6)
|
|
110
|
-
discards: Integer(stats_as_string.fetch(7)
|
|
111
|
-
acks: Integer(stats_as_string.fetch(8)
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
size: Integer(stats_as_string.fetch(9) || 2_147_483_647),
|
|
147
|
+
runs: Integer(stats_as_string.fetch(0).then { |value| value == "" ? 0 : value }),
|
|
148
|
+
assertions: Integer(stats_as_string.fetch(1).then { |value| value == "" ? 0 : value }),
|
|
149
|
+
passes: Integer(stats_as_string.fetch(2).then { |value| value == "" ? 0 : value }),
|
|
150
|
+
failures: Integer(stats_as_string.fetch(3).then { |value| value == "" ? 0 : value }),
|
|
151
|
+
errors: Integer(stats_as_string.fetch(4).then { |value| value == "" ? 0 : value }),
|
|
152
|
+
skips: Integer(stats_as_string.fetch(5).then { |value| value == "" ? 0 : value }),
|
|
153
|
+
requeues: Integer(stats_as_string.fetch(6).then { |value| value == "" ? 0 : value }),
|
|
154
|
+
discards: Integer(stats_as_string.fetch(7).then { |value| value == "" ? 0 : value }),
|
|
155
|
+
acks: Integer(stats_as_string.fetch(8).then { |value| value == "" ? 0 : value }),
|
|
156
|
+
|
|
157
|
+
# Before the producer initializes counters, a sentinel prevents the
|
|
158
|
+
# absent size from making an unpublished run appear complete.
|
|
159
|
+
size: Integer(stats_as_string.fetch(9).then { |value| value == "" ? 2_147_483_647 : value }),
|
|
117
160
|
)
|
|
118
161
|
end
|
|
162
|
+
rescue ArgumentError, TypeError => parse_error
|
|
163
|
+
raise CoordinatorStateError, "invalid Redis aggregate: #{parse_error.message}"
|
|
119
164
|
end
|
|
120
165
|
|
|
121
166
|
sig { override.returns(T::Boolean) }
|
|
@@ -123,150 +168,432 @@ module Minitest
|
|
|
123
168
|
@aborted
|
|
124
169
|
end
|
|
125
170
|
|
|
126
|
-
sig { override.
|
|
127
|
-
def
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
consumer_group_exists = true
|
|
149
|
-
else
|
|
150
|
-
raise
|
|
151
|
-
end
|
|
152
|
-
end
|
|
171
|
+
sig { override.returns(T::Boolean) }
|
|
172
|
+
def valid_combined_results?
|
|
173
|
+
# Reporter success must be based on one fresh atomic snapshot. Counters
|
|
174
|
+
# alone can look complete after production_complete is evicted.
|
|
175
|
+
@combined_results = nil
|
|
176
|
+
results = combined_results
|
|
177
|
+
results.valid? && @combined_results_production_complete == true
|
|
178
|
+
rescue Redis::BaseError, CoordinatorStateError
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
sig { override.returns(T::Boolean) }
|
|
183
|
+
def persisted_truncation?
|
|
184
|
+
truncated_key = key("truncated")
|
|
185
|
+
generation_key = key("truncated_generation")
|
|
186
|
+
return false unless redis.type(truncated_key) == "string"
|
|
187
|
+
return false unless redis.type(generation_key) == "string"
|
|
188
|
+
|
|
189
|
+
redis.get(truncated_key) == "1" && !redis.get(generation_key).to_s.empty?
|
|
190
|
+
rescue Redis::BaseError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
153
193
|
|
|
154
|
-
|
|
194
|
+
sig { returns(T::Boolean) }
|
|
195
|
+
def stalled?
|
|
196
|
+
!stall_diagnostic.nil?
|
|
197
|
+
end
|
|
155
198
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
sig { returns(T::Boolean) }
|
|
200
|
+
def registration_rejected?
|
|
201
|
+
@registration_rejected
|
|
202
|
+
end
|
|
160
203
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
204
|
+
sig { returns(T::Boolean) }
|
|
205
|
+
def retry_refused_due_to_truncation?
|
|
206
|
+
@retry_refused_due_to_truncation
|
|
207
|
+
end
|
|
164
208
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if combined_results.abort?
|
|
170
|
-
# We mark this run as aborted, which causes this worker to not be successful.
|
|
171
|
-
@aborted = true
|
|
209
|
+
sig { returns(T::Boolean) }
|
|
210
|
+
def current_attempt_truncated?
|
|
211
|
+
@attempt_truncated
|
|
212
|
+
end
|
|
172
213
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
214
|
+
sig { returns(T::Boolean) }
|
|
215
|
+
def truncation_state_invalid?
|
|
216
|
+
@truncation_state_invalid
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
sig { returns(T::Boolean) }
|
|
220
|
+
def superseded?
|
|
221
|
+
@superseded
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
sig { override.params(test_selector: TestSelector).void }
|
|
225
|
+
def produce(test_selector:)
|
|
226
|
+
production_heartbeat_thread = T.let(nil, T.nilable(Thread))
|
|
227
|
+
propagate_heartbeat_error = T.let(false, T::Boolean)
|
|
228
|
+
|
|
229
|
+
registration_keys = [
|
|
230
|
+
stream_key,
|
|
231
|
+
key("attempt_generation"),
|
|
232
|
+
key("stalled"),
|
|
233
|
+
key("production_complete"),
|
|
234
|
+
key("production_heartbeat"),
|
|
235
|
+
key("retry_set"),
|
|
236
|
+
]
|
|
237
|
+
registration_keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
|
|
238
|
+
registration_keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
|
|
239
|
+
registration_keys.push(
|
|
240
|
+
key("truncated"),
|
|
241
|
+
key("completed_at"),
|
|
242
|
+
key("retry_snapshot_digest"),
|
|
243
|
+
key("truncated_generation"),
|
|
244
|
+
key("retention_ttl"),
|
|
245
|
+
)
|
|
246
|
+
registration = T.let(nil, T.untyped)
|
|
247
|
+
registration_mode = T.let(-1, Integer)
|
|
248
|
+
leader = T.let(false, T::Boolean)
|
|
249
|
+
awaited_generation = T.let(nil, T.nilable(String))
|
|
250
|
+
loop do
|
|
251
|
+
registration = T.unsafe(execute_script(
|
|
252
|
+
script_name: :register_consumergroup,
|
|
253
|
+
keys: registration_keys,
|
|
254
|
+
argv: [
|
|
255
|
+
BASE_GROUP_NAME,
|
|
256
|
+
configuration.key_ttl_seconds,
|
|
257
|
+
configuration.max_failures || "",
|
|
258
|
+
SecureRandom.uuid,
|
|
259
|
+
configuration.completion_grace_seconds,
|
|
260
|
+
awaited_generation || "",
|
|
261
|
+
],
|
|
262
|
+
))
|
|
263
|
+
|
|
264
|
+
leader = registration.fetch(0) == 1
|
|
265
|
+
@attempt_generation = String(registration.fetch(1))
|
|
266
|
+
@group_name = "#{BASE_GROUP_NAME}-#{@attempt_generation}"
|
|
267
|
+
registration_mode = Integer(registration.fetch(2))
|
|
268
|
+
if registration_mode == -3
|
|
269
|
+
@aborted = true
|
|
270
|
+
@truncated_follower = true
|
|
271
|
+
@retry_refused_due_to_truncation = true
|
|
272
|
+
elsif registration_mode == -4
|
|
273
|
+
@aborted = true
|
|
274
|
+
@registration_rejected = true
|
|
275
|
+
emit_message(<<~ERROR)
|
|
276
|
+
ERROR: minitest-distributed rejected a Redis key TTL change for an existing run.
|
|
277
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
278
|
+
Active workers must use the same key TTL, and later retries cannot decrease the retained TTL.
|
|
279
|
+
ERROR
|
|
280
|
+
elsif registration_mode == -5
|
|
281
|
+
@aborted = true
|
|
282
|
+
@truncation_state_invalid = true
|
|
283
|
+
emit_message(<<~ERROR)
|
|
284
|
+
ERROR: minitest-distributed cannot retry because the retained truncation fence is incomplete.
|
|
285
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
286
|
+
The run remains failed; restore or expire its retained state before reusing this run ID.
|
|
287
|
+
ERROR
|
|
208
288
|
end
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
289
|
+
break unless registration_mode == -2
|
|
290
|
+
|
|
291
|
+
sleep(Float(registration.fetch(5)))
|
|
292
|
+
awaited_generation = String(registration.fetch(1))
|
|
212
293
|
end
|
|
294
|
+
return unless leader
|
|
295
|
+
|
|
296
|
+
previous_failures = T.cast(registration.fetch(3), T::Array[String])
|
|
297
|
+
previous_errors = T.cast(registration.fetch(4), T::Array[String])
|
|
298
|
+
production_heartbeat_thread = start_production_heartbeat unless registration_mode == 3
|
|
299
|
+
|
|
300
|
+
tests = T.let(
|
|
301
|
+
case registration_mode
|
|
302
|
+
when 0 # First attempt for a new run ID.
|
|
303
|
+
tests_from_selector = test_selector.tests
|
|
304
|
+
adjust_combined_results(
|
|
305
|
+
ResultAggregate.new(size: tests_from_selector.size),
|
|
306
|
+
allow_missing_stats: true,
|
|
307
|
+
)
|
|
308
|
+
tests_from_selector
|
|
309
|
+
when 1 # Valid completed attempt; use selective retry behavior.
|
|
310
|
+
if configuration.retry_failures
|
|
311
|
+
test_identifiers_to_retry = T.let(previous_failures + previous_errors, T::Array[String])
|
|
312
|
+
retry_tests = materialize_retry_tests(test_identifiers_to_retry)
|
|
313
|
+
if retry_tests
|
|
314
|
+
total_failures = retry_tests.length
|
|
315
|
+
adjust_combined_results(
|
|
316
|
+
ResultAggregate.new(
|
|
317
|
+
size: total_failures,
|
|
318
|
+
failures: -previous_failures.length,
|
|
319
|
+
errors: -previous_errors.length,
|
|
320
|
+
requeues: total_failures,
|
|
321
|
+
),
|
|
322
|
+
clear_retry_lists: true,
|
|
323
|
+
)
|
|
324
|
+
retry_tests
|
|
325
|
+
else
|
|
326
|
+
emit_message(<<~WARNING)
|
|
327
|
+
WARNING: The previous attempt retained an invalid retry identifier.
|
|
328
|
+
Running the full test suite instead of a selective retry.
|
|
329
|
+
WARNING
|
|
330
|
+
tests_from_selector = test_selector.tests
|
|
331
|
+
reset_combined_results_for_full_rerun(size: tests_from_selector.size)
|
|
332
|
+
tests_from_selector
|
|
333
|
+
end
|
|
334
|
+
else
|
|
335
|
+
adjust_combined_results(ResultAggregate.new(size: 0))
|
|
336
|
+
[]
|
|
337
|
+
end
|
|
338
|
+
when 2 # Inconsistent or explicitly stalled state; rerun everything.
|
|
339
|
+
emit_message(<<~WARNING)
|
|
340
|
+
WARNING: The previous attempt lost Redis coordinator state.
|
|
341
|
+
Running the full test suite instead of a selective retry.
|
|
342
|
+
WARNING
|
|
343
|
+
tests_from_selector = test_selector.tests
|
|
344
|
+
adjust_combined_results(
|
|
345
|
+
ResultAggregate.new(size: tests_from_selector.size),
|
|
346
|
+
allow_missing_stats: true,
|
|
347
|
+
)
|
|
348
|
+
tests_from_selector
|
|
349
|
+
when 3 # The previous attempt intentionally stopped at max_failures.
|
|
350
|
+
@aborted = true
|
|
351
|
+
@retry_refused_due_to_truncation = true
|
|
352
|
+
[]
|
|
353
|
+
else
|
|
354
|
+
raise "Unknown Redis registration mode: #{registration_mode}"
|
|
355
|
+
end,
|
|
356
|
+
T::Array[Minitest::Runnable],
|
|
357
|
+
)
|
|
213
358
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
359
|
+
unless @retry_refused_due_to_truncation
|
|
360
|
+
begin
|
|
361
|
+
publish_tests(tests)
|
|
362
|
+
propagate_heartbeat_error = true
|
|
363
|
+
rescue Redis::CommandError => error
|
|
364
|
+
raise unless handle_publish_race(error)
|
|
217
365
|
end
|
|
218
366
|
end
|
|
367
|
+
ensure
|
|
368
|
+
if production_heartbeat_thread
|
|
369
|
+
stop_production_heartbeat(
|
|
370
|
+
production_heartbeat_thread,
|
|
371
|
+
propagate_error: propagate_heartbeat_error,
|
|
372
|
+
)
|
|
373
|
+
end
|
|
219
374
|
end
|
|
220
375
|
|
|
376
|
+
# The loop intentionally keeps the stale/fresh claim and diagnostic state
|
|
377
|
+
# transitions together so each iteration observes one coherent snapshot.
|
|
378
|
+
# rubocop:disable Metrics/BlockNesting, Lint/RedundantCopDisableDirective
|
|
221
379
|
sig { override.params(reporter: AbstractReporter).void }
|
|
222
380
|
def consume(reporter:)
|
|
381
|
+
return if @retry_refused_due_to_truncation || @registration_rejected || @truncation_state_invalid || superseded?
|
|
382
|
+
|
|
223
383
|
exponential_backoff = INITIAL_BACKOFF
|
|
384
|
+
last_progress_at = monotonic_time
|
|
385
|
+
initial_results = combined_results
|
|
386
|
+
observed_acks = T.let(initial_results.acks, T.nilable(Integer))
|
|
387
|
+
observed_size = T.let(initial_results.size, T.nilable(Integer))
|
|
388
|
+
observed_production_heartbeat = current_production_heartbeat
|
|
389
|
+
pending_stall_warning_emitted = T.let(false, T::Boolean)
|
|
390
|
+
drained_mismatch_detected_at = T.let(nil, T.nilable(Float))
|
|
391
|
+
incomplete_production_detected_at = T.let(nil, T.nilable(Float))
|
|
392
|
+
|
|
224
393
|
loop do
|
|
394
|
+
# Other workers can advance or complete the run without touching this
|
|
395
|
+
# process's memoized aggregate. Refresh once per polling iteration.
|
|
396
|
+
@combined_results = nil
|
|
397
|
+
|
|
225
398
|
# First, see if there are any pending tests from other workers to claim.
|
|
226
399
|
stale_runnables = claim_stale_runnables
|
|
227
400
|
process_batch(stale_runnables, reporter)
|
|
401
|
+
break if commit_observed_truncation? || superseded?
|
|
228
402
|
|
|
229
403
|
# Then, try to process a regular batch of messages
|
|
230
404
|
fresh_runnables = claim_fresh_runnables(block: exponential_backoff)
|
|
231
405
|
process_batch(fresh_runnables, reporter)
|
|
406
|
+
break if commit_observed_truncation? || superseded?
|
|
407
|
+
|
|
408
|
+
run_results = combined_results
|
|
232
409
|
|
|
233
410
|
# If we have acked the same amount of tests as we were supposed to, the run
|
|
234
411
|
# is complete and we can exit our loop. Generally, only one worker will detect
|
|
235
|
-
# this condition. The
|
|
236
|
-
#
|
|
237
|
-
# will start to fail - see the rescue block below.
|
|
238
|
-
|
|
412
|
+
# this condition. The other workers will quit their consumer loop because the
|
|
413
|
+
# consumer group will be deleted by the first worker, and their Redis commands
|
|
414
|
+
# will start to fail - see the rescue block below. Counters can be 0/0 before
|
|
415
|
+
# an empty run finishes publishing, so production completion is also required.
|
|
416
|
+
break if run_complete?(run_results)
|
|
417
|
+
|
|
418
|
+
# We also abort a run if we reach the maximum number of failures.
|
|
419
|
+
if run_results.abort? && !run_results.complete?
|
|
420
|
+
mark_run_truncated
|
|
421
|
+
break
|
|
422
|
+
end
|
|
239
423
|
|
|
240
|
-
|
|
241
|
-
|
|
424
|
+
processed_batch = stale_runnables.any? || fresh_runnables.any?
|
|
425
|
+
if processed_batch
|
|
426
|
+
last_progress_at = monotonic_time
|
|
427
|
+
observed_acks = run_results.acks
|
|
428
|
+
observed_size = run_results.size
|
|
429
|
+
drained_mismatch_detected_at = nil
|
|
430
|
+
incomplete_production_detected_at = nil
|
|
431
|
+
else
|
|
432
|
+
now = monotonic_time
|
|
433
|
+
confirmation_interval = [configuration.stall_timeout_seconds, STALL_CONFIRMATION_SECONDS].min
|
|
434
|
+
confirmation_due = if drained_mismatch_detected_at
|
|
435
|
+
now - drained_mismatch_detected_at >= confirmation_interval
|
|
436
|
+
elsif incomplete_production_detected_at
|
|
437
|
+
now - incomplete_production_detected_at >= configuration.stall_timeout_seconds
|
|
438
|
+
else
|
|
439
|
+
true
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
if now - last_progress_at >= configuration.stall_timeout_seconds && confirmation_due
|
|
443
|
+
probe = probe_stall
|
|
444
|
+
@combined_results = nil
|
|
445
|
+
|
|
446
|
+
if probe.invalid_values.any?
|
|
447
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
448
|
+
ERROR: minitest-distributed found invalid numeric Redis coordinator state.
|
|
449
|
+
#{format_probe_state(probe)}
|
|
450
|
+
Invalid values: #{probe.invalid_values.join(", ")}
|
|
451
|
+
DIAGNOSTIC
|
|
452
|
+
break
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Another worker may have completed the run while our memoized aggregate
|
|
456
|
+
# was stale. Treat the fresh counters as authoritative.
|
|
457
|
+
if probe.production_complete && !probe.acks.nil? && probe.acks == probe.size
|
|
458
|
+
# The probe checks completion/liveness fields only. Validate all
|
|
459
|
+
# retained statistics and the same-snapshot production marker
|
|
460
|
+
# before accepting terminal success.
|
|
461
|
+
terminal_results = combined_results
|
|
462
|
+
break if run_complete?(terminal_results)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
counters_changed = probe.acks != observed_acks || probe.size != observed_size
|
|
466
|
+
heartbeat_changed = probe.production_heartbeat != observed_production_heartbeat
|
|
467
|
+
if counters_changed
|
|
468
|
+
observed_acks = probe.acks
|
|
469
|
+
observed_size = probe.size
|
|
470
|
+
end
|
|
471
|
+
observed_production_heartbeat = probe.production_heartbeat
|
|
472
|
+
|
|
473
|
+
stream_empty = probe.pending_count.zero? && probe.lag == 0
|
|
474
|
+
if probe.production_complete && stream_empty
|
|
475
|
+
incomplete_production_detected_at = nil
|
|
476
|
+
if drained_mismatch_detected_at && !counters_changed
|
|
477
|
+
abort_stalled_run(probe)
|
|
478
|
+
break
|
|
479
|
+
else
|
|
480
|
+
# The probe is pipelined rather than transactional. Confirm an
|
|
481
|
+
# unchanged mismatch so a final atomic commit interleaved with the
|
|
482
|
+
# probe cannot cause a false abort.
|
|
483
|
+
drained_mismatch_detected_at = now
|
|
484
|
+
end
|
|
485
|
+
elsif !probe.production_complete && stream_empty
|
|
486
|
+
drained_mismatch_detected_at = nil
|
|
487
|
+
production_progressed = counters_changed || heartbeat_changed
|
|
488
|
+
if incomplete_production_detected_at && !production_progressed
|
|
489
|
+
abort_stalled_run(probe)
|
|
490
|
+
break
|
|
491
|
+
elsif production_progressed
|
|
492
|
+
# A live leader refreshes this heartbeat while it selects and
|
|
493
|
+
# publishes tests, so arbitrarily slow discovery is not mistaken
|
|
494
|
+
# for a dead producer.
|
|
495
|
+
incomplete_production_detected_at = nil
|
|
496
|
+
last_progress_at = now
|
|
497
|
+
else
|
|
498
|
+
# No producer heartbeat was observed. Confirm once more after a
|
|
499
|
+
# full stall interval before declaring the leader dead.
|
|
500
|
+
incomplete_production_detected_at = now
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
drained_mismatch_detected_at = nil
|
|
504
|
+
incomplete_production_detected_at = nil
|
|
505
|
+
|
|
506
|
+
if probe.pending_count > 0 && !pending_stall_warning_emitted
|
|
507
|
+
emit_message(format_pending_stall_warning(probe))
|
|
508
|
+
pending_stall_warning_emitted = true
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# A non-empty PEL may be waiting for the legitimate
|
|
512
|
+
# test_timeout_seconds * test_batch_size reclaim path. Undelivered
|
|
513
|
+
# entries can likewise be claimed by another worker. Wait another
|
|
514
|
+
# full interval before probing again.
|
|
515
|
+
last_progress_at = now
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
242
519
|
|
|
243
520
|
# To make sure we don't end up in a busy loop overwhelming Redis with commands
|
|
244
521
|
# when there is no work to do, we increase the blocking time exponentially,
|
|
245
522
|
# and reset it to the initial value if we processed any tests.
|
|
246
|
-
|
|
247
|
-
|
|
523
|
+
#
|
|
524
|
+
# The backoff is capped at MAX_BACKOFF to bound how long a worker can sit
|
|
525
|
+
# inside a single XREADGROUP BLOCK call. Without a cap, after ~15 empty
|
|
526
|
+
# iterations the worker is blocked in Redis for 5+ minutes and cannot
|
|
527
|
+
# re-check `complete?` / `abort?` until the BLOCK returns, which manifests
|
|
528
|
+
# as a long post-100% teardown hang when pipelined XACKs race the progress
|
|
529
|
+
# reporter.
|
|
530
|
+
exponential_backoff = if processed_batch
|
|
531
|
+
INITIAL_BACKOFF
|
|
248
532
|
else
|
|
249
|
-
exponential_backoff
|
|
533
|
+
next_backoff(exponential_backoff)
|
|
250
534
|
end
|
|
251
535
|
end
|
|
252
536
|
|
|
253
537
|
cleanup
|
|
254
538
|
rescue Redis::CommandError => ce
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
539
|
+
coordinator_state_error = ce.message.start_with?(
|
|
540
|
+
"NOGROUP",
|
|
541
|
+
"WRONGTYPE",
|
|
542
|
+
"COORDINATORSTATE",
|
|
543
|
+
"COORDINATORSTREAM",
|
|
544
|
+
"STALEATTEMPT",
|
|
545
|
+
"COORDINATORCONFIG",
|
|
546
|
+
) || ce.message.include?("no such key")
|
|
547
|
+
if coordinator_state_error
|
|
548
|
+
# A normal cleanup and missing/evicted state can produce similar Redis
|
|
549
|
+
# errors. Fresh counters distinguish a terminal run from data loss.
|
|
550
|
+
handle_coordinator_state_error(ce)
|
|
551
|
+
cleanup if stalled?
|
|
263
552
|
else
|
|
264
553
|
raise
|
|
265
554
|
end
|
|
555
|
+
rescue CoordinatorStateError => parse_error
|
|
556
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
557
|
+
ERROR: minitest-distributed could not parse Redis coordinator state.
|
|
558
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
559
|
+
parse_error=#{parse_error.message.inspect}
|
|
560
|
+
DIAGNOSTIC
|
|
561
|
+
cleanup if stalled?
|
|
562
|
+
ensure
|
|
563
|
+
# Another worker may commit the final batch and clean up while this
|
|
564
|
+
# worker is unwinding from NOGROUP. Report and validate against one
|
|
565
|
+
# last fresh aggregate rather than a pre-cleanup local cache.
|
|
566
|
+
@combined_results = nil
|
|
266
567
|
end
|
|
568
|
+
# rubocop:enable Metrics/BlockNesting, Lint/RedundantCopDisableDirective
|
|
267
569
|
|
|
268
570
|
private
|
|
269
571
|
|
|
572
|
+
sig { params(error: Redis::CommandError).returns(T::Boolean) }
|
|
573
|
+
def handle_publish_race(error)
|
|
574
|
+
expected_race = error.message.start_with?("NOGROUP", "COORDINATORSTREAM", "STALEATTEMPT") ||
|
|
575
|
+
error.message.include?("no such key")
|
|
576
|
+
return false unless expected_race
|
|
577
|
+
|
|
578
|
+
if error.message.start_with?("STALEATTEMPT")
|
|
579
|
+
@superseded = true
|
|
580
|
+
return true
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
if attempt_truncated?
|
|
584
|
+
@attempt_truncated = true
|
|
585
|
+
@aborted = true
|
|
586
|
+
return true
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
if attempt_superseded?
|
|
590
|
+
@superseded = true
|
|
591
|
+
return true
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
false
|
|
595
|
+
end
|
|
596
|
+
|
|
270
597
|
sig { returns(Redis) }
|
|
271
598
|
def redis
|
|
272
599
|
@redis ||= Redis.new(
|
|
@@ -277,7 +604,7 @@ module Minitest
|
|
|
277
604
|
)
|
|
278
605
|
end
|
|
279
606
|
|
|
280
|
-
sig { returns(T.nilable(T::Array[
|
|
607
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
281
608
|
def custom_middlewares
|
|
282
609
|
return unless ENV.key?("MINITEST_DISTRIBUTED_REDIS_LOG")
|
|
283
610
|
|
|
@@ -290,22 +617,1074 @@ module Minitest
|
|
|
290
617
|
{ log_file: logger }.compact
|
|
291
618
|
end
|
|
292
619
|
|
|
620
|
+
sig { returns(String) }
|
|
621
|
+
def read_results_script
|
|
622
|
+
@read_results_script ||= redis.script(:load, <<~LUA)
|
|
623
|
+
-- KEYS: ten statistics followed by production_complete.
|
|
624
|
+
local max_safe_integer = 9007199254740991
|
|
625
|
+
local production_type = redis.call('TYPE', KEYS[11]).ok
|
|
626
|
+
local production_complete = false
|
|
627
|
+
if production_type == 'string' then
|
|
628
|
+
if redis.call('GET', KEYS[11]) ~= '1' then
|
|
629
|
+
return redis.error_reply('COORDINATORSTATE invalid production_complete marker')
|
|
630
|
+
end
|
|
631
|
+
production_complete = true
|
|
632
|
+
elseif production_type ~= 'none' then
|
|
633
|
+
return redis.error_reply('COORDINATORSTATE invalid production_complete type')
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
local values = {}
|
|
637
|
+
local existing_count = 0
|
|
638
|
+
for stat_index = 1, 10 do
|
|
639
|
+
local stat_type = redis.call('TYPE', KEYS[stat_index]).ok
|
|
640
|
+
if stat_type == 'none' then
|
|
641
|
+
if production_complete then
|
|
642
|
+
return redis.error_reply('COORDINATORSTATE missing terminal statistic ' .. KEYS[stat_index])
|
|
643
|
+
end
|
|
644
|
+
values[stat_index] = ''
|
|
645
|
+
elseif stat_type ~= 'string' then
|
|
646
|
+
return redis.error_reply('COORDINATORSTATE invalid statistic type ' .. KEYS[stat_index])
|
|
647
|
+
else
|
|
648
|
+
local validation = redis.pcall('INCRBY', KEYS[stat_index], 0)
|
|
649
|
+
if type(validation) == 'table' and validation.err then
|
|
650
|
+
return redis.error_reply('COORDINATORSTATE invalid statistic ' .. KEYS[stat_index])
|
|
651
|
+
end
|
|
652
|
+
local value = tonumber(validation)
|
|
653
|
+
if not value or value < 0 or value > max_safe_integer then
|
|
654
|
+
return redis.error_reply('COORDINATORSTATE unsafe statistic ' .. KEYS[stat_index])
|
|
655
|
+
end
|
|
656
|
+
values[stat_index] = value
|
|
657
|
+
existing_count = existing_count + 1
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
if existing_count > 0 and existing_count < 10 then
|
|
662
|
+
return redis.error_reply('COORDINATORSTATE partial aggregate statistics')
|
|
663
|
+
elseif existing_count == 10 then
|
|
664
|
+
local runs = values[1]
|
|
665
|
+
local reported = values[3] + values[4] + values[5] + values[6]
|
|
666
|
+
if values[9] > values[10] or runs < values[7] + values[8] or
|
|
667
|
+
runs - values[7] - values[8] ~= reported then
|
|
668
|
+
return redis.error_reply('COORDINATORSTATE inconsistent aggregate statistics')
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
local reply = {production_complete and 1 or 0}
|
|
673
|
+
for stat_index = 1, 10 do
|
|
674
|
+
if values[stat_index] == '' then
|
|
675
|
+
reply[#reply + 1] = ''
|
|
676
|
+
else
|
|
677
|
+
reply[#reply + 1] = values[stat_index]
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
return reply
|
|
681
|
+
LUA
|
|
682
|
+
end
|
|
683
|
+
|
|
293
684
|
sig { returns(String) }
|
|
294
685
|
def register_consumergroup_script
|
|
295
|
-
@register_consumergroup_script ||=
|
|
296
|
-
--
|
|
297
|
-
--
|
|
298
|
-
--
|
|
299
|
-
--
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
686
|
+
@register_consumergroup_script ||= redis.script(:load, <<~LUA)
|
|
687
|
+
-- KEYS: stream, generation token, stalled, production_complete,
|
|
688
|
+
-- production_heartbeat, retry_set, ten statistics, the
|
|
689
|
+
-- skipped/failed/error lists, truncated, completed_at, then the
|
|
690
|
+
-- completed retry-snapshot digest, truncating generation, and the
|
|
691
|
+
-- monotonic retained-state TTL.
|
|
692
|
+
local max_safe_integer = 9007199254740991
|
|
693
|
+
local invalid_active_state = false
|
|
694
|
+
local requested_ttl = tonumber(ARGV[2])
|
|
695
|
+
local retention_type = redis.call('TYPE', KEYS[24]).ok
|
|
696
|
+
local retained_ttl = nil
|
|
697
|
+
if retention_type == 'string' then
|
|
698
|
+
local validation = redis.pcall('INCRBY', KEYS[24], 0)
|
|
699
|
+
if type(validation) == 'table' and validation.err then
|
|
700
|
+
invalid_active_state = true
|
|
701
|
+
else
|
|
702
|
+
retained_ttl = tonumber(validation)
|
|
703
|
+
if not retained_ttl or retained_ttl <= 0 then
|
|
704
|
+
invalid_active_state = true
|
|
705
|
+
elseif requested_ttl < retained_ttl then
|
|
706
|
+
return {0, '', -4, {}, {}}
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
elseif retention_type ~= 'none' then
|
|
710
|
+
invalid_active_state = true
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
local stream_type = redis.call('TYPE', KEYS[1]).ok
|
|
714
|
+
local stream_exists = stream_type == 'stream'
|
|
715
|
+
if stream_type ~= 'none' and stream_type ~= 'stream' then
|
|
716
|
+
invalid_active_state = true
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
local generation_type = redis.call('TYPE', KEYS[2]).ok
|
|
720
|
+
local current_generation = nil
|
|
721
|
+
if generation_type == 'string' then
|
|
722
|
+
current_generation = redis.call('GET', KEYS[2])
|
|
723
|
+
if current_generation == '' then
|
|
724
|
+
current_generation = nil
|
|
725
|
+
invalid_active_state = true
|
|
726
|
+
end
|
|
727
|
+
elseif generation_type ~= 'none' then
|
|
728
|
+
invalid_active_state = true
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
local function read_marker(key)
|
|
732
|
+
local marker_type = redis.call('TYPE', key).ok
|
|
733
|
+
if marker_type == 'none' then
|
|
734
|
+
return false, false
|
|
735
|
+
elseif marker_type == 'string' and redis.call('GET', key) == '1' then
|
|
736
|
+
return true, false
|
|
737
|
+
end
|
|
738
|
+
return false, true
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
local stalled, stalled_invalid = read_marker(KEYS[3])
|
|
742
|
+
local production_complete, production_complete_invalid = read_marker(KEYS[4])
|
|
743
|
+
local truncated, truncated_invalid = read_marker(KEYS[20])
|
|
744
|
+
local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
|
|
745
|
+
local truncated_generation = nil
|
|
746
|
+
local truncation_fence_invalid = false
|
|
747
|
+
if truncated then
|
|
748
|
+
if truncated_generation_type == 'string' then
|
|
749
|
+
truncated_generation = redis.call('GET', KEYS[23])
|
|
750
|
+
if truncated_generation == '' then
|
|
751
|
+
truncated_generation = nil
|
|
752
|
+
truncation_fence_invalid = true
|
|
753
|
+
end
|
|
754
|
+
else
|
|
755
|
+
truncation_fence_invalid = true
|
|
756
|
+
end
|
|
757
|
+
elseif truncated_generation_type ~= 'none' then
|
|
758
|
+
truncated_invalid = true
|
|
759
|
+
end
|
|
760
|
+
if stalled_invalid or production_complete_invalid or truncated_invalid then
|
|
761
|
+
invalid_active_state = true
|
|
762
|
+
end
|
|
763
|
+
if truncation_fence_invalid or (truncated and not current_generation) then
|
|
764
|
+
return {0, current_generation or '', -5, {}, {}}
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
local heartbeat_type = redis.call('TYPE', KEYS[5]).ok
|
|
768
|
+
if heartbeat_type ~= 'none' and heartbeat_type ~= 'string' then
|
|
769
|
+
invalid_active_state = true
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
local function retry_snapshot_digest(failed, errors)
|
|
773
|
+
local pieces = {'failures', tostring(#failed)}
|
|
774
|
+
for _, identifier in ipairs(failed) do
|
|
775
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
776
|
+
pieces[#pieces + 1] = identifier
|
|
777
|
+
end
|
|
778
|
+
pieces[#pieces + 1] = 'errors'
|
|
779
|
+
pieces[#pieces + 1] = tostring(#errors)
|
|
780
|
+
for _, identifier in ipairs(errors) do
|
|
781
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
782
|
+
pieces[#pieces + 1] = identifier
|
|
783
|
+
end
|
|
784
|
+
return redis.sha1hex(table.concat(pieces, string.char(0)))
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
local function read_safe_integer(key)
|
|
788
|
+
if redis.call('TYPE', key).ok ~= 'string' then
|
|
789
|
+
return nil
|
|
790
|
+
end
|
|
791
|
+
local validation = redis.pcall('INCRBY', key, 0)
|
|
792
|
+
if type(validation) == 'table' and validation.err then
|
|
793
|
+
return nil
|
|
794
|
+
end
|
|
795
|
+
local value = tonumber(validation)
|
|
796
|
+
if not value or value < 0 or value > max_safe_integer then
|
|
797
|
+
return nil
|
|
798
|
+
end
|
|
799
|
+
return value
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
local required_stats = {}
|
|
803
|
+
for key_index = 7, 16 do
|
|
804
|
+
required_stats[#required_stats + 1] = KEYS[key_index]
|
|
805
|
+
end
|
|
806
|
+
local existing_stat_count = redis.call('EXISTS', unpack(required_stats))
|
|
807
|
+
local stat_values = {}
|
|
808
|
+
if existing_stat_count == 10 then
|
|
809
|
+
for key_index = 7, 16 do
|
|
810
|
+
local value = read_safe_integer(KEYS[key_index])
|
|
811
|
+
if value == nil then
|
|
812
|
+
invalid_active_state = true
|
|
813
|
+
else
|
|
814
|
+
stat_values[#stat_values + 1] = value
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
if not invalid_active_state then
|
|
818
|
+
local runs = stat_values[1]
|
|
819
|
+
local reported = stat_values[3] + stat_values[4] + stat_values[5] + stat_values[6]
|
|
820
|
+
if stat_values[9] > stat_values[10] or runs < stat_values[7] + stat_values[8] or
|
|
821
|
+
runs - stat_values[7] - stat_values[8] ~= reported then
|
|
822
|
+
invalid_active_state = true
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
elseif existing_stat_count > 0 or production_complete then
|
|
826
|
+
invalid_active_state = true
|
|
827
|
+
end
|
|
828
|
+
if not retained_ttl and (stream_exists or current_generation or existing_stat_count > 0) then
|
|
829
|
+
invalid_active_state = true
|
|
830
|
+
end
|
|
831
|
+
if existing_stat_count > 0 and not current_generation then
|
|
832
|
+
invalid_active_state = true
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
-- Active pre-production attempts are safe to join only after all
|
|
836
|
+
-- retained state passes validation. Terminal attempts fall through
|
|
837
|
+
-- to grace handling and fenced retry takeover below.
|
|
838
|
+
if stream_exists and current_generation and not stalled and not invalid_active_state then
|
|
839
|
+
local terminal_complete = production_complete and existing_stat_count == 10 and
|
|
840
|
+
stat_values[9] == stat_values[10]
|
|
841
|
+
if truncated and truncated_generation ~= current_generation then
|
|
842
|
+
if requested_ttl ~= retained_ttl then
|
|
843
|
+
return {0, current_generation, -4, {}, {}}
|
|
844
|
+
end
|
|
845
|
+
-- A mode-3 replacement generation is already active. Join it as
|
|
846
|
+
-- an explicitly aborted follower instead of replacing its token.
|
|
847
|
+
return {0, current_generation, -3, {}, {}}
|
|
848
|
+
elseif not truncated and not terminal_complete then
|
|
849
|
+
if requested_ttl ~= retained_ttl then
|
|
850
|
+
return {0, current_generation, -4, {}, {}}
|
|
851
|
+
end
|
|
852
|
+
return {0, current_generation, -1, {}, {}}
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
local mode = 0 -- new run
|
|
857
|
+
local previous_failures = {}
|
|
858
|
+
local previous_errors = {}
|
|
859
|
+
|
|
860
|
+
if stalled or invalid_active_state then
|
|
861
|
+
mode = 2 -- fail-closed full rerun
|
|
862
|
+
elseif existing_stat_count == 10 then
|
|
863
|
+
local failures = stat_values[4]
|
|
864
|
+
local errors = stat_values[5]
|
|
865
|
+
local acks = stat_values[9]
|
|
866
|
+
local size = stat_values[10]
|
|
867
|
+
|
|
868
|
+
if truncated then
|
|
869
|
+
mode = 3 -- intentionally aborted at max_failures
|
|
870
|
+
elseif acks == size and production_complete then
|
|
871
|
+
local retry_set_type = redis.call('TYPE', KEYS[6]).ok
|
|
872
|
+
local skipped_list_type = redis.call('TYPE', KEYS[17]).ok
|
|
873
|
+
local failed_list_type = redis.call('TYPE', KEYS[18]).ok
|
|
874
|
+
local error_list_type = redis.call('TYPE', KEYS[19]).ok
|
|
875
|
+
local digest_type = redis.call('TYPE', KEYS[22]).ok
|
|
876
|
+
if (retry_set_type ~= 'none' and retry_set_type ~= 'set') or
|
|
877
|
+
(skipped_list_type ~= 'none' and skipped_list_type ~= 'list') or
|
|
878
|
+
(failed_list_type ~= 'none' and failed_list_type ~= 'list') or
|
|
879
|
+
(error_list_type ~= 'none' and error_list_type ~= 'list') or digest_type ~= 'string' then
|
|
880
|
+
mode = 2
|
|
881
|
+
else
|
|
882
|
+
previous_failures = redis.call('LRANGE', KEYS[18], 0, -1)
|
|
883
|
+
previous_errors = redis.call('LRANGE', KEYS[19], 0, -1)
|
|
884
|
+
local expected_digest = redis.call('GET', KEYS[22])
|
|
885
|
+
local actual_digest = retry_snapshot_digest(previous_failures, previous_errors)
|
|
886
|
+
if #previous_failures ~= failures or #previous_errors ~= errors or expected_digest ~= actual_digest then
|
|
887
|
+
mode = 2
|
|
888
|
+
else
|
|
889
|
+
mode = 1 -- valid selective retry
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
else
|
|
893
|
+
mode = 2
|
|
894
|
+
end
|
|
895
|
+
elseif existing_stat_count > 0 then
|
|
896
|
+
mode = 2
|
|
897
|
+
else
|
|
898
|
+
-- Auxiliary state without statistics is an abandoned/corrupt run,
|
|
899
|
+
-- not a genuinely new run ID. The generation token is excluded
|
|
900
|
+
-- because it intentionally survives cleanup until the shared TTL.
|
|
901
|
+
local auxiliary_count = redis.call(
|
|
902
|
+
'EXISTS', KEYS[3], KEYS[4], KEYS[5], KEYS[6], KEYS[17], KEYS[18], KEYS[19], KEYS[20], KEYS[21], KEYS[22], KEYS[23], KEYS[24]
|
|
903
|
+
)
|
|
904
|
+
if auxiliary_count > 0 then
|
|
905
|
+
mode = 2
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
-- A completed attempt must remain fenced for the full grace period,
|
|
910
|
+
-- whether or not its stream survived cleanup. Wait and re-register
|
|
911
|
+
-- rather than joining a terminal generation with no retry work.
|
|
912
|
+
if mode == 1 and current_generation then
|
|
913
|
+
local completed_at = nil
|
|
914
|
+
if redis.call('TYPE', KEYS[21]).ok == 'string' then
|
|
915
|
+
completed_at = tonumber(redis.call('GET', KEYS[21]))
|
|
916
|
+
end
|
|
917
|
+
if not completed_at then
|
|
918
|
+
mode = 2
|
|
919
|
+
elseif ARGV[6] ~= current_generation then
|
|
920
|
+
local redis_time = redis.call('TIME')
|
|
921
|
+
local now = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
|
|
922
|
+
local grace = tonumber(ARGV[5])
|
|
923
|
+
local remaining_grace = grace - (now - completed_at)
|
|
924
|
+
if remaining_grace > grace then
|
|
925
|
+
remaining_grace = grace
|
|
926
|
+
end
|
|
927
|
+
if remaining_grace > 0 then
|
|
928
|
+
-- The terminal snapshot may have been written with a shorter
|
|
929
|
+
-- TTL than this retry invocation requests. Refresh every
|
|
930
|
+
-- validated retained key before sleeping so the snapshot
|
|
931
|
+
-- survives the full current grace period and safety margin.
|
|
932
|
+
redis.call('SET', KEYS[24], requested_ttl, 'EX', requested_ttl)
|
|
933
|
+
for key_index = 1, #KEYS do
|
|
934
|
+
if redis.call('EXISTS', KEYS[key_index]) == 1 then
|
|
935
|
+
redis.call('EXPIRE', KEYS[key_index], ARGV[2])
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
return {0, current_generation, -2, {}, {}, tostring(remaining_grace)}
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
-- No active attempt reaches here. A random token never repeats after
|
|
944
|
+
-- expiry/eviction, so old workers cannot regain authority over a new
|
|
945
|
+
-- attempt. All destructive changes and group creation are atomic.
|
|
946
|
+
redis.call('DEL', KEYS[1])
|
|
947
|
+
if mode == 2 then
|
|
948
|
+
for key_index = 3, 23 do
|
|
949
|
+
redis.call('DEL', KEYS[key_index])
|
|
950
|
+
end
|
|
951
|
+
previous_failures = {}
|
|
952
|
+
previous_errors = {}
|
|
953
|
+
else
|
|
954
|
+
redis.call('DEL', KEYS[4], KEYS[5], KEYS[6], KEYS[21], KEYS[22])
|
|
955
|
+
if mode == 1 then
|
|
956
|
+
redis.call('SET', KEYS[15], 0, 'EX', ARGV[2])
|
|
957
|
+
redis.call('SET', KEYS[16], 0, 'EX', ARGV[2])
|
|
958
|
+
elseif mode ~= 3 then
|
|
959
|
+
redis.call('DEL', KEYS[15], KEYS[16])
|
|
960
|
+
end
|
|
961
|
+
if mode == 3 then
|
|
962
|
+
redis.call('SET', KEYS[20], 1, 'EX', ARGV[2])
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
local generation = ARGV[4]
|
|
967
|
+
redis.call('SET', KEYS[2], generation, 'EX', ARGV[2])
|
|
968
|
+
redis.call('SET', KEYS[24], requested_ttl, 'EX', requested_ttl)
|
|
969
|
+
if mode ~= 3 then
|
|
970
|
+
local group_name = ARGV[1] .. '-' .. generation
|
|
971
|
+
redis.call('XGROUP', 'CREATE', KEYS[1], group_name, '0', 'MKSTREAM')
|
|
972
|
+
redis.call('SET', KEYS[5], 0, 'EX', ARGV[2])
|
|
973
|
+
end
|
|
974
|
+
-- Retained state may come from an attempt with a shorter TTL. Align
|
|
975
|
+
-- every surviving key with this generation before returning so a
|
|
976
|
+
-- mode-3 follower cannot lose its truncation discriminator between
|
|
977
|
+
-- registration and result adjustment.
|
|
978
|
+
for key_index = 1, #KEYS do
|
|
979
|
+
if redis.call('EXISTS', KEYS[key_index]) == 1 then
|
|
980
|
+
redis.call('EXPIRE', KEYS[key_index], ARGV[2])
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
return {1, generation, mode, previous_failures, previous_errors}
|
|
984
|
+
LUA
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
sig { returns(String) }
|
|
988
|
+
def reset_results_script
|
|
989
|
+
@reset_results_script ||= redis.script(:load, <<~LUA)
|
|
990
|
+
-- KEYS match adjust_results_script: generation, stream, retry_set,
|
|
991
|
+
-- control markers, ten statistics, three result lists, completed_at,
|
|
992
|
+
-- retry_snapshot_digest, truncated_generation, and retention_ttl.
|
|
993
|
+
if redis.call('TYPE', KEYS[1]).ok ~= 'string' or redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
|
994
|
+
return redis.error_reply('STALEATTEMPT missing or mismatched generation')
|
|
995
|
+
elseif redis.call('TYPE', KEYS[2]).ok ~= 'stream' then
|
|
996
|
+
return redis.error_reply('COORDINATORSTREAM missing or invalid stream')
|
|
997
|
+
end
|
|
998
|
+
if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
|
|
999
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1000
|
+
end
|
|
1001
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
|
|
1002
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1003
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1004
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1005
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
local size = tonumber(ARGV[3])
|
|
1009
|
+
if not size or size < 0 or size % 1 ~= 0 or size > 9007199254740991 then
|
|
1010
|
+
return redis.error_reply('COORDINATORSTATE unsafe full-rerun size')
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
redis.call('DEL', KEYS[3], KEYS[4], KEYS[6], KEYS[7])
|
|
1014
|
+
for key_index = 8, 23 do
|
|
1015
|
+
redis.call('DEL', KEYS[key_index])
|
|
1016
|
+
end
|
|
1017
|
+
local reply = {}
|
|
1018
|
+
for stat_index = 1, 10 do
|
|
1019
|
+
local value = stat_index == 10 and size or 0
|
|
1020
|
+
redis.call('SET', KEYS[stat_index + 7], value, 'EX', effective_ttl)
|
|
1021
|
+
reply[stat_index] = value
|
|
1022
|
+
end
|
|
1023
|
+
redis.call('EXPIRE', KEYS[1], effective_ttl)
|
|
1024
|
+
redis.call('EXPIRE', KEYS[2], effective_ttl)
|
|
1025
|
+
redis.call('EXPIRE', KEYS[5], effective_ttl)
|
|
1026
|
+
redis.call('EXPIRE', KEYS[24], effective_ttl)
|
|
1027
|
+
return reply
|
|
1028
|
+
LUA
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
sig { returns(String) }
|
|
1032
|
+
def commit_results_script
|
|
1033
|
+
@commit_results_script ||= redis.script(:load, <<~LUA)
|
|
1034
|
+
local result_count = tonumber(ARGV[3])
|
|
1035
|
+
local expected_generation = ARGV[4]
|
|
1036
|
+
local argument_index = 5
|
|
1037
|
+
local commit_results = {}
|
|
1038
|
+
-- runs, assertions, passes, failures, errors, skips, requeues,
|
|
1039
|
+
-- discards, acks, size
|
|
1040
|
+
local deltas = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
1041
|
+
|
|
1042
|
+
local function retry_snapshot_digest(failed_key, error_key)
|
|
1043
|
+
local failed = redis.call('LRANGE', failed_key, 0, -1)
|
|
1044
|
+
local errors = redis.call('LRANGE', error_key, 0, -1)
|
|
1045
|
+
local pieces = {'failures', tostring(#failed)}
|
|
1046
|
+
for _, identifier in ipairs(failed) do
|
|
1047
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
1048
|
+
pieces[#pieces + 1] = identifier
|
|
1049
|
+
end
|
|
1050
|
+
pieces[#pieces + 1] = 'errors'
|
|
1051
|
+
pieces[#pieces + 1] = tostring(#errors)
|
|
1052
|
+
for _, identifier in ipairs(errors) do
|
|
1053
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
1054
|
+
pieces[#pieces + 1] = identifier
|
|
1055
|
+
end
|
|
1056
|
+
return redis.sha1hex(table.concat(pieces, string.char(0)))
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
-- Every statistics key is created before any stream entries are
|
|
1060
|
+
-- published. Refuse to recreate missing state as zero: doing so can
|
|
1061
|
+
-- make an incomplete run look complete when both acks and size become
|
|
1062
|
+
-- 0. This preflight occurs before any write because Redis does not
|
|
1063
|
+
-- roll back earlier script writes when a later command errors.
|
|
1064
|
+
if redis.call('TYPE', KEYS[19]).ok ~= 'string' then
|
|
1065
|
+
return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[19])
|
|
1066
|
+
end
|
|
1067
|
+
local current_generation = redis.call('GET', KEYS[19])
|
|
1068
|
+
if not current_generation then
|
|
1069
|
+
return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[19])
|
|
1070
|
+
elseif current_generation ~= expected_generation then
|
|
1071
|
+
return redis.error_reply('STALEATTEMPT expected generation ' .. expected_generation)
|
|
1072
|
+
end
|
|
1073
|
+
if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
|
|
1074
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1075
|
+
end
|
|
1076
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
|
|
1077
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1078
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1079
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1080
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
if redis.call('TYPE', KEYS[1]).ok ~= 'stream' then
|
|
1084
|
+
return redis.error_reply('COORDINATORSTREAM missing or invalid key ' .. KEYS[1])
|
|
1085
|
+
end
|
|
1086
|
+
local groups = redis.pcall('XINFO', 'GROUPS', KEYS[1])
|
|
1087
|
+
local group_exists = false
|
|
1088
|
+
if not groups.err then
|
|
1089
|
+
for _, group in ipairs(groups) do
|
|
1090
|
+
for field_index = 1, #group, 2 do
|
|
1091
|
+
if group[field_index] == 'name' and group[field_index + 1] == ARGV[1] then
|
|
1092
|
+
group_exists = true
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
if not group_exists then
|
|
1098
|
+
return redis.error_reply('COORDINATORSTREAM missing consumer group ' .. ARGV[1])
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
local retry_type = redis.call('TYPE', KEYS[2]).ok
|
|
1102
|
+
if retry_type ~= 'none' and retry_type ~= 'set' then
|
|
1103
|
+
return redis.error_reply('COORDINATORSTATE invalid retry set type')
|
|
1104
|
+
end
|
|
1105
|
+
for list_index = 13, 15 do
|
|
1106
|
+
local list_type = redis.call('TYPE', KEYS[list_index]).ok
|
|
1107
|
+
if list_type ~= 'none' and list_type ~= 'list' then
|
|
1108
|
+
return redis.error_reply('COORDINATORSTATE invalid result list type')
|
|
1109
|
+
end
|
|
1110
|
+
end
|
|
1111
|
+
local production_type = redis.call('TYPE', KEYS[16]).ok
|
|
1112
|
+
local production_complete = false
|
|
1113
|
+
if production_type == 'string' then
|
|
1114
|
+
if redis.call('GET', KEYS[16]) ~= '1' then
|
|
1115
|
+
return redis.error_reply('COORDINATORSTATE invalid production marker')
|
|
1116
|
+
end
|
|
1117
|
+
production_complete = true
|
|
1118
|
+
elseif production_type ~= 'none' then
|
|
1119
|
+
return redis.error_reply('COORDINATORSTATE invalid production marker type')
|
|
1120
|
+
end
|
|
1121
|
+
local digest_type = redis.call('TYPE', KEYS[22]).ok
|
|
1122
|
+
if digest_type ~= 'none' and digest_type ~= 'string' then
|
|
1123
|
+
return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
local max_safe_integer = 9007199254740991
|
|
1127
|
+
local stat_values = {}
|
|
1128
|
+
for stat_index = 1, 10 do
|
|
1129
|
+
local stat_key = KEYS[stat_index + 2]
|
|
1130
|
+
if redis.call('TYPE', stat_key).ok ~= 'string' then
|
|
1131
|
+
return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. stat_key)
|
|
1132
|
+
end
|
|
1133
|
+
local validation = redis.pcall('INCRBY', stat_key, 0)
|
|
1134
|
+
if type(validation) == 'table' and validation.err then
|
|
1135
|
+
return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
|
|
1136
|
+
end
|
|
1137
|
+
local value = tonumber(validation)
|
|
1138
|
+
if value < 0 or value > max_safe_integer then
|
|
1139
|
+
return redis.error_reply('COORDINATORSTATE unsafe statistic value ' .. stat_key)
|
|
1140
|
+
end
|
|
1141
|
+
stat_values[stat_index] = value
|
|
1142
|
+
end
|
|
1143
|
+
local runs = stat_values[1]
|
|
1144
|
+
local reported = stat_values[3] + stat_values[4] + stat_values[5] + stat_values[6]
|
|
1145
|
+
if stat_values[9] > stat_values[10] or runs < stat_values[7] + stat_values[8] or
|
|
1146
|
+
runs - stat_values[7] - stat_values[8] ~= reported then
|
|
1147
|
+
return redis.error_reply('COORDINATORSTATE inconsistent aggregate statistics')
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
local truncated_type = redis.call('TYPE', KEYS[20]).ok
|
|
1151
|
+
local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
|
|
1152
|
+
local truncated = false
|
|
1153
|
+
if truncated_type == 'string' then
|
|
1154
|
+
if redis.call('GET', KEYS[20]) ~= '1' or truncated_generation_type ~= 'string' then
|
|
1155
|
+
return redis.error_reply('COORDINATORSTATE invalid truncated marker')
|
|
1156
|
+
end
|
|
1157
|
+
truncated = true
|
|
1158
|
+
elseif truncated_type ~= 'none' or truncated_generation_type ~= 'none' then
|
|
1159
|
+
return redis.error_reply('COORDINATORSTATE invalid truncated marker type')
|
|
1160
|
+
end
|
|
1161
|
+
if truncated then
|
|
1162
|
+
local truncated_reply = {}
|
|
1163
|
+
for result_index = 1, result_count do
|
|
1164
|
+
truncated_reply[result_index] = 0
|
|
1165
|
+
end
|
|
1166
|
+
for stat_index = 1, 10 do
|
|
1167
|
+
truncated_reply[result_count + stat_index] = stat_values[stat_index]
|
|
1168
|
+
end
|
|
1169
|
+
truncated_reply[result_count + 11] = production_complete and 1 or 0
|
|
1170
|
+
truncated_reply[result_count + 12] = 1
|
|
1171
|
+
for key_index = 1, #KEYS do
|
|
1172
|
+
redis.call('EXPIRE', KEYS[key_index], effective_ttl)
|
|
1173
|
+
end
|
|
1174
|
+
return truncated_reply
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
local allowed_result_types = {
|
|
1178
|
+
passed = true, failed = true, error = true, skipped = true,
|
|
1179
|
+
discarded = true, requeued = true
|
|
1180
|
+
}
|
|
1181
|
+
local validation_index = argument_index
|
|
1182
|
+
local assertion_total = 0
|
|
1183
|
+
for _ = 1, result_count do
|
|
1184
|
+
local result_type = ARGV[validation_index + 1]
|
|
1185
|
+
local assertions = tonumber(ARGV[validation_index + 4])
|
|
1186
|
+
if not allowed_result_types[result_type] or not assertions or assertions < 0 or assertions % 1 ~= 0 then
|
|
1187
|
+
return redis.error_reply('COORDINATORSTATE invalid result payload')
|
|
1188
|
+
end
|
|
1189
|
+
assertion_total = assertion_total + assertions
|
|
1190
|
+
validation_index = validation_index + 5
|
|
1191
|
+
end
|
|
1192
|
+
local maximum_deltas = {
|
|
1193
|
+
result_count, assertion_total, result_count, result_count, result_count,
|
|
1194
|
+
result_count, result_count, result_count, result_count, 0
|
|
1195
|
+
}
|
|
1196
|
+
for stat_index = 1, 10 do
|
|
1197
|
+
if math.abs(stat_values[stat_index]) + math.abs(maximum_deltas[stat_index]) > max_safe_integer then
|
|
1198
|
+
return redis.error_reply('COORDINATORSTATE unsafe statistic delta')
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
for result_index = 1, result_count do
|
|
1203
|
+
local entry_id = ARGV[argument_index]
|
|
1204
|
+
local result_type = ARGV[argument_index + 1]
|
|
1205
|
+
local attempt_id = ARGV[argument_index + 2]
|
|
1206
|
+
local identifier = ARGV[argument_index + 3]
|
|
1207
|
+
local assertions = tonumber(ARGV[argument_index + 4])
|
|
1208
|
+
argument_index = argument_index + 5
|
|
1209
|
+
|
|
1210
|
+
local committed
|
|
1211
|
+
if result_type == 'requeued' then
|
|
1212
|
+
committed = redis.call('SADD', KEYS[2], attempt_id)
|
|
1213
|
+
deltas[7] = deltas[7] + 1
|
|
1214
|
+
else
|
|
1215
|
+
committed = redis.call('XACK', KEYS[1], ARGV[1], entry_id)
|
|
1216
|
+
if committed == 1 then
|
|
1217
|
+
deltas[9] = deltas[9] + 1
|
|
1218
|
+
if result_type == 'passed' then
|
|
1219
|
+
deltas[3] = deltas[3] + 1
|
|
1220
|
+
elseif result_type == 'failed' then
|
|
1221
|
+
deltas[4] = deltas[4] + 1
|
|
1222
|
+
redis.call('LPUSH', KEYS[14], identifier)
|
|
1223
|
+
elseif result_type == 'error' then
|
|
1224
|
+
deltas[5] = deltas[5] + 1
|
|
1225
|
+
redis.call('LPUSH', KEYS[15], identifier)
|
|
1226
|
+
elseif result_type == 'skipped' then
|
|
1227
|
+
deltas[6] = deltas[6] + 1
|
|
1228
|
+
redis.call('LPUSH', KEYS[13], identifier)
|
|
1229
|
+
elseif result_type == 'discarded' then
|
|
1230
|
+
deltas[8] = deltas[8] + 1
|
|
1231
|
+
end
|
|
1232
|
+
else
|
|
1233
|
+
-- Another worker already ACKed this entry, so the local result
|
|
1234
|
+
-- will be reported as discarded.
|
|
1235
|
+
deltas[8] = deltas[8] + 1
|
|
1236
|
+
end
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
deltas[1] = deltas[1] + 1
|
|
1240
|
+
deltas[2] = deltas[2] + assertions
|
|
1241
|
+
commit_results[result_index] = committed
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
local reply = commit_results
|
|
1245
|
+
for stat_index = 1, 10 do
|
|
1246
|
+
reply[result_count + stat_index] = redis.call('INCRBY', KEYS[stat_index + 2], deltas[stat_index])
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
local updated_acks = reply[result_count + 9]
|
|
1250
|
+
local updated_size = reply[result_count + 10]
|
|
1251
|
+
if updated_acks == updated_size and production_complete then
|
|
1252
|
+
local redis_time = redis.call('TIME')
|
|
1253
|
+
local completed_at = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
|
|
1254
|
+
redis.call('SET', KEYS[21], completed_at, 'EX', effective_ttl)
|
|
1255
|
+
local digest = retry_snapshot_digest(KEYS[14], KEYS[15])
|
|
1256
|
+
redis.call('SET', KEYS[22], digest, 'EX', effective_ttl)
|
|
1257
|
+
end
|
|
1258
|
+
reply[result_count + 11] = production_complete and 1 or 0
|
|
1259
|
+
reply[result_count + 12] = 0
|
|
1260
|
+
|
|
1261
|
+
for key_index = 1, #KEYS do
|
|
1262
|
+
redis.call('EXPIRE', KEYS[key_index], effective_ttl)
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
return reply
|
|
1266
|
+
LUA
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
sig { returns(String) }
|
|
1270
|
+
def adjust_results_script
|
|
1271
|
+
@adjust_results_script ||= redis.script(:load, <<~LUA)
|
|
1272
|
+
if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
|
|
1273
|
+
return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[1])
|
|
1274
|
+
end
|
|
1275
|
+
local current_generation = redis.call('GET', KEYS[1])
|
|
1276
|
+
if not current_generation then
|
|
1277
|
+
return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[1])
|
|
1278
|
+
elseif current_generation ~= ARGV[1] then
|
|
1279
|
+
return redis.error_reply('STALEATTEMPT expected generation ' .. ARGV[1])
|
|
1280
|
+
end
|
|
1281
|
+
if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
|
|
1282
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1283
|
+
end
|
|
1284
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
|
|
1285
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1286
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1287
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1288
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
local stream_type = redis.call('TYPE', KEYS[2]).ok
|
|
1292
|
+
local retry_type = redis.call('TYPE', KEYS[3]).ok
|
|
1293
|
+
if stream_type ~= 'stream' or (retry_type ~= 'none' and retry_type ~= 'set') then
|
|
1294
|
+
return redis.error_reply('COORDINATORSTATE invalid run key type')
|
|
1295
|
+
end
|
|
1296
|
+
for list_index = 18, 20 do
|
|
1297
|
+
local list_type = redis.call('TYPE', KEYS[list_index]).ok
|
|
1298
|
+
if list_type ~= 'none' and list_type ~= 'list' then
|
|
1299
|
+
return redis.error_reply('COORDINATORSTATE invalid result list type')
|
|
1300
|
+
end
|
|
1301
|
+
end
|
|
1302
|
+
local digest_type = redis.call('TYPE', KEYS[22]).ok
|
|
1303
|
+
if digest_type ~= 'none' and digest_type ~= 'string' then
|
|
1304
|
+
return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
|
|
1305
|
+
end
|
|
1306
|
+
local truncated_type = redis.call('TYPE', KEYS[7]).ok
|
|
1307
|
+
local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
|
|
1308
|
+
if truncated_type == 'string' then
|
|
1309
|
+
if redis.call('GET', KEYS[7]) ~= '1' or truncated_generation_type ~= 'string' then
|
|
1310
|
+
return redis.error_reply('COORDINATORSTATE invalid truncated state')
|
|
1311
|
+
end
|
|
1312
|
+
elseif truncated_type ~= 'none' or truncated_generation_type ~= 'none' then
|
|
1313
|
+
return redis.error_reply('COORDINATORSTATE invalid truncated state type')
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
local max_safe_integer = 9007199254740991
|
|
1317
|
+
for stat_index = 1, 10 do
|
|
1318
|
+
local stat_key = KEYS[stat_index + 7]
|
|
1319
|
+
local stat_type = redis.call('TYPE', stat_key).ok
|
|
1320
|
+
if stat_type ~= 'none' and stat_type ~= 'string' then
|
|
1321
|
+
return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
|
|
1322
|
+
elseif stat_type == 'none' and ARGV[4] ~= '1' then
|
|
1323
|
+
return redis.error_reply('COORDINATORSTATE missing required key ' .. stat_key)
|
|
1324
|
+
end
|
|
1325
|
+
local current_value = 0
|
|
1326
|
+
if stat_type == 'string' then
|
|
1327
|
+
local validation = redis.pcall('INCRBY', stat_key, 0)
|
|
1328
|
+
if type(validation) == 'table' and validation.err then
|
|
1329
|
+
return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
|
|
1330
|
+
end
|
|
1331
|
+
current_value = tonumber(validation)
|
|
1332
|
+
end
|
|
1333
|
+
local delta = tonumber(ARGV[stat_index + 4])
|
|
1334
|
+
local updated_value = delta and current_value + delta or nil
|
|
1335
|
+
if not delta or delta % 1 ~= 0 or updated_value < 0 or updated_value > max_safe_integer then
|
|
1336
|
+
return redis.error_reply('COORDINATORSTATE unsafe statistic value ' .. stat_key)
|
|
1337
|
+
end
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
if ARGV[3] == '1' then
|
|
1341
|
+
redis.call('DEL', KEYS[19], KEYS[20])
|
|
1342
|
+
end
|
|
1343
|
+
redis.call('DEL', KEYS[22])
|
|
1344
|
+
local reply = {}
|
|
1345
|
+
for stat_index = 1, 10 do
|
|
1346
|
+
reply[stat_index] = redis.call('INCRBY', KEYS[stat_index + 7], ARGV[stat_index + 4])
|
|
1347
|
+
end
|
|
1348
|
+
for key_index = 1, #KEYS do
|
|
1349
|
+
redis.call('EXPIRE', KEYS[key_index], effective_ttl)
|
|
1350
|
+
end
|
|
1351
|
+
return reply
|
|
1352
|
+
LUA
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
sig { returns(String) }
|
|
1356
|
+
def publish_tests_script
|
|
1357
|
+
@publish_tests_script ||= redis.script(:load, <<~LUA)
|
|
1358
|
+
if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
|
|
1359
|
+
return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[1])
|
|
1360
|
+
end
|
|
1361
|
+
local current_generation = redis.call('GET', KEYS[1])
|
|
1362
|
+
if not current_generation then
|
|
1363
|
+
return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[1])
|
|
1364
|
+
elseif current_generation ~= ARGV[1] then
|
|
1365
|
+
return redis.error_reply('STALEATTEMPT expected generation ' .. ARGV[1])
|
|
1366
|
+
elseif redis.call('TYPE', KEYS[2]).ok ~= 'stream' then
|
|
1367
|
+
return redis.error_reply('COORDINATORSTREAM missing or invalid key ' .. KEYS[2])
|
|
1368
|
+
end
|
|
1369
|
+
if redis.call('TYPE', KEYS[11]).ok ~= 'string' then
|
|
1370
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1371
|
+
end
|
|
1372
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[11], 0)
|
|
1373
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1374
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1375
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1376
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1377
|
+
end
|
|
1378
|
+
|
|
1379
|
+
local groups = redis.pcall('XINFO', 'GROUPS', KEYS[2])
|
|
1380
|
+
local group_exists = false
|
|
1381
|
+
if not groups.err then
|
|
1382
|
+
for _, group in ipairs(groups) do
|
|
1383
|
+
for field_index = 1, #group, 2 do
|
|
1384
|
+
if group[field_index] == 'name' and group[field_index + 1] == ARGV[5] then
|
|
1385
|
+
group_exists = true
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
if not group_exists then
|
|
1391
|
+
return redis.error_reply('COORDINATORSTREAM missing consumer group ' .. ARGV[5])
|
|
1392
|
+
end
|
|
1393
|
+
for list_index = 8, 9 do
|
|
1394
|
+
local list_type = redis.call('TYPE', KEYS[list_index]).ok
|
|
1395
|
+
if list_type ~= 'none' and list_type ~= 'list' then
|
|
1396
|
+
return redis.error_reply('COORDINATORSTATE invalid retry result list type')
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
local digest_type = redis.call('TYPE', KEYS[10]).ok
|
|
1400
|
+
if digest_type ~= 'none' and digest_type ~= 'string' then
|
|
1401
|
+
return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
local function retry_snapshot_digest(failed_key, error_key)
|
|
1405
|
+
local failed = redis.call('LRANGE', failed_key, 0, -1)
|
|
1406
|
+
local errors = redis.call('LRANGE', error_key, 0, -1)
|
|
1407
|
+
local pieces = {'failures', tostring(#failed)}
|
|
1408
|
+
for _, identifier in ipairs(failed) do
|
|
1409
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
1410
|
+
pieces[#pieces + 1] = identifier
|
|
1411
|
+
end
|
|
1412
|
+
pieces[#pieces + 1] = 'errors'
|
|
1413
|
+
pieces[#pieces + 1] = tostring(#errors)
|
|
1414
|
+
for _, identifier in ipairs(errors) do
|
|
1415
|
+
pieces[#pieces + 1] = tostring(string.len(identifier))
|
|
1416
|
+
pieces[#pieces + 1] = identifier
|
|
1417
|
+
end
|
|
1418
|
+
return redis.sha1hex(table.concat(pieces, string.char(0)))
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
local max_safe_integer = 9007199254740991
|
|
1422
|
+
local function read_safe_counter(key)
|
|
1423
|
+
if redis.call('TYPE', key).ok ~= 'string' then
|
|
1424
|
+
return nil
|
|
1425
|
+
end
|
|
1426
|
+
local validation = redis.pcall('INCRBY', key, 0)
|
|
1427
|
+
if type(validation) == 'table' and validation.err then
|
|
1428
|
+
return nil
|
|
1429
|
+
end
|
|
1430
|
+
local value = tonumber(validation)
|
|
1431
|
+
if not value or value < 0 or value > max_safe_integer then
|
|
1432
|
+
return nil
|
|
1433
|
+
end
|
|
1434
|
+
return value
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
local acks = read_safe_counter(KEYS[5])
|
|
1438
|
+
local size = read_safe_counter(KEYS[6])
|
|
1439
|
+
if not acks or not size or acks > size then
|
|
1440
|
+
return redis.error_reply('COORDINATORSTATE invalid completion counters')
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
local test_count = tonumber(ARGV[4])
|
|
1444
|
+
local argument_index = 6
|
|
1445
|
+
for _ = 1, test_count do
|
|
1446
|
+
redis.call(
|
|
1447
|
+
'XADD', KEYS[2], '*',
|
|
1448
|
+
'class_name', ARGV[argument_index],
|
|
1449
|
+
'method_name', ARGV[argument_index + 1]
|
|
1450
|
+
)
|
|
1451
|
+
argument_index = argument_index + 2
|
|
1452
|
+
end
|
|
1453
|
+
if ARGV[3] == '1' then
|
|
1454
|
+
redis.call('SET', KEYS[3], 1, 'EX', effective_ttl)
|
|
1455
|
+
if acks == size then
|
|
1456
|
+
local redis_time = redis.call('TIME')
|
|
1457
|
+
local completed_at = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
|
|
1458
|
+
redis.call('SET', KEYS[7], completed_at, 'EX', effective_ttl)
|
|
1459
|
+
local digest = retry_snapshot_digest(KEYS[8], KEYS[9])
|
|
1460
|
+
redis.call('SET', KEYS[10], digest, 'EX', effective_ttl)
|
|
1461
|
+
end
|
|
1462
|
+
end
|
|
1463
|
+
for key_index = 1, #KEYS do
|
|
1464
|
+
redis.call('EXPIRE', KEYS[key_index], effective_ttl)
|
|
1465
|
+
end
|
|
1466
|
+
return test_count
|
|
1467
|
+
LUA
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
sig { returns(String) }
|
|
1471
|
+
def heartbeat_script
|
|
1472
|
+
@heartbeat_script ||= redis.script(:load, <<~LUA)
|
|
1473
|
+
local generation_type = redis.call('TYPE', KEYS[1]).ok
|
|
1474
|
+
if generation_type == 'none' then
|
|
1475
|
+
return 0
|
|
1476
|
+
elseif generation_type ~= 'string' then
|
|
1477
|
+
return redis.error_reply('COORDINATORSTATE invalid key type ' .. KEYS[1])
|
|
1478
|
+
end
|
|
1479
|
+
local current_generation = redis.call('GET', KEYS[1])
|
|
1480
|
+
if not current_generation or current_generation ~= ARGV[1] then
|
|
1481
|
+
return 0
|
|
1482
|
+
end
|
|
1483
|
+
if redis.call('TYPE', KEYS[3]).ok ~= 'string' then
|
|
1484
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1485
|
+
end
|
|
1486
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[3], 0)
|
|
1487
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1488
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1489
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1490
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1491
|
+
end
|
|
1492
|
+
local heartbeat_type = redis.call('TYPE', KEYS[2]).ok
|
|
1493
|
+
if heartbeat_type ~= 'none' and heartbeat_type ~= 'string' then
|
|
1494
|
+
return redis.error_reply('COORDINATORSTATE invalid key type ' .. KEYS[2])
|
|
1495
|
+
end
|
|
1496
|
+
redis.call('INCR', KEYS[2])
|
|
1497
|
+
redis.call('EXPIRE', KEYS[1], effective_ttl)
|
|
1498
|
+
redis.call('EXPIRE', KEYS[2], effective_ttl)
|
|
1499
|
+
redis.call('EXPIRE', KEYS[3], effective_ttl)
|
|
1500
|
+
return 1
|
|
1501
|
+
LUA
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
sig { returns(String) }
|
|
1505
|
+
def mark_attempt_state_script
|
|
1506
|
+
@mark_attempt_state_script ||= redis.script(:load, <<~LUA)
|
|
1507
|
+
if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
|
|
1508
|
+
return 0
|
|
1509
|
+
end
|
|
1510
|
+
local current_generation = redis.call('GET', KEYS[1])
|
|
1511
|
+
if not current_generation or current_generation ~= ARGV[1] then
|
|
1512
|
+
return 0
|
|
1513
|
+
end
|
|
1514
|
+
if redis.call('TYPE', KEYS[7]).ok ~= 'string' then
|
|
1515
|
+
return redis.error_reply('COORDINATORSTATE missing retention TTL')
|
|
1516
|
+
end
|
|
1517
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[7], 0)
|
|
1518
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1519
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1520
|
+
not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
|
|
1521
|
+
return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
local production_type = redis.call('TYPE', KEYS[3]).ok
|
|
1525
|
+
if production_type == 'string' then
|
|
1526
|
+
if redis.call('GET', KEYS[3]) ~= '1' then
|
|
1527
|
+
return redis.error_reply('COORDINATORSTATE invalid production marker')
|
|
1528
|
+
end
|
|
1529
|
+
local function read_safe_counter(key)
|
|
1530
|
+
if redis.call('TYPE', key).ok ~= 'string' then
|
|
1531
|
+
return nil
|
|
1532
|
+
end
|
|
1533
|
+
local validation = redis.pcall('INCRBY', key, 0)
|
|
1534
|
+
if type(validation) == 'table' and validation.err then
|
|
1535
|
+
return nil
|
|
1536
|
+
end
|
|
1537
|
+
local value = tonumber(validation)
|
|
1538
|
+
if not value or value < 0 or value > 9007199254740991 then
|
|
1539
|
+
return nil
|
|
1540
|
+
end
|
|
1541
|
+
return value
|
|
1542
|
+
end
|
|
1543
|
+
local acks = read_safe_counter(KEYS[4])
|
|
1544
|
+
local size = read_safe_counter(KEYS[5])
|
|
1545
|
+
if not acks or not size or acks > size then
|
|
1546
|
+
return redis.error_reply('COORDINATORSTATE invalid completion counters')
|
|
1547
|
+
elseif acks == size then
|
|
1548
|
+
return 2
|
|
1549
|
+
end
|
|
1550
|
+
elseif production_type ~= 'none' then
|
|
1551
|
+
return redis.error_reply('COORDINATORSTATE invalid production marker type')
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
redis.call('SET', KEYS[2], 1, 'EX', effective_ttl)
|
|
1555
|
+
redis.call('SET', KEYS[6], current_generation, 'EX', effective_ttl)
|
|
1556
|
+
redis.call('EXPIRE', KEYS[1], effective_ttl)
|
|
1557
|
+
redis.call('EXPIRE', KEYS[7], effective_ttl)
|
|
1558
|
+
return 1
|
|
306
1559
|
LUA
|
|
307
1560
|
end
|
|
308
1561
|
|
|
1562
|
+
sig { returns(String) }
|
|
1563
|
+
def cleanup_script
|
|
1564
|
+
@cleanup_script ||= redis.script(:load, <<~LUA)
|
|
1565
|
+
if redis.call('TYPE', KEYS[2]).ok ~= 'string' then
|
|
1566
|
+
return 0
|
|
1567
|
+
end
|
|
1568
|
+
local current_generation = redis.call('GET', KEYS[2])
|
|
1569
|
+
if not current_generation or current_generation ~= ARGV[1] then
|
|
1570
|
+
return 0
|
|
1571
|
+
end
|
|
1572
|
+
if redis.call('TYPE', KEYS[3]).ok ~= 'string' then
|
|
1573
|
+
return 0
|
|
1574
|
+
end
|
|
1575
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[3], 0)
|
|
1576
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1577
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1578
|
+
not effective_ttl or tonumber(ARGV[3]) > effective_ttl then
|
|
1579
|
+
return 0
|
|
1580
|
+
end
|
|
1581
|
+
|
|
1582
|
+
redis.call('EXPIRE', KEYS[2], effective_ttl)
|
|
1583
|
+
redis.call('EXPIRE', KEYS[3], effective_ttl)
|
|
1584
|
+
redis.pcall('XGROUP', 'DESTROY', KEYS[1], ARGV[2])
|
|
1585
|
+
redis.call('DEL', KEYS[1])
|
|
1586
|
+
return 1
|
|
1587
|
+
LUA
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
sig { returns(String) }
|
|
1591
|
+
def abort_script
|
|
1592
|
+
@abort_script ||= redis.script(:load, <<~LUA)
|
|
1593
|
+
if redis.call('TYPE', KEYS[2]).ok ~= 'string' then
|
|
1594
|
+
return 0
|
|
1595
|
+
end
|
|
1596
|
+
local current_generation = redis.call('GET', KEYS[2])
|
|
1597
|
+
if not current_generation or current_generation ~= ARGV[1] then
|
|
1598
|
+
return 0
|
|
1599
|
+
end
|
|
1600
|
+
|
|
1601
|
+
if redis.call('TYPE', KEYS[4]).ok ~= 'string' then
|
|
1602
|
+
return 0
|
|
1603
|
+
end
|
|
1604
|
+
local retention_validation = redis.pcall('INCRBY', KEYS[4], 0)
|
|
1605
|
+
local effective_ttl = tonumber(retention_validation)
|
|
1606
|
+
if (type(retention_validation) == 'table' and retention_validation.err) or
|
|
1607
|
+
not effective_ttl or tonumber(ARGV[3]) > effective_ttl then
|
|
1608
|
+
return 0
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
redis.call('EXPIRE', KEYS[2], effective_ttl)
|
|
1612
|
+
redis.call('EXPIRE', KEYS[4], effective_ttl)
|
|
1613
|
+
redis.call('SET', KEYS[3], 1, 'EX', effective_ttl)
|
|
1614
|
+
redis.pcall('XGROUP', 'DESTROY', KEYS[1], ARGV[2])
|
|
1615
|
+
redis.call('DEL', KEYS[1])
|
|
1616
|
+
return 1
|
|
1617
|
+
LUA
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
sig { params(script_name: Symbol).returns(String) }
|
|
1621
|
+
def resolve_script(script_name)
|
|
1622
|
+
case script_name
|
|
1623
|
+
when :register_consumergroup then register_consumergroup_script
|
|
1624
|
+
when :read_results then read_results_script
|
|
1625
|
+
when :reset_results then reset_results_script
|
|
1626
|
+
when :commit_results then commit_results_script
|
|
1627
|
+
when :cleanup then cleanup_script
|
|
1628
|
+
when :abort then abort_script
|
|
1629
|
+
when :adjust_results then adjust_results_script
|
|
1630
|
+
when :publish_tests then publish_tests_script
|
|
1631
|
+
when :heartbeat then heartbeat_script
|
|
1632
|
+
when :mark_attempt_state then mark_attempt_state_script
|
|
1633
|
+
else raise ArgumentError, "Unknown Redis script: #{script_name}"
|
|
1634
|
+
end
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
sig do
|
|
1638
|
+
params(
|
|
1639
|
+
script_name: Symbol,
|
|
1640
|
+
keys: T::Array[String],
|
|
1641
|
+
argv: T::Array[T.untyped],
|
|
1642
|
+
).returns(T.untyped)
|
|
1643
|
+
end
|
|
1644
|
+
def execute_script(script_name:, keys:, argv:)
|
|
1645
|
+
attempts = T.let(0, Integer)
|
|
1646
|
+
begin
|
|
1647
|
+
if script_name == :read_results
|
|
1648
|
+
redis.evalsha(resolve_script(script_name), keys: keys, argv: argv)
|
|
1649
|
+
else
|
|
1650
|
+
# Script loading and EVALSHA share one serialized no-reconnect
|
|
1651
|
+
# scope. redis-rb toggles reconnect state outside its command
|
|
1652
|
+
# monitor, so resolving the SHA before taking this mutex could let
|
|
1653
|
+
# the heartbeat invalidate a producer's pending SCRIPT LOAD.
|
|
1654
|
+
@mutating_script_mutex.synchronize do
|
|
1655
|
+
script_sha = resolve_script(script_name)
|
|
1656
|
+
redis.without_reconnect do
|
|
1657
|
+
redis.evalsha(script_sha, keys: keys, argv: argv)
|
|
1658
|
+
end
|
|
1659
|
+
end
|
|
1660
|
+
end
|
|
1661
|
+
rescue Redis::CommandError => error
|
|
1662
|
+
if error.message.start_with?("NOSCRIPT") && attempts.zero?
|
|
1663
|
+
attempts += 1
|
|
1664
|
+
clear_script_cache(script_name)
|
|
1665
|
+
retry
|
|
1666
|
+
end
|
|
1667
|
+
raise
|
|
1668
|
+
end
|
|
1669
|
+
end
|
|
1670
|
+
|
|
1671
|
+
sig { params(script_name: Symbol).void }
|
|
1672
|
+
def clear_script_cache(script_name)
|
|
1673
|
+
case script_name
|
|
1674
|
+
when :register_consumergroup then @register_consumergroup_script = nil
|
|
1675
|
+
when :read_results then @read_results_script = nil
|
|
1676
|
+
when :reset_results then @reset_results_script = nil
|
|
1677
|
+
when :commit_results then @commit_results_script = nil
|
|
1678
|
+
when :cleanup then @cleanup_script = nil
|
|
1679
|
+
when :abort then @abort_script = nil
|
|
1680
|
+
when :adjust_results then @adjust_results_script = nil
|
|
1681
|
+
when :publish_tests then @publish_tests_script = nil
|
|
1682
|
+
when :heartbeat then @heartbeat_script = nil
|
|
1683
|
+
when :mark_attempt_state then @mark_attempt_state_script = nil
|
|
1684
|
+
else raise ArgumentError, "Unknown Redis script: #{script_name}"
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
|
|
309
1688
|
sig { params(block: Integer).returns(T::Array[EnqueuedRunnable]) }
|
|
310
1689
|
def claim_fresh_runnables(block:)
|
|
311
1690
|
result = redis.xreadgroup(
|
|
@@ -316,7 +1695,9 @@ module Minitest
|
|
|
316
1695
|
block: block,
|
|
317
1696
|
count: configuration.test_batch_size,
|
|
318
1697
|
)
|
|
319
|
-
|
|
1698
|
+
claims = result.fetch(stream_key, [])
|
|
1699
|
+
@combined_results = nil if claims.any?
|
|
1700
|
+
EnqueuedRunnable.from_redis_stream_claim(claims, configuration: configuration)
|
|
320
1701
|
end
|
|
321
1702
|
|
|
322
1703
|
sig do
|
|
@@ -437,48 +1818,502 @@ module Minitest
|
|
|
437
1818
|
enqueued_runnables
|
|
438
1819
|
end
|
|
439
1820
|
|
|
1821
|
+
sig { params(tests: T::Array[Minitest::Runnable]).void }
|
|
1822
|
+
def publish_tests(tests)
|
|
1823
|
+
generation = T.must(@attempt_generation)
|
|
1824
|
+
batches = tests.each_slice(PRODUCTION_BATCH_SIZE)
|
|
1825
|
+
batches = [[]].each if tests.empty?
|
|
1826
|
+
batches.each_with_index do |batch, index|
|
|
1827
|
+
final_batch = tests.empty? || (index + 1) * PRODUCTION_BATCH_SIZE >= tests.length
|
|
1828
|
+
argv = T.let(
|
|
1829
|
+
[generation, configuration.key_ttl_seconds, final_batch ? 1 : 0, batch.length, group_name],
|
|
1830
|
+
T::Array[T.untyped],
|
|
1831
|
+
)
|
|
1832
|
+
batch.each do |test|
|
|
1833
|
+
argv << T.must(test.class.name)
|
|
1834
|
+
argv << test.name
|
|
1835
|
+
end
|
|
1836
|
+
execute_script(
|
|
1837
|
+
script_name: :publish_tests,
|
|
1838
|
+
keys: [
|
|
1839
|
+
key("attempt_generation"),
|
|
1840
|
+
stream_key,
|
|
1841
|
+
key("production_complete"),
|
|
1842
|
+
key("production_heartbeat"),
|
|
1843
|
+
key("acks"),
|
|
1844
|
+
key("size"),
|
|
1845
|
+
key("completed_at"),
|
|
1846
|
+
list_key(ResultType::Failed.serialize),
|
|
1847
|
+
list_key(ResultType::Error.serialize),
|
|
1848
|
+
key("retry_snapshot_digest"),
|
|
1849
|
+
key("retention_ttl"),
|
|
1850
|
+
],
|
|
1851
|
+
argv: argv,
|
|
1852
|
+
)
|
|
1853
|
+
end
|
|
1854
|
+
end
|
|
1855
|
+
|
|
1856
|
+
sig { void }
|
|
1857
|
+
def mark_run_truncated
|
|
1858
|
+
generation = T.must(@attempt_generation)
|
|
1859
|
+
applied = execute_script(
|
|
1860
|
+
script_name: :mark_attempt_state,
|
|
1861
|
+
keys: [
|
|
1862
|
+
key("attempt_generation"),
|
|
1863
|
+
key("truncated"),
|
|
1864
|
+
key("production_complete"),
|
|
1865
|
+
key("acks"),
|
|
1866
|
+
key("size"),
|
|
1867
|
+
key("truncated_generation"),
|
|
1868
|
+
key("retention_ttl"),
|
|
1869
|
+
],
|
|
1870
|
+
argv: [generation, configuration.key_ttl_seconds],
|
|
1871
|
+
)
|
|
1872
|
+
return if [1, 2].include?(applied) || attempt_superseded?
|
|
1873
|
+
|
|
1874
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
1875
|
+
ERROR: minitest-distributed could not persist max-failures truncation state.
|
|
1876
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
1877
|
+
The attempt generation key may have expired, been evicted, or been deleted.
|
|
1878
|
+
DIAGNOSTIC
|
|
1879
|
+
end
|
|
1880
|
+
|
|
1881
|
+
sig { returns(Thread) }
|
|
1882
|
+
def start_production_heartbeat
|
|
1883
|
+
generation = T.must(@attempt_generation)
|
|
1884
|
+
interval = [configuration.stall_timeout_seconds / 2, MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS].min
|
|
1885
|
+
control = HeartbeatControl.new
|
|
1886
|
+
@production_heartbeat_control = control
|
|
1887
|
+
@production_heartbeat_error = nil
|
|
1888
|
+
Thread.new do
|
|
1889
|
+
Thread.current.report_on_exception = false
|
|
1890
|
+
loop do
|
|
1891
|
+
stop_requested = control.mutex.synchronize do
|
|
1892
|
+
control.condition.wait(control.mutex, interval) unless control.stop_requested
|
|
1893
|
+
control.stop_requested
|
|
1894
|
+
end
|
|
1895
|
+
break if stop_requested
|
|
1896
|
+
|
|
1897
|
+
begin
|
|
1898
|
+
updated = execute_script(
|
|
1899
|
+
script_name: :heartbeat,
|
|
1900
|
+
keys: [key("attempt_generation"), key("production_heartbeat"), key("retention_ttl")],
|
|
1901
|
+
argv: [generation, configuration.key_ttl_seconds],
|
|
1902
|
+
)
|
|
1903
|
+
break unless updated == 1
|
|
1904
|
+
rescue Redis::BaseConnectionError
|
|
1905
|
+
# Redis clients reconnect on the next command. Keep this watchdog
|
|
1906
|
+
# alive so a transient connection failure does not silently stop
|
|
1907
|
+
# heartbeats during slow test discovery.
|
|
1908
|
+
next
|
|
1909
|
+
rescue StandardError => error
|
|
1910
|
+
@production_heartbeat_error = error
|
|
1911
|
+
break
|
|
1912
|
+
end
|
|
1913
|
+
end
|
|
1914
|
+
end
|
|
1915
|
+
end
|
|
1916
|
+
|
|
1917
|
+
sig { params(thread: Thread, propagate_error: T::Boolean).void }
|
|
1918
|
+
def stop_production_heartbeat(thread, propagate_error:)
|
|
1919
|
+
if (control = @production_heartbeat_control)
|
|
1920
|
+
control.mutex.synchronize do
|
|
1921
|
+
control.stop_requested = true
|
|
1922
|
+
control.condition.broadcast
|
|
1923
|
+
end
|
|
1924
|
+
end
|
|
1925
|
+
thread.join
|
|
1926
|
+
@production_heartbeat_control = nil
|
|
1927
|
+
heartbeat_error = @production_heartbeat_error
|
|
1928
|
+
raise heartbeat_error if heartbeat_error && propagate_error
|
|
1929
|
+
end
|
|
1930
|
+
|
|
1931
|
+
sig { params(results: ResultAggregate).returns(T::Boolean) }
|
|
1932
|
+
def run_complete?(results)
|
|
1933
|
+
results.equal?(@combined_results) && results.complete? && @combined_results_production_complete == true
|
|
1934
|
+
end
|
|
1935
|
+
|
|
1936
|
+
sig { returns(T.nilable(Integer)) }
|
|
1937
|
+
def current_production_heartbeat
|
|
1938
|
+
raw_heartbeat = read_control_string("production_heartbeat")
|
|
1939
|
+
return unless raw_heartbeat
|
|
1940
|
+
|
|
1941
|
+
heartbeat = parse_redis_integer(raw_heartbeat)
|
|
1942
|
+
raise CoordinatorStateError, "production_heartbeat=#{raw_heartbeat.inspect}" unless heartbeat
|
|
1943
|
+
|
|
1944
|
+
heartbeat
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
# Read all the state needed to distinguish a legitimately slow pending
|
|
1948
|
+
# test from a drained queue whose completion counters can no longer agree.
|
|
1949
|
+
# This deliberately bypasses `@combined_results`, which is memoized.
|
|
1950
|
+
sig { returns(StallProbe) }
|
|
1951
|
+
def probe_stall
|
|
1952
|
+
counters, pending_summary, groups, stream_info, production_complete_type, heartbeat_type,
|
|
1953
|
+
generation_type = redis.pipelined do |pipeline|
|
|
1954
|
+
pipeline.mget(key("acks"), key("size"), key("production_complete"), key("production_heartbeat"))
|
|
1955
|
+
pipeline.xpending(stream_key, group_name)
|
|
1956
|
+
pipeline.xinfo("groups", stream_key)
|
|
1957
|
+
pipeline.xinfo("stream", stream_key)
|
|
1958
|
+
pipeline.call("TYPE", key("production_complete"))
|
|
1959
|
+
pipeline.call("TYPE", key("production_heartbeat"))
|
|
1960
|
+
pipeline.call("TYPE", key("attempt_generation"))
|
|
1961
|
+
end
|
|
1962
|
+
|
|
1963
|
+
raw_acks, raw_size, raw_production_complete, raw_production_heartbeat = T.unsafe(counters)
|
|
1964
|
+
group = T.unsafe(groups).find { |candidate| candidate.fetch("name") == group_name }
|
|
1965
|
+
raw_lag = group&.fetch("lag", nil)
|
|
1966
|
+
invalid_values = T.let([], T::Array[String])
|
|
1967
|
+
acks = parse_redis_integer(raw_acks)
|
|
1968
|
+
size = parse_redis_integer(raw_size)
|
|
1969
|
+
lag = parse_redis_integer(raw_lag)
|
|
1970
|
+
production_heartbeat = parse_redis_integer(raw_production_heartbeat)
|
|
1971
|
+
unless ["none", "string"].include?(production_complete_type)
|
|
1972
|
+
invalid_values << "production_complete_type=#{production_complete_type}"
|
|
1973
|
+
end
|
|
1974
|
+
unless ["none", "string"].include?(heartbeat_type)
|
|
1975
|
+
invalid_values << "production_heartbeat_type=#{heartbeat_type}"
|
|
1976
|
+
end
|
|
1977
|
+
invalid_values << "attempt_generation_type=#{generation_type}" unless generation_type == "string"
|
|
1978
|
+
if !raw_production_complete.nil? && raw_production_complete != "1"
|
|
1979
|
+
invalid_values << "production_complete=#{raw_production_complete.inspect}"
|
|
1980
|
+
end
|
|
1981
|
+
invalid_values << "acks=#{raw_acks.inspect}" if !raw_acks.nil? && acks.nil?
|
|
1982
|
+
invalid_values << "size=#{raw_size.inspect}" if !raw_size.nil? && size.nil?
|
|
1983
|
+
invalid_values << "lag=#{raw_lag.inspect}" if !raw_lag.nil? && lag.nil?
|
|
1984
|
+
if !raw_production_heartbeat.nil? && production_heartbeat.nil?
|
|
1985
|
+
invalid_values << "production_heartbeat=#{raw_production_heartbeat.inspect}"
|
|
1986
|
+
end
|
|
1987
|
+
|
|
1988
|
+
# Redis added the explicit group lag field in version 7. On older
|
|
1989
|
+
# versions, equal delivery and stream IDs still prove that there are no
|
|
1990
|
+
# undelivered entries because this coordinator never trims the stream.
|
|
1991
|
+
if lag.nil? && group && group.fetch("last-delivered-id") == T.unsafe(stream_info).fetch("last-generated-id")
|
|
1992
|
+
lag = 0
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
raw_pending_count = T.unsafe(pending_summary).fetch("size")
|
|
1996
|
+
pending_count = parse_redis_integer(raw_pending_count)
|
|
1997
|
+
invalid_values << "pending=#{raw_pending_count.inspect}" if pending_count.nil?
|
|
1998
|
+
|
|
1999
|
+
StallProbe.new(
|
|
2000
|
+
acks: acks,
|
|
2001
|
+
size: size,
|
|
2002
|
+
pending_count: pending_count || 0,
|
|
2003
|
+
lag: lag,
|
|
2004
|
+
production_complete: raw_production_complete == "1",
|
|
2005
|
+
production_heartbeat: production_heartbeat,
|
|
2006
|
+
invalid_values: invalid_values,
|
|
2007
|
+
)
|
|
2008
|
+
end
|
|
2009
|
+
|
|
2010
|
+
sig { params(probe: StallProbe).void }
|
|
2011
|
+
def abort_stalled_run(probe)
|
|
2012
|
+
abort_with_diagnostic(format_stall_diagnostic(probe))
|
|
2013
|
+
end
|
|
2014
|
+
|
|
2015
|
+
sig { params(error: Redis::CommandError).void }
|
|
2016
|
+
def handle_coordinator_state_error(error)
|
|
2017
|
+
if attempt_truncated?
|
|
2018
|
+
@attempt_truncated = true
|
|
2019
|
+
@aborted = true
|
|
2020
|
+
emit_message(<<~DIAGNOSTIC)
|
|
2021
|
+
ERROR: minitest-distributed stopped this worker after another worker truncated the run.
|
|
2022
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
2023
|
+
redis_error=#{error.message.inspect}
|
|
2024
|
+
The run was intentionally cut short at max_failures before this local batch could be committed.
|
|
2025
|
+
DIAGNOSTIC
|
|
2026
|
+
return
|
|
2027
|
+
end
|
|
2028
|
+
if attempt_superseded?
|
|
2029
|
+
@superseded = true
|
|
2030
|
+
return
|
|
2031
|
+
end
|
|
2032
|
+
|
|
2033
|
+
@combined_results = nil
|
|
2034
|
+
begin
|
|
2035
|
+
results = combined_results
|
|
2036
|
+
rescue CoordinatorStateError => parse_error
|
|
2037
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
2038
|
+
ERROR: minitest-distributed could not parse Redis coordinator statistics.
|
|
2039
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
2040
|
+
redis_error=#{error.message.inspect} parse_error=#{parse_error.message.inspect}
|
|
2041
|
+
DIAGNOSTIC
|
|
2042
|
+
return
|
|
2043
|
+
rescue Redis::CommandError => state_error
|
|
2044
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
2045
|
+
ERROR: minitest-distributed lost required Redis coordinator state and aborted the run.
|
|
2046
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
2047
|
+
redis_error=#{error.message.inspect} state_error=#{state_error.message.inspect}
|
|
2048
|
+
The retained coordinator statistics are missing, invalid, or inconsistent.
|
|
2049
|
+
The run is incomplete, so this was not normal cleanup.
|
|
2050
|
+
DIAGNOSTIC
|
|
2051
|
+
return
|
|
2052
|
+
end
|
|
2053
|
+
mandatory_state_missing = error.message.start_with?("COORDINATORSTATE", "WRONGTYPE")
|
|
2054
|
+
return if !mandatory_state_missing && run_complete?(results)
|
|
2055
|
+
|
|
2056
|
+
state = "run_id=#{configuration.run_id} worker_id=#{configuration.worker_id} " \
|
|
2057
|
+
"acks=#{results.acks} size=#{results.size} redis_error=#{error.message.inspect}"
|
|
2058
|
+
diagnostic = <<~DIAGNOSTIC
|
|
2059
|
+
ERROR: minitest-distributed lost required Redis coordinator state and aborted the run.
|
|
2060
|
+
#{state}
|
|
2061
|
+
The run is incomplete, so this was not normal cleanup. A run key may have expired, been evicted, or been deleted.
|
|
2062
|
+
DIAGNOSTIC
|
|
2063
|
+
abort_with_diagnostic(diagnostic)
|
|
2064
|
+
rescue CoordinatorStateError => parse_error
|
|
2065
|
+
abort_with_diagnostic(<<~DIAGNOSTIC)
|
|
2066
|
+
ERROR: minitest-distributed could not parse Redis coordinator state.
|
|
2067
|
+
run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
|
|
2068
|
+
redis_error=#{error.message.inspect} parse_error=#{parse_error.message.inspect}
|
|
2069
|
+
DIAGNOSTIC
|
|
2070
|
+
end
|
|
2071
|
+
|
|
2072
|
+
sig { returns(T::Boolean) }
|
|
2073
|
+
def attempt_superseded?
|
|
2074
|
+
attempt_generation = @attempt_generation
|
|
2075
|
+
return false unless attempt_generation
|
|
2076
|
+
|
|
2077
|
+
generation_key = key("attempt_generation")
|
|
2078
|
+
return false unless redis.type(generation_key) == "string"
|
|
2079
|
+
|
|
2080
|
+
current_generation = redis.get(generation_key)
|
|
2081
|
+
return false if current_generation.nil?
|
|
2082
|
+
|
|
2083
|
+
current_generation != attempt_generation
|
|
2084
|
+
end
|
|
2085
|
+
|
|
2086
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
2087
|
+
def read_control_string(name)
|
|
2088
|
+
control_key = key(name)
|
|
2089
|
+
key_type = redis.type(control_key)
|
|
2090
|
+
return if key_type == "none"
|
|
2091
|
+
raise CoordinatorStateError, "#{name} has Redis type #{key_type}" unless key_type == "string"
|
|
2092
|
+
|
|
2093
|
+
redis.get(control_key)
|
|
2094
|
+
end
|
|
2095
|
+
|
|
2096
|
+
sig { returns(T::Boolean) }
|
|
2097
|
+
def commit_observed_truncation?
|
|
2098
|
+
@attempt_truncated
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
sig { returns(T::Boolean) }
|
|
2102
|
+
def attempt_truncated?
|
|
2103
|
+
attempt_generation = @attempt_generation
|
|
2104
|
+
return false unless attempt_generation
|
|
2105
|
+
|
|
2106
|
+
truncated_key = key("truncated")
|
|
2107
|
+
truncated_generation_key = key("truncated_generation")
|
|
2108
|
+
return false unless redis.type(truncated_key) == "string"
|
|
2109
|
+
return false unless redis.type(truncated_generation_key) == "string"
|
|
2110
|
+
|
|
2111
|
+
redis.get(truncated_key) == "1" && redis.get(truncated_generation_key) == attempt_generation
|
|
2112
|
+
end
|
|
2113
|
+
|
|
2114
|
+
sig { params(diagnostic: String).void }
|
|
2115
|
+
def abort_locally_with_diagnostic(diagnostic)
|
|
2116
|
+
@aborted = true
|
|
2117
|
+
@stall_diagnostic = diagnostic
|
|
2118
|
+
emit_message(diagnostic)
|
|
2119
|
+
end
|
|
2120
|
+
|
|
2121
|
+
sig { params(diagnostic: String).void }
|
|
2122
|
+
def abort_with_diagnostic(diagnostic)
|
|
2123
|
+
generation = T.must(@attempt_generation)
|
|
2124
|
+
applied = execute_script(
|
|
2125
|
+
script_name: :abort,
|
|
2126
|
+
keys: [stream_key, key("attempt_generation"), key("stalled"), key("retention_ttl")],
|
|
2127
|
+
argv: [generation, group_name, configuration.key_ttl_seconds],
|
|
2128
|
+
)
|
|
2129
|
+
return if applied != 1 && attempt_superseded?
|
|
2130
|
+
|
|
2131
|
+
# If ownership evidence itself was evicted, do not mutate shared state,
|
|
2132
|
+
# but still fail this worker with the diagnostic rather than silently
|
|
2133
|
+
# continuing or reporting success.
|
|
2134
|
+
abort_locally_with_diagnostic(diagnostic)
|
|
2135
|
+
end
|
|
2136
|
+
|
|
2137
|
+
sig { params(probe: StallProbe).returns(String) }
|
|
2138
|
+
def format_stall_diagnostic(probe)
|
|
2139
|
+
<<~DIAGNOSTIC
|
|
2140
|
+
ERROR: minitest-distributed detected inconsistent Redis coordinator state and aborted the run.
|
|
2141
|
+
#{format_probe_state(probe)}
|
|
2142
|
+
The consumer group has no pending or undelivered tests, but acks does not equal size.
|
|
2143
|
+
One or more coordinator keys may have been evicted or deleted; the run cannot make further progress.
|
|
2144
|
+
DIAGNOSTIC
|
|
2145
|
+
end
|
|
2146
|
+
|
|
2147
|
+
sig { params(probe: StallProbe).returns(String) }
|
|
2148
|
+
def format_pending_stall_warning(probe)
|
|
2149
|
+
timeout = format("%g", configuration.stall_timeout_seconds)
|
|
2150
|
+
reclaim_after = format("%g", configuration.test_timeout_seconds * configuration.test_batch_size)
|
|
2151
|
+
<<~WARNING
|
|
2152
|
+
WARNING: minitest-distributed has made no progress for #{timeout}s, but Redis still has pending tests.
|
|
2153
|
+
#{format_probe_state(probe)}
|
|
2154
|
+
The worker will keep waiting; pending tests become reclaimable after approximately #{reclaim_after}s.
|
|
2155
|
+
WARNING
|
|
2156
|
+
end
|
|
2157
|
+
|
|
2158
|
+
sig { params(probe: StallProbe).returns(String) }
|
|
2159
|
+
def format_probe_state(probe)
|
|
2160
|
+
"run_id=#{configuration.run_id} worker_id=#{configuration.worker_id} " \
|
|
2161
|
+
"acks=#{format_probe_value(probe.acks)} size=#{format_probe_value(probe.size)} " \
|
|
2162
|
+
"pending=#{probe.pending_count} lag=#{format_probe_value(probe.lag)} " \
|
|
2163
|
+
"production_complete=#{probe.production_complete} " \
|
|
2164
|
+
"production_heartbeat=#{format_probe_value(probe.production_heartbeat)}"
|
|
2165
|
+
end
|
|
2166
|
+
|
|
2167
|
+
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
2168
|
+
def parse_redis_integer(value)
|
|
2169
|
+
integer = case value
|
|
2170
|
+
when Integer then value
|
|
2171
|
+
when String then value.match?(/\A-?\d+\z/) ? value.to_i : nil
|
|
2172
|
+
end
|
|
2173
|
+
return unless integer
|
|
2174
|
+
|
|
2175
|
+
integer if integer.between?(0, MAX_SAFE_STATISTIC)
|
|
2176
|
+
end
|
|
2177
|
+
|
|
2178
|
+
sig { params(value: T.nilable(Integer)).returns(String) }
|
|
2179
|
+
def format_probe_value(value)
|
|
2180
|
+
value.nil? ? "missing" : value.to_s
|
|
2181
|
+
end
|
|
2182
|
+
|
|
2183
|
+
sig { params(message: String).void }
|
|
2184
|
+
def emit_message(message)
|
|
2185
|
+
(@output || $stderr).puts(message)
|
|
2186
|
+
end
|
|
2187
|
+
|
|
2188
|
+
sig { returns(Float) }
|
|
2189
|
+
def monotonic_time
|
|
2190
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
2191
|
+
end
|
|
2192
|
+
|
|
440
2193
|
sig { void }
|
|
441
2194
|
def cleanup
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
2195
|
+
generation = T.must(@attempt_generation)
|
|
2196
|
+
execute_script(
|
|
2197
|
+
script_name: :cleanup,
|
|
2198
|
+
keys: [stream_key, key("attempt_generation"), key("retention_ttl")],
|
|
2199
|
+
argv: [generation, group_name, configuration.key_ttl_seconds],
|
|
2200
|
+
)
|
|
2201
|
+
end
|
|
2202
|
+
|
|
2203
|
+
# The keys the coordinator writes for a run, other than the stream, are plain
|
|
2204
|
+
# counters, lists and sets that intentionally survive `cleanup`: retry mode and
|
|
2205
|
+
# the summary reporters read them after the stream is gone. Nothing ever removes
|
|
2206
|
+
# them, so a coordinator accumulates roughly a dozen keys per run, forever.
|
|
2207
|
+
#
|
|
2208
|
+
# That matters most where the coordinator is a shared Redis running an
|
|
2209
|
+
# `allkeys-lru` eviction policy at maxmemory, because LRU is then free to evict
|
|
2210
|
+
# the keys of a *running* build. Losing one is silent and unrecoverable: a run
|
|
2211
|
+
# only finishes when `acks == size`, so every worker keeps polling a drained
|
|
2212
|
+
# queue, printing nothing, until CI kills it.
|
|
2213
|
+
#
|
|
2214
|
+
# An expiry does not stop an eviction, and is not claimed to. It bounds what a
|
|
2215
|
+
# finished run leaves behind, which is the part this gem owns.
|
|
2216
|
+
STATS_KEY_NAMES = T.let(
|
|
2217
|
+
["runs", "assertions", "passes", "failures", "errors", "skips", "requeues", "discards", "acks", "size"].freeze,
|
|
2218
|
+
T::Array[String],
|
|
2219
|
+
)
|
|
2220
|
+
private_constant :STATS_KEY_NAMES
|
|
2221
|
+
|
|
2222
|
+
LIST_KEY_RESULT_TYPES = T.let(
|
|
2223
|
+
[ResultType::Skipped, ResultType::Failed, ResultType::Error].freeze,
|
|
2224
|
+
T::Array[ResultType],
|
|
2225
|
+
)
|
|
2226
|
+
private_constant :LIST_KEY_RESULT_TYPES
|
|
2227
|
+
|
|
2228
|
+
sig { params(identifiers: T::Array[String]).returns(T.nilable(T::Array[Minitest::Runnable])) }
|
|
2229
|
+
def materialize_retry_tests(identifiers)
|
|
2230
|
+
identifiers.map do |identifier|
|
|
2231
|
+
runnable = DefinedRunnable.from_identifier(identifier)
|
|
2232
|
+
raise NameError, "retained test method no longer exists: #{identifier}" unless runnable.respond_to?(runnable.name)
|
|
2233
|
+
|
|
2234
|
+
runnable
|
|
462
2235
|
end
|
|
2236
|
+
rescue StandardError
|
|
2237
|
+
nil
|
|
2238
|
+
end
|
|
463
2239
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
2240
|
+
sig { params(size: Integer).void }
|
|
2241
|
+
def reset_combined_results_for_full_rerun(size:)
|
|
2242
|
+
generation = T.must(@attempt_generation)
|
|
2243
|
+
keys = [
|
|
2244
|
+
key("attempt_generation"),
|
|
2245
|
+
stream_key,
|
|
2246
|
+
key("retry_set"),
|
|
2247
|
+
key("production_complete"),
|
|
2248
|
+
key("production_heartbeat"),
|
|
2249
|
+
key("stalled"),
|
|
2250
|
+
key("truncated"),
|
|
2251
|
+
]
|
|
2252
|
+
keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
|
|
2253
|
+
keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
|
|
2254
|
+
keys.push(
|
|
2255
|
+
key("completed_at"),
|
|
2256
|
+
key("retry_snapshot_digest"),
|
|
2257
|
+
key("truncated_generation"),
|
|
2258
|
+
key("retention_ttl"),
|
|
2259
|
+
)
|
|
2260
|
+
updated = execute_script(
|
|
2261
|
+
script_name: :reset_results,
|
|
2262
|
+
keys: keys,
|
|
2263
|
+
argv: [generation, configuration.key_ttl_seconds, size],
|
|
2264
|
+
)
|
|
2265
|
+
update_combined_results(T.cast(updated, T::Array[Integer]), production_complete: false)
|
|
2266
|
+
end
|
|
2267
|
+
|
|
2268
|
+
sig do
|
|
2269
|
+
params(
|
|
2270
|
+
results: ResultAggregate,
|
|
2271
|
+
clear_retry_lists: T::Boolean,
|
|
2272
|
+
allow_missing_stats: T::Boolean,
|
|
2273
|
+
).void
|
|
2274
|
+
end
|
|
2275
|
+
def adjust_combined_results(results, clear_retry_lists: false, allow_missing_stats: false)
|
|
2276
|
+
generation = T.must(@attempt_generation)
|
|
2277
|
+
keys = [
|
|
2278
|
+
key("attempt_generation"),
|
|
2279
|
+
stream_key,
|
|
2280
|
+
key("retry_set"),
|
|
2281
|
+
key("production_complete"),
|
|
2282
|
+
key("production_heartbeat"),
|
|
2283
|
+
key("stalled"),
|
|
2284
|
+
key("truncated"),
|
|
2285
|
+
]
|
|
2286
|
+
keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
|
|
2287
|
+
keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
|
|
2288
|
+
keys.push(
|
|
2289
|
+
key("completed_at"),
|
|
2290
|
+
key("retry_snapshot_digest"),
|
|
2291
|
+
key("truncated_generation"),
|
|
2292
|
+
key("retention_ttl"),
|
|
476
2293
|
)
|
|
2294
|
+
argv = [
|
|
2295
|
+
generation,
|
|
2296
|
+
configuration.key_ttl_seconds,
|
|
2297
|
+
clear_retry_lists ? 1 : 0,
|
|
2298
|
+
allow_missing_stats ? 1 : 0,
|
|
2299
|
+
results.runs,
|
|
2300
|
+
results.assertions,
|
|
2301
|
+
results.passes,
|
|
2302
|
+
results.failures,
|
|
2303
|
+
results.errors,
|
|
2304
|
+
results.skips,
|
|
2305
|
+
results.requeues,
|
|
2306
|
+
results.discards,
|
|
2307
|
+
results.acks,
|
|
2308
|
+
results.size,
|
|
2309
|
+
]
|
|
2310
|
+
updated = execute_script(script_name: :adjust_results, keys: keys, argv: argv)
|
|
2311
|
+
update_combined_results(T.cast(updated, T::Array[Integer]), production_complete: false)
|
|
477
2312
|
end
|
|
478
2313
|
|
|
479
2314
|
sig { params(name: String).returns(String) }
|
|
480
2315
|
def key(name)
|
|
481
|
-
"
|
|
2316
|
+
"#{REDIS_PROTOCOL_NAMESPACE}/#{configuration.run_id}/#{name}"
|
|
482
2317
|
end
|
|
483
2318
|
|
|
484
2319
|
sig { params(name: String).returns(String) }
|
|
@@ -486,6 +2321,119 @@ module Minitest
|
|
|
486
2321
|
key("#{name}_list")
|
|
487
2322
|
end
|
|
488
2323
|
|
|
2324
|
+
sig do
|
|
2325
|
+
params(
|
|
2326
|
+
results: T::Array[[EnqueuedRunnable, Minitest::Result]],
|
|
2327
|
+
).returns(T::Array[EnqueuedRunnable::Result])
|
|
2328
|
+
end
|
|
2329
|
+
def commit_results(results)
|
|
2330
|
+
arguments = T.let(
|
|
2331
|
+
[group_name, configuration.key_ttl_seconds, results.size, T.must(@attempt_generation)],
|
|
2332
|
+
T::Array[T.untyped],
|
|
2333
|
+
)
|
|
2334
|
+
results.each do |enqueued_runnable, result|
|
|
2335
|
+
arguments.push(
|
|
2336
|
+
enqueued_runnable.entry_id,
|
|
2337
|
+
ResultType.of(result).serialize,
|
|
2338
|
+
enqueued_runnable.attempt_id,
|
|
2339
|
+
enqueued_runnable.identifier,
|
|
2340
|
+
result.assertions,
|
|
2341
|
+
)
|
|
2342
|
+
end
|
|
2343
|
+
|
|
2344
|
+
keys = [stream_key, key("retry_set")]
|
|
2345
|
+
keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
|
|
2346
|
+
keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
|
|
2347
|
+
keys.push(
|
|
2348
|
+
key("production_complete"),
|
|
2349
|
+
key("stalled"),
|
|
2350
|
+
key("production_heartbeat"),
|
|
2351
|
+
key("attempt_generation"),
|
|
2352
|
+
key("truncated"),
|
|
2353
|
+
key("completed_at"),
|
|
2354
|
+
key("retry_snapshot_digest"),
|
|
2355
|
+
key("truncated_generation"),
|
|
2356
|
+
key("retention_ttl"),
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
response = T.unsafe(execute_script(script_name: :commit_results, keys: keys, argv: arguments))
|
|
2360
|
+
commit_statuses = T.cast(response.take(results.size), T::Array[Integer])
|
|
2361
|
+
aggregate_response = T.cast(response.drop(results.size), T::Array[Integer])
|
|
2362
|
+
attempt_truncated = aggregate_response.pop == 1
|
|
2363
|
+
production_complete = aggregate_response.pop == 1
|
|
2364
|
+
if attempt_truncated
|
|
2365
|
+
@attempt_truncated = true
|
|
2366
|
+
@aborted = true
|
|
2367
|
+
end
|
|
2368
|
+
update_combined_results(aggregate_response, production_complete: production_complete)
|
|
2369
|
+
build_runnable_results(results, commit_statuses, force_discard: attempt_truncated)
|
|
2370
|
+
rescue Redis::CommandError => error
|
|
2371
|
+
if error.message.start_with?("STALEATTEMPT")
|
|
2372
|
+
@superseded = true
|
|
2373
|
+
# The Lua script already proved supersession atomically. Do not
|
|
2374
|
+
# re-read generation or truncation keys here: they may be evicted
|
|
2375
|
+
# before Ruby handles the response, but the executed batch must
|
|
2376
|
+
# still be recorded locally as discarded.
|
|
2377
|
+
return build_runnable_results(results, Array.new(results.size, 0), force_discard: true)
|
|
2378
|
+
end
|
|
2379
|
+
|
|
2380
|
+
cleanup_race_error = error.message.start_with?("NOGROUP", "COORDINATORCONFIG", "COORDINATORSTREAM") ||
|
|
2381
|
+
error.message.include?("no such key")
|
|
2382
|
+
raise unless cleanup_race_error
|
|
2383
|
+
|
|
2384
|
+
# Another worker may have completed and cleaned up while this batch was
|
|
2385
|
+
# running. Preserve the reporter contract by presenting these local
|
|
2386
|
+
# results as uncommitted/discarded, but only when fresh counters prove
|
|
2387
|
+
# the shared run is already terminal. Incomplete state is handled by
|
|
2388
|
+
# consume's fail-closed coordinator-state path.
|
|
2389
|
+
@combined_results = nil
|
|
2390
|
+
terminal_results = combined_results
|
|
2391
|
+
raise unless run_complete?(terminal_results)
|
|
2392
|
+
|
|
2393
|
+
build_runnable_results(results, Array.new(results.size, 0), force_discard: true)
|
|
2394
|
+
end
|
|
2395
|
+
|
|
2396
|
+
sig do
|
|
2397
|
+
params(
|
|
2398
|
+
results: T::Array[[EnqueuedRunnable, Minitest::Result]],
|
|
2399
|
+
commit_statuses: T::Array[Integer],
|
|
2400
|
+
force_discard: T::Boolean,
|
|
2401
|
+
).returns(T::Array[EnqueuedRunnable::Result])
|
|
2402
|
+
end
|
|
2403
|
+
def build_runnable_results(results, commit_statuses, force_discard: false)
|
|
2404
|
+
results.each_with_index.map do |(enqueued_runnable, result), index|
|
|
2405
|
+
commit = if commit_statuses.fetch(index) == 1
|
|
2406
|
+
EnqueuedRunnable::Result::Commit.success
|
|
2407
|
+
else
|
|
2408
|
+
EnqueuedRunnable::Result::Commit.failure
|
|
2409
|
+
end
|
|
2410
|
+
EnqueuedRunnable::Result.new(
|
|
2411
|
+
enqueued_runnable: enqueued_runnable,
|
|
2412
|
+
initial_result: result,
|
|
2413
|
+
commit: commit,
|
|
2414
|
+
force_discard: force_discard,
|
|
2415
|
+
)
|
|
2416
|
+
end
|
|
2417
|
+
end
|
|
2418
|
+
|
|
2419
|
+
sig { params(updated: T::Array[Integer], production_complete: T::Boolean).void }
|
|
2420
|
+
def update_combined_results(updated, production_complete:)
|
|
2421
|
+
@combined_results_production_complete = production_complete
|
|
2422
|
+
@combined_results = ResultAggregate.new(
|
|
2423
|
+
max_failures: configuration.max_failures,
|
|
2424
|
+
runs: updated.fetch(0),
|
|
2425
|
+
assertions: updated.fetch(1),
|
|
2426
|
+
passes: updated.fetch(2),
|
|
2427
|
+
failures: updated.fetch(3),
|
|
2428
|
+
errors: updated.fetch(4),
|
|
2429
|
+
skips: updated.fetch(5),
|
|
2430
|
+
requeues: updated.fetch(6),
|
|
2431
|
+
discards: updated.fetch(7),
|
|
2432
|
+
acks: updated.fetch(8),
|
|
2433
|
+
size: updated.fetch(9),
|
|
2434
|
+
)
|
|
2435
|
+
end
|
|
2436
|
+
|
|
489
2437
|
sig { params(batch: T::Array[EnqueuedRunnable], reporter: AbstractReporter).void }
|
|
490
2438
|
def process_batch(batch, reporter)
|
|
491
2439
|
return 0 if batch.empty?
|
|
@@ -498,42 +2446,17 @@ module Minitest
|
|
|
498
2446
|
[enqueued_runnable, enqueued_runnable.run]
|
|
499
2447
|
end
|
|
500
2448
|
|
|
501
|
-
#
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
results.each do |enqueued_runnable, initial_result|
|
|
505
|
-
runnable_results << enqueued_runnable.commit_result(initial_result) do |result_to_commit|
|
|
506
|
-
if ResultType.of(result_to_commit) == ResultType::Requeued
|
|
507
|
-
sadd_future = pipeline.sadd(key("retry_set"), [enqueued_runnable.attempt_id])
|
|
508
|
-
EnqueuedRunnable::Result::Commit.new { sadd_future.value > 0 }
|
|
509
|
-
else
|
|
510
|
-
xack_future = pipeline.xack(stream_key, group_name, enqueued_runnable.entry_id)
|
|
511
|
-
EnqueuedRunnable::Result::Commit.new { xack_future.value == 1 }
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
batch_result_aggregate = ResultAggregate.new
|
|
2449
|
+
# XACK/SADD, result-list writes, statistics and expiries are committed
|
|
2450
|
+
# atomically so a diagnostic probe cannot observe a valid half-commit.
|
|
2451
|
+
runnable_results = commit_results(results)
|
|
518
2452
|
runnable_results.each do |runnable_result|
|
|
519
2453
|
# Complete the reporter contract by calling `record` with the result.
|
|
520
2454
|
reporter.record(runnable_result.committed_result)
|
|
521
2455
|
|
|
522
|
-
#
|
|
523
|
-
|
|
2456
|
+
# The combined statistics were updated by `commit_results`; only this
|
|
2457
|
+
# worker's local statistics remain to be recorded here.
|
|
524
2458
|
local_results.update_with_result(runnable_result)
|
|
525
|
-
|
|
526
|
-
case (result_type = ResultType.of(runnable_result.committed_result))
|
|
527
|
-
when ResultType::Skipped, ResultType::Failed, ResultType::Error
|
|
528
|
-
redis.lpush(list_key(result_type.serialize), runnable_result.enqueued_runnable.identifier)
|
|
529
|
-
when ResultType::Passed, ResultType::Requeued, ResultType::Discarded
|
|
530
|
-
# noop
|
|
531
|
-
else
|
|
532
|
-
T.absurd(result_type)
|
|
533
|
-
end
|
|
534
2459
|
end
|
|
535
|
-
|
|
536
|
-
adjust_combined_results(batch_result_aggregate)
|
|
537
2460
|
end
|
|
538
2461
|
|
|
539
2462
|
sig { returns(T.nilable(Logger)) }
|
|
@@ -543,8 +2466,40 @@ module Minitest
|
|
|
543
2466
|
@logger ||= T.let(Logger.new(log_path), T.nilable(Logger))
|
|
544
2467
|
end
|
|
545
2468
|
|
|
2469
|
+
sig { params(backoff: Integer).returns(Integer) }
|
|
2470
|
+
def next_backoff(backoff)
|
|
2471
|
+
[backoff << 1, MAX_BACKOFF].min
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
REDIS_PROTOCOL_NAMESPACE = "minitest/v3"
|
|
2475
|
+
private_constant :REDIS_PROTOCOL_NAMESPACE
|
|
2476
|
+
|
|
2477
|
+
BASE_GROUP_NAME = "minitest-distributed-v3"
|
|
2478
|
+
private_constant :BASE_GROUP_NAME
|
|
2479
|
+
|
|
2480
|
+
# Confirm a drained mismatch because the diagnostic probe itself uses
|
|
2481
|
+
# multiple pipelined Redis commands and is not an atomic snapshot.
|
|
2482
|
+
STALL_CONFIRMATION_SECONDS = 30.0
|
|
2483
|
+
private_constant :STALL_CONFIRMATION_SECONDS
|
|
2484
|
+
|
|
2485
|
+
MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS = 30.0
|
|
2486
|
+
private_constant :MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS
|
|
2487
|
+
|
|
2488
|
+
MAX_SAFE_STATISTIC = 9_007_199_254_740_991
|
|
2489
|
+
private_constant :MAX_SAFE_STATISTIC
|
|
2490
|
+
|
|
2491
|
+
PRODUCTION_BATCH_SIZE = 100
|
|
2492
|
+
private_constant :PRODUCTION_BATCH_SIZE
|
|
2493
|
+
|
|
546
2494
|
INITIAL_BACKOFF = 10 # milliseconds
|
|
547
2495
|
private_constant :INITIAL_BACKOFF
|
|
2496
|
+
|
|
2497
|
+
# Cap on the XREADGROUP BLOCK timeout used by `consume`. Reached after roughly
|
|
2498
|
+
# 9 consecutive empty iterations (10 ms * 2^9 = 5120 ms). Bounds the worst-case
|
|
2499
|
+
# time a worker can be unresponsive to `complete?` / `abort?` after the queue
|
|
2500
|
+
# is drained.
|
|
2501
|
+
MAX_BACKOFF = 5_000 # milliseconds
|
|
2502
|
+
private_constant :MAX_BACKOFF
|
|
548
2503
|
end
|
|
549
2504
|
end
|
|
550
2505
|
end
|