spring-projects / spring-projects/spring-framework

New threads added to thread pool inherit active database transaction in tests

Open
#33,383 5 comments 6 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

in: test status: waiting-for-triage
Dominant language
Java
Stars
60.2k
Forks
38.8k
Avg merge
5d 2h
Merged PRs (30d)
27

Description

TransactionalTestExecutionListener's sanity check for open transactions can cause spurious test failures when JUnit tests are run concurrently on code that uses ForkJoinPool.managedBlock().

One possible fix would be to skip the sanity check the first time a test runs on a particular thread.

More details:

When concurrent test execution is enabled in JUnit 5, it creates a ForkJoinPool to run the tests.

TransactionalTestExecutionListener has a sanity check that runs before each test method to make sure that the current thread doesn't have a transaction left over from a previous test that failed abnormally. If it finds one, it throws an exception and the test fails with

java.lang.IllegalStateException: Cannot start new transaction without ending existing transaction" in TransactionalTestExecutionListener

The thread-local variable in TransactionContextHolder that holds the current transaction is inheritable, meaning that any child threads created from inside a test method will inherit the open transaction from the parent thread. This is correct in cases where test threads are spawning short-lived temporary threads. But it does the wrong thing if a test calls code that adds a thread to the current thread pool.

One such method is ForkJoinPool.managedBlock(), whose contract says that if it's called from a thread that's managed by a ForkJoinPool, and the pool has no available threads, it will add a new thread to the pool to ensure there's spare capacity.

When that happens during a concurrent JUnit run, the newly-added spare thread (which, since it was spawned from a thread with an open transaction, inherits the transaction context) immediately starts working on the next test in the pool's task queue. The sanity check in TransactionalTestExecutionListener sees the inherited transaction context and bombs out. The end user sees tests failing seemingly at random.

In my application, I worked around this by adding a listener that runs before TransactionalTestExecutionListener and clears the transaction context the first time it executes on a given thread. Kotlin code:

package org.springframework.test.context.transaction

import org.springframework.core.annotation.Order
import org.springframework.test.context.TestContext
import org.springframework.test.context.TestExecutionListener

@Order(3000) // TransactionalTestExecutionListener is order 4000
class InheritedTransactionRemover : TestExecutionListener {
  private val transactionRemoved = ThreadLocal.withInitial { false }

  override fun beforeTestMethod(testContext: TestContext) {
    if (!transactionRemoved.get()) {
      TransactionContextHolder.removeCurrentTransactionContext()
      transactionRemoved.set(true)
    }
  }
}

This fixes the random test failures for me. I believe the same concept would probably be the right fix for the problem in general: skip the sanity check the first time a given thread is used to run tests. There can't be a leftover transaction from an earlier test in that situation.

Note that this class has to go in the org.springframework.test.context.transaction package because TransactionContextHolder is package-private. I'm not using JPMS (modules) so I'm not sure if additional steps would be needed to make it work with modules.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with TransactionalTestExecutionListener and TransactionContextHolder in the org.springframework.test.context.transaction package, then inspect how concurrent JUnit 5 execution and ForkJoinPool.managedBlock() create worker threads. Reproduce the inherited transaction context during concurrent tests and verify that a newly used thread does not trigger the sanity-check failure while genuine leftover transactions still do.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring
Domain
database, testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.