/*
* ModeShape (http://www.modeshape.org)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.modeshape.jcr.txn;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
import javax.transaction.HeuristicMixedException;
import javax.transaction.HeuristicRollbackException;
import javax.transaction.InvalidTransactionException;
import javax.transaction.NotSupportedException;
import javax.transaction.RollbackException;
import javax.transaction.Status;
import javax.transaction.Synchronization;
import javax.transaction.SystemException;
import javax.transaction.TransactionManager;
import javax.transaction.xa.XAResource;
import org.modeshape.common.logging.Logger;
import org.modeshape.jcr.JcrI18n;
import org.modeshape.jcr.cache.change.ChangeSet;
import org.modeshape.jcr.cache.document.TransactionalWorkspaceCache;
import org.modeshape.jcr.cache.document.WorkspaceCache;
import org.modeshape.schematic.TransactionListener;
/**
* An abstraction for the logic of working with transactions. Sessions use this to create local transactions around a single set
* of changes (persisted via the {@link javax.jcr.Session#save()} invocation), but the implementation actually and transparently
* coordinates the work based upon whether there is already an existing (container-managed or user-managed) transaction associated
* with the current thread.
* <p>
* The basic workflow is as follows. When transient changes are to be persisted, a new ModeShape transaction is {@link #begin()
* begun}. The resulting ModeShape {@link Transaction} represents the local transaction, to register {@link TransactionFunction
* functions} that are to be called upon successful transaction commit, and then either {@link Transaction#commit() committed} or
* {@link Transaction#rollback() rolled back}. If committed, then any changes made should be forwarded to
* {@link #updateCache(WorkspaceCache, ChangeSet, Transaction)}.
* </p>
* <p>
* In the typical case where no JTA transactions are being used with JCR, then each time changes are made to a Session and
* {@link javax.jcr.Session#save()} is called a new transaction will be created, the changes applied to the workspace, the
* transaction will be committed, and the workspace cache will be updated based upon the changes.
* </p>
* <p>
* However, when JTA (local or distributed) transactions are being used with JCR (that is, container-managed transactions or
* user-managed transactions), then {@link javax.jcr.Session#save()} still needs to be called (see Chapter 21 of the JSR-283
* specification), but the changes are not persisted until the transaction is completed. In other words, even when one or more
* Session.save() calls are made, the changes are persisted and made visible to the rest of the repository users only when the
* transaction successfully commits. In these cases, the current thread is already associated with a transaction when
* {@link javax.jcr.Session#save()} is called, and therefore ModeShape simply uses that transaction but defers the monitoring and
* cache update calls until after commit.
* </p>
* <p>
* Note that when distributed (XA) transactions are used, ModeShape properly integrates and uses the XA transaction but does not
* register itself as an {@link XAResource}. Therefore, even when XA transactions only involve the JCR repository as
* the single resource, ModeShape enlists only a single resource, allowing the transaction manager to optimize the 2PC with a
* single resource as a 1PC transaction. (Rather than enlisting the repository as an XAResource, ModeShape registers
* a {@link Synchronization} with the transaction to be notified when the transaction commits successfully, and it uses this to
* dictate when the events and session's changes are made visible to other sessions.
* </p>
*/
public class Transactions {
private static final ThreadLocal<NestableThreadLocalTransaction> LOCAL_TRANSACTION = new ThreadLocal<>();
private static final Set<String> ACTIVE_TRACE_SYNCHRONIZATIONS = new HashSet<>();
protected final TransactionManager txnMgr;
protected final Logger logger = Logger.getLogger(Transactions.class);
private final TransactionListener listener;
private final ConcurrentMap<javax.transaction.Transaction, SynchronizedTransaction> transactionTable;
/**
* Creates a new instance wrapping an existing transaction manager and a transaction listener.
*
* @param txnMgr a {@link TransactionManager} instance; may not be null
* @param listener a {@link TransactionListener} instance; may not be null
*/
public Transactions(TransactionManager txnMgr, TransactionListener listener) {
this.txnMgr = txnMgr;
this.listener = listener;
this.transactionTable = new ConcurrentHashMap<>();
}
/**
* Determine if the current thread is already associated with an existing transaction.
*
* @return true if there is an existing transaction, or false if there is none
* @throws SystemException If the transaction service fails in an unexpected way.
*/
public boolean isCurrentlyInTransaction() throws SystemException {
return txnMgr.getTransaction() != null;
}
/**
* Get a string representation of the current transaction if there already is an existing transaction.
*
* @return a string representation of the transaction if there is an existing transaction, or null if there is none
*/
public String currentTransactionId() {
try {
javax.transaction.Transaction txn = txnMgr.getTransaction();
return txn != null ? txn.toString() : null;
} catch (SystemException e) {
return null;
}
}
/**
* Get the transaction manager.
*
* @return the transaction manager
*/
public TransactionManager getTransactionManager() {
return txnMgr;
}
/**
* Starts a new transaction if one does not already exist, and associate it with the calling thread.
*
* @return the ModeShape transaction
* @throws NotSupportedException If the calling thread is already associated with a transaction, and nested transactions are
* not supported.
* @throws SystemException If the transaction service fails in an unexpected way.
*/
public Transaction begin() throws NotSupportedException, SystemException, RollbackException {
// check if there isn't an active transaction already
NestableThreadLocalTransaction localTx = LOCAL_TRANSACTION.get();
if (localTx != null) {
// we have an existing local transaction so we need to be aware of nesting by calling 'begin'
if (logger.isTraceEnabled()) {
logger.trace("Found active ModeShape transaction '{0}' ", localTx);
}
return localTx.begin();
}
// Get the transaction currently associated with this thread (if there is one) ...
javax.transaction.Transaction txn = txnMgr.getTransaction();
if (txn != null && Status.STATUS_ACTIVE != txn.getStatus()) {
// there is a user transaction which is not valid, so abort everything
throw new IllegalStateException(JcrI18n.errorInvalidUserTransaction.text(txn));
}
if (txn == null) {
// There is no transaction or a leftover one which isn't active, so start a local one ...
txnMgr.begin();
// create our wrapper ...
localTx = new NestableThreadLocalTransaction(txnMgr);
localTx.begin();
// notify the listener
localTx.started();
return logTransactionInformation(localTx);
}
// There's an existing tx, meaning user transactions are being used
SynchronizedTransaction synchronizedTransaction = transactionTable.get(txn);
if (synchronizedTransaction != null) {
// notify the listener
synchronizedTransaction.started();
// we've already started our own transaction so just return it as is
return logTransactionInformation(synchronizedTransaction);
} else {
synchronizedTransaction = new SynchronizedTransaction(txnMgr, txn);
transactionTable.put(txn, synchronizedTransaction);
// and register a synchronization
txn.registerSynchronization(synchronizedTransaction);
// and notify the listener
synchronizedTransaction.started();
return logTransactionInformation(synchronizedTransaction);
}
}
private Transaction logTransactionInformation( Transaction transaction ) throws SystemException {
if (!logger.isTraceEnabled()) {
return transaction;
}
logger.trace("Created & stored new ModeShape synchronized transaction '{0}' ", transaction);
javax.transaction.Transaction txn = txnMgr.getTransaction();
assert txn != null;
final String id = txn.toString();
// Register a synchronization for this transaction ...
if (!ACTIVE_TRACE_SYNCHRONIZATIONS.contains(id)) {
if (transaction instanceof SynchronizedTransaction) {
logger.trace("Found user transaction {0}", txn);
} else {
logger.trace("Begin transaction {0}", id);
}
// Only if we don't already have one ...
try {
txn.registerSynchronization(new TransactionTracer(id));
} catch (RollbackException e) {
// This transaction has been marked for rollback only ...
return new RollbackOnlyTransaction();
}
} else {
logger.trace("Tracer already registered for transaction {0}", id);
}
return transaction;
}
/**
* Returns a the current ModeShape transaction, if one exists.
* <p>
* A ModeShape transaction may not necessarily exist when a
* {@link javax.transaction.Transaction} is active. This is because ModeShape transactions are only created when a
* {@link org.modeshape.jcr.JcrSession} is saved.
* </p>
*
* @return either a {@link org.modeshape.jcr.txn.Transactions.Transaction instance} or {@code null} if no ModeShape transaction
* exists
*/
public Transaction currentTransaction() {
Transaction localTx = LOCAL_TRANSACTION.get();
if (localTx != null) {
return localTx;
}
try {
javax.transaction.Transaction txn = txnMgr.getTransaction();
if (txn == null) {
// no active transaction
return null;
}
if (Status.STATUS_ACTIVE != txn.getStatus()) {
// there is a user transaction which is not valid, so abort everything
throw new IllegalStateException(JcrI18n.errorInvalidUserTransaction.text(txn));
}
return transactionTable.get(txn);
} catch (SystemException e) {
logger.debug(e, "Cannot determine if there is an active transaction or not");
return null;
}
}
/**
* Commits the current transaction, if one exists.
*
* @throws IllegalStateException If the calling thread is not associated with a transaction.
* @throws SystemException If the transaction service fails in an unexpected way.
* @throws HeuristicMixedException If a heuristic decision was made and some some parts of the transaction have been
* committed while other parts have been rolled back.
* @throws HeuristicRollbackException If a heuristic decision to roll back the transaction was made.
*/
public void commit() throws HeuristicRollbackException, RollbackException, HeuristicMixedException, SystemException {
Transaction transaction = currentTransaction();
if (transaction == null) {
throw new IllegalStateException("No active transaction");
}
transaction.commit();
}
/**
* Rolls back the current transaction, if one exists.
*
* @throws IllegalStateException If the calling thread is not associated with a transaction.
* @throws SystemException If the transaction service fails in an unexpected way.
*/
public void rollback() throws SystemException {
Transaction transaction = currentTransaction();
if (transaction == null) {
throw new IllegalStateException("No active transaction");
}
transaction.rollback();
}
/**
* Notify the workspace of the supplied changes, if and when the current transaction is completed. If the current thread is
* not associated with a transaction when this method is called (e.g., the transaction was started, changes were made, the
* transaction was committed, and then this method was called), then the workspace is notified immediately. Otherwise, the
* notifications will be accumulated until the current transaction is committed.
*
* @param workspace the workspace to which the changes were made; may not be null
* @param changes the changes; may be null if there are no changes
* @param transaction the transaction with which the changes were made; may not be null
*/
public void updateCache( final WorkspaceCache workspace, final ChangeSet changes, Transaction transaction ) {
if (changes == null || changes.isEmpty()) {
return;
}
if (transaction instanceof SynchronizedTransaction) {
// only issue the changes when the transaction is successfully committed
transaction.uponCommit(() -> workspace.changed(changes));
if (workspace instanceof TransactionalWorkspaceCache) {
((TransactionalWorkspaceCache) workspace).changedWithinTransaction(changes);
}
} else if (transaction instanceof RollbackOnlyTransaction) {
// The transaction has been marked for rollback only, so no need to even capture these changes because
// no changes will ever escape the Session ...
} else {
// in all other cases we want to dispatch the changes immediately
workspace.changed(changes);
}
}
/**
* Suspends the existing transaction, if there is one.
*
* @return either the {@link javax.transaction.Transaction} which was suspended or {@code null} if there isn't such a
* transaction.
* @throws SystemException if the operation fails.
* @see javax.transaction.TransactionManager#suspend()
*/
public javax.transaction.Transaction suspend() throws SystemException {
return txnMgr.suspend();
}
/**
* Resumes a transaction that was previously suspended via the {@link org.modeshape.jcr.txn.Transactions#suspend()} call. If
* there is no such transaction or there is another active transaction, nothing happens.
*
* @param transaction a {@link javax.transaction.Transaction} instance which was suspended previously or {@code null}
* @throws javax.transaction.SystemException if the operation fails.
* @see javax.transaction.TransactionManager#resume(javax.transaction.Transaction)
*/
public void resume( javax.transaction.Transaction transaction ) throws SystemException {
if (transaction != null && txnMgr.getTransaction() == null) {
try {
txnMgr.resume(transaction);
} catch (InvalidTransactionException e) {
throw new RuntimeException(e);
}
}
}
/**
* The representation of a ModeShape fine-grained transaction for use when saving the changes made to a Session. Note that
* this transaction may wrap a newly-created transaction for the current thread, or it may wrap an existing and on-going
* transaction for the current thread. In either case, the caller still {@link #commit() commits} or {@link #rollback()
* rollsback} the transaction as normal when it's work is done.
*/
public interface Transaction {
/**
* Returns a unique identifier for the transaction.
*
* @return a String, never {@code null}
*/
String id();
/**
* Returns the status associated with the current transaction
*
* @return an {@code int} code representing a transaction status.
* @throws SystemException - If the transaction service fails in an unexpected way.
* @see javax.transaction.Status
*/
int status() throws SystemException;
/**
* Register a function that will be called when the current transaction completes. The function will be executed
* regardless whether the transaction was committed or rolled back and regardless if the commit or rollback call failed
* or not.
*
* @param function the completion function
*/
void uponCompletion( TransactionFunction function );
/**
* Register a function that will be called after the current transaction has been committed successfully, or immediately if there is not
* currently an active transaction. If the transaction is rolled back, this function will not be executed.
*
* @param function the completion function
*/
void uponCommit( TransactionFunction function );
/**
* Commit the transaction currently associated with the calling thread.
*
* @throws RollbackException If the transaction was marked for rollback only, the transaction is rolled back and this
* exception is thrown.
* @throws IllegalStateException If the calling thread is not associated with a transaction.
* @throws SystemException If the transaction service fails in an unexpected way.
* @throws HeuristicMixedException If a heuristic decision was made and some some parts of the transaction have been
* committed while other parts have been rolled back.
* @throws HeuristicRollbackException If a heuristic decision to roll back the transaction was made.
* @throws SecurityException If the caller is not allowed to commit this transaction.
*/
void commit()
throws RollbackException, HeuristicMixedException, HeuristicRollbackException, SecurityException,
IllegalStateException, SystemException;
/**
* Rolls back the transaction currently associated with the calling thread.
*
* @throws IllegalStateException If the transaction is in a state where it cannot be rolled back. This could be because
* the calling thread is not associated with a transaction, or because it is in the {@link javax.transaction.Status#STATUS_PREPARED
* prepared state}.
* @throws SecurityException If the caller is not allowed to roll back this transaction.
* @throws SystemException If the transaction service fails in an unexpected way.
*/
void rollback() throws IllegalStateException, SecurityException, SystemException;
}
/**
* A function that should be executed in relation to a transaction.
*/
public interface TransactionFunction {
void execute();
}
protected abstract class BaseTransaction implements Transaction {
protected final TransactionManager txnMgr;
protected final String id;
private final LinkedHashSet<TransactionFunction> uponCompletionFunctions = new LinkedHashSet<>();
private final LinkedHashSet<TransactionFunction> uponCommitFunctions = new LinkedHashSet<>();
protected BaseTransaction( TransactionManager txnMgr ) {
this.txnMgr = txnMgr;
this.id = UUID.randomUUID().toString();
}
protected BaseTransaction started() {
Transactions.this.listener.txStarted(id);
return this;
}
@Override
public void uponCompletion( TransactionFunction function ) {
uponCompletionFunctions.add(function);
}
@Override
public void uponCommit(TransactionFunction function) {
uponCommitFunctions.add(function);
}
@Override
public int status() throws SystemException {
return txnMgr.getStatus();
}
@Override
public String id() {
return id;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append("[");
sb.append("id='").append(id).append('\'');
sb.append(']');
return sb.toString();
}
protected void executeFunctionsUponCompletion() {
try {
uponCompletionFunctions.forEach(TransactionFunction::execute);
} finally {
uponCompletionFunctions.clear();
}
}
protected void executeFunctionsUponCommit() {
try {
uponCommitFunctions.forEach(TransactionFunction::execute);
} finally {
uponCommitFunctions.clear();
}
}
}
protected abstract class SimpleTransaction extends BaseTransaction {
protected SimpleTransaction( TransactionManager txnMgr ) {
super(txnMgr);
}
@Override
public void rollback() throws IllegalStateException, SecurityException, SystemException {
boolean canRollback = canRollback();
try {
if (canRollback) {
// rollback first
txnMgr.rollback();
}
} finally {
if (canRollback) {
// notify the listener always
listener.txRolledback(id);
// even if rollback fails, we want to execute the complete functions to try and leave the repository into a consistent state
// because a rollback was requested in the first place, meaning something went wrong during the UOW
executeFunctionsUponCompletion();
}
}
}
@Override
public void commit()
throws RollbackException, SecurityException,
IllegalStateException, SystemException {
try {
// commit first
txnMgr.commit();
// notify the listener
listener.txCommitted(id);
// run the ModeShape commit functions after we've notified the listener
executeFunctionsUponCommit();
} catch (RollbackException | HeuristicMixedException | HeuristicRollbackException e) {
listener.txRolledback(id);
throw new RollbackException(e.getMessage());
} catch (SystemException e) {
listener.txRolledback(id);
throw e;
} finally {
// even if commit fails, we want to execute the complete functions to try and leave the repository into a consistent state
executeFunctionsUponCompletion();
}
}
protected boolean canRollback() {
try {
switch (super.status()) {
case Status.STATUS_ACTIVE:
case Status.STATUS_COMMITTING:
case Status.STATUS_PREPARED:
case Status.STATUS_PREPARING:
case Status.STATUS_MARKED_ROLLBACK:
return true;
default: {
return false;
}
}
} catch (SystemException e) {
return false;
}
}
}
protected abstract class TraceableSimpleTransaction extends SimpleTransaction {
protected TraceableSimpleTransaction( TransactionManager txnMgr ) {
super(txnMgr);
}
@Override
public void rollback() throws IllegalStateException, SecurityException, SystemException {
if (logger.isTraceEnabled()) {
logger.trace("Rolling back transaction '{0}'", id);
}
super.rollback();
}
@Override
public void commit()
throws RollbackException, SecurityException,
IllegalStateException, SystemException {
if (logger.isTraceEnabled()) {
super.commit();
logger.trace("Committed transaction '{0}'", id);
} else {
super.commit();
}
}
}
protected class NestableThreadLocalTransaction extends TraceableSimpleTransaction {
private AtomicInteger nestedLevel = new AtomicInteger(0);
protected NestableThreadLocalTransaction( TransactionManager txnMgr ) {
super(txnMgr);
LOCAL_TRANSACTION.set(this);
}
@Override
public void rollback() throws IllegalStateException, SecurityException, SystemException {
try {
super.rollback();
} finally {
cleanup();
}
}
@Override
public void commit() throws RollbackException, SecurityException,
IllegalStateException, SystemException {
if (nestedLevel.getAndDecrement() == 1) {
try {
super.commit();
} finally {
cleanup();
}
} else {
logger.trace("Not committing transaction because it's nested within another transaction. Only the top level transaction should commit");
}
}
protected void cleanup() {
LOCAL_TRANSACTION.remove();
nestedLevel = null;
}
protected NestableThreadLocalTransaction begin() {
nestedLevel.incrementAndGet();
return this;
}
}
protected final class SynchronizedTransaction extends BaseTransaction implements Synchronization {
private final javax.transaction.Transaction transaction;
protected SynchronizedTransaction( TransactionManager txnMgr, javax.transaction.Transaction transaction ) {
super(txnMgr);
this.transaction = transaction;
}
@Override
public void commit() {
if (logger.isTraceEnabled()) {
logger.trace("'{0}' ignoring commit call coming from ModeShape.", id);
}
//nothing by default
}
@Override
public void rollback() {
if (logger.isTraceEnabled()) {
logger.trace("'{0}' ignoring rollback call coming from ModeShape.", id);
}
// nothing by default
}
@Override
public void beforeCompletion() {
// nothing before completion...
}
@Override
public void afterCompletion(int status) {
if (logger.isTraceEnabled()) {
logger.trace("Synchronization for '{0}' notified after completion with status '{1}'", id, status);
}
try {
if (Status.STATUS_COMMITTED == status) {
listener.txCommitted(id);
executeFunctionsUponCommit();
} else if (Status.STATUS_ROLLEDBACK == status){
listener.txRolledback(id);
}
} finally {
try {
executeFunctionsUponCompletion();
} finally {
transactionTable.remove(this.transaction);
}
}
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append("[local ").append("txId='")
.append(id).append("', original tx='")
.append(transaction).append("']");
return sb.toString();
}
}
protected class RollbackOnlyTransaction implements Transaction {
protected RollbackOnlyTransaction() {
}
@Override
public String id() {
return "";
}
@Override
public int status() {
return Status.STATUS_MARKED_ROLLBACK;
}
@Override
public void commit() {
// do nothing
}
@Override
public void rollback() {
// do nothing
}
@Override
public void uponCompletion(TransactionFunction function) {
// do nothing
}
@Override
public void uponCommit(TransactionFunction function) {
// do nothing
}
}
protected final class TransactionTracer implements Synchronization {
private String txnId;
protected TransactionTracer( String id ) {
txnId = id;
ACTIVE_TRACE_SYNCHRONIZATIONS.add(id);
}
@Override
public void beforeCompletion() {
// do nothing else ...
}
@Override
public void afterCompletion( int status ) {
ACTIVE_TRACE_SYNCHRONIZATIONS.remove(txnId);
switch (status) {
case Status.STATUS_COMMITTED:
logger.trace("Commit transaction '{0}'", txnId);
break;
case Status.STATUS_ROLLEDBACK:
logger.trace("Roll back transaction '{0}'", txnId);
break;
default:
// Don't do anything ...
break;
}
}
}
}