This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch perf/8.0.x-ci-wall-clock-regression in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 8a3a79ffb689ed33e9639585fb6fd1ce10a992b0 Author: James Fredley <[email protected]> AuthorDate: Fri Aug 7 21:56:50 2026 -0400 Add deterministic test task sharding Introduce a root-only build-logic plugin that partitions Test tasks with stable SHA-256 assignments while preserving existing execution predicates. Cover lifecycle validation, late task registration, aggregate test facades, and disjoint shard manifests with TestKit. Assisted-by: opencode:gpt-5.6-sol --- build-logic/plugins/build.gradle | 4 + .../grails/buildsrc/TestTaskShardingPlugin.groovy | 174 +++++++++ .../buildsrc/TestTaskShardingPluginSpec.groovy | 387 +++++++++++++++++++++ build.gradle | 3 +- 4 files changed, 567 insertions(+), 1 deletion(-) diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index d90ef94b82..e6d687e1f8 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -123,5 +123,9 @@ gradlePlugin { id = 'org.apache.grails.buildsrc.autoconfiguration-imports' implementationClass = 'org.apache.grails.buildsrc.AutoConfigurationImportsPlugin' } + register('testTaskShardingPlugin') { + id = 'org.apache.grails.buildsrc.test-task-sharding' + implementationClass = 'org.apache.grails.buildsrc.TestTaskShardingPlugin' + } } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy new file mode 100644 index 0000000000..d5ad8b4c1c --- /dev/null +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/TestTaskShardingPlugin.groovy @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import groovy.transform.CompileStatic + +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.execution.TaskExecutionGraph +import org.gradle.api.tasks.testing.Test + +/** + * Deterministically partitions root-build {@link Test} tasks across CI shards. + */ +@CompileStatic +class TestTaskShardingPlugin implements Plugin<Project> { + + static final String SHARD_COUNT_PROPERTY = 'testShardCount' + static final String SHARD_INDEX_PROPERTY = 'testShardIndex' + static final String SHARD_TASK_NAME = 'testShard' + private static final String MANIFEST_PREFIX = 'TEST_SHARD_MANIFEST' + + @Override + void apply(Project project) { + if (project != project.rootProject) { + throw new GradleException('TestTaskShardingPlugin must be applied to the root project only.') + } + + ShardConfiguration configuration = readConfiguration(project) + if (configuration == null) { + return + } + + Set<Test> candidateTasks = new LinkedHashSet<>() + project.allprojects { Project candidateProject -> + candidateProject.tasks.withType(Test).configureEach { Test task -> + candidateTasks.add(task) + task.onlyIf { + isSelectedForShard(task, configuration) + } + } + } + registerShardTask(project, candidateTasks, configuration) + project.gradle.taskGraph.whenReady { TaskExecutionGraph taskGraph -> + emitManifest(project, candidateTasks, configuration) + } + } + + private static ShardConfiguration readConfiguration(Project project) { + boolean hasCount = project.hasProperty(SHARD_COUNT_PROPERTY) + boolean hasIndex = project.hasProperty(SHARD_INDEX_PROPERTY) + if (!hasCount && !hasIndex) { + return null + } + if (hasCount != hasIndex) { + throw new GradleException("${SHARD_COUNT_PROPERTY} and ${SHARD_INDEX_PROPERTY} must be supplied together") + } + + int shardCount = parseInteger(project, SHARD_COUNT_PROPERTY) + int shardIndex = parseInteger(project, SHARD_INDEX_PROPERTY) + if (shardCount < 1) { + throw new GradleException("${SHARD_COUNT_PROPERTY} must be at least 1") + } + if (shardIndex < 0 || shardIndex >= shardCount) { + throw new GradleException("${SHARD_INDEX_PROPERTY} must be in the range [0, ${shardCount})") + } + new ShardConfiguration(shardCount, shardIndex) + } + + private static int parseInteger(Project project, String propertyName) { + String value = project.findProperty(propertyName)?.toString() + try { + Integer.parseInt(value) + } catch (NumberFormatException ignored) { + throw new GradleException("${propertyName} must be an integer") + } + } + + private static void registerShardTask(Project rootProject, Set<Test> candidateTasks, ShardConfiguration configuration) { + rootProject.tasks.register(SHARD_TASK_NAME) { Task task -> + task.group = 'verification' + task.description = 'Runs the Test tasks assigned to the current deterministic shard.' + task.dependsOn { + collectCandidateTasks(rootProject, candidateTasks).findAll { Test testTask -> + isSelectedForShard(testTask, configuration) + } + } + } + } + + private static void emitManifest(Project rootProject, Set<Test> candidateTasks, ShardConfiguration configuration) { + List<Test> candidates = collectCandidateTasks(rootProject, candidateTasks) + List<String> candidatePaths = candidates.collect { Test task -> normalizeTaskPath(task.path) } + validateUniqueTaskPaths(candidatePaths) + if (candidates.empty) { + throw new GradleException('No Test tasks were found for sharding') + } + + List<String> selectedPaths = candidates.findAll { Test task -> + isSelectedForShard(task, configuration) + }.collect { Test task -> normalizeTaskPath(task.path) }.sort() + rootProject.logger.lifecycle("${MANIFEST_PREFIX} totalCandidates=${candidatePaths.size()} shardIndex=${configuration.shardIndex} shardCount=${configuration.shardCount} selectedTasks=${selectedPaths.join(',')}") + } + + private static List<Test> collectCandidateTasks(Project rootProject, Set<Test> candidateTasks) { + rootProject.allprojects.each { Project project -> + project.tasks.withType(Test).each { Test task -> + candidateTasks.add(task) + } + } + candidateTasks.toList().sort { Test left, Test right -> + normalizeTaskPath(left.path) <=> normalizeTaskPath(right.path) + } + } + + private static boolean isSelectedForShard(Test task, ShardConfiguration configuration) { + if (task.project.path == ':grails-test-report') { + return configuration.shardIndex == 0 + } + shardFor(normalizeTaskPath(task.path), configuration.shardCount) == configuration.shardIndex + } + + static void validateUniqueTaskPaths(Collection<String> taskPaths) { + Set<String> seen = new LinkedHashSet<>() + taskPaths.each { String taskPath -> + String normalizedPath = normalizeTaskPath(taskPath) + if (!seen.add(normalizedPath)) { + throw new IllegalArgumentException("Duplicate normalized Gradle Test task path: ${normalizedPath}") + } + } + } + + private static String normalizeTaskPath(String taskPath) { + String normalizedPath = taskPath.replace('\\', '/') + normalizedPath.startsWith(':') ? normalizedPath : ":${normalizedPath}" + } + + static int shardFor(String taskPath, int shardCount) { + byte[] digest = MessageDigest.getInstance('SHA-256').digest(taskPath.getBytes(StandardCharsets.UTF_8)) + new BigInteger(1, digest).mod(BigInteger.valueOf(shardCount)).intValue() + } + + private static final class ShardConfiguration { + final int shardCount + final int shardIndex + + ShardConfiguration(int shardCount, int shardIndex) { + this.shardCount = shardCount + this.shardIndex = shardIndex + } + } +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy new file mode 100644 index 0000000000..1a7ae38210 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/TestTaskShardingPluginSpec.groovy @@ -0,0 +1,387 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Path + +class TestTaskShardingPluginSpec extends Specification { + + @TempDir + Path testProjectDir + + def setup() { + writeMultiProjectFixture(false) + } + + def "does nothing when shard properties are absent"() { + when: + BuildResult result = run('build') + + then: + !result.output.contains('TEST_SHARD_MANIFEST') + !result.output.contains('testShard') + result.task(':alpha:test').outcome == TaskOutcome.NO_SOURCE + result.task(':beta:test').outcome == TaskOutcome.NO_SOURCE + result.task(':gamma:test').outcome == TaskOutcome.NO_SOURCE + result.task(':disabled:test').outcome == TaskOutcome.SKIPPED + } + + def "requires valid paired shard properties"() { + when: + BuildResult result = runFail(*arguments) + + then: + result.output.contains(message) + + where: + arguments | message + ['help', '-PtestShardCount=2'] | 'testShardCount and testShardIndex must be supplied together' + ['help', '-PtestShardIndex=0'] | 'testShardCount and testShardIndex must be supplied together' + ['help', '-PtestShardCount=0', '-PtestShardIndex=0'] | 'testShardCount must be at least 1' + ['help', '-PtestShardCount=2', '-PtestShardIndex=2'] | 'testShardIndex must be in the range [0, 2)' + ['help', '-PtestShardCount=two', '-PtestShardIndex=0'] | 'testShardCount must be an integer' + ['help', '-PtestShardCount=2', '-PtestShardIndex=zero'] | 'testShardIndex must be an integer' + } + + def "rejects application to a non-root project"() { + given: + testProjectDir.resolve('settings.gradle').toFile().text = "include 'child'" + testProjectDir.resolve('build.gradle').toFile().text = '' + def childDir = testProjectDir.resolve('child').toFile() + childDir.mkdirs() + new File(childDir, 'build.gradle').text = """ + plugins { + id 'org.apache.grails.buildsrc.test-task-sharding' + } + """ + + when: + BuildResult result = runFail('help') + + then: + result.output.contains('TestTaskShardingPlugin must be applied to the root project only.') + } + + def "assigns Test task candidates deterministically, disjointly, and exhaustively"() { + given: + Set<String> baseline = [':alpha:test', ':beta:test', ':disabled:test', ':gamma:test'] as Set + + when: + Map<Integer, Set<String>> twoWayAssignments = assignmentsFor(2) + Map<Integer, Set<String>> threeWayAssignments = assignmentsFor(3) + + then: + assignmentsAreDisjointAndExhaustive(twoWayAssignments, baseline) + assignmentsAreDisjointAndExhaustive(threeWayAssignments, baseline) + + and: "repeated invocations select the same paths" + shardPaths(run('testShard', '-PtestShardCount=3', '-PtestShardIndex=1')) == threeWayAssignments[1] + } + + def "preserves existing false onlyIf predicates and filters full builds to the current shard"() { + when: + BuildResult result = run('build', '-PtestShardCount=2', '-PtestShardIndex=0') + Set<String> selected = shardPaths(result) + + then: + selected + result.task(':disabled:test').outcome == TaskOutcome.SKIPPED + + and: "selected tasks remain enabled while the other eligible tasks are skipped" + [':alpha:test', ':beta:test', ':gamma:test'].each { String path -> + assert result.task(path).outcome == (selected.contains(path) ? TaskOutcome.NO_SOURCE : TaskOutcome.SKIPPED) + } + } + + def "testShard depends only on selected Test task candidates and emits a manifest"() { + when: + BuildResult result = run('testShard', '-PtestShardCount=3', '-PtestShardIndex=2') + Set<String> selected = shardPaths(result) + + then: + result.task(':testShard').outcome in [TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE] + result.output.contains('TEST_SHARD_MANIFEST totalCandidates=4 shardIndex=2 shardCount=3 selectedTasks=') + + and: + [':alpha:test', ':beta:test', ':disabled:test', ':gamma:test'].each { String path -> + assert result.output.contains("> Task ${path}") == selected.contains(path) + } + } + + def "fails when sharding is requested without Test tasks"() { + given: + writeEmptyFixture() + + when: + BuildResult result = runFail('help', '-PtestShardCount=2', '-PtestShardIndex=0') + + then: + result.output.contains('No Test tasks were found for sharding') + } + + def "keeps existing onlyIf predicates for execution time"() { + given: + writeDynamicOnlyFixture() + + when: + BuildResult result = run('testShard', '-PtestShardCount=1', '-PtestShardIndex=0') + + then: + result.task(':dynamic:enableDynamic').outcome == TaskOutcome.SUCCESS + result.task(':dynamic:dynamicTest').outcome == TaskOutcome.NO_SOURCE + } + + def "includes Test tasks registered by later projectsEvaluated callbacks"() { + given: + writeLifecycleFixture() + + when: + BuildResult result = run('testShard', '-PtestShardCount=1', '-PtestShardIndex=0') + + then: + shardPaths(result).contains(':late:lateTest') + result.task(':late:lateTest').outcome == TaskOutcome.NO_SOURCE + } + + def "pins the aggregate Test facade to shard zero without scheduling its leaf closure in siblings"() { + given: + writeLifecycleFixture() + + when: + BuildResult sibling = run('testShard', '-PtestShardCount=2', '-PtestShardIndex=1') + Set<String> selected = shardPaths(sibling) + String unselectedLeaf = [':alpha:leafTest', ':beta:leafTest', ':dynamic:dynamicTest', ':late:lateTest'].find { String path -> + TestTaskShardingPlugin.shardFor(path, 2) != 1 + } + + then: + !selected.contains(':grails-test-report:test') + !sibling.output.contains('> Task :grails-test-report:test') + unselectedLeaf != null + !sibling.output.contains("> Task ${unselectedLeaf}") + + when: + BuildResult shardZeroBuild = run('build', '-PtestShardCount=2', '-PtestShardIndex=0') + + then: + shardPaths(shardZeroBuild).contains(':grails-test-report:test') + shardZeroBuild.task(':grails-test-report:test').outcome == TaskOutcome.NO_SOURCE + shardZeroBuild.task(':buildMarker').outcome == TaskOutcome.SUCCESS + + and: "the non-Test build marker is not part of sibling testShard jobs" + !sibling.output.contains('> Task :buildMarker') + } + + def "rejects duplicate normalized task paths"() { + when: + TestTaskShardingPlugin.validateUniqueTaskPaths([':alpha:test', ':alpha:test']) + + then: + def error = thrown(IllegalArgumentException) + error.message == 'Duplicate normalized Gradle Test task path: :alpha:test' + } + + private Map<Integer, Set<String>> assignmentsFor(int shardCount) { + (0..<shardCount).collectEntries { int shardIndex -> + BuildResult result = run('testShard', "-PtestShardCount=${shardCount}", "-PtestShardIndex=${shardIndex}") + [(shardIndex): shardPaths(result)] + } + } + + private static void assignmentsAreDisjointAndExhaustive(Map<Integer, Set<String>> assignments, Set<String> baseline) { + Set<String> union = assignments.values().flatten() as Set + assert union == baseline + assignments.each { int shardIndex, Set<String> paths -> + assignments.each { int otherShardIndex, Set<String> otherPaths -> + if (shardIndex < otherShardIndex) { + assert paths.intersect(otherPaths).empty + } + } + } + } + + private BuildResult run(String... arguments) { + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(arguments + ['--stacktrace']) + .withPluginClasspath() + .build() + } + + private BuildResult runFail(String... arguments) { + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(arguments + ['--stacktrace']) + .withPluginClasspath() + .buildAndFail() + } + + private static Set<String> shardPaths(BuildResult result) { + String manifest = result.output.readLines().find { it.startsWith('TEST_SHARD_MANIFEST ') } + assert manifest != null + String selected = manifest.substring(manifest.indexOf('selectedTasks=') + 'selectedTasks='.length()) + selected ? selected.split(',') as Set : [] as Set + } + + private void writeMultiProjectFixture(boolean disableAllTests) { + testProjectDir.resolve('settings.gradle').toFile().text = "include 'alpha', 'beta', 'gamma', 'disabled'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'base' + id 'org.apache.grails.buildsrc.test-task-sharding' + } + + tasks.named('build') { + dependsOn(':alpha:test', ':beta:test', ':gamma:test', ':disabled:test') + } + """ + ['alpha', 'beta', 'gamma', 'disabled'].each { String name -> + def projectDir = testProjectDir.resolve(name).toFile() + projectDir.mkdirs() + boolean disabled = disableAllTests || name == 'disabled' + new File(projectDir, 'build.gradle').text = """ + plugins { + id 'java' + } + + tasks.named('test') { + onlyIf { ${!disabled} } + } + """ + } + } + + private void writeEmptyFixture() { + testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'base' + id 'org.apache.grails.buildsrc.test-task-sharding' + } + """ + } + + private void writeLifecycleFixture() { + testProjectDir.resolve('settings.gradle').toFile().text = "include 'alpha', 'beta', 'dynamic', 'grails-test-report', 'late'" + testProjectDir.resolve('build.gradle').toFile().text = """ + import org.gradle.api.tasks.testing.Test + + plugins { + id 'base' + id 'org.apache.grails.buildsrc.test-task-sharding' + } + + tasks.register('buildMarker') { + doLast { + logger.lifecycle('BUILD_MARKER') + } + } + tasks.named('build') { + dependsOn('buildMarker', ':grails-test-report:test') + } + gradle.projectsEvaluated { + project(':late').tasks.register('lateTest', Test) { + testClassesDirs = files(layout.buildDirectory.dir('late-test-classes')) + classpath = files() + } + } + """ + writeTestTask('alpha', 'leafTest') + writeTestTask('beta', 'leafTest') + def dynamicDir = testProjectDir.resolve('dynamic').toFile() + dynamicDir.mkdirs() + new File(dynamicDir, 'build.gradle').text = """ + import org.gradle.api.tasks.testing.Test + + tasks.register('enableDynamic') { + doLast { + rootProject.file('dynamic-enabled').text = 'enabled' + } + } + tasks.register('dynamicTest', Test) { + dependsOn('enableDynamic') + onlyIf { + rootProject.file('dynamic-enabled').exists() + } + testClassesDirs = files(layout.buildDirectory.dir('dynamic-test-classes')) + classpath = files() + } + """ + def reportDir = testProjectDir.resolve('grails-test-report').toFile() + reportDir.mkdirs() + new File(reportDir, 'build.gradle').text = """ + import org.gradle.api.tasks.testing.Test + + tasks.register('test', Test) { + dependsOn(':alpha:leafTest', ':beta:leafTest', ':dynamic:dynamicTest', ':late:lateTest') + testClassesDirs = files(layout.buildDirectory.dir('report-test-classes')) + classpath = files() + } + """ + testProjectDir.resolve('late').toFile().mkdirs() + } + + private void writeDynamicOnlyFixture() { + testProjectDir.resolve('settings.gradle').toFile().text = "include 'dynamic'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'base' + id 'org.apache.grails.buildsrc.test-task-sharding' + } + """ + def dynamicDir = testProjectDir.resolve('dynamic').toFile() + dynamicDir.mkdirs() + new File(dynamicDir, 'build.gradle').text = """ + import org.gradle.api.tasks.testing.Test + + tasks.register('enableDynamic') { + doLast { + rootProject.file('dynamic-enabled').text = 'enabled' + } + } + tasks.register('dynamicTest', Test) { + dependsOn('enableDynamic') + onlyIf { + rootProject.file('dynamic-enabled').exists() + } + testClassesDirs = files(layout.buildDirectory.dir('dynamic-test-classes')) + classpath = files() + } + """ + } + + private void writeTestTask(String projectName, String taskName) { + def projectDir = testProjectDir.resolve(projectName).toFile() + projectDir.mkdirs() + new File(projectDir, 'build.gradle').text = """ + import org.gradle.api.tasks.testing.Test + + tasks.register('${taskName}', Test) { + testClassesDirs = files(layout.buildDirectory.dir('${taskName}-classes')) + classpath = files() + } + """ + } +} diff --git a/build.gradle b/build.gradle index 564acd6aa0..ce00bbd208 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ plugins { id 'org.apache.grails.gradle.grails-violation-aggregation' id 'org.apache.grails.gradle.grails-ij-formatter' id 'org.apache.grails.gradle.grails-validate-actions' + id 'org.apache.grails.buildsrc.test-task-sharding' } import java.time.Instant @@ -141,4 +142,4 @@ apply { // logger.lifecycle("\t- ${dep}") // } // } -//} \ No newline at end of file +//}
