Skip to content

TransactionCoordinator's exclusivitylock isn't transferred by detach()/attach() #4135

Description

@Jamie-SA

Version

6.2.0

What happened?

TransactionCoordinator's exclusivitylock isn't transferred by detach()/attach(), so completing a transaction from a different thread than began it always throws IllegalMonitorStateException

Version

6.2.0 (also present on main as of this report — the relevant code hasn't changed).

Summary

TransactionalSystem.detach()/attach(TransactionCoordinatorState) (implemented by TransactionalBase in jena-dboe-transaction) are documented/intended to let a transaction be suspended off one thread and resumed on another. That works for the transaction's own ThreadLocal state and its components' per-thread state — but TransactionCoordinator's "non-exclusive mode" lock is never transferred, so completing (commit()/abort()/end()) a transaction from any thread other than the one that called begin() throws unconditionally:

java.lang.IllegalMonitorStateException: attempt to unlock read lock, not locked by current thread
        at java.util.concurrent.locks.ReentrantReadWriteLock$Sync.unmatchedUnlockException(ReentrantReadWriteLock.java:448)
        at java.util.concurrent.locks.ReentrantReadWriteLock$Sync.tryReleaseShared(ReentrantReadWriteLock.java:432)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.releaseShared(AbstractQueuedSynchronizer.java:1180)
        at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.unlock(ReentrantReadWriteLock.java:897)
        at org.apache.jena.dboe.transaction.txn.TransactionCoordinator.finishNonExclusiveMode(TransactionCoordinator.java:496)
        at org.apache.jena.dboe.transaction.txn.TransactionCoordinator.finishActiveTransaction(TransactionCoordinator.java:1029)
        at org.apache.jena.dboe.transaction.txn.TransactionCoordinator.completed(TransactionCoordinator.java:845)
        at org.apache.jena.dboe.transaction.txn.Transaction.endInternal(Transaction.java:219)
        at org.apache.jena.dboe.transaction.txn.Transaction.abort(Transaction.java:190)
        at org.apache.jena.dboe.transaction.txn.TransactionalBase.abort(TransactionalBase.java:161)

Steps to reproduce

Dataset ds = TDB2Factory.connectDataset(Location.mem());
DatasetGraphTDB dsg = TDBInternal.getDatasetGraphTDB(ds);

Thread a = new Thread(() -> {
    dsg.begin(TxnType.WRITE);
    TransactionCoordinatorState state = dsg.getTxnSystem().detach();
    // hand `state` off to another thread, e.g. via a queue/field
});
a.start(); a.join();

Thread b = new Thread(() -> {
    dsg.getTxnSystem().attach(state);   // resumes fine
    dsg.getDefaultGraph().add(someTriple());
    dsg.abort();                        // <-- throws IllegalMonitorStateException here
    dsg.end();
});
b.start(); b.join();

This is deterministic, not a race: it fails on the very first attempt to complete on a different thread, regardless of timing.

Root cause

TransactionCoordinator.java:

  • Line 100: private ReadWriteLock exclusivitylock = new ReentrantReadWriteLock(); — comment at line 98: // "one exclusive, or many other" lock which happens to be called ReadWriteLock.
  • Lines 486–492 (tryNonExclusiveMode): every ordinary transaction acquires the read side of this lock. Called from begin(TxnType, boolean) at line 661.
  • Line 494: finishNonExclusiveMode() releases it via exclusivitylock.readLock().unlock(). Called from finishActiveTransaction() (line 1029), from completed() (line 845), from Transaction.endInternal() (Transaction.java:213-219) — i.e. on whatever thread finally commits/aborts/ends.

java.util.concurrent.locks.ReentrantReadWriteLock's read lock tracks per-thread hold counts internally and throws IllegalMonitorStateException if a thread with a zero hold count calls unlock(). Because it's acquired on the begin()-calling thread and never transferred, any other thread that completes the transaction has a hold count of zero and crashes.

TransactionCoordinator.detach(Transaction)/attach(TransactionCoordinatorState) (lines 356–373) — the methods TransactionalBase.detach()/attach() delegate to — only call txn.detach()/txn.attach() and each component's detach()/attach(). Neither method references exclusivitylock at all. So the lock's thread affiliation is silently left pointing at the original begin() thread through any number of detach/attach cycles.

There is no thread-identity check or documentation anywhere in Transaction.java/TransactionCoordinator.java warning that begin() and completion must happen on the same thread; the constraint comes entirely from the JDK lock's own semantics, not from anything Jena manages.

Why this looks like a real gap, not a misuse

A grep across the whole codebase (jena-fuseki2, jena-tdb2, jena-arq, jena-db) turns up no production call sites for TransactionalSystem.attach()/detach() outside the dboe-transaction module itself. The only test that exercises them (jena-db/jena-dboe-transaction/src/test/java/org/apache/jena/dboe/transaction/TestTxnSwitching.java) performs every detach()/attach() pair and the final commit()/end() on the single JUnit test thread. So the existing test suite can't have caught this: it never tries the "begin on thread A, complete on thread B" sequence that detach()/attach() seem designed to enable in the first place.

Suggested fix

exclusivitylock's read side is only ever used as a counting/blocking mechanism ("how many non-exclusive transactions are active, block them out for exclusive mode") — it doesn't need per-thread ownership semantics. Replacing it with a java.util.concurrent.Semaphore (already used elsewhere in this same class for writersWaiting, per the class's existing style) would make acquire/release thread-agnostic and fix this without touching detach()/attach()'s contract at all. Alternatively, TransactionCoordinator.attach() could explicitly transfer lock ownership (there's no public JDK API for that with ReentrantReadWriteLock, which is part of why a Semaphore seems like the simpler fix).

Context

Found while prototyping a Fuseki module that holds a client-facing transaction across multiple HTTP requests (necessarily served by different threads from Jetty's pool) — see #4123 . Worked around there by pinning every operation for a given transaction to one dedicated thread for its whole lifetime, but that's a workaround, not a fix — the underlying detach()/attach() API can't actually do what its name suggests for anyone who wants completion to happen on a different thread than begin().

  • Are you interested in contributing a solution yourself? Possibly, if the Semaphore approach above sounds right to a maintainer — wanted to flag the root cause clearly first since it touches core transaction-coordination code I'd want sign-off on before proposing a specific patch.

Relevant output and stacktrace

Are you interested in making a pull request?

Maybe

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions