This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-7051-02ae12bb6bc98ba55b376bbacef43a07178b08d1
in repository https://gitbox.apache.org/repos/asf/texera.git

commit f9cfaceed3b6dfff6a07929443eba840422e2ea5
Author: Yicong Huang <[email protected]>
AuthorDate: Wed Jul 29 19:02:06 2026 -0400

    test(workflow-core): add @NonParallelTest to de-flake shared-MinIO suites 
(#7051)
    
    ### What changes were proposed in this PR?
    
    De-flake `S3StorageClientSpec`, which intermittently fails **4 tests**
    in the `build / amber` CI job (scope `WorkflowCore / Test / test`).
    
    **Root cause.** Four suites mix `S3StorageTestBase` and share a JVM-wide
    `S3StorageClient.s3Client` plus a single `StorageConfig.s3Endpoint`, all
    pointed at one shared MinIO container: `S3StorageClientSpec`,
    `LargeBinaryManagerSpec`, `LargeBinaryInputStreamSpec`,
    `LargeBinaryOutputStreamSpec`. ScalaTest's parallel suite distributor
    runs them concurrently within a single in-process test task, so they
    contend for that one container and intermittently time out. The existing
    `Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)` only
    bounds sbt **task** concurrency, not ScalaTest's in-JVM distributor (CI
    logs show `pool-N-thread-M-ScalaTest-running-...Spec`).
    
    **Fix — a reusable `@NonParallelTest` tag.**
    - New `org.apache.texera.common.tags.NonParallelTest` — a Java
    `@TagAnnotation` mirroring amber's `IntegrationTest`. Suites carrying it
    must not run concurrently with one another.
    - Tag the four MinIO-backed suites.
    - `common/workflow-core/build.sbt`: `Test / testGrouping` gives each
    tagged suite its own forked JVM. sbt runs forked groups one at a time
    (`Tags.ForkedTestGroup` limit), so tagged suites never overlap; every
    untagged suite stays in a single in-process group and keeps running in
    parallel. The forked working directory is pinned to the repo root to
    match the in-process cwd.
    - This mirrors the FileService approach for testcontainer suites, but
    keyed on an **opt-in annotation** instead of a hard-coded name pattern,
    so future container-backed suites just add `@NonParallelTest`.
    - Detection note: sbt discovers ScalaTest suites by subclass
    fingerprint, not by annotation, so the tag is not visible in
    `definedTests`; it is detected reflectively through the test class
    loader (`loadClass` does not run static initializers; a load failure
    falls back to the parallel group).
    - Also bound the concurrent-upload burst in the *>1000 objects* test
    from 16 to 4 threads, so a single suite no longer floods the container.
    
    **Why not `@IntegrationTest`?** That tag/lane lives only in the `amber`
    module (`amber/src/test/integration`). `amber` depends on
    `workflow-core`, so a workflow-core suite cannot reference it. The
    established convention for common-module container-backed tests (e.g.
    file-service's LakeFS/MinIO suites) is to keep them in normal `Test`
    scope and isolate them via forked JVMs — which is what this PR does.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7049
    
    ### How was this PR tested?
    
    - `sbt reload` loads the build cleanly and type-checks the reflective
    `testGrouping` (the `testLoader`/`Tests.InProcess`/`Tests.SubProcess`
    usage); `WorkflowCore/Test/forkOptions` confirms the forked working
    directory resolves to the repo root.
    - Full test compile + a live `testGrouping` run require the JOOQ codegen
    prerequisite (a database) and Docker (MinIO), neither available in the
    authoring environment, so the runtime grouping and de-flaking are
    validated by CI. Since the failure is flaky, reviewers may want to
    re-run the `amber` job a couple of times to confirm stability.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 common/workflow-core/build.sbt                     | 39 +++++++++++++++++
 .../apache/texera/common/tags/NonParallelTest.java | 51 ++++++++++++++++++++++
 .../service/util/LargeBinaryInputStreamSpec.scala  |  2 +
 .../service/util/LargeBinaryManagerSpec.scala      |  2 +
 .../service/util/LargeBinaryOutputStreamSpec.scala |  2 +
 .../texera/service/util/S3StorageClientSpec.scala  |  7 ++-
 6 files changed, 101 insertions(+), 2 deletions(-)

diff --git a/common/workflow-core/build.sbt b/common/workflow-core/build.sbt
index 11ec0b76e5..0d48fc4048 100644
--- a/common/workflow-core/build.sbt
+++ b/common/workflow-core/build.sbt
@@ -34,6 +34,45 @@ ThisBuild / conflictManager := ConflictManager.latestRevision
 // Restrict parallel execution of tests to avoid conflicts
 Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)
 
+// Suites tagged @org.apache.texera.common.tags.NonParallelTest must not run 
concurrently with one
+// another. They share a JVM-wide singleton backed by an external resource — 
the MinIO-backed
+// suites mixing S3StorageTestBase share one S3StorageClient.s3Client and one
+// StorageConfig.s3Endpoint pointed at a single MinIO container — so 
ScalaTest's parallel suite
+// distributor otherwise runs them together, they contend, and intermittently 
time out (flaky; see
+// issue #7049). The Global Tags.limit(Tags.Test, 1) above only bounds sbt 
task concurrency, not
+// ScalaTest's in-JVM distributor.
+//
+// Give each tagged suite its own forked JVM; sbt runs forked groups one at a 
time (the
+// Tags.ForkedTestGroup limit), so tagged suites never overlap. Every untagged 
suite stays in a
+// single forked group and keeps running in parallel inside that one JVM. This 
mirrors the
+// FileService testcontainer setup (Test/fork := true + per-group SubProcess), 
which is the shape
+// sbt-jacoco supports for forked coverage — a partially-forked layout (some 
groups InProcess)
+// breaks jacoco's offline instrumentation in the forked JVMs. Forked JVMs 
default their working
+// directory to the module dir; pin it to the repo root to match the 
in-process cwd.
+//
+// sbt discovers ScalaTest suites by subclass fingerprint, not by annotation, 
so the tag is not
+// visible in `definedTests`; load each suite class through the test class 
loader and check for the
+// annotation reflectively (loadClass does not run static initializers; a load 
failure falls back
+// to the parallel group).
+Test / fork := true
+Test / forkOptions := (Test / forkOptions).value
+  .withWorkingDirectory((ThisBuild / baseDirectory).value)
+Test / testGrouping := {
+  val opts = (Test / forkOptions).value
+  val loader = (Test / testLoader).value
+  val nonParallelTag =
+    loader
+      .loadClass("org.apache.texera.common.tags.NonParallelTest")
+      .asInstanceOf[Class[_ <: java.lang.annotation.Annotation]]
+  val (serial, parallel) = (Test / definedTests).value.partition { test =>
+    try loader.loadClass(test.name).isAnnotationPresent(nonParallelTag)
+    catch { case _: Throwable => false }
+  }
+  val serialGroups =
+    serial.map(suite => Tests.Group(suite.name, Seq(suite), 
Tests.SubProcess(opts)))
+  Tests.Group("parallel", parallel, Tests.SubProcess(opts)) +: serialGroups
+}
+
 
 /////////////////////////////////////////////////////////////////////////////
 // Compiler Options
diff --git 
a/common/workflow-core/src/test/java/org/apache/texera/common/tags/NonParallelTest.java
 
b/common/workflow-core/src/test/java/org/apache/texera/common/tags/NonParallelTest.java
new file mode 100644
index 0000000000..249fc4dce5
--- /dev/null
+++ 
b/common/workflow-core/src/test/java/org/apache/texera/common/tags/NonParallelTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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
+ *
+ *   http://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.texera.common.tags;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.scalatest.TagAnnotation;
+
+/**
+ * Class-level marker for ScalaTest specs that must not run concurrently with 
one another.
+ * {@code common/workflow-core/build.sbt} reflects over the tagged suites and 
gives each its own
+ * forked JVM via {@code Test/testGrouping}; sbt runs forked groups one at a 
time (the
+ * {@code Tags.ForkedTestGroup} limit), so tagged suites are serialized while 
every untagged suite
+ * runs in parallel in a single shared group.
+ *
+ * <p>Use this for suites that share a JVM-wide singleton backed by an 
external resource and would
+ * otherwise contend when the ScalaTest distributor runs them in parallel — 
e.g. the MinIO-backed
+ * suites mixing {@code S3StorageTestBase}, which share one {@code 
S3StorageClient.s3Client} and
+ * {@code StorageConfig} endpoint (see issue #7049).
+ *
+ * <p>Written in Java rather than Scala on purpose: the annotation must be 
visible through
+ * {@code java.lang.annotation} reflection (it is discovered by {@code 
isAnnotationPresent} at
+ * build time, and {@code @TagAnnotation} makes it a first-class ScalaTest 
tag). A Scala
+ * {@code class extends StaticAnnotation} does not produce a runtime-retained 
JVM annotation, so it
+ * would be invisible.
+ */
+@TagAnnotation
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE})
+public @interface NonParallelTest {
+}
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryInputStreamSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryInputStreamSpec.scala
index 0e307d8e42..21b6e8c29b 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryInputStreamSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryInputStreamSpec.scala
@@ -20,12 +20,14 @@
 package org.apache.texera.service.util
 
 import org.apache.texera.amber.core.tuple.LargeBinary
+import org.apache.texera.common.tags.NonParallelTest
 import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
 import org.scalatest.funsuite.AnyFunSuite
 
 import java.io.{ByteArrayInputStream, IOException}
 import scala.util.Random
 
+@NonParallelTest
 class LargeBinaryInputStreamSpec
     extends AnyFunSuite
     with S3StorageTestBase
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryManagerSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryManagerSpec.scala
index eeb1bce7cd..4b5e7b3a37 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryManagerSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryManagerSpec.scala
@@ -20,9 +20,11 @@
 package org.apache.texera.service.util
 
 import org.apache.texera.amber.core.tuple.LargeBinary
+import org.apache.texera.common.tags.NonParallelTest
 import org.scalatest.funsuite.AnyFunSuite
 import org.scalatest.BeforeAndAfterEach
 
+@NonParallelTest
 class LargeBinaryManagerSpec extends AnyFunSuite with S3StorageTestBase with 
BeforeAndAfterEach {
 
   /** Execution id used by the bulk of the tests. */
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryOutputStreamSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryOutputStreamSpec.scala
index ed2a2fbace..35e7595013 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryOutputStreamSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/LargeBinaryOutputStreamSpec.scala
@@ -20,12 +20,14 @@
 package org.apache.texera.service.util
 
 import org.apache.texera.amber.core.tuple.LargeBinary
+import org.apache.texera.common.tags.NonParallelTest
 import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
 import org.scalatest.funsuite.AnyFunSuite
 
 import java.io.IOException
 import scala.util.Random
 
+@NonParallelTest
 class LargeBinaryOutputStreamSpec
     extends AnyFunSuite
     with S3StorageTestBase
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/S3StorageClientSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/S3StorageClientSpec.scala
index bcd50dc23d..78765381ff 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/service/util/S3StorageClientSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/service/util/S3StorageClientSpec.scala
@@ -20,6 +20,7 @@
 package org.apache.texera.service.util
 
 import org.apache.texera.common.config.StorageConfig
+import org.apache.texera.common.tags.NonParallelTest
 import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
 import org.scalatest.funsuite.AnyFunSuite
 import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, 
StaticCredentialsProvider}
@@ -34,6 +35,7 @@ import scala.concurrent.{Await, ExecutionContext, Future}
 import scala.jdk.CollectionConverters._
 import scala.util.Random
 
+@NonParallelTest
 class S3StorageClientSpec
     extends AnyFunSuite
     with S3StorageTestBase
@@ -435,8 +437,9 @@ class S3StorageClientSpec
     val prefix = "delete-dir/large"
     val objectCount = 1001
 
-    // Upload concurrently to keep the test reasonably fast.
-    val pool = Executors.newFixedThreadPool(16)
+    // Upload with bounded concurrency to keep the test reasonably fast 
without flooding the
+    // shared MinIO container (a 16-way burst was a contributor to the 
flakiness in issue #7049).
+    val pool = Executors.newFixedThreadPool(4)
     implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(pool)
     try {
       val uploads = (0 until objectCount).map { i =>

Reply via email to