minitest-distributed 0.2.13 → 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 +2129 -195
- 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,124 +168,354 @@ module Minitest
|
|
|
123
168
|
@aborted
|
|
124
169
|
end
|
|
125
170
|
|
|
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
|
|
193
|
+
|
|
194
|
+
sig { returns(T::Boolean) }
|
|
195
|
+
def stalled?
|
|
196
|
+
!stall_diagnostic.nil?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sig { returns(T::Boolean) }
|
|
200
|
+
def registration_rejected?
|
|
201
|
+
@registration_rejected
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig { returns(T::Boolean) }
|
|
205
|
+
def retry_refused_due_to_truncation?
|
|
206
|
+
@retry_refused_due_to_truncation
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
sig { returns(T::Boolean) }
|
|
210
|
+
def current_attempt_truncated?
|
|
211
|
+
@attempt_truncated
|
|
212
|
+
end
|
|
213
|
+
|
|
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
|
+
|
|
126
224
|
sig { override.params(test_selector: TestSelector).void }
|
|
127
225
|
def produce(test_selector:)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
288
|
end
|
|
289
|
+
break unless registration_mode == -2
|
|
290
|
+
|
|
291
|
+
sleep(Float(registration.fetch(5)))
|
|
292
|
+
awaited_generation = String(registration.fetch(1))
|
|
152
293
|
end
|
|
294
|
+
return unless leader
|
|
153
295
|
|
|
154
|
-
|
|
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
|
|
155
299
|
|
|
156
300
|
tests = T.let(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# test suite as returned by the test selector to run.
|
|
160
|
-
|
|
301
|
+
case registration_mode
|
|
302
|
+
when 0 # First attempt for a new run ID.
|
|
161
303
|
tests_from_selector = test_selector.tests
|
|
162
|
-
adjust_combined_results(
|
|
304
|
+
adjust_combined_results(
|
|
305
|
+
ResultAggregate.new(size: tests_from_selector.size),
|
|
306
|
+
allow_missing_stats: true,
|
|
307
|
+
)
|
|
163
308
|
tests_from_selector
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
177
335
|
adjust_combined_results(ResultAggregate.new(size: 0))
|
|
178
336
|
[]
|
|
179
|
-
else
|
|
180
|
-
previous_failures, previous_errors, _deleted = redis.multi do |pipeline|
|
|
181
|
-
pipeline.lrange(list_key(ResultType::Failed.serialize), 0, -1)
|
|
182
|
-
pipeline.lrange(list_key(ResultType::Error.serialize), 0, -1)
|
|
183
|
-
pipeline.del(list_key(ResultType::Failed.serialize), list_key(ResultType::Error.serialize))
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# We set the `size` key to the number of tests we are planning to schedule.
|
|
187
|
-
# We also adjust the number of failures and errors back to 0.
|
|
188
|
-
# We set the number of requeues to the number of tests that failed, so the
|
|
189
|
-
# run statistics will reflect that we retried some failed test.
|
|
190
|
-
#
|
|
191
|
-
# However, normally requeues are not acked, as we expect the test to be acked
|
|
192
|
-
# by another worker later. This makes the test loop think iot is already done.
|
|
193
|
-
# To prevent this, we initialize the number of acks negatively, so it evens out
|
|
194
|
-
# in the statistics.
|
|
195
|
-
total_failures = previous_failures.length + previous_errors.length
|
|
196
|
-
adjust_combined_results(ResultAggregate.new(
|
|
197
|
-
size: total_failures,
|
|
198
|
-
failures: -previous_failures.length,
|
|
199
|
-
errors: -previous_errors.length,
|
|
200
|
-
requeues: total_failures,
|
|
201
|
-
))
|
|
202
|
-
|
|
203
|
-
# For subsequent attempts, we check the list of previous failures and
|
|
204
|
-
# errors, and only schedule to re-run those tests. This allows for faster
|
|
205
|
-
# retries of potentially flaky tests.
|
|
206
|
-
test_identifiers_to_retry = T.let(previous_failures + previous_errors, T::Array[String])
|
|
207
|
-
test_identifiers_to_retry.map { |identifier| DefinedRunnable.from_identifier(identifier) }
|
|
208
337
|
end
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
352
|
[]
|
|
353
|
+
else
|
|
354
|
+
raise "Unknown Redis registration mode: #{registration_mode}"
|
|
212
355
|
end,
|
|
213
356
|
T::Array[Minitest::Runnable],
|
|
214
357
|
)
|
|
215
358
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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)
|
|
219
365
|
end
|
|
220
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
|
|
221
374
|
end
|
|
222
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
|
|
223
379
|
sig { override.params(reporter: AbstractReporter).void }
|
|
224
380
|
def consume(reporter:)
|
|
381
|
+
return if @retry_refused_due_to_truncation || @registration_rejected || @truncation_state_invalid || superseded?
|
|
382
|
+
|
|
225
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
|
+
|
|
226
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
|
+
|
|
227
398
|
# First, see if there are any pending tests from other workers to claim.
|
|
228
399
|
stale_runnables = claim_stale_runnables
|
|
229
400
|
process_batch(stale_runnables, reporter)
|
|
401
|
+
break if commit_observed_truncation? || superseded?
|
|
230
402
|
|
|
231
403
|
# Then, try to process a regular batch of messages
|
|
232
404
|
fresh_runnables = claim_fresh_runnables(block: exponential_backoff)
|
|
233
405
|
process_batch(fresh_runnables, reporter)
|
|
406
|
+
break if commit_observed_truncation? || superseded?
|
|
407
|
+
|
|
408
|
+
run_results = combined_results
|
|
234
409
|
|
|
235
410
|
# If we have acked the same amount of tests as we were supposed to, the run
|
|
236
411
|
# is complete and we can exit our loop. Generally, only one worker will detect
|
|
237
|
-
# this condition. The
|
|
238
|
-
#
|
|
239
|
-
# will start to fail - see the rescue block below.
|
|
240
|
-
|
|
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
|
|
423
|
+
|
|
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
|
|
241
454
|
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
244
519
|
|
|
245
520
|
# To make sure we don't end up in a busy loop overwhelming Redis with commands
|
|
246
521
|
# when there is no work to do, we increase the blocking time exponentially,
|
|
@@ -252,30 +527,73 @@ module Minitest
|
|
|
252
527
|
# re-check `complete?` / `abort?` until the BLOCK returns, which manifests
|
|
253
528
|
# as a long post-100% teardown hang when pipelined XACKs race the progress
|
|
254
529
|
# reporter.
|
|
255
|
-
exponential_backoff = if
|
|
256
|
-
next_backoff(exponential_backoff)
|
|
257
|
-
else
|
|
530
|
+
exponential_backoff = if processed_batch
|
|
258
531
|
INITIAL_BACKOFF
|
|
532
|
+
else
|
|
533
|
+
next_backoff(exponential_backoff)
|
|
259
534
|
end
|
|
260
535
|
end
|
|
261
536
|
|
|
262
537
|
cleanup
|
|
263
538
|
rescue Redis::CommandError => ce
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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?
|
|
272
552
|
else
|
|
273
553
|
raise
|
|
274
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
|
|
275
567
|
end
|
|
568
|
+
# rubocop:enable Metrics/BlockNesting, Lint/RedundantCopDisableDirective
|
|
276
569
|
|
|
277
570
|
private
|
|
278
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
|
+
|
|
279
597
|
sig { returns(Redis) }
|
|
280
598
|
def redis
|
|
281
599
|
@redis ||= Redis.new(
|
|
@@ -286,7 +604,7 @@ module Minitest
|
|
|
286
604
|
)
|
|
287
605
|
end
|
|
288
606
|
|
|
289
|
-
sig { returns(T.nilable(T::Array[
|
|
607
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
290
608
|
def custom_middlewares
|
|
291
609
|
return unless ENV.key?("MINITEST_DISTRIBUTED_REDIS_LOG")
|
|
292
610
|
|
|
@@ -299,22 +617,1074 @@ module Minitest
|
|
|
299
617
|
{ log_file: logger }.compact
|
|
300
618
|
end
|
|
301
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
|
+
|
|
302
684
|
sig { returns(String) }
|
|
303
685
|
def register_consumergroup_script
|
|
304
|
-
@register_consumergroup_script ||=
|
|
305
|
-
--
|
|
306
|
-
--
|
|
307
|
-
--
|
|
308
|
-
--
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
1501
|
LUA
|
|
316
1502
|
end
|
|
317
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
|
|
1559
|
+
LUA
|
|
1560
|
+
end
|
|
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
|
+
|
|
318
1688
|
sig { params(block: Integer).returns(T::Array[EnqueuedRunnable]) }
|
|
319
1689
|
def claim_fresh_runnables(block:)
|
|
320
1690
|
result = redis.xreadgroup(
|
|
@@ -325,7 +1695,9 @@ module Minitest
|
|
|
325
1695
|
block: block,
|
|
326
1696
|
count: configuration.test_batch_size,
|
|
327
1697
|
)
|
|
328
|
-
|
|
1698
|
+
claims = result.fetch(stream_key, [])
|
|
1699
|
+
@combined_results = nil if claims.any?
|
|
1700
|
+
EnqueuedRunnable.from_redis_stream_claim(claims, configuration: configuration)
|
|
329
1701
|
end
|
|
330
1702
|
|
|
331
1703
|
sig do
|
|
@@ -446,48 +1818,502 @@ module Minitest
|
|
|
446
1818
|
enqueued_runnables
|
|
447
1819
|
end
|
|
448
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
|
+
|
|
449
2193
|
sig { void }
|
|
450
2194
|
def cleanup
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
2235
|
end
|
|
2236
|
+
rescue StandardError
|
|
2237
|
+
nil
|
|
2238
|
+
end
|
|
472
2239
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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"),
|
|
485
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)
|
|
486
2312
|
end
|
|
487
2313
|
|
|
488
2314
|
sig { params(name: String).returns(String) }
|
|
489
2315
|
def key(name)
|
|
490
|
-
"
|
|
2316
|
+
"#{REDIS_PROTOCOL_NAMESPACE}/#{configuration.run_id}/#{name}"
|
|
491
2317
|
end
|
|
492
2318
|
|
|
493
2319
|
sig { params(name: String).returns(String) }
|
|
@@ -495,6 +2321,119 @@ module Minitest
|
|
|
495
2321
|
key("#{name}_list")
|
|
496
2322
|
end
|
|
497
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
|
+
|
|
498
2437
|
sig { params(batch: T::Array[EnqueuedRunnable], reporter: AbstractReporter).void }
|
|
499
2438
|
def process_batch(batch, reporter)
|
|
500
2439
|
return 0 if batch.empty?
|
|
@@ -507,42 +2446,17 @@ module Minitest
|
|
|
507
2446
|
[enqueued_runnable, enqueued_runnable.run]
|
|
508
2447
|
end
|
|
509
2448
|
|
|
510
|
-
#
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
results.each do |enqueued_runnable, initial_result|
|
|
514
|
-
runnable_results << enqueued_runnable.commit_result(initial_result) do |result_to_commit|
|
|
515
|
-
if ResultType.of(result_to_commit) == ResultType::Requeued
|
|
516
|
-
sadd_future = pipeline.sadd(key("retry_set"), [enqueued_runnable.attempt_id])
|
|
517
|
-
EnqueuedRunnable::Result::Commit.new { sadd_future.value > 0 }
|
|
518
|
-
else
|
|
519
|
-
xack_future = pipeline.xack(stream_key, group_name, enqueued_runnable.entry_id)
|
|
520
|
-
EnqueuedRunnable::Result::Commit.new { xack_future.value == 1 }
|
|
521
|
-
end
|
|
522
|
-
end
|
|
523
|
-
end
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
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)
|
|
527
2452
|
runnable_results.each do |runnable_result|
|
|
528
2453
|
# Complete the reporter contract by calling `record` with the result.
|
|
529
2454
|
reporter.record(runnable_result.committed_result)
|
|
530
2455
|
|
|
531
|
-
#
|
|
532
|
-
|
|
2456
|
+
# The combined statistics were updated by `commit_results`; only this
|
|
2457
|
+
# worker's local statistics remain to be recorded here.
|
|
533
2458
|
local_results.update_with_result(runnable_result)
|
|
534
|
-
|
|
535
|
-
case (result_type = ResultType.of(runnable_result.committed_result))
|
|
536
|
-
when ResultType::Skipped, ResultType::Failed, ResultType::Error
|
|
537
|
-
redis.lpush(list_key(result_type.serialize), runnable_result.enqueued_runnable.identifier)
|
|
538
|
-
when ResultType::Passed, ResultType::Requeued, ResultType::Discarded
|
|
539
|
-
# noop
|
|
540
|
-
else
|
|
541
|
-
T.absurd(result_type)
|
|
542
|
-
end
|
|
543
2459
|
end
|
|
544
|
-
|
|
545
|
-
adjust_combined_results(batch_result_aggregate)
|
|
546
2460
|
end
|
|
547
2461
|
|
|
548
2462
|
sig { returns(T.nilable(Logger)) }
|
|
@@ -557,6 +2471,26 @@ module Minitest
|
|
|
557
2471
|
[backoff << 1, MAX_BACKOFF].min
|
|
558
2472
|
end
|
|
559
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
|
+
|
|
560
2494
|
INITIAL_BACKOFF = 10 # milliseconds
|
|
561
2495
|
private_constant :INITIAL_BACKOFF
|
|
562
2496
|
|