Skip to content

[improve][ci] Shorten CI - Unit - Other: start the jcloud tests first, skip JVM-wide threads in the leak detector - #27305

Merged
merlimat merged 2 commits into
apache:masterfrom
merlimat:mmerli/unit-other-step-time
Nov 2, 2026
Merged

merlimat merged 2 commits into
apache:masterfrom
merlimat:mmerli/unit-other-step-time

Conversation

@merlimat

@merlimat merlimat commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Motivation

CI - Unit - Other runs the tests of about 40 modules in one Gradle build on a 4-vCPU runner: 42 test tasks and 125
test JVMs, with the CPU 80–96 % busy for most of the build. Two things waste time in every run.

  1. The tiered-storage-jcloud tests run last, and alone. test requests the test tasks by project path, and Gradle
    starts the ready tasks in that order, so :tiered-storage:tiered-storage-jcloud:test starts last in every run. A
    dry run of the group plans it as task 575 of 610. Most of its 2–4 minutes is BlobStoreManagedLedgerOffloaderTest
    (81–201 s) on one fork, so the build ends with one test JVM busy and three lanes idle. Over 58 scheduled master runs
    (4 Oct – 2 Nov), this tail had a median of 75 s, with 56 s of the four lanes' capacity unused.

  2. Many test JVMs sleep 10 s at the end for threads that never end. CI sets THREAD_LEAK_DETECTOR_WAIT_MILLIS=10000.
    Gradle runs a test JVM's classes in one TestNG context, so ThreadLeakDetectorListener checks once, when the JVM's
    tests are done. If a thread started during the run is still alive, it polls for up to 10 s, then writes a leak
    report. 33–51 of the 125 JVMs per run wait the full 10 s. In 68–73 % of those, every reported thread lives as long
    as the JVM:

    • ForkJoinPool.commonPool-delayScheduler. Since Java 25, CompletableFuture.delayedExecutor and orTimeout run
      on the common pool's delay scheduler. Before, they ran on the CompletableFutureDelayScheduler thread, which the
      listener already skips. 12–15 JVMs per JDK 25 run report it, none on JDK 21.
    • Cleaner-0: a java.lang.ref.Cleaner thread. PulsarExecutors.AutoShutdownExecutorService keeps its Cleaner
      in a static field.
    • pulsar-auth-blocking-shared: V5AuthContexts.SharedBlockingExecutor, a pool that lives as long as the JVM and
      whose idle threads retire after 60 s.
    • JVM internals that start on first use: JFR Recorder Thread, JFR Periodic Tasks, Java2D Disposer,
      VirtualThread-unblocker.

    The same waits happen in the other unit test jobs.

For example, in the scheduled master run 41443581285
(2 Nov 12:18 UTC, JDK 25), OTHER's main Gradle build took 943 s:

  • The jcloud tests started at 749 s and ended at 943 s. From 875 s on they ran alone, and the runner's CPU dropped to
    56 %, then 21 %.

  • 41 test JVMs waited ~11.5 s after their last test class, while the other 84 took ~1.3 s to exit. In 30 of the 41,
    the job log's leak report lists only threads from the list above, for example:

    Summary: Tests in class org.apache.pulsar.transaction.coordinator.TransactionMetadataStoreProviderTest, org.apache.pulsar.transaction.coordinator.impl.TxnLogBufferedWriterTest created 1 new threads.
    

    The thread was ForkJoinPool.commonPool-delayScheduler.

Locally (JDK 26, THREAD_LEAK_DETECTOR_WAIT_MILLIS=10000), on current master:

Test task Wall time Leak reports
:pulsar-client-auth-athenz:test 16 s 1: pulsar-auth-blocking-shared
:pulsar-transaction:pulsar-transaction-coordinator:test 28 s 1: ForkJoinPool.commonPool-delayScheduler
:pulsar-functions:pulsar-functions-worker:test 22 s 3: Cleaner-0 twice, function-worker-leader-*

Modifications

  • run_unit_group_gradle.sh: the OTHER group requests :tiered-storage:tiered-storage-jcloud:test before test.
    Gradle's plan then puts it before the other test tasks: a dry run shows the same 610 tasks, with the jcloud tests at
    task 50, right after their compile tasks. They now run alongside the other modules' tests instead of after them.
  • ThreadLeakDetectorListener.shouldSkipThread skips the threads listed above, the way it already skips
    CompletableFutureDelayScheduler, process reaper and others. A Cleaner-N thread is skipped only in the JDK's
    InnocuousThreadGroup. Real leak candidates are still reported, for example function-worker-leader-*,
    WebSocket@… and HDFS LeaseRenewer. shouldSkipThread becomes package-private for the new test.

Expected saving on the OTHER test step: about 1–1.5 min. Replaying each of the 58 runs' test JVM lanes on 4 lanes,
moving the jcloud tests saves 21–105 s (median 57 s), and skipping the singleton waits saves up to 41–62 s more. That
second figure is an upper bound, since the runner is CPU-bound. The leak reports in every unit job also get shorter.

The two changes are independent, one commit each. #27299 changes other parts of run_unit_group_gradle.sh (not the
OTHER group), and the two merge cleanly.

Verifying this change

  • Make sure that the change passes the CI checks.

  • New ThreadLeakDetectorListenerTest. It starts a Cleaner and a CompletableFuture delayed task and checks that the
    listener skips their threads. It also checks that an ordinary thread, and a thread named Cleaner-0 outside the
    InnocuousThreadGroup, are still reported. Without the change, both skip checks fail on JDK 26 (Cleaner-0 and
    ForkJoinPool.commonPool-delayScheduler aren't skipped). With it, all three pass.

  • The three test tasks above, with the 10 s wait, after the change:

    Test task Wall time Leak reports
    :pulsar-client-auth-athenz:test 3 s none
    :pulsar-transaction:pulsar-transaction-coordinator:test 22 s none
    :pulsar-functions:pulsar-functions-worker:test 20 s 1: function-worker-leader-*, still reported

    The functions-worker task is barely faster, because the fork with the real leak report still waits 10 s.

  • ./pulsar-build/run_unit_group_gradle.sh OTHER --dry-run --max-workers=1: the main build plans the same 610 tasks as
    before. :tiered-storage:tiered-storage-jcloud:test moves from task 575 to task 50 and is the first test task. The
    DnsResolverTest build is unchanged.

  • shellcheck pulsar-build/run_unit_group_gradle.sh: the same two findings as before (line 59, untouched).
    ./gradlew quickCheck passes.

  • On CI, the OTHER job's leak summary should list only the real candidates.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

…R unit test group

`test` requests the test tasks by project path, and Gradle starts the ready
tasks in that order, so :tiered-storage:tiered-storage-jcloud:test started
last in every CI run (task 575 of 610). Most of its 2-4 minutes is
BlobStoreManagedLedgerOffloaderTest on one fork, so the build ended with one
test JVM busy and three lanes idle: a median of 75 s over the 58 scheduled
master runs from 4 Oct to 2 Nov.

Request it before `test`, so it is planned first (task 50 of the same 610)
and runs alongside the other modules' tests.
CI sets THREAD_LEAK_DETECTOR_WAIT_MILLIS=10000, and under Gradle the
listener checks once per test JVM, when its tests are done. Any thread
started during the run that is still alive makes it wait the full 10 s and
write a leak report. In CI - Unit - Other, 33-51 of the 125 test JVMs per
run waited, and in 68-73% of them every reported thread lives as long as
the JVM:

- ForkJoinPool.commonPool-delayScheduler: since Java 25 it runs
  CompletableFuture.delayedExecutor and orTimeout, in place of the
  CompletableFutureDelayScheduler thread that was already skipped
- Cleaner-N in the JDK's InnocuousThreadGroup: a java.lang.ref.Cleaner
  thread, e.g. from the static Cleaner in PulsarExecutors
- pulsar-auth-blocking-shared: the process-wide pool of V5AuthContexts
- JFR Recorder Thread, JFR Periodic Tasks, Java2D Disposer and
  VirtualThread-unblocker, started by the JVM on first use

Skip them like the other JVM-wide threads, and test the rules on the
threads the JDK actually starts.
@merlimat
merlimat merged commit 34add3c into apache:master Nov 2, 2026
44 checks passed
@merlimat merlimat added this to the 5.1.0 milestone Nov 2, 2026
lhotari pushed a commit that referenced this pull request Nov 4, 2026
…, skip JVM-wide threads in the leak detector (#27305)

(cherry picked from commit 34add3c)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants