padded_cell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/Cargo.lock +518 -0
  4. data/Cargo.toml +41 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +184 -0
  7. data/exe/padded_cell +6 -0
  8. data/ext/padded_cell_native/core/Cargo.toml +20 -0
  9. data/ext/padded_cell_native/core/README.md +50 -0
  10. data/ext/padded_cell_native/core/src/discover/tests.rs +107 -0
  11. data/ext/padded_cell_native/core/src/discover.rs +454 -0
  12. data/ext/padded_cell_native/core/src/hash/tests.rs +13 -0
  13. data/ext/padded_cell_native/core/src/hash.rs +13 -0
  14. data/ext/padded_cell_native/core/src/lib.rs +30 -0
  15. data/ext/padded_cell_native/core/src/plan/tests.rs +61 -0
  16. data/ext/padded_cell_native/core/src/plan.rs +109 -0
  17. data/ext/padded_cell_native/core/src/rng/tests.rs +17 -0
  18. data/ext/padded_cell_native/core/src/rng.rs +28 -0
  19. data/ext/padded_cell_native/extconf.rb +46 -0
  20. data/ext/padded_cell_native/ffi/Cargo.toml +19 -0
  21. data/ext/padded_cell_native/ffi/src/lib.rs +120 -0
  22. data/lib/padded_cell/active_support_hooks.rb +30 -0
  23. data/lib/padded_cell/cell.rb +87 -0
  24. data/lib/padded_cell/cli.rb +97 -0
  25. data/lib/padded_cell/discovery.rb +24 -0
  26. data/lib/padded_cell/hooks.rb +33 -0
  27. data/lib/padded_cell/loader.rb +82 -0
  28. data/lib/padded_cell/native.rb +23 -0
  29. data/lib/padded_cell/pool.rb +202 -0
  30. data/lib/padded_cell/railtie.rb +18 -0
  31. data/lib/padded_cell/reporters.rb +106 -0
  32. data/lib/padded_cell/results.rb +108 -0
  33. data/lib/padded_cell/runner.rb +131 -0
  34. data/lib/padded_cell/scheduler.rb +21 -0
  35. data/lib/padded_cell/selection.rb +78 -0
  36. data/lib/padded_cell/timings.rb +30 -0
  37. data/lib/padded_cell/version.rb +5 -0
  38. data/lib/padded_cell.rb +29 -0
  39. metadata +113 -0
@@ -0,0 +1,454 @@
1
+ //! Static Minitest discovery.
2
+ //!
3
+ //! A test class is a class whose superclass constant ends in `Test` or
4
+ //! `TestCase` (`Minitest::Test`, `ActiveSupport::TestCase`, an app's own
5
+ //! `BaseTest`...). Its tests are `def test_*` methods and Rails'
6
+ //! `test "name"` declarations. Nothing is evaluated.
7
+
8
+ use std::iter::FusedIterator;
9
+ use std::num::NonZeroUsize;
10
+ use std::path::{Path, PathBuf};
11
+ use std::{fs, io, slice, thread};
12
+
13
+ use ruby_prism::{
14
+ CallNode, ClassNode, DefNode, Location, ModuleNode, Node, SingletonClassNode, Visit,
15
+ };
16
+
17
+ #[cfg(test)]
18
+ mod tests;
19
+
20
+ /// The tests of one source. Each class name is stored once, however many
21
+ /// tests the class has.
22
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
23
+ pub struct Discovery {
24
+ classes: Vec<String>,
25
+ tests: Vec<Entry>,
26
+ }
27
+
28
+ #[derive(Debug, Clone, PartialEq, Eq)]
29
+ struct Entry {
30
+ class: usize,
31
+ name: String,
32
+ start_line: usize,
33
+ end_line: usize,
34
+ }
35
+
36
+ /// A discovered test, borrowed from its [`Discovery`].
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub struct TestDef<'a> {
39
+ /// Full constant name of the class: `Admin::UsersControllerTest`.
40
+ pub class_name: &'a str,
41
+ /// Index of the class in [`Discovery::classes`].
42
+ pub class: usize,
43
+ /// Method name: `test_index_lists_users` for `test "index lists users"`.
44
+ pub name: &'a str,
45
+ /// 1-based line where the definition starts.
46
+ pub start_line: usize,
47
+ /// 1-based line where it ends.
48
+ pub end_line: usize,
49
+ }
50
+
51
+ impl TestDef<'_> {
52
+ /// `Class#method`, the id Minitest filters match.
53
+ #[must_use]
54
+ pub fn id(&self) -> String {
55
+ format!("{}#{}", self.class_name, self.name)
56
+ }
57
+ }
58
+
59
+ impl Discovery {
60
+ /// Names of the test classes that have tests, in order of appearance.
61
+ #[must_use]
62
+ pub fn classes(&self) -> &[String] {
63
+ &self.classes
64
+ }
65
+
66
+ /// Number of tests.
67
+ #[must_use]
68
+ pub const fn len(&self) -> usize {
69
+ self.tests.len()
70
+ }
71
+
72
+ /// Whether no test was found.
73
+ #[must_use]
74
+ pub const fn is_empty(&self) -> bool {
75
+ self.tests.is_empty()
76
+ }
77
+
78
+ /// The tests, in source order.
79
+ #[must_use]
80
+ pub fn iter(&self) -> Iter<'_> {
81
+ Iter { classes: &self.classes, tests: self.tests.iter() }
82
+ }
83
+ }
84
+
85
+ impl<'a> IntoIterator for &'a Discovery {
86
+ type Item = TestDef<'a>;
87
+ type IntoIter = Iter<'a>;
88
+
89
+ fn into_iter(self) -> Iter<'a> {
90
+ self.iter()
91
+ }
92
+ }
93
+
94
+ /// Iterator over the tests of a [`Discovery`].
95
+ #[derive(Debug, Clone)]
96
+ pub struct Iter<'a> {
97
+ classes: &'a [String],
98
+ tests: slice::Iter<'a, Entry>,
99
+ }
100
+
101
+ impl<'a> Iterator for Iter<'a> {
102
+ type Item = TestDef<'a>;
103
+
104
+ fn next(&mut self) -> Option<TestDef<'a>> {
105
+ let entry = self.tests.next()?;
106
+ Some(TestDef {
107
+ class_name: &self.classes[entry.class],
108
+ class: entry.class,
109
+ name: &entry.name,
110
+ start_line: entry.start_line,
111
+ end_line: entry.end_line,
112
+ })
113
+ }
114
+
115
+ fn size_hint(&self) -> (usize, Option<usize>) {
116
+ self.tests.size_hint()
117
+ }
118
+ }
119
+
120
+ impl ExactSizeIterator for Iter<'_> {}
121
+
122
+ impl FusedIterator for Iter<'_> {}
123
+
124
+ /// A test file that could not be read.
125
+ #[derive(Debug, thiserror::Error)]
126
+ #[error("{}: {source}", path.display())]
127
+ pub struct FileError {
128
+ /// The file.
129
+ pub path: PathBuf,
130
+ /// Why it could not be read.
131
+ #[source]
132
+ pub source: io::Error,
133
+ }
134
+
135
+ /// The tests defined in `source`, in source order. Syntax errors do not
136
+ /// stop discovery: what Prism recovers is reported.
137
+ #[must_use]
138
+ pub fn discover(source: &[u8]) -> Discovery {
139
+ let result = ruby_prism::parse(source);
140
+ let mut visitor = Visitor::new(source);
141
+ visitor.visit(&result.node());
142
+ visitor.discovery
143
+ }
144
+
145
+ /// [`discover`] for every source, spread over the available cores. Results
146
+ /// keep the order of `sources`.
147
+ #[must_use]
148
+ pub fn discover_many<S: AsRef<[u8]> + Sync>(sources: &[S]) -> Vec<Discovery> {
149
+ let mut found = vec![Discovery::default(); sources.len()];
150
+ let Ok(()) = fill(sources, &mut found, |source| {
151
+ Ok::<_, std::convert::Infallible>(discover(source.as_ref()))
152
+ });
153
+ found
154
+ }
155
+
156
+ /// Reads and discovers one file, parsed from the buffer it was read into.
157
+ ///
158
+ /// # Errors
159
+ ///
160
+ /// [`FileError`] when the file cannot be read.
161
+ pub fn discover_file(path: impl AsRef<Path>) -> Result<Discovery, FileError> {
162
+ let path = path.as_ref();
163
+ let source = fs::read(path).map_err(|source| FileError { path: path.to_owned(), source })?;
164
+ Ok(discover(&source))
165
+ }
166
+
167
+ /// [`discover_file`] for every path, spread over the available cores.
168
+ /// Results keep the order of `paths`.
169
+ ///
170
+ /// # Errors
171
+ ///
172
+ /// The [`FileError`] of the first unreadable file, in input order.
173
+ pub fn discover_files<P: AsRef<Path> + Sync>(paths: &[P]) -> Result<Vec<Discovery>, FileError> {
174
+ let mut found = vec![Discovery::default(); paths.len()];
175
+ fill(paths, &mut found, |path| discover_file(path))?;
176
+ Ok(found)
177
+ }
178
+
179
+ /// Runs `f` over `items` on scoped threads, each writing its own slots of
180
+ /// `out`. Returns the first error in input order.
181
+ ///
182
+ /// Scoped threads: none outlives the call, so a process may fork right after
183
+ /// (a pool such as rayon's would leave its threads behind in the parent and
184
+ /// a broken pool in the child).
185
+ fn fill<T, R, E>(items: &[T], out: &mut [R], f: impl Fn(&T) -> Result<R, E> + Sync) -> Result<(), E>
186
+ where
187
+ T: Sync,
188
+ R: Send,
189
+ E: Send,
190
+ {
191
+ let run = |items: &[T], out: &mut [R]| -> Result<(), E> {
192
+ for (item, slot) in items.iter().zip(out) {
193
+ *slot = f(item)?;
194
+ }
195
+ Ok(())
196
+ };
197
+ let run = &run;
198
+ let threads = thread::available_parallelism().map_or(1, NonZeroUsize::get).min(items.len());
199
+ if threads <= 1 {
200
+ return run(items, out);
201
+ }
202
+ let chunk = items.len().div_ceil(threads);
203
+ thread::scope(|scope| {
204
+ let workers: Vec<_> = items
205
+ .chunks(chunk)
206
+ .zip(out.chunks_mut(chunk))
207
+ .map(|(items, out)| scope.spawn(move || run(items, out)))
208
+ .collect();
209
+ // Chunks are in input order and each stops at its first error.
210
+ workers.into_iter().try_for_each(|worker| {
211
+ worker.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
212
+ })
213
+ })
214
+ }
215
+
216
+ /// Walks the tree. Names are borrowed from the parse result until a test
217
+ /// class needs its full name.
218
+ struct Visitor<'pr> {
219
+ lines: Lines<'pr>,
220
+ /// Constant names of the enclosing namespaces, outermost first.
221
+ scope: Vec<&'pr str>,
222
+ /// Where the current namespace starts in `scope`: an absolute path
223
+ /// (`class ::A`) starts a new one.
224
+ scope_start: usize,
225
+ /// Set inside a namespace whose name is not a constant (`class foo::A`):
226
+ /// nothing below it has a known name.
227
+ dynamic: bool,
228
+ test_class: TestClass,
229
+ discovery: Discovery,
230
+ }
231
+
232
+ /// Byte offset to 1-based line, without an index: a cursor counts the
233
+ /// newlines between the offsets it is asked for. A pre-order walk asks for
234
+ /// start offsets in increasing order, and an end is counted from its start,
235
+ /// so the source is read about once.
236
+ struct Lines<'s> {
237
+ source: &'s [u8],
238
+ offset: usize,
239
+ line: usize,
240
+ }
241
+
242
+ impl Lines<'_> {
243
+ fn at(&mut self, offset: usize) -> usize {
244
+ let newlines =
245
+ |bytes: &[u8]| bytes.iter().fold(0, |n, &byte| n + usize::from(byte == b'\n'));
246
+ // Going back never passes line 1: overflow either way is a bug.
247
+ self.line = if offset >= self.offset {
248
+ self.line.strict_add(newlines(&self.source[self.offset..offset]))
249
+ } else {
250
+ self.line.strict_sub(newlines(&self.source[offset..self.offset]))
251
+ };
252
+ self.offset = offset;
253
+ self.line
254
+ }
255
+ }
256
+
257
+ /// Where the visitor stands: outside a test class, or inside one whose name
258
+ /// is joined and stored when its first test is found (abstract base classes
259
+ /// never are).
260
+ #[derive(Debug, Clone, Copy)]
261
+ enum TestClass {
262
+ Outside,
263
+ Pending,
264
+ Stored(usize),
265
+ }
266
+
267
+ impl<'pr> Visitor<'pr> {
268
+ const fn new(source: &'pr [u8]) -> Self {
269
+ Self {
270
+ lines: Lines { source, offset: 0, line: 1 },
271
+ scope: Vec::new(),
272
+ scope_start: 0,
273
+ dynamic: false,
274
+ test_class: TestClass::Outside,
275
+ discovery: Discovery { classes: Vec::new(), tests: Vec::new() },
276
+ }
277
+ }
278
+
279
+ fn within(&mut self, path: &Node<'pr>, test: bool, visit: impl FnOnce(&mut Self)) {
280
+ let (depth, scope_start, dynamic, test_class) =
281
+ (self.scope.len(), self.scope_start, self.dynamic, self.test_class);
282
+ let constant = self.push_constant(path).is_some();
283
+ self.dynamic |= !constant;
284
+ self.test_class =
285
+ if test && !self.dynamic { TestClass::Pending } else { TestClass::Outside };
286
+ visit(self);
287
+ self.scope.truncate(depth);
288
+ (self.scope_start, self.dynamic, self.test_class) = (scope_start, dynamic, test_class);
289
+ }
290
+
291
+ /// Pushes the names of a constant path onto the scope, or returns `None`
292
+ /// when a part of it is not a constant.
293
+ fn push_constant(&mut self, node: &Node<'pr>) -> Option<()> {
294
+ let (parent, name) = constant(node)?;
295
+ match parent {
296
+ Parent::None => {}
297
+ Parent::Root => self.scope_start = self.scope.len(),
298
+ Parent::Path(parent) => self.push_constant(&parent)?,
299
+ }
300
+ self.scope.push(name);
301
+ Some(())
302
+ }
303
+
304
+ /// Stores the current namespace as a class name, joined once.
305
+ fn add_class(&mut self) -> usize {
306
+ let names = &self.scope[self.scope_start..];
307
+ let length =
308
+ names.iter().map(|name| name.len()).sum::<usize>() + 2 * names.len().saturating_sub(1);
309
+ let mut class = String::with_capacity(length);
310
+ for (index, name) in names.iter().enumerate() {
311
+ if index > 0 {
312
+ class.push_str("::");
313
+ }
314
+ class.push_str(name);
315
+ }
316
+ self.discovery.classes.push(class);
317
+ self.discovery.classes.len() - 1
318
+ }
319
+
320
+ const fn in_test_class(&self) -> bool {
321
+ !matches!(self.test_class, TestClass::Outside)
322
+ }
323
+
324
+ fn record(&mut self, name: String, location: &Location<'_>) {
325
+ let class = match self.test_class {
326
+ TestClass::Outside => return,
327
+ TestClass::Stored(class) => class,
328
+ TestClass::Pending => {
329
+ let class = self.add_class();
330
+ self.test_class = TestClass::Stored(class);
331
+ class
332
+ }
333
+ };
334
+ let entry = Entry {
335
+ class,
336
+ name,
337
+ start_line: self.lines.at(location.start_offset()),
338
+ end_line: self.lines.at(location.end_offset()),
339
+ };
340
+ self.discovery.tests.push(entry);
341
+ }
342
+ }
343
+
344
+ impl<'pr> Visit<'pr> for Visitor<'pr> {
345
+ fn visit_module_node(&mut self, node: &ModuleNode<'pr>) {
346
+ self.within(&node.constant_path(), false, |visitor| {
347
+ ruby_prism::visit_module_node(visitor, node);
348
+ });
349
+ }
350
+
351
+ fn visit_class_node(&mut self, node: &ClassNode<'pr>) {
352
+ let test = node.superclass().is_some_and(|superclass| test_superclass(&superclass));
353
+ self.within(&node.constant_path(), test, |visitor| {
354
+ ruby_prism::visit_class_node(visitor, node);
355
+ });
356
+ }
357
+
358
+ /// `class << self` keeps the namespace, but its methods are not tests.
359
+ fn visit_singleton_class_node(&mut self, node: &SingletonClassNode<'pr>) {
360
+ let saved = std::mem::replace(&mut self.test_class, TestClass::Outside);
361
+ ruby_prism::visit_singleton_class_node(self, node);
362
+ self.test_class = saved;
363
+ }
364
+
365
+ /// A method body defines nothing on the class: not descended into.
366
+ fn visit_def_node(&mut self, node: &DefNode<'pr>) {
367
+ let name = node.name().as_slice();
368
+ if self.in_test_class()
369
+ && node.receiver().is_none()
370
+ && name.starts_with(b"test_")
371
+ && let Some(name) = utf8(name)
372
+ {
373
+ self.record(name.to_owned(), &node.location());
374
+ }
375
+ }
376
+
377
+ fn visit_call_node(&mut self, node: &CallNode<'pr>) {
378
+ if self.in_test_class()
379
+ && node.receiver().is_none()
380
+ && node.name().as_slice() == b"test"
381
+ && let Some(name) = declared_name(node)
382
+ {
383
+ self.record(name, &node.location());
384
+ }
385
+ ruby_prism::visit_call_node(self, node);
386
+ }
387
+ }
388
+
389
+ /// Whether a superclass is a constant path ending in `Test` or `TestCase`.
390
+ fn test_superclass(node: &Node<'_>) -> bool {
391
+ constant_tail(node).is_some_and(|name| name.ends_with("Test") || name.ends_with("TestCase"))
392
+ }
393
+
394
+ /// The last name of a constant path, when every part of it is a constant.
395
+ fn constant_tail<'pr>(node: &Node<'pr>) -> Option<&'pr str> {
396
+ let (parent, name) = constant(node)?;
397
+ if let Parent::Path(parent) = parent {
398
+ constant_tail(&parent)?;
399
+ }
400
+ Some(name)
401
+ }
402
+
403
+ /// What a constant's name is relative to.
404
+ enum Parent<'pr> {
405
+ /// `A`: the enclosing namespace.
406
+ None,
407
+ /// `::A`: the top level.
408
+ Root,
409
+ /// `B::A`: another constant path (or an expression, for `foo::A`).
410
+ Path(Node<'pr>),
411
+ }
412
+
413
+ /// A constant node's own name, borrowed from the parse result, and what it
414
+ /// is relative to. `None` for any other node.
415
+ fn constant<'pr>(node: &Node<'pr>) -> Option<(Parent<'pr>, &'pr str)> {
416
+ if let Some(read) = node.as_constant_read_node() {
417
+ return Some((Parent::None, utf8(read.name().as_slice())?));
418
+ }
419
+ let path = node.as_constant_path_node()?;
420
+ let parent = path.parent().map_or(Parent::Root, Parent::Path);
421
+ Some((parent, utf8(path.name()?.as_slice())?))
422
+ }
423
+
424
+ /// `test_<name>` for `test "name"` with one plain string argument, the way
425
+ /// `ActiveSupport::Testing::Declarative` names it:
426
+ /// `"test_#{name.gsub(/\s+/, '_')}"`, Ruby's `\s` being space, `\t`, `\n`,
427
+ /// `\v`, `\f` and `\r`.
428
+ fn declared_name(node: &CallNode<'_>) -> Option<String> {
429
+ let arguments = node.arguments()?.arguments();
430
+ let mut arguments = arguments.iter();
431
+ let first = arguments.next()?;
432
+ if arguments.next().is_some() {
433
+ return None;
434
+ }
435
+ let string = first.as_string_node()?;
436
+ let name = utf8(string.unescaped())?;
437
+ let mut declared = String::with_capacity("test_".len() + name.len());
438
+ declared.push_str("test_");
439
+ let mut in_whitespace = false;
440
+ for c in name.chars() {
441
+ let whitespace = matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r');
442
+ if !whitespace {
443
+ declared.push(c);
444
+ } else if !in_whitespace {
445
+ declared.push('_');
446
+ }
447
+ in_whitespace = whitespace;
448
+ }
449
+ Some(declared)
450
+ }
451
+
452
+ fn utf8(bytes: &[u8]) -> Option<&str> {
453
+ std::str::from_utf8(bytes).ok()
454
+ }
@@ -0,0 +1,13 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn matches_the_reference_values() {
5
+ let cases: &[(&str, u64)] = &[
6
+ ("", 0xcbf2_9ce4_8422_2325),
7
+ ("a", 0xaf63_dc4c_8601_ec8c),
8
+ ("foobar", 0x8594_4171_f739_67e8),
9
+ ];
10
+ for (input, expected) in cases {
11
+ assert_eq!(fnv1a64(input.as_bytes()), *expected, "input: {input:?}");
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ //! FNV-1a 64. Shards split on it: unlike std's randomly seeded hasher, it is
2
+ //! stable across runs, processes and machines, so CI shards agree.
3
+
4
+ #[cfg(test)]
5
+ mod tests;
6
+
7
+ /// The FNV-1a 64-bit hash of `bytes`.
8
+ #[must_use]
9
+ pub fn fnv1a64(bytes: &[u8]) -> u64 {
10
+ bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
11
+ (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
12
+ })
13
+ }
@@ -0,0 +1,30 @@
1
+ //! Static Minitest discovery and deterministic test scheduling: the engine
2
+ //! of the [padded_cell](https://github.com/seuros/padded_cell) test runner.
3
+ //!
4
+ //! ```
5
+ //! let source = b"class UserTest < Minitest::Test\n def test_valid\n end\nend\n";
6
+ //! let found = padded_cell::discover(source);
7
+ //! let test = found.iter().next().ok_or("no test")?;
8
+ //! assert_eq!(test.id(), "UserTest#test_valid");
9
+ //! assert_eq!((test.start_line, test.end_line), (2, 3));
10
+ //!
11
+ //! let classes: Vec<&str> = found.iter().map(|test| test.class_name).collect();
12
+ //! assert_eq!(padded_cell::plan(&classes, &[-1.0], 42, 0, 1), [vec![0]]);
13
+ //! # Ok::<(), Box<dyn std::error::Error>>(())
14
+ //! ```
15
+
16
+ mod discover;
17
+ mod hash;
18
+ mod plan;
19
+ mod rng;
20
+
21
+ pub use discover::{
22
+ Discovery, FileError, Iter, TestDef, discover, discover_file, discover_files, discover_many,
23
+ };
24
+ pub use hash::fnv1a64;
25
+ pub use plan::plan;
26
+ pub use rng::SplitMix64;
27
+
28
+ #[cfg(doctest)]
29
+ #[doc = include_str!("../README.md")]
30
+ struct ReadmeDoctests;
@@ -0,0 +1,61 @@
1
+ use super::*;
2
+
3
+ const UNKNOWN: f64 = -1.0;
4
+
5
+ fn classes(tests: usize, classes: usize) -> Vec<String> {
6
+ (0..tests).map(|test| format!("Suite{}Test", test % classes)).collect()
7
+ }
8
+
9
+ #[test]
10
+ fn every_test_lands_in_exactly_one_shard_with_its_class() {
11
+ let classes = classes(200, 23);
12
+ let durations = vec![UNKNOWN; classes.len()];
13
+ let shards: Vec<Vec<Vec<usize>>> =
14
+ (0..4).map(|shard| plan(&classes, &durations, 7, shard, 4)).collect();
15
+ let mut seen: Vec<usize> = shards.iter().flatten().flatten().copied().collect();
16
+ seen.sort_unstable();
17
+ assert_eq!(seen, (0..classes.len()).collect::<Vec<_>>());
18
+ for batch in shards.iter().flatten() {
19
+ assert!(batch.iter().all(|&test| classes[test] == classes[batch[0]]), "{batch:?}");
20
+ }
21
+ assert_eq!(shards.iter().flatten().count(), 23, "one batch per class");
22
+ }
23
+
24
+ #[test]
25
+ fn the_seed_decides_the_order() {
26
+ let classes = classes(50, 5);
27
+ let durations = vec![UNKNOWN; classes.len()];
28
+ let first = plan(&classes, &durations, 42, 0, 1);
29
+ assert_eq!(first, plan(&classes, &durations, 42, 0, 1));
30
+ assert_ne!(first, plan(&classes, &durations, 43, 0, 1));
31
+ }
32
+
33
+ #[test]
34
+ fn slowest_class_and_test_first_unknown_counts_as_the_median() {
35
+ // A: 1.0 + 3.0; B: 5.0; C: unknown, the median of 1.0, 3.0, 5.0 = 3.0.
36
+ let classes = ["A", "A", "B", "C"];
37
+ let durations = [1.0, 3.0, 5.0, UNKNOWN];
38
+ assert_eq!(plan(&classes, &durations, 0, 0, 1), [vec![2], vec![1, 0], vec![3]]);
39
+ }
40
+
41
+ #[test]
42
+ fn pinned_order() {
43
+ // Same input, same expected batches in test/plan_test.rb: the engines
44
+ // agree even without the native extension.
45
+ let classes = ["A", "B", "A", "C", "B", "A", "D", "C"];
46
+ let durations = [0.5, UNKNOWN, 0.5, 2.0, UNKNOWN, 0.1, UNKNOWN, 0.5];
47
+ assert_eq!(
48
+ plan(&classes, &durations, 1234, 0, 1),
49
+ [vec![3, 7], vec![2, 0, 5], vec![1, 4], vec![6]]
50
+ );
51
+ let unknown = [UNKNOWN; 8];
52
+ assert_eq!(plan(&classes, &unknown, 1234, 0, 3), [vec![6], vec![0, 5, 2], vec![1, 4]]);
53
+ assert_eq!(plan(&classes, &unknown, 1234, 1, 3), Vec::<Vec<usize>>::new());
54
+ assert_eq!(plan(&classes, &unknown, 1234, 2, 3), [vec![3, 7]]);
55
+ }
56
+
57
+ #[test]
58
+ fn nothing_to_plan() {
59
+ let classes: [&str; 0] = [];
60
+ assert_eq!(plan(&classes, &[], 0, 0, 1), Vec::<Vec<usize>>::new());
61
+ }
@@ -0,0 +1,109 @@
1
+ //! Run order. Tests stay grouped by class, as Minitest and Rails run them:
2
+ //! per-class setup such as fixtures loads once.
3
+ //!
4
+ //! 1. group tests by class, in order of first appearance;
5
+ //! 2. keep the classes whose FNV-1a 64 hash modulo `shard_count` is
6
+ //! `shard_index`;
7
+ //! 3. Fisher-Yates shuffle the classes, then each class's tests, with one
8
+ //! [`SplitMix64`] seeded by `seed`;
9
+ //! 4. within a class, slowest test first; classes, slowest total first. Sorts
10
+ //! are stable, so ties keep the shuffled order. A negative duration means
11
+ //! unknown and counts as the median of the known ones (0.0 when none are
12
+ //! known).
13
+
14
+ use std::cmp::Ordering;
15
+ use std::collections::HashMap;
16
+
17
+ use crate::hash::fnv1a64;
18
+ use crate::rng::SplitMix64;
19
+
20
+ #[cfg(test)]
21
+ mod tests;
22
+
23
+ /// Batches of test indices, one per class, in run order, for one shard.
24
+ ///
25
+ /// `classes[i]` is the class of test `i` and `durations[i]` its last known run
26
+ /// time in seconds, or any negative number when unknown.
27
+ ///
28
+ /// # Panics
29
+ ///
30
+ /// When `shard_count` is 0, or `durations` is shorter than `classes`.
31
+ #[must_use]
32
+ pub fn plan<S: AsRef<[u8]>>(
33
+ classes: &[S],
34
+ durations: &[f64],
35
+ seed: u64,
36
+ shard_index: u64,
37
+ shard_count: u64,
38
+ ) -> Vec<Vec<usize>> {
39
+ let mut batches = group(classes, shard_index, shard_count);
40
+ let mut rng = SplitMix64::new(seed);
41
+ shuffle(&mut batches, &mut rng);
42
+ for batch in &mut batches {
43
+ shuffle(batch, &mut rng);
44
+ }
45
+
46
+ let mut known = Vec::with_capacity(classes.len());
47
+ known.extend(batches.iter().flatten().map(|&test| durations[test]).filter(|d| *d >= 0.0));
48
+ let fallback = median(known);
49
+ let estimate = |test: usize| {
50
+ let duration = durations[test];
51
+ if duration < 0.0 { fallback } else { duration }
52
+ };
53
+ let total = |batch: &[usize]| batch.iter().fold(0.0, |sum, &test| sum + estimate(test));
54
+
55
+ // Stable sorts: ties keep the shuffled order.
56
+ for batch in &mut batches {
57
+ batch.sort_by(|&a, &b| descending(estimate(a), estimate(b)));
58
+ }
59
+ batches.sort_by(|a, b| descending(total(a), total(b)));
60
+ batches
61
+ }
62
+
63
+ /// The tests of each class in this shard, classes in order of first
64
+ /// appearance. Each class is hashed once.
65
+ fn group<S: AsRef<[u8]>>(classes: &[S], shard_index: u64, shard_count: u64) -> Vec<Vec<usize>> {
66
+ let mut slots: HashMap<&[u8], Option<usize>> = HashMap::new();
67
+ let mut batches: Vec<Vec<usize>> = Vec::new();
68
+ for (test, class) in classes.iter().enumerate() {
69
+ let class = class.as_ref();
70
+ let slot = *slots.entry(class).or_insert_with(|| {
71
+ (fnv1a64(class) % shard_count == shard_index).then(|| {
72
+ batches.push(Vec::new());
73
+ batches.len() - 1
74
+ })
75
+ });
76
+ if let Some(batch) = slot {
77
+ batches[batch].push(test);
78
+ }
79
+ }
80
+ batches
81
+ }
82
+
83
+ fn descending(a: f64, b: f64) -> Ordering {
84
+ b.total_cmp(&a)
85
+ }
86
+
87
+ fn shuffle<T>(items: &mut [T], rng: &mut SplitMix64) {
88
+ for i in (1..items.len()).rev() {
89
+ let bound = u64::try_from(i + 1).unwrap_or(u64::MAX);
90
+ // Below i + 1, so it fits.
91
+ let j = usize::try_from(rng.next_u64() % bound).unwrap_or(i);
92
+ items.swap(i, j);
93
+ }
94
+ }
95
+
96
+ /// The median of the known durations, 0.0 without any. Selection, not a
97
+ /// full sort: the value is the same.
98
+ fn median(mut known: Vec<f64>) -> f64 {
99
+ let len = known.len();
100
+ if len == 0 {
101
+ return 0.0;
102
+ }
103
+ let (lower, &mut upper, _) = known.select_nth_unstable_by(len / 2, f64::total_cmp);
104
+ if !len.is_multiple_of(2) {
105
+ return upper;
106
+ }
107
+ let below = lower.iter().copied().max_by(f64::total_cmp).unwrap_or(upper);
108
+ below.midpoint(upper)
109
+ }