/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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.apache.isis.core.runtime.system.transaction; import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.isis.applib.services.command.Command; import org.apache.isis.applib.services.command.CommandContext; import org.apache.isis.applib.services.iactn.Interaction; import org.apache.isis.applib.services.iactn.InteractionContext; import org.apache.isis.core.commons.authentication.AuthenticationSession; import org.apache.isis.core.commons.components.SessionScopedComponent; import org.apache.isis.core.commons.exceptions.IsisException; import org.apache.isis.core.metamodel.services.ServicesInjector; import org.apache.isis.core.runtime.persistence.objectstore.transaction.PersistenceCommand; import org.apache.isis.core.runtime.system.persistence.PersistenceSession; import org.apache.isis.core.runtime.system.session.IsisSession; public class IsisTransactionManager implements SessionScopedComponent { private static final Logger LOG = LoggerFactory.getLogger(IsisTransactionManager.class); private int transactionLevel; private IsisSession session; /** * Holds the current or most recently completed transaction. */ private IsisTransaction currentTransaction; //region > constructor, fields private final PersistenceSession persistenceSession; private final AuthenticationSession authenticationSession; private final ServicesInjector servicesInjector; private final CommandContext commandContext; private final InteractionContext interactionContext; public IsisTransactionManager( final PersistenceSession persistenceSession, final AuthenticationSession authenticationSession, final ServicesInjector servicesInjector) { this.persistenceSession = persistenceSession; this.authenticationSession = authenticationSession; this.servicesInjector = servicesInjector; this.commandContext = this.servicesInjector.lookupServiceElseFail(CommandContext.class); this.interactionContext = this.servicesInjector.lookupServiceElseFail(InteractionContext.class); } public PersistenceSession getPersistenceSession() { return persistenceSession; } //endregion //region > open, close public void open() { assert session != null; } public void close() { if (getCurrentTransaction() != null) { try { abortTransaction(); } catch (final Exception e2) { LOG.error("failure during abort", e2); } } session = null; } //endregion //region > current transaction (if any) /** * The current transaction, if any. */ public IsisTransaction getCurrentTransaction() { return currentTransaction; } public int getTransactionLevel() { return transactionLevel; } //endregion //region > Transactional Execution /** * Run the supplied {@link Runnable block of code (closure)} in a * {@link IsisTransaction transaction}. * * <p> * If a transaction is in progress, then * uses that. Otherwise will {@link #startTransaction() start} a transaction * before running the block and {@link #endTransaction() commit} it at the * end. * </p> * * <p> * If the closure throws an exception, then will {@link #abortTransaction() abort} the transaction if was * started here, or will ensure that an already-in-progress transaction cannot commit. * </p> */ public void executeWithinTransaction(final TransactionalClosure closure) { executeWithinTransaction(null, closure); } public void executeWithinTransaction( final Command existingCommandIfAny, final TransactionalClosure closure) { final boolean initiallyInTransaction = inTransaction(); if (!initiallyInTransaction) { startTransaction(existingCommandIfAny); } try { closure.execute(); if (!initiallyInTransaction) { endTransaction(); } } catch (final RuntimeException ex) { if (!initiallyInTransaction) { try { abortTransaction(); } catch (final Exception e) { LOG.error("Abort failure after exception", e); throw new IsisTransactionManagerException("Abort failure: " + e.getMessage(), ex); } } else { // ensure that this xactn cannot be committed getCurrentTransaction().setAbortCause(new IsisException(ex)); } throw ex; } } /** * Run the supplied {@link Runnable block of code (closure)} in a * {@link IsisTransaction transaction}. * * <p> * If a transaction is in progress, then uses that. Otherwise will {@link #startTransaction() start} a transaction * before running the block and {@link #endTransaction() commit} it at the * end. * </p> * * <p> * If the closure throws an exception, then will {@link #abortTransaction() abort} the transaction if was * started here, or will ensure that an already-in-progress transaction cannot commit. * </p> */ public <Q> Q executeWithinTransaction(final TransactionalClosureWithReturn<Q> closure) { return executeWithinTransaction(null, closure); } public <Q> Q executeWithinTransaction( final Command existingCommandIfAny, final TransactionalClosureWithReturn<Q> closure) { final boolean initiallyInTransaction = inTransaction(); if (!initiallyInTransaction) { startTransaction(existingCommandIfAny); } try { final Q retVal = closure.execute(); if (!initiallyInTransaction) { endTransaction(); } return retVal; } catch (final RuntimeException ex) { if (!initiallyInTransaction) { abortTransaction(); } else { // ensure that this xactn cannot be committed (sets state to MUST_ABORT), and capture the cause so can be rendered appropriately by some higher level in the call stack getCurrentTransaction().setAbortCause(new IsisException(ex)); } throw ex; } } public boolean inTransaction() { return getCurrentTransaction() != null && !getCurrentTransaction().getState().isComplete(); } //endregion //region > startTransaction public void startTransaction() { startTransaction(null); } /** * @param existingCommandIfAny - specifically, a previously persisted background {@link Command}, now being executed by a background execution service. */ public void startTransaction(final Command existingCommandIfAny) { boolean noneInProgress = false; if (getCurrentTransaction() == null || getCurrentTransaction().getState().isComplete()) { noneInProgress = true; // previously we called __isis_startRequest here on all RequestScopedServices. This is now // done earlier, in PersistenceSession#open(). If we introduce support for @TransactionScoped // services, then this would be the place to initialize them. // allow the command to be overridden (if running as a background command with a parent command supplied) final Interaction interaction = interactionContext.getInteraction(); final Command command; if (existingCommandIfAny != null) { commandContext.setCommand(existingCommandIfAny); interaction.setTransactionId(existingCommandIfAny.getTransactionId()); } command = commandContext.getCommand(); final UUID transactionId = command.getTransactionId(); this.currentTransaction = new IsisTransaction(transactionId, interaction.next(Interaction.Sequence.TRANSACTION.id()), authenticationSession, servicesInjector); transactionLevel = 0; persistenceSession.startTransaction(); } transactionLevel++; if (LOG.isDebugEnabled()) { LOG.debug("startTransaction: level " + (transactionLevel - 1) + "->" + (transactionLevel) + (noneInProgress ? " (no transaction in progress or was previously completed; transaction created)" : "")); } } //endregion //region > flushTransaction public boolean flushTransaction() { if (LOG.isDebugEnabled()) { LOG.debug("flushTransaction"); } if (getCurrentTransaction() != null) { getCurrentTransaction().flush(); } return false; } //endregion //region > endTransaction, abortTransaction /** * Ends the transaction if nesting level is 0 (but will abort the transaction instead, * even if nesting level is not 0, if an {@link IsisTransaction#getAbortCause() abort cause} * has been {@link IsisTransaction#setAbortCause(IsisException) set}. * * <p> * If in the process of committing the transaction an exception is thrown, then this will * be handled and will abort the transaction instead. * * <p> * If an abort cause has been set (or an exception occurs), then will throw this * exception in turn. */ public void endTransaction() { final IsisTransaction transaction = getCurrentTransaction(); if (transaction == null) { // allow this method to be called >1 with no adverse affects if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: level {} (no transaction exists)", transactionLevel); } return; } else if (transaction.getState().isComplete()) { // allow this method to be called >1 with no adverse affects if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: level {} (previous transaction completed)", transactionLevel); } return; } else { if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: level {}->{}", transactionLevel, transactionLevel - 1); } } try { endTransactionInternal(); } finally { final IsisTransaction.State state = getCurrentTransaction().getState(); int transactionLevel = this.transactionLevel; if(transactionLevel == 0 && !state.isComplete()) { LOG.error("endTransaction: inconsistency detected between transactionLevel {} and transactionState '{}'", transactionLevel, state); } } } private void endTransactionInternal() { final IsisTransaction transaction = getCurrentTransaction(); // terminate the transaction early if an abort cause was already set. RuntimeException abortCause = this.getCurrentTransaction().getAbortCause(); if(transaction.getState().mustAbort()) { if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: aborting instead [EARLY TERMINATION], abort cause '{}' has been set", abortCause.getMessage()); } try { abortTransaction(); // just in case any different exception was raised... abortCause = this.getCurrentTransaction().getAbortCause(); } catch(RuntimeException ex) { // ... or, capture this most recent exception abortCause = ex; } if(abortCause != null) { // // previously (<1.15.0) we threw this exception. // however, this results in an attempt to forward onto the error page, even if the error has been // recognised at the UI layer. // // so instead we just return (mirroring DataNucleus' own logic on a failed attempt to abort, namely // to just ignore the attempt to abort and carry on.) // // (have also manually verified that if the exception is NOT recognized by the UI layer, then the // exception does propagate to the top and we redirect to the error page correctly). // return; } else { // assume that any rendering of the problem has been done lower down the stack. return; } } // we don't decrement the transactionLevel just yet, because an exception might end up being thrown // (meaning there would be more faffing around to ensure that the transactionLevel // and state of the currentTransaction remain in sync) int nextTransactionLevel = transactionLevel - 1; if ( nextTransactionLevel > 0) { transactionLevel --; } else if ( nextTransactionLevel == 0) { // // TODO: granted, this is some fairly byzantine coding. but I'm trying to account for different types // of object store implementations that could start throwing exceptions at any stage. // once the contract/API for the objectstore is better tied down, hopefully can simplify this... // if(abortCause == null) { if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: committing"); } try { getCurrentTransaction().preCommit(); } catch(Exception ex) { // just in case any new exception was raised... // this bizarre code because an InvocationTargetException (which is not a RuntimeException) was // being thrown due to a coding error in a domain object abortCause = ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex); getCurrentTransaction().setAbortCause(new IsisTransactionManagerException(ex)); } } if(abortCause == null) { try { persistenceSession.endTransaction(); } catch(Exception ex) { // just in case any new exception was raised... abortCause = ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex); // hacky... moving the transaction back to something other than COMMITTED getCurrentTransaction().setAbortCause(new IsisTransactionManagerException(ex)); } } // // ok, everything that could have thrown an exception is now done, // so it's safe to decrement the transaction level // transactionLevel = 0; // previously we called __isis_endRequest here on all RequestScopedServices. This is now // done later, in PersistenceSession#close(). If we introduce support for @TransactionScoped // services, then this would be the place to finalize them. // // finally, if an exception was thrown, we rollback the transaction // if(abortCause != null) { if (LOG.isDebugEnabled()) { LOG.debug("endTransaction: aborting instead, abort cause has been set"); } try { abortTransaction(); } catch(RuntimeException ex) { // ignore; nothing to do: // * we want the existing abortCause to be available // * the transactionLevel is correctly now at 0. } throw abortCause; } else { // keeping things in sync getCurrentTransaction().commit(); } } else { // transactionLevel < 0 LOG.error("endTransaction: transactionLevel={}", transactionLevel); transactionLevel = 0; IllegalStateException ex = new IllegalStateException(" no transaction running to end (transactionLevel < 0)"); getCurrentTransaction().setAbortCause(new IsisException(ex)); throw ex; } } public void abortTransaction() { if (getCurrentTransaction() != null) { getCurrentTransaction().markAsAborted(); transactionLevel = 0; persistenceSession.abortTransaction(); } } //endregion //region > addCommand public void addCommand(final PersistenceCommand command) { getCurrentTransaction().addCommand(command); } //endregion }