/*
* Copyright 2013 Gordon Burgett and individual contributors
*
* 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.xflatdb.xflat.transaction;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.xflatdb.xflat.XFlatException;
import org.xflatdb.xflat.convert.ConversionException;
import org.xflatdb.xflat.convert.Converter;
import org.xflatdb.xflat.db.EngineBase;
import org.xflatdb.xflat.db.EngineTransactionManager;
import org.xflatdb.xflat.db.XFlatDatabase;
import org.xflatdb.xflat.util.DocumentFileWrapper;
/**
* A {@link TransactionManager} that uses the current thread as the context for transactions.
* Each transaction opened by this manager will be bound to the current thread, and
* the {@link #getTransaction() } method will return the transaction open on the current
* thread, if any.
* <p/>
* This is the default TransactionManager used by XFlat.
* @author Gordon
*/
public class ThreadContextTransactionManager extends EngineTransactionManager {
private Map<Long, AmbientThreadedTransactionScope> currentTransactions = new ConcurrentHashMap<>();
private Map<Long, AmbientThreadedTransactionScope> committedTransactions = new ConcurrentHashMap<>();
private DocumentFileWrapper journalWrapper;
private Document transactionJournal = null;
private Log log = LogFactory.getLog(getClass());
/**
* Creates a new ThreadContextTransactionManager, which will manage a mapping
* of threads to transactions.
* @param wrapper A wrapper which wraps the file to which this Transaction Manager
* can save its Transaction Journal, for recovery in case of catastrophic error.
*/
public ThreadContextTransactionManager(DocumentFileWrapper wrapper){
this.journalWrapper = wrapper;
}
/**
* Gets the Id of the current context, which is the current thread's ID.
* @return The current thread's ID.
*/
protected Long getContextId(){
return Thread.currentThread().getId();
}
@Override
public Transaction getTransaction() {
TransactionBase tx = currentTransactions.get(getContextId());
if(tx == null || tx.getOptions().getPropagation() == Propagation.NOT_SUPPORTED)
return null;
return tx.getTransaction();
}
@Override
public TransactionScope openTransaction() throws TransactionPropagationException {
return openTransaction(TransactionOptions.DEFAULT);
}
@Override
public TransactionScope openTransaction(TransactionOptions options) throws TransactionPropagationException {
AmbientThreadedTransactionScope ret;
long contextId = getContextId();
switch(options.getPropagation()){
case MANDATORY:
ret = currentTransactions.get(contextId);
if(ret == null || ret.getOptions().getPropagation() == Propagation.NOT_SUPPORTED){
throw new TransactionPropagationException("propagation MANDATORY, but no current transaction.");
}
//use the current transaction, with a wrapper to prevent
//this instance from closing it prematurely.
return new WrappingTransactionScope(ret, options.getReadOnly());
case NESTED:
throw new UnsupportedOperationException("Nested transactions not yet supported");
case NEVER:
ret = currentTransactions.get(contextId);
if(ret != null && ret.getOptions().getPropagation() != Propagation.NOT_SUPPORTED){
throw new TransactionPropagationException("propagation NEVER, but current transaction exists");
}
//return a shell object representing the non-transactional operation.
return new EmptyTransactionScope(options);
case NOT_SUPPORTED:
ret = currentTransactions.remove(contextId);
if(ret == null || ret.getOptions().getPropagation() == Propagation.NOT_SUPPORTED){
//we are already operating non-transactionally, just need
//to return a shell object.
return new EmptyTransactionScope(options);
}
//need to return a shell that will also replace the suspended
//transaction when it is closed.
return new NotSupportedTransaction(ret, options);
case REQUIRED:
ret = currentTransactions.get(contextId);
if(ret == null || ret.getOptions().getPropagation() == Propagation.NOT_SUPPORTED){
//no current transaction, create a new one
//suspending the NOT_SUPPORTED transaction if it exists.
ret = new AmbientThreadedTransactionScope(generateNewId(), ret, options);
currentTransactions.put(contextId, ret);
}
//use the current transaction, with a wrapper to prevent
//this instance from closing it prematurely.
return new WrappingTransactionScope(ret, options.getReadOnly());
case REQUIRES_NEW:
//create a new transaction, suspending the current one if it exists.
ret = currentTransactions.get(contextId);
ret = new AmbientThreadedTransactionScope(generateNewId(), ret, options);
currentTransactions.put(contextId, ret);
//use the current transaction, with a wrapper to prevent
//this instance from closing it prematurely.
return new WrappingTransactionScope(ret, options.getReadOnly());
case SUPPORTS:
ret = currentTransactions.get(contextId);
if(ret == null || ret.getOptions().getPropagation() == Propagation.NOT_SUPPORTED){
//we are already operating non-transactionally, just need
//to return a shell object.
return new EmptyTransactionScope(options);
}
//use the current transaction, with a wrapper to prevent
//this instance from closing it prematurely.
return new WrappingTransactionScope(ret, options.getReadOnly());
default:
throw new UnsupportedOperationException("Propagation behavior not supported: " + options.getPropagation().toString());
}
}
@Override
public long isTransactionCommitted(long transactionId) {
TransactionBase tx = committedTransactions.get(transactionId);
return tx == null ? -1 : tx.commitId;
}
@Override
public boolean isTransactionReverted(long transactionId) {
//if we find it in the current transactions, check the transaction
for(TransactionBase tx : currentTransactions.values()){
if(tx.id == transactionId){
return tx.isReverted();
}
}
//otherwise it might be in the committed transactions, if so it is not reverted.
if(committedTransactions.get(transactionId) != null){
return false;
}
//if we lost it then it's reverted.
return true;
}
@Override
public long transactionlessCommitId() {
return generateNewId();
}
@Override
public long getLowestOpenTransaction() {
long lowest = Long.MAX_VALUE;
for(TransactionBase tx : currentTransactions.values()){
if(tx.id < lowest){
lowest = tx.id;
}
}
return lowest;
}
@Override
public void bindEngineToCurrentTransaction(EngineBase engine) {
TransactionBase tx = currentTransactions.get(getContextId());
if(tx == null){
return;
}
//we can get away with just adding it in an unsynchronized context because
//this is never going to be called at the same time as unbind, since unbind
//always happens in the context of a commit or revert (which is the same thread as this)
//or another thread's cleanup after the transaction is closed.
tx.boundEngines.add(engine);
}
@Override
public synchronized void unbindEngineExceptFrom(EngineBase engine, Collection<Long> transactionIds) {
Iterator<AmbientThreadedTransactionScope> it = this.committedTransactions.values().iterator();
while(it.hasNext()){
TransactionBase tx = it.next();
if(transactionIds.contains(tx.id)){
continue;
}
//try to remove its binding
tx.boundEngines.remove(engine);
if(tx.boundEngines.isEmpty()){
//remove it from the committed transactions if it is empty.
it.remove();
}
}
}
@Override
public boolean anyOpenTransactions() {
return !this.currentTransactions.isEmpty();
}
private void loadJournal() throws IOException, JDOMException{
transactionJournal = journalWrapper.readFile();
if(transactionJournal == null){
transactionJournal = new Document();
transactionJournal.setRootElement(new Element("transactionJournal"));
}
}
private synchronized void commit(AmbientThreadedTransactionScope tx) throws TransactionException {
//journal the entry so we can recover if catastrophic failure occurs
TransactionJournalEntry entry = new TransactionJournalEntry();
entry.txId = tx.id;
entry.commitId = tx.commitId;
for(EngineBase e : tx.boundEngines){
entry.tableNames.add(e.getTableName());
}
Element entryElement = null;
if(tx.options.isDurable()){
//use the transaction journal to ensure durability
try {
if(transactionJournal == null){
loadJournal();
}
entryElement = toElement.convert(entry);
transactionJournal.getRootElement().addContent(entryElement);
journalWrapper.writeFile(transactionJournal);
} catch (ConversionException | IOException | JDOMException ex) {
throw new TransactionException("Unable to commit, could not access journal file " + journalWrapper, ex);
}
}
//commit all, and if any fail revert all.
try{
for(EngineBase e : tx.boundEngines){
if(log.isTraceEnabled())
log.trace(String.format("committing transaction %d to table %s", tx.id, e.getTableName()));
e.commit(tx.getTransaction(), tx.getOptions());
}
}
catch(Exception ex){
try{
//uncommit
tx.commitId = -1;
tx.revert();
}catch(XFlatException ex2){
throw new TransactionException("Unable to commit, and another error occured during revert: " + ex2.getMessage(), ex);
}
//we were able to revert all, no need to keep the transaction in the journal.
if(tx.options.isDurable()){
transactionJournal.getRootElement().removeContent(entryElement);
try {
journalWrapper.writeFile(transactionJournal);
} catch (IOException ioEx) {
//this is not the most important exception
}
}
if(ex instanceof TransactionException)
throw ex;
throw new TransactionException("Unable to commit: " + ex.getMessage(), ex);
}
//remove it from the transaction journal
if(tx.options.isDurable()){
transactionJournal.getRootElement().removeContent(entryElement);
try {
journalWrapper.writeFile(transactionJournal);
} catch (IOException ex) {
throw new TransactionException("Unable to commit, could not access journal file " + journalWrapper, ex);
}
}
//we're all committed, so we can finally say so.
committedTransactions.put(tx.id, tx);
}
private void revert(Iterable<EngineBase> boundEngines, long txId, boolean isRecovering) {
Set<String> failedReverts = null;
RuntimeException last = null;
for(EngineBase e : boundEngines){
try{
e.revert(txId, isRecovering);
}catch(RuntimeException ex){
LogFactory.getLog(getClass()).error(ex);
if(failedReverts == null)
failedReverts = new HashSet<>();
failedReverts.add(e.getTableName());
last = ex;
}
}
if(failedReverts != null && failedReverts.size() > 0){
StringBuilder msg = new StringBuilder("Unable to revert all bound engines, the data in the following engines may be corrupt: ");
for(String s : failedReverts){
msg.append(s).append(", ");
}
//the exceptions we caught were all runtime exceptions, so we are going to throw a runtime exception
throw new XFlatException(msg.toString(), last);
}
}
@Override
public void close() {
//all transactions auto-revert now.
this.currentTransactions.clear();
}
@Override
public void recover(XFlatDatabase db) {
//open the journal
try {
if(transactionJournal == null){
loadJournal();
}
} catch (IOException | JDOMException ex) {
throw new XFlatException("Unable to recover, could not access journal file " + journalWrapper, ex);
}
try{
Iterator<Element> children = transactionJournal.getRootElement().getChildren().iterator();
while(children.hasNext()){
TransactionJournalEntry entry;
try {
entry = fromElement.convert(children.next());
} catch (ConversionException ex) {
//entry is corrupt, remove and continue
children.remove();
continue;
}
List<EngineBase> toRevert = new ArrayList<>();
for(String table : entry.tableNames){
toRevert.add(db.getEngine(table));
}
//revert the transaction in all the engines
revert(toRevert, entry.txId, true);
//successful revert - remove the entry
children.remove();
//save the journal after each successful revert
this.journalWrapper.writeFile(transactionJournal);
}
}catch(XFlatException | IOException ex){
throw new XFlatException("Unable to recover", ex);
}
}
@Override
public boolean isCommitInProgress(long transactionId) {
TransactionBase tx = this.currentTransactions.get(transactionId);
if(tx == null)
return false;
//in-progress if the commit ID was assigned and the tx was not yet committed.
return tx.commitId != -1 && !tx.isCommitted();
}
/**
* The base class for the different types of transactions handled by this
* transaction manager. The different implementations are dependent on
* the propagation level used when the transaction was opened.
*/
protected abstract class TransactionBase implements TransactionScope {
protected TransactionOptions options;
protected AtomicBoolean isCompleted = new AtomicBoolean(false);
protected AtomicBoolean isRollbackOnly = new AtomicBoolean(false);
protected volatile boolean isClosed = false;
protected final long id;
protected AmbientThreadedTransactionScope suspended;
//we can get away with this being an unsynchronized HashSet because it will only ever be added to by one
//thread, and then only so long as the transaction is open, and then will be removed from
//by a different thread, but only one at a time, synchronized elsewhere, and after all adds are finished.
final Set<EngineBase> boundEngines = new HashSet<>();
protected AtomicReference<Set<TransactionListener>> listeners = new AtomicReference<>(null);
protected long commitId = -1;
//The transaction representing this transaction scope.
private final Transaction transaction = new Transaction(){
@Override
public long getTransactionId() {
return id;
}
@Override
public long getCommitId() {
return commitId;
}
@Override
public boolean isCommitted() {
return TransactionBase.this.isCommitted();
}
@Override
public boolean isReverted() {
return TransactionBase.this.isReverted();
}
@Override
public boolean isReadOnly() {
return options.getReadOnly();
}
};
public Transaction getTransaction(){
return transaction;
}
protected TransactionBase(long id, AmbientThreadedTransactionScope suspended, TransactionOptions options){
this.id = id;
this.suspended = suspended;
this.options = options;
if(this.options.getReadOnly()){
this.isRollbackOnly.set(true);
}
}
protected void fireEvent(int event){
Set<TransactionListener> listeners = this.listeners.get();
if(listeners == null)
return;
TransactionEventObject evtObj = new TransactionEventObject(ThreadContextTransactionManager.this, this.transaction, event);
synchronized(listeners){
for(Object l : listeners.toArray()){
((TransactionListener)l).TransactionEvent(evtObj);
}
}
}
@Override
public boolean isCommitted() {
return this.isCompleted.get() && commitId > -1;
}
@Override
public boolean isReverted() {
return isCompleted.get() && commitId == -1;
}
@Override
public void putTransactionListener(TransactionListener listener) {
Set<TransactionListener> l = this.listeners.get();
if(l == null){
l = new HashSet<>();
if(!this.listeners.compareAndSet(null, l)){
l = this.listeners.get();
}
}
synchronized(l){
l.add(listener);
}
}
@Override
public void removeTransactionListener(TransactionListener listener) {
Set<TransactionListener> l = this.listeners.get();
if(l == null){
return;
}
synchronized(l){
l.remove(listener);
}
}
@Override
public void setRevertOnly() {
this.isRollbackOnly.set(true);
}
@Override
public TransactionOptions getOptions() {
return this.options;
}
@Override
public void close() {
if(suspended != null && !suspended.isClosed){
//need to put back the suspended transaction
ThreadContextTransactionManager.this.currentTransactions.put(getContextId(), suspended);
suspended = null;
}
this.isClosed = true;
}
}
/**
* A Transaction that is meant to exist within the context of one thread.
* There should be no cross-thread transactional data access, only cross-thread
* state querying.
*/
protected class AmbientThreadedTransactionScope extends TransactionBase {
private List<WrappingTransactionScope> uncommittedScopes = new LinkedList<>();
private List<WrappingTransactionScope> wrappingScopes = new LinkedList<>();
protected AmbientThreadedTransactionScope(long id, AmbientThreadedTransactionScope suspended, TransactionOptions options){
super(id, suspended, options);
}
@Override
public void commit() throws TransactionException {
throw new UnsupportedOperationException("should not be called directly");
}
private void doCommit() throws TransactionException {
if(this.isRollbackOnly.get()){
throw new IllegalTransactionStateException("Cannot commit a rollback-only transaction");
}
if(this.isCompleted.get()){
throw new IllegalTransactionStateException("Cannot commit a completed transaction");
}
commitId = generateNewId();
ThreadContextTransactionManager.this.commit(this);
//soon as commit returns, we are committed.
this.isCompleted.set(true);
fireEvent(TransactionEventObject.COMMITTED);
}
void completeWrappingScope(WrappingTransactionScope scope) throws TransactionException {
if(uncommittedScopes.remove(scope) && uncommittedScopes.isEmpty()){
//all wrapping transaction scopes have completed, we can commit
doCommit();
}
//otherwise we do nothing, simply mark the wrapping scope as completed by removing it from the list.
}
void addWrappingScope(WrappingTransactionScope scope){
synchronized(this){
//add them at the beginning because the most recent ones
//are most likely to close first.
if(!scope.getOptions().getReadOnly()){
//only add it to uncommitted scopes if it can actually write.
//ReadOnly scopes can close without commit, but an explicit revert
//will still revert the entire ambient scope.
uncommittedScopes.add(0, scope);
}
wrappingScopes.add(0, scope);
}
}
@Override
public void revert() {
if(!isCompleted.compareAndSet(false, true)){
throw new IllegalTransactionStateException("Cannot rollback a completed transaction");
}
if(!this.options.getReadOnly()){
doRevert();
}
fireEvent(TransactionEventObject.REVERTED);
}
protected void doRevert() {
ThreadContextTransactionManager.this.revert(this.boundEngines, this.id, false);
}
void closeWrappingScope(WrappingTransactionScope scope){
synchronized(this){
if(uncommittedScopes.remove(scope) && !this.isCompleted.get()){
//the scope was uncommitted, need to revert the transaction
revert();
}
if(wrappingScopes.remove(scope) && wrappingScopes.isEmpty()){
//all wrapping transaction scopes have closed, we can close.
close();
}
//otherwise, can't close yet, still have some open scopes.
}
}
@Override
public void close(){
if(isCompleted.compareAndSet(false, true)){
//we completed in the close, need to revert.
doRevert();
}
//remove the transaction scope from the current transactions map
Iterator<AmbientThreadedTransactionScope> it = currentTransactions.values().iterator();
while(it.hasNext()){
//Object equality because we don't know which
if(it.next() == this){
it.remove();
break;
}
}
super.close();
}
}
/**
* A transaction that implements the {@link Propagation#NOT_SUPPORTED} behavior,
* maintaining a reference to the suspended transaction so that it can be
* replaced when this is closed.
*/
protected class NotSupportedTransaction extends TransactionBase {
public NotSupportedTransaction(AmbientThreadedTransactionScope suspended, TransactionOptions options){
super(-1, suspended, options);
}
@Override
public void commit() throws TransactionException {
throw new IllegalTransactionStateException("Cannot commit a transaction opened with propagation " +
"NEVER or NOT_SUPPORTED");
}
@Override
public void revert() {
throw new IllegalTransactionStateException("Cannot revert a transaction opened with propagation " +
"NEVER or NOT_SUPPORTED");
}
}
/**
* A transaction object that represents no open transaction. This is
* created by opening a transaction with the {@link Propagation#NEVER} or
* with {@link Propagation#NOT_SUPPORTED} when the
*/
protected class EmptyTransactionScope implements TransactionScope{
private TransactionOptions options;
private volatile boolean isCommitted = false;
private volatile boolean isReverted = false;
private volatile boolean isClosed = false;
public EmptyTransactionScope(TransactionOptions options){
this.options = options;
}
@Override
public void commit() throws TransactionException {
throw new IllegalTransactionStateException("Cannot commit a transaction opened with propagation " +
"NEVER or NOT_SUPPORTED");
}
@Override
public void revert() {
throw new IllegalTransactionStateException("Cannot revert a transaction opened with propagation " +
"NEVER or NOT_SUPPORTED");
}
@Override
public void setRevertOnly() {
}
@Override
public boolean isCommitted() {
return isCommitted;
}
@Override
public boolean isReverted() {
return isReverted;
}
@Override
public TransactionOptions getOptions() {
return options;
}
@Override
public void close() {
//nothing to do
isClosed = true;
}
@Override
public void putTransactionListener(TransactionListener listener) {
}
@Override
public void removeTransactionListener(TransactionListener listener) {
}
}
/**
* A TransactionScope object that provides a view onto the ambient scope.
* There may be multiple wrapping transaction scopes all pointing to the
* same ambient transaction scope. When ALL of these is committed, the
* underlying ambient transaction is committed.
*/
protected class WrappingTransactionScope implements TransactionScope {
private AmbientThreadedTransactionScope wrapped;
private TransactionOptions options;
protected WrappingTransactionScope(AmbientThreadedTransactionScope wrapped, boolean isReadOnly){
this.wrapped = wrapped;
this.options = wrapped.getOptions().withReadOnly(isReadOnly);
wrapped.addWrappingScope(this);
}
@Override
public void commit() throws TransactionException {
wrapped.completeWrappingScope(this);
}
@Override
public void revert() {
wrapped.revert();
}
@Override
public void setRevertOnly() {
wrapped.setRevertOnly();
}
@Override
public boolean isCommitted() {
return wrapped.isCommitted();
}
@Override
public boolean isReverted() {
return wrapped.isReverted();
}
@Override
public TransactionOptions getOptions() {
return this.options;
}
@Override
public void close() {
wrapped.closeWrappingScope(this);
}
@Override
public void putTransactionListener(TransactionListener listener) {
wrapped.putTransactionListener(listener);
}
@Override
public void removeTransactionListener(TransactionListener listener) {
wrapped.removeTransactionListener(listener);
}
}
//<editor-fold desc="transaction journal">
private class TransactionJournalEntry{
public long txId;
public long commitId;
public Set<String> tableNames = new HashSet<>();
}
private Converter<TransactionJournalEntry, Element> toElement = new Converter<TransactionJournalEntry, Element>(){
@Override
public Element convert(TransactionJournalEntry source) throws ConversionException {
Element ret = new Element("entry");
ret.setAttribute("txId", Long.toString(source.txId));
ret.setAttribute("commit", Long.toString(source.commitId));
for(String s : source.tableNames){
ret.addContent(new Element("table").setText(s));
}
return ret;
}
};
private Converter<Element, TransactionJournalEntry> fromElement = new Converter<Element, TransactionJournalEntry>(){
@Override
public TransactionJournalEntry convert(Element source) throws ConversionException {
TransactionJournalEntry ret = new TransactionJournalEntry();
try{
String txId = source.getAttributeValue("txId");
if(txId == null){
throw new ConversionException("txId attribute required");
}
ret.txId = Long.parseLong(txId);
String commitId = source.getAttributeValue("commit");
if(commitId != null){
ret.commitId = Long.parseLong(commitId);
}
for(Element e : source.getChildren("table")){
ret.tableNames.add(e.getText());
}
}
catch(NumberFormatException ex){
throw new ConversionException("Conversion failure", ex);
}
return ret;
}
};
//</editor-fold>
}