Friday, February 18, 2011

Transaction Management in .NET

This article discusses two topics: transaction management in a Windows application and transaction management in Web Services. They do not have big difference except that a Windows application can both initiate and participate in a transaction, while a web service can only initiate a transaction.

Transaction Management in a Windows Application

¨    How is a transaction managed

Distributed transaction is achieved by one transaction coordinator and one or several resource managers. Transaction coordinator is provided by COM+ (and therefore is on application side), and a resource managers are on database side, which is responsible for commit and rollback (sophistigated database systems such as SQL server and Oracle can commit and roll back, while simpler ones such as Access and dBASE can not).
COM+ Component Services provides a transaction coordinator which can create and manage transactions. A transaction may involve several resource managers from different database systems. The transaction coordinator and its resource managers can all be distributed across network. The transaction coordinator waits until all resource managers say “Everything is fine and I am ready to commit”, then it notifies all of them to commit. If any of them replies failure, it will notify all the rest to roll back.
If a transaction is interrupted by a failure, both the transaction coordinator and the resource managers should be able to recover from it. A recovered transaction coordinator should remember which operation is still pending and contact its resource managers to resolve it – either to ask the uncommitted to commit or the rest to roll back. A recovered database resource manager should be able to remember the uncommited operation, and contact its transaction coordinator to resolve it – either to commit if the coordinator is still waiting, or to roll back if the coordinator has lost patience and informed all other resource managers to roll back.

¨    Transaction management in .NET

.NET dose not yet include its own transaction coordinator. Instead it provides a wrapper for the COM+ Component Services. To enable transaction, the following conditions must be fulfilled:
1.    The class which starts the transaction must inherit from System.EnterpriseServices.ServicedComponent.
2.    Use TransactionAttribute in front of the class which initiates the transaction to set the transaction type:
    [Transaction(TransactionOption.Required)]
3.    The assembly which contains this ServicedComponent must be strongly named (and therefore all assemblies that are invoked by this assembly should be strongly named). See section Create an Assembly with Strong Name for details. This is a requirement of the run time not the compiler.
How commit and rollback message are sent by the COM+ transaction coordinator to the database resource manager is hidden from the programmer, but the programmer decides whether to commit or rollback.
You have two ways to make the commit decision:
1.    Manual: call static method SetComplete or SetAbort of class System.EnterpriseServices.ContextUtil to vote commit or rollback. The thread of a transaction may go through several assemblies, only the class which initiates the transaction needs to inherit from ServicedComponent, and later votes overwrites previous votes. For example, in a transaction, assembly A calls assembly B to do one database manipulation then calls assembly C to do another, and B votes SetAbort and C votes SetComplete, the transaction will commit.
2.    Automatic: if the method which initiates the transaction completes without unhandled exception, then the transaction is deemed successful and all resource managers are told to commit. To do this, put [AutoComplete] attrubute before this method. With AutoComplete attribute, the manual way also works.
Considering the above, it seems to me that the more natural and simple way to decide commit or rollback is to use exceptions. Handle exceptions in branch methods that you can handle and does not affect the ACID principle of the transaction, and let worse exceptions pop up to the transaction root method. Then you can either use automatic commit – let the exception pop up further to abort the transaction, or use manual commit – catch the exception and call SetAbort in the catch block.
This branch method that performs one of a set of database manipulations can be in another local assembly or a service across the network exposed by .NET Remoting, but can not be a web service, because a web service can only start its own transaction, but can not participate in an existing transaction. For how a web service starts its own transaction, see the “Transaction with Web Service” section of my Web Services tutorial.

¨    Example

The following example transfer money between a saving and a check account, which are stored in different databases. It contains four projects:
1.    Saving: withdraw money from or deposit money to saving account;
2.    Check: withdraw money from or deposit money to check account;
3.    Client: contains a windows form and a Transaction class to do the transaction.
The form looks like:

The code below is to show that later SetAbort/SetComplete votes overwrites earlier ones:

//******************************************* Saving DLL *********************************************
using System;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace NsSaving
{
    public class Saving
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               ContextUtil.SetAbort();
            else
               ContextUtil.SetComplete();

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("UPDATE Saving SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();

        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM Saving WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}

//******************************************* Check DLL *********************************************
using System;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace NsCheck
{
    public class Check
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               ContextUtil.SetAbort();
            else
               ContextUtil.SetComplete();

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("UPDATE [Check] SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();
        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM [Check] WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}

//******************************************* Client DLL *********************************************

using System;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace Client
{
    [Transaction(TransactionOption.RequiresNew)]
    public class Transaction : System.EnterpriseServices.ServicedComponent
    {
        public void Transact(float fltSign, int iAccId, float fltAmount, bool fAbortSaving, bool fAbortCheck)
        {
            try
            {
               NsSaving.Saving saving = new NsSaving.Saving();
               saving.Transact(iAccId, -fltSign * fltAmount, fAbortSaving);

               NsCheck.Check check = new NsCheck.Check();
               check.Transact(iAccId, fltSign * fltAmount, fAbortCheck);
            }
            catch (Exception ex)
            {
               ContextUtil.SetAbort();
               throw ex;
            }
        }
    }

}

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Data;

namespace Client
{
    public class Form1 : System.Windows.Forms.Form
    {
        // generated code ....

        private void btnTransfer_Click(object sender, System.EventArgs e)
        {
            float fltSign = -1.0f;

            if (rdbtnSavingToCheck.Checked)
               fltSign = 1.0f;

            int iAccId = Convert.ToInt32(tbAccId.Text);
            float fltAmount = Convert.ToSingle(tbAmount.Text);
            bool fAbortSaving = cbAbortSaving.Checked;
            bool fAbortCheck = cbAbortCheck.Checked;

            try
            {
               Transaction transaction = new Transaction();
               transaction.Transact(fltSign, iAccId, fltAmount, fAbortSaving, fAbortCheck);
            }
            catch (Exception ex)
            {
               MessageBox.Show(ex.ToString());
            }

            ShowBalances(iAccId);
        }

        private void ShowBalances(int iAccId)
        {
            try {
               NsSaving.Saving saving = new NsSaving.Saving();
               float fltSaving = saving.GetBalance(iAccId);

               NsCheck.Check check = new NsCheck.Check();
               float fltCheck = check.GetBalance(iAccId);

               tbSaving.Text = Convert.ToString(fltSaving);
               tbCheck.Text = Convert.ToString(fltCheck);
            }
            catch (Exception ex)
            {
               MessageBox.Show(ex.ToString());
            }
        }

        private void Form1_Load(object sender, System.EventArgs e)
        {
            ShowBalances(1);
        }
    }
}
If you tick the “Abort saving” checkbox but not the “Abort check”, because SetAbort in Saving happens first and SetComplete in Check happens later, the transaction will commit.
A better way is to use exception in all branch methods:

//******************************************* Saving DLL *********************************************
using System;
using System.Data.OleDb;

namespace NsSaving
{
    public class Saving
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               throw new Exception("MANNUALLY CAUSED ERROR!");

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("UPDATE Saving SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();

        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM Saving WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}

//******************************************* Check DLL *********************************************
using System;
using System.Data.OleDb;

namespace NsCheck
{
    public class Check
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               throw new Exception("MANNUALLY CAUSED ERROR!");

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("UPDATE [Check] SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();
        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=FLIU2000\\NetSDK");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM [Check] WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}
In the transaction root method, you can either use manual or automatic commit. To use manual:

    [Transaction(TransactionOption.RequiresNew)]
    public class Transaction : System.EnterpriseServices.ServicedComponent
    {
        public void Transact(float fltSign, int iAccId, float fltAmount, bool fAbortSaving, bool fAbortCheck)
        {
            try
            {
               NsSaving.Saving saving = new NsSaving.Saving();
               saving.Transact(iAccId, -fltSign * fltAmount, fAbortSaving);

               NsCheck.Check check = new NsCheck.Check();
               check.Transact(iAccId, fltSign * fltAmount, fAbortCheck);
              
               ContextUtil.SetComplete();
            }
            catch (Exception ex)
            {
               ContextUtil.SetAbort();
               throw ex;
            }
        }
    }

To use automatic commit:

    [Transaction(TransactionOption.RequiresNew)]
    public class Transaction : System.EnterpriseServices.ServicedComponent
    {
        [AutoComplete]
        public void Transact(float fltSign, int iAccId, float fltAmount, bool fAbortSaving, bool fAbortCheck)
        {
            try
            {
               NsSaving.Saving saving = new NsSaving.Saving();
               saving.Transact(iAccId, -fltSign * fltAmount, fAbortSaving);

               NsCheck.Check check = new NsCheck.Check();
               check.Transact(iAccId, fltSign * fltAmount, fAbortCheck);
            }
            catch (Exception ex)
            {
               throw ex;
            }
        }
    }

Transaction Management in a Web Service

Unlike the transaction management scenario in non-web-service applications, where a class has to inherit from System.EnterpriseServices.ServicedComponent and put [Transaction(TransactionOption.RequiresNew)] attribute in front of the class definition to be able to start a transaction, a web service inherently supports transaction. All you need to do is to set the TransactionOption property of the WebMethodAttribute to TransactionOption.RequiresNew. A web method is by default “AutoComplete”, which means that if the method completes naturally the transaction commits. If unhandled exception pops up the transaction rolls back.
An important point to remember about transaction with web service is: a web service can start its own transaction, but it does not participate in existing transaction. Suppose a method starts a transaction, calls a web service to do one of the database manipulations, and after the web service successfully returns, the next manipulation fails. The transaction is aborted, but the database manipulation done by the web service is not going to be rolled back. This is because of the stateless nature of web services: you can not ask it to do something and later go back asking it to undo. It forgets what it has done before.

¨    Example of a web service starting a transaction:

using System.Diagnostics;
using System.Web;
using System.Web.Services;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace BankTransacService
{
    public class TransacService : System.Web.Services.WebService
    {
        public TransacService()
        {
            //CODEGEN: This call is required by the ASP.NET Web Services Designer
            InitializeComponent();
        }

        [WebMethod(TransactionOption = TransactionOption.RequiresNew)]
        public void Transfer(bool fSavingToCheck, int iAccountId, float fltAmount, bool fCauseError)
        {
            string strFromDbName = null;
            string strFromTableName = null;
            string strToDbName = null;
            string strToTableName = null;

            if (fSavingToCheck)
            {
               strFromDbName = "TempDb1";
               strFromTableName = "Saving";
               strToDbName = "TempDb2";
               strToTableName = "[Check]";
            }
            else
            {
               strFromDbName = "TempDb2";
               strFromTableName = "[Check]";
               strToDbName = "TempDb1";
               strToTableName = "Saving";
            }

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=" + strFromDbName + ";Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("UPDATE " + strFromTableName + " SET Balance = Balance - " + fltAmount +
               " WHERE AccountId = " + iAccountId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();

            if (fCauseError)
               throw new Exception("MANUALLY CAUSED ERROR!");

            cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=" +
               strToDbName + ";Data Source=sealand");
            cmd = new OleDbCommand("UPDATE " + strToTableName + " SET Balance = Balance + " + fltAmount + " WHERE AccountId = " +
               iAccountId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();
        }

        [WebMethod]
        public void GetBalances(int iAccountId, ref float fltBalanceSaving, ref float fltBalanceCheck)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM Saving WHERE AccountId = " + iAccountId, cn);
            cn.Open();
            fltBalanceSaving = (float)cmd.ExecuteScalar();
            cn.Close();

            cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=sealand");
            cmd = new OleDbCommand("SELECT Balance FROM [Check] WHERE AccountId = " + iAccountId, cn);
            cn.Open();
            fltBalanceCheck = (float)cmd.ExecuteScalar();
            cn.Close();
        }
    }
}
The form client that invokes the web service is shown below:


        private void btnTransfer_Click(object sender, System.EventArgs e)
        {
            bool fSavingToCheck = rdbtnSavingToCheck.Checked;
            int iAccId = Convert.ToInt32(tbAccId.Text);
            float fltAmount = Convert.ToSingle(tbAmount.Text);
            bool fCauseError = cbCauseError.Checked;

            try
            {
               mService.Transfer(fSavingToCheck, iAccId, fltAmount, fCauseError);
            }
            catch (Exception ex)
            {
               MessageBox.Show(ex.ToString());
            }

            ShowBalances(iAccId);
        }

        private void ShowBalances(int iAccId)
        {
            float fltSaving = 0, fltCheck = 0;
            mService.GetBalances(iAccId, ref fltSaving, ref fltCheck);
            tbSaving.Text = Convert.ToString(fltSaving);
            tbCheck.Text = Convert.ToString(fltCheck);
        }

        private void Form1_Load(object sender, System.EventArgs e)
        {
            ShowBalances(1);
        }
    }
When error happens, the transaction is successfully rolled back.

¨    Example of web services participating in transactions

The solution contains two web services, one wraps the Check class, one wraps the Saving class.  The client form is exactly the same as previous example. Its Transaction class starts a transaction, then invoke the two web services to do the withdraw and deposite.

// ************************************* Saving class **********************************************
using System;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace NsTransactionClasses
{
    public class Saving
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               throw new Exception("MANNUALLY CAUSED ERROR!");

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("UPDATE Saving SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();
        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb1;Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM Saving WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}

// ************************************* Check class **********************************************
using System;
using System.Data.OleDb;
using System.EnterpriseServices;

namespace NsTransactionClasses
{
    public class Check
    {
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            if (fCauseError)
               throw new Exception("MANNUALLY CAUSED ERROR!");

            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("UPDATE [Check] SET Balance = Balance + " + fltAmount + " WHERE AccountId = " + iAccId, cn);
            cn.Open();
            cmd.ExecuteNonQuery();
            cn.Close();
        }

        public float GetBalance(int iAccId)
        {
            OleDbConnection cn = new OleDbConnection("Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" +
               "Initial Catalog=TempDb2;Data Source=sealand");
            OleDbCommand cmd = new OleDbCommand("SELECT Balance FROM [Check] WHERE AccountId = " + iAccId, cn);
            cn.Open();
            float fltBalance = (float)cmd.ExecuteScalar();
            cn.Close();
            return fltBalance;
        }
    }
}

// ************************************* Web Service for Saving **********************************************
using System;
using System.Collections;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Web;
using System.Web.Services;
using System.EnterpriseServices;

namespace WebServiceSaving
{
        [WebMethod(TransactionOption = TransactionOption.Required)]
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            NsTransactionClasses.Saving saving = new NsTransactionClasses.Saving();
            saving.Transact(iAccId, fltAmount, fCauseError);
        }

        [WebMethod]
        public float GetBalance(int iAccId)
        {
            NsTransactionClasses.Saving saving = new NsTransactionClasses.Saving();
            return saving.GetBalance(iAccId);
        }
    }
}

// ************************************* Web Service for Check **********************************************
using System;
using System.Collections;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Web;
using System.Web.Services;
using System.EnterpriseServices;

namespace WebServiceCheck
{
        [WebMethod(TransactionOption = TransactionOption.Required)]
        public void Transact(int iAccId, float fltAmount, bool fCauseError)
        {
            NsTransactionClasses.Check check = new NsTransactionClasses.Check();
            check.Transact(iAccId, fltAmount, fCauseError);
        }

        [WebMethod]
        public float GetBalance(int iAccId)
        {
            NsTransactionClasses.Check check = new NsTransactionClasses.Check();
            return check.GetBalance(iAccId);
        }
    }
}

// *********************************** Transaction class – client side *****************************************
using System;
using System.EnterpriseServices;

namespace Client
{
    [Transaction(TransactionOption.RequiresNew)]
    public class TransactionWebServices : System.EnterpriseServices.ServicedComponent
    {
        private ClientWebService.BankSavingService.Service1 mSaving = new ClientWebService.BankSavingService.Service1();
        private ClientWebService.BankCheckService.Service1 mCheck = new ClientWebService.BankCheckService.Service1();

        [AutoComplete]
        public void Transact(float fltSign, int iAccId, float fltAmount, bool fCauseError)
        {
            mSaving.Transact(iAccId, -fltSign * fltAmount, false);
            mCheck.Transact(iAccId, fltSign * fltAmount, fCauseError);
        }

        public void GetBalance(int iAccId, ref float fltSaving, ref float fltCheck)
        {
            fltSaving = mSaving.GetBalance(iAccId);
            fltCheck = mCheck.GetBalance(iAccId);
        }
    }
}

// ************************************* Client form **********************************************
        private TransactionWebServices mTransaction = new TransactionWebServices();

        private void btnTransfer_Click(object sender, System.EventArgs e)
        {
            float fltSign = -1.0f;

            if (rdbtnSavingToCheck.Checked)
               fltSign = 1.0f;

            int iAccId = Convert.ToInt32(tbAccId.Text);
            float fltAmount = Convert.ToSingle(tbAmount.Text);
            bool fCauseError = cbCauseError.Checked;

            try
            {
               mTransaction.Transact(fltSign, iAccId, fltAmount, fCauseError);
            }
            catch (Exception ex)
            {
               MessageBox.Show(ex.ToString());
            }

            ShowBalances(iAccId);
        }

        private void ShowBalances(int iAccId)
        {
            try {
               float fltSaving = 0, fltCheck = 0;
               mTransaction.GetBalance(iAccId, ref fltSaving, ref fltCheck);
               tbSaving.Text = Convert.ToString(fltSaving);
               tbCheck.Text = Convert.ToString(fltCheck);
            }
            catch (Exception ex)
            {
               MessageBox.Show(ex.ToString());
            }
        }

        private void Form1_Load(object sender, System.EventArgs e)
        {
            ShowBalances(1);
        }
When error happens, the database manipulation done by the saving web service can not be rolled back.

No comments:

Post a Comment