Skip to content

fix: auto-tune ForkJoinPool minimum-runnable on JDK 25+#2890

Merged
He-Pin merged 2 commits intomainfrom
minimum-runnable-auto-jdk21
Apr 23, 2026
Merged

fix: auto-tune ForkJoinPool minimum-runnable on JDK 25+#2890
He-Pin merged 2 commits intomainfrom
minimum-runnable-auto-jdk21

Conversation

@He-Pin
Copy link
Copy Markdown
Member

@He-Pin He-Pin commented Apr 23, 2026

Summary

  • Add ForkJoinExecutorConfigurator.resolveMinimumRunnable that auto-scales the minimum-runnable setting on JDK 25+ (min(8, max(1, parallelism / 2))) while keeping the legacy value of 1 on older JDKs. Explicit user values (including 0) are still honoured.
  • Switch the default in reference.conf from 1 to the sentinel -1 (auto) and update the doc block.
  • New ForkJoinExecutorConfiguratorSpec covering: pure-function matrix; directional checks (auto value strictly higher on JDK 25+, never exceeds documented cap of 8); wiring integration that proves the resolved value reaches the ForkJoinExecutorServiceFactory.

Context

JDK-8300995 / JDK-8321335 changed ForkJoinPool asyncMode (FIFO) compensation-thread creation to be much more conservative. Pekko fork-join dispatchers using the prior default minimum-runnable = 1 are then prone to starvation under blocking workloads on JDK 21+, which is the root cause behind the workflow override added in #2889.

That workflow override only protects nightly CI. This PR pushes the same protection into the library so production users on JDK 25+ get the mitigation automatically.

Behaviour matrix

minimum-runnable JDK Effective value
-1 (default) < 25 1 (legacy)
-1 (default) >= 25 min(8, max(1, parallelism / 2))
0 any 0 (compensation disabled)
any positive N any N

Escape hatch for users on JDK 21+ who want to keep the prior behaviour: set pekko.actor.default-dispatcher.fork-join-executor.minimum-runnable = 1 (or any explicit value).

Compatibility

  • No constructor signatures changed (PekkoForkJoinPool and ForkJoinExecutorServiceFactory keep their minimumRunnable: Int = 1 defaults).
  • No MiMa filter required.

Follow-up

Once this PR is merged and a nightly run confirms green, a small follow-up PR will drop the EXTRA_JVM_OPTS block that #2889 added to .github/workflows/nightly-builds.yml (the workflow override becomes redundant).

Test plan

  • sbt scalafmtAll
  • sbt actor/compile actor-tests/Test/compile
  • sbt 'actor-tests/testOnly org.apache.pekko.dispatch.ForkJoinExecutorConfiguratorSpec' — all 10 tests pass (1 pending on JDK 21+ for the legacy branch)
  • sbt 'actor-tests/testOnly org.apache.pekko.dispatch.ForkJoinPoolStarvationSpec' — still pending as before, no regression
  • Nightly Builds workflow on main after merge confirms JDK 17 / 21 / 25 matrix is green

Motivation:
JDK-8300995 / JDK-8321335 changed compensation-thread creation in
ForkJoinPool asyncMode (FIFO) to be much more conservative. Pekko fork-join
dispatchers using the prior default `minimum-runnable = 1` are then prone
to starvation under blocking workloads on JDK 21+, which has shown up as
flaky nightly runs (#2870) and is the root cause behind the workflow
override added in #2889.

Modification:
* Introduce `ForkJoinExecutorConfigurator.resolveMinimumRunnable`, an
  internal helper that computes the effective `minimum-runnable` value
  from the configured value, the dispatcher parallelism, and the running
  JDK major version. A negative configured value (the new default `-1`)
  triggers the JDK-aware policy: on JDK 21+ the value becomes
  `min(8, max(1, parallelism / 2))`; on JDK < 21 it stays at `1`.
  Non-negative values are honoured verbatim, so explicit `0` still
  disables compensation entirely and explicit positive values (including
  `1`) keep their existing meaning.
* Change `pekko.actor.default-dispatcher.fork-join-executor.minimum-runnable`
  in `reference.conf` to the sentinel `-1` and update the doc block to
  describe the new auto-selection rule.
* Add `ForkJoinExecutorConfiguratorSpec` with three groups of assertions:
  (1) pure-function matrix on `resolveMinimumRunnable`; (2) directional
  checks asserting the auto policy strictly raises the value on JDK 21+
  and never exceeds the documented cap of 8; (3) wiring integration that
  builds a `ForkJoinExecutorServiceFactory` from a real dispatcher config
  and verifies the resolved value reaches the factory (guarding against
  regressions of the resolver wiring).

Result:
Production users on JDK 21+ now benefit from the same starvation
mitigation that #2889 bolted onto the nightly CI workflow. Source and
binary compatibility are preserved (constructor defaults stay at `1`,
no signature changes, no MiMa filter required). Users wanting to opt
out can set `minimum-runnable = 1` (or any explicit value) to restore
the previous behaviour.
@He-Pin He-Pin requested a review from pjfanning April 23, 2026 12:41
@He-Pin He-Pin added this to the 2.0.0-M2 milestone Apr 23, 2026
@He-Pin
Copy link
Copy Markdown
Member Author

He-Pin commented Apr 23, 2026

@pjfanning I think this should be the only way to make JDK25 pass

@pjfanning
Copy link
Copy Markdown
Member

@He-Pin can you update the dispatcher docs? I'm also wary of changing the default Java 21 behaviour. We've got strong evidence from the nightly tests that Java 25 could really do with the change you are making here but Java 21 seems fine.

@pjfanning
Copy link
Copy Markdown
Member

Maybe it is best as the jdk issue affects Java 21 but just doesn't seem to have quite the same impact there

* License header: replace abbreviated header on the new
  ForkJoinExecutorConfiguratorSpec with the canonical Apache 2.0
  header used by other clean-room test files in the project
  (per pjfanning's review comment).

* Narrow auto-policy scope from JDK 21+ to JDK 25+: nightly evidence
  shows the asyncMode (FIFO) compensation-thread regression
  (JDK-8300995 / JDK-8321335) surfaces most clearly on the JDK 25
  line, while JDK 21 has been running fine on the legacy default of
  1 for years. Keep the default unchanged on JDK 21 to avoid a
  silent behaviour change for users who are not affected.

* Document the new auto-tuning behaviour in both
  docs/src/main/paradox/dispatchers.md (classic) and
  docs/src/main/paradox/typed/dispatchers.md, including the opt-out
  instructions.

* Update reference.conf doc comment, configurator scaladoc, and the
  spec assertions / pending guards to reflect the JDK 25+ scope.
@He-Pin
Copy link
Copy Markdown
Member Author

He-Pin commented Apr 23, 2026

@pjfanning thanks for the review — addressed all three comments in 904eef2:

  1. License header on ForkJoinExecutorConfiguratorSpec.scala — replaced the abbreviated header with the canonical Apache 2.0 header used by other clean-room test files in the project (e.g. ByteStringBuilderSpec, ByteBufferCleanerSpec). Since this is a brand-new file with no derivation, no Lightbend copyright is needed.

  2. Narrowed the auto-policy from JDK 21+ to JDK 25+, per your guidance — nightly evidence is strongest on the JDK 25 line; JDK 21 has been running fine on the legacy default of 1 for years and there's no need for a silent behaviour change there. Updated resolveMinimumRunnable, the reference.conf doc comment, the spec matrix, and the directional/pending guards to match.

  3. Updated dispatcher docs — added a note in both docs/src/main/paradox/dispatchers.md (classic) and docs/src/main/paradox/typed/dispatchers.md describing the new auto-tuning behaviour and how to opt out (set minimum-runnable explicitly).

Local actor-tests/testOnly ForkJoinExecutorConfiguratorSpec is green (10 passed, 1 pending — the JDK<25 branch is naturally pending on a JDK 25 host).

Copy link
Copy Markdown
Member

@pjfanning pjfanning left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@He-Pin He-Pin merged commit 980d2bd into main Apr 23, 2026
9 checks passed
@He-Pin He-Pin deleted the minimum-runnable-auto-jdk21 branch April 23, 2026 18:54
@pjfanning pjfanning changed the title fix: auto-tune ForkJoinPool minimum-runnable on JDK 21+ fix: auto-tune ForkJoinPool minimum-runnable on JDK 25+ Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants