From 10af890acb8eb4aa60d24e5ab31f7c72160d5962 Mon Sep 17 00:00:00 2001 From: Nariman Gardi <110671428+NarimanGardi@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:36:07 +0300 Subject: [PATCH] fix: distribute shard tests round-robin to avoid PCRE backtrack limit The no-cache --shard fallback chunked tests contiguously. Because --list-tests returns classes sorted, a chunk clustered classes sharing long common prefixes; joined into one unanchored --filter alternation it trips PCRE's backtrack limit on large suites, so the shard matched nothing and exited "No tests found". Interleave round-robin like the timings path already does for new tests. --- src/Plugins/Shard.php | 26 +++++++++++++++++++++++++- tests/Unit/Plugins/Shard.php | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Plugins/Shard.php diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index 814125f20..922745996 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -132,7 +132,7 @@ public function handleArguments(array $arguments): array self::$timeBalanced = true; self::$shardsOutdated = $newTests !== []; } else { - $testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? []; + $testsToRun = self::distribute($tests, $index, $total); } self::$shard = [ @@ -215,6 +215,30 @@ private function buildFilterArgument(mixed $testsToRun): string return addslashes(implode('|', $testsToRun)); } + /** + * Distributes the given tests across the shards round-robin, returning the + * slice for the requested shard. + * + * Interleaving rather than chunking contiguously matters: --list-tests + * returns classes sorted, so a contiguous chunk clusters classes that share + * long common prefixes. Joined into one unanchored --filter alternation, + * that trips PCRE's backtrack limit on large suites and the shard then + * matches nothing. Scattering the prefixes keeps the filter within limits. + * + * @param list $tests + * @return list + */ + public static function distribute(array $tests, int $index, int $total): array + { + $partitions = array_fill(0, $total, []); + + foreach ($tests as $i => $test) { + $partitions[$i % $total][] = $test; + } + + return $partitions[$index - 1] ?? []; + } + /** * Adds output after the Test Suite execution. */ diff --git a/tests/Unit/Plugins/Shard.php b/tests/Unit/Plugins/Shard.php new file mode 100644 index 000000000..ee4c3a617 --- /dev/null +++ b/tests/Unit/Plugins/Shard.php @@ -0,0 +1,36 @@ + "Tests\\Feature\\Class{$i}", range(1, 7)); + + expect(Shard::distribute($tests, 1, 3))->toBe(['Tests\\Feature\\Class1', 'Tests\\Feature\\Class4', 'Tests\\Feature\\Class7']) + ->and(Shard::distribute($tests, 2, 3))->toBe(['Tests\\Feature\\Class2', 'Tests\\Feature\\Class5']) + ->and(Shard::distribute($tests, 3, 3))->toBe(['Tests\\Feature\\Class3', 'Tests\\Feature\\Class6']); +}); + +it('assigns every test to exactly one shard', function () { + $tests = array_map(fn (int $i): string => "Tests\\Feature\\Class{$i}", range(1, 50)); + + $assigned = array_merge(...array_map( + fn (int $shard): array => Shard::distribute($tests, $shard, 4), + [1, 2, 3, 4], + )); + + expect($assigned)->toHaveCount(50) + ->and(array_unique($assigned))->toHaveCount(50); +}); + +it('scatters same-prefix clusters across shards', function () { + // --list-tests returns classes sorted, so same-prefix families arrive contiguously. + $tests = [ + ...array_map(fn (int $i): string => "Tests\\Feature\\Admin\\Class{$i}", range(1, 4)), + ...array_map(fn (int $i): string => "Tests\\Unit\\Service\\Class{$i}", range(1, 4)), + ]; + + // Round-robin mixes both families into a shard instead of one homogeneous cluster. + expect(Shard::distribute($tests, 1, 2)) + ->toContain('Tests\\Feature\\Admin\\Class1') + ->toContain('Tests\\Unit\\Service\\Class1'); +});