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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions