/*
* 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.List;
import java.util.UUID;
import com.google.common.collect.Lists;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.isis.applib.annotation.Programmatic;
import org.apache.isis.applib.services.HasTransactionId;
import org.apache.isis.applib.services.WithTransactionScope;
import org.apache.isis.applib.services.xactn.Transaction;
import org.apache.isis.core.commons.authentication.AuthenticationSession;
import org.apache.isis.core.commons.authentication.MessageBroker;
import org.apache.isis.core.commons.components.TransactionScopedComponent;
import org.apache.isis.core.commons.exceptions.IsisException;
import org.apache.isis.core.commons.util.ToString;
import org.apache.isis.core.metamodel.adapter.ObjectAdapter;
import org.apache.isis.core.metamodel.services.ServicesInjector;
import org.apache.isis.core.metamodel.services.publishing.PublishingServiceInternal;
import org.apache.isis.applib.services.xactn.TransactionState;
import org.apache.isis.core.runtime.persistence.objectstore.transaction.CreateObjectCommand;
import org.apache.isis.core.runtime.persistence.objectstore.transaction.DestroyObjectCommand;
import org.apache.isis.core.runtime.persistence.objectstore.transaction.PersistenceCommand;
import org.apache.isis.core.runtime.services.auditing.AuditingServiceInternal;
import org.apache.isis.core.runtime.services.persistsession.PersistenceSessionServiceInternalDefault;
/**
* Used by the {@link IsisTransactionManager} to captures a set of changes to be
* applied.
*
* <p>
* Note that methods such as <tt>flush()</tt>, <tt>commit()</tt> and
* <tt>abort()</tt> are not part of the API. The place to control transactions
* is through the {@link IsisTransactionManager transaction manager}, because
* some implementations may support nesting and such like. It is also the job of
* the {@link IsisTransactionManager} to ensure that the underlying persistence
* mechanism (for example, the <tt>ObjectStore</tt>) is also committed.
*/
public class IsisTransaction implements TransactionScopedComponent, Transaction {
public static class Placeholder {
public static Placeholder NEW = new Placeholder("[NEW]");
public static Placeholder DELETED = new Placeholder("[DELETED]");
private final String str;
public Placeholder(String str) {
this.str = str;
}
@Override
public String toString() {
return str;
}
}
public enum State {
/**
* Started, still in progress.
*
* <p>
* May {@link IsisTransaction#flush() flush},
* {@link IsisTransaction#commit() commit} or
* {@link IsisTransaction#markAsAborted() abort}.
*/
IN_PROGRESS(TransactionState.IN_PROGRESS),
/**
* Started, but has hit an exception.
*
* <p>
* May not {@link IsisTransaction#flush()} or
* {@link IsisTransaction#commit() commit} (will throw an
* {@link IllegalStateException}), but can only
* {@link IsisTransaction#markAsAborted() abort}.
*
* <p>
* Similar to <tt>setRollbackOnly</tt> in EJBs.
*/
MUST_ABORT(TransactionState.MUST_ABORT),
/**
* Completed, having successfully committed.
*
* <p>
* May not {@link IsisTransaction#flush()} or
* {@link IsisTransaction#markAsAborted() abort}.
* {@link IsisTransaction#commit() commit} (will throw
* {@link IllegalStateException}).
*/
COMMITTED(TransactionState.COMMITTED),
/**
* Completed, having aborted.
*
* <p>
* May not {@link IsisTransaction#flush()},
* {@link IsisTransaction#commit() commit} or
* {@link IsisTransaction#markAsAborted() abort} (will throw
* {@link IllegalStateException}).
*/
ABORTED(TransactionState.ABORTED);
public final TransactionState transactionState;
State(TransactionState transactionState){
this.transactionState = transactionState;
}
/**
* Whether it is valid to {@link IsisTransaction#commit() commit} this
* {@link IsisTransaction transaction}.
*/
public boolean canCommit() {
return this == IN_PROGRESS;
}
/**
* Whether it is valid to {@link IsisTransaction#markAsAborted() abort} this
* {@link IsisTransaction transaction}.
*/
public boolean canAbort() {
return this == IN_PROGRESS || this == MUST_ABORT;
}
/**
* Whether the {@link IsisTransaction transaction} is complete (and so a
* new one can be started).
*/
public boolean isComplete() {
return this == COMMITTED || this == ABORTED;
}
public boolean mustAbort() {
return this == MUST_ABORT;
}
public TransactionState getRuntimeContextState() {
return transactionState;
}
}
private static final Logger LOG = LoggerFactory.getLogger(IsisTransaction.class);
//region > constructor, fields
private final UUID interactionId;
private final int sequence;
private final AuthenticationSession authenticationSession;
private final List<PersistenceCommand> persistenceCommands = Lists.newArrayList();
private final IsisTransactionManager transactionManager;
private final MessageBroker messageBroker;
private final PublishingServiceInternal publishingServiceInternal;
private final AuditingServiceInternal auditingServiceInternal;
private final List<WithTransactionScope> withTransactionScopes;
private IsisException abortCause;
public IsisTransaction(
final UUID interactionId,
final int sequence,
final AuthenticationSession authenticationSession,
final ServicesInjector servicesInjector) {
this.interactionId = interactionId;
this.sequence = sequence;
this.authenticationSession = authenticationSession;
final PersistenceSessionServiceInternalDefault persistenceSessionService = servicesInjector
.lookupServiceElseFail(PersistenceSessionServiceInternalDefault.class);
this.transactionManager = persistenceSessionService.getTransactionManager();
this.messageBroker = authenticationSession.getMessageBroker();
this.publishingServiceInternal = servicesInjector.lookupServiceElseFail(PublishingServiceInternal.class);
this.auditingServiceInternal = servicesInjector.lookupServiceElseFail(AuditingServiceInternal.class);
withTransactionScopes = servicesInjector.lookupServices(WithTransactionScope.class);
this.state = State.IN_PROGRESS;
if (LOG.isDebugEnabled()) {
LOG.debug("new transaction " + this);
}
}
//endregion
//region > transactionId
@Programmatic
public final UUID getTransactionId() {
return interactionId;
}
/**
* Just throws an exception (the issue is that {@link HasTransactionId} really ought not to expose the setter).
*/
@Override
public void setTransactionId(UUID transactionId) {
throw new IllegalStateException("Transaction Id cannot be set on Transaction object");
}
@Override
public int getSequence() {
return sequence;
}
//endregion
//region > state
private State state;
public State getState() {
return state;
}
private void setState(final State state) {
this.state = state;
}
//endregion
//region > commands
/**
* Add the non-null command to the list of commands to execute at the end of
* the transaction.
*/
public void addCommand(final PersistenceCommand command) {
if (command == null) {
return;
}
final ObjectAdapter onObject = command.onAdapter();
// Destroys are ignored when preceded by a create, or another destroy
if (command instanceof DestroyObjectCommand) {
if (alreadyHasCreate(onObject)) {
removeCreate(onObject);
if (LOG.isDebugEnabled()) {
LOG.debug("ignored both create and destroy command " + command);
}
return;
}
if (alreadyHasDestroy(onObject)) {
if (LOG.isDebugEnabled()) {
LOG.debug("ignored command " + command + " as command already recorded");
}
return;
}
}
if (LOG.isDebugEnabled()) {
LOG.debug("add command " + command);
}
persistenceCommands.add(command);
}
private boolean alreadyHasCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
return getCommand(commandClass, onObject) != null;
}
private boolean alreadyHasCreate(final ObjectAdapter onObject) {
return alreadyHasCommand(CreateObjectCommand.class, onObject);
}
private boolean alreadyHasDestroy(final ObjectAdapter onObject) {
return alreadyHasCommand(DestroyObjectCommand.class, onObject);
}
private PersistenceCommand getCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
for (final PersistenceCommand command : persistenceCommands) {
if (command.onAdapter().equals(onObject)) {
if (commandClass.isAssignableFrom(command.getClass())) {
return command;
}
}
}
return null;
}
private void removeCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
final PersistenceCommand toDelete = getCommand(commandClass, onObject);
persistenceCommands.remove(toDelete);
}
private void removeCreate(final ObjectAdapter onObject) {
removeCommand(CreateObjectCommand.class, onObject);
}
//endregion
//region > flush
public final void flush() {
// have removed THIS guard because we hit a situation where a xactn is aborted
// from a no-arg action, the Wicket viewer attempts to render a new page that (of course)
// contains the service menu items, and some of the 'disableXxx()' methods of those
// service actions perform repository queries (while xactn is still in a state of ABORTED)
//
// ensureThatState(getState().canFlush(), is(true), "state is: " + getState());
//
if (LOG.isDebugEnabled()) {
LOG.debug("flush transaction " + this);
}
try {
doFlush();
} catch (final RuntimeException ex) {
setAbortCause(new IsisTransactionFlushException(ex));
throw ex;
}
}
/**
* <p>
* Called by both {@link #commit()} and by {@link #flush()}:
* <table>
* <tr>
* <th>called from</th>
* <th>next {@link #getState() state} if ok</th>
* <th>next {@link #getState() state} if exception</th>
* </tr>
* <tr>
* <td>{@link #commit()}</td>
* <td>{@link State#COMMITTED}</td>
* <td>{@link State#ABORTED}</td>
* </tr>
* <tr>
* <td>{@link #flush()}</td>
* <td>{@link State#IN_PROGRESS}</td>
* <td>{@link State#MUST_ABORT}</td>
* </tr>
* </table>
*/
private void doFlush() {
//
// it's possible that in executing these commands that more will be created.
// so we keep flushing until no more are available (ISIS-533)
//
// this is a do...while rather than a while... just for backward compatibilty
// with previous algorithm that always went through the execute phase at least once.
//
do {
// this algorithm ensures that we never execute the same command twice,
// and also allow new commands to be added to end
final List<PersistenceCommand> persistenceCommandList = Lists.newArrayList(persistenceCommands);
if(!persistenceCommandList.isEmpty()) {
// so won't be processed again if a flush is encountered subsequently
persistenceCommands.removeAll(persistenceCommandList);
try {
this.transactionManager.getPersistenceSession().execute(persistenceCommandList);
for (PersistenceCommand persistenceCommand : persistenceCommandList) {
if (persistenceCommand instanceof DestroyObjectCommand) {
final ObjectAdapter adapter = persistenceCommand.onAdapter();
adapter.setVersion(null);
}
}
} catch (final RuntimeException ex) {
// if there's an exception, we want to make sure that
// all commands are cleared and propagate
persistenceCommands.clear();
throw ex;
}
}
} while(!persistenceCommands.isEmpty());
}
//endregion
//region > preCommit, commit
void preCommit() {
assert getState().canCommit();
assert abortCause == null;
if (LOG.isDebugEnabled()) {
LOG.debug("preCommit transaction " + this);
}
if (getState() == State.COMMITTED) {
if (LOG.isInfoEnabled()) {
LOG.info("already committed; ignoring");
}
return;
}
try {
auditingServiceInternal.audit();
publishingServiceInternal.publishObjects();
doFlush();
} catch (final RuntimeException ex) {
setAbortCause(new IsisTransactionManagerException(ex));
throw ex;
} finally {
for (WithTransactionScope withTransactionScope : withTransactionScopes) {
withTransactionScope.resetForNextTransaction();
}
}
}
void commit() {
assert getState().canCommit();
assert abortCause == null;
if (LOG.isDebugEnabled()) {
LOG.debug("postCommit transaction " + this);
}
if (getState() == State.COMMITTED) {
if (LOG.isInfoEnabled()) {
LOG.info("already committed; ignoring");
}
return;
}
setState(State.COMMITTED);
}
//endregion
//region > abortCause, markAsAborted
/**
* internal API called by IsisTransactionManager only
*/
final void markAsAborted() {
assert getState().canAbort();
if (LOG.isInfoEnabled()) {
LOG.info("abort transaction " + this);
}
setState(State.ABORTED);
}
/**
* Indicate that the transaction must be aborted, and that there is
* an unhandled exception to be rendered somehow.
*
* <p>
* If the cause is subsequently rendered by code higher up the stack, then the
* cause can be {@link #clearAbortCause() cleared}. Note that this keeps the transaction in a state of
* {@link State#MUST_ABORT}.
*
* <p>
* If the cause is to be discarded completely (eg background command execution), then
* {@link #clearAbortCauseAndContinue()} can be used.
*/
public void setAbortCause(IsisException abortCause) {
setState(State.MUST_ABORT);
this.abortCause = abortCause;
}
public IsisException getAbortCause() {
return abortCause;
}
@Override
public void clearAbortCause() {
abortCause = null;
}
public void clearAbortCauseAndContinue() {
setState(State.IN_PROGRESS);
clearAbortCause();
}
//endregion
//region > toString
@Override
public String toString() {
return appendTo(new ToString(this)).toString();
}
private ToString appendTo(final ToString str) {
str.append("state", state);
str.append("commands", persistenceCommands.size());
return str;
}
//endregion
//region > getMessageBroker
/**
* The {@link org.apache.isis.core.commons.authentication.MessageBroker} for this transaction.
*
* <p>
* Injected in constructor
*
* @deprecated - obtain the {@link org.apache.isis.core.commons.authentication.MessageBroker} instead from the {@link AuthenticationSession}.
*/
public MessageBroker getMessageBroker() {
return messageBroker;
}
//endregion
}