SavepointManager.java

/*
** Module   : SavepointManager.java
** Abstract : Savepoint and DMO state manager.
**
** Copyright (c) 2022-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 ECF 20200315 First revision. Manages units of database work and synchronizes database and
**                  application state as they are committed or rolled back.
** 002 ECF 20200916 Made debug logging more efficient when disabled.
**     CA  20201003 Use an identity HashSet where possible.
**     ECF 20210519 Update a DMO's state to track when the DMO needs to remain in the session cache. NO-UNDO
**                  DMO's are not tracked, because these are copies of the originals.
**     ECF 20210917 Discard current block in response to Finalizable events, rather than Commitable events,
**                  to ensure the transaction level is correctly at 0 at full transaction end.
**     ECF 20210924 Refactored block cleanup code.
**     OM  20211021 Added temporary code for catching ConcurrentModificationException in Block.rollback().
**                  Fixed concurrent modification by iterating a copy of the original set.
**     CA  20220215 Fixed cleanupBlock - the parentBlock needs to be the one just below the top of the stack. 
**     CA  20220210 Removed Commitable.rollbackPending and its notifications, as there is no actual 
**                  implementation of this method.
**     OM  20220701 Made lazilySetSavepoints() even lazier, allowing the innermost block to be set up with
**                  with a Savepoint at a later time when the invoker requires.
**     OM  20221020 Reverted OM 20211021 because of multitude of false positive logs.
**     AL2 20230104 Added JMX for profiling redo.
**                  Added JMX for profiling NO-UNDO operation tracing.
**     CA  20220630 Cleanup the 'redoable' list once the full transaction ends.
**     CA  20230116 Avoid creating a collection until is needed.
** 003 RAA 20230208 Removed all code that was linked to redo operations as it is no longer needed.
**     RAA 20230302 Removed the instrumentation for tracing redo and no-undo changes.
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 CA  20230607 TRANSACTION-MODE AUTO must preserve the tx in the root appserver block - this is required
**                  because in modes other than State-free, requests can be executed on the same agent who had
**                  TRANSACTION-MODE AUTO, and this must see as active the tx initially set.
** 006 DDF 20240509 Added deregisterBlockHooks() to deregister SavepointManager from the global block
**                  in transaction initiated by TRANSACTION-MODE AUTO.
** 007 BS  20230512 Shared cache instance per persistent database.
** 008 RAA 20240627 Made the calls to FastFindCache tenant-aware.
** 009 OM  20240909 FFCAche does not require knowledge of current tenant.
** 010 TJD 20220912 Fix for LazyUndoables not being properly restored before rollback
**     TJD 20230301 Resetting changeset state on sub-tx level decrease
** 010 TJD 20220912 Fix for LazyUndoables not being properly restored before rollback
**     TJD 20230301 Resetting changeset state on sub-tx level decrease
**     TJD 20230303 Changeset roll-up on sub-transaction commit 
**     TJD 20231019 fix resetChangeset function name
** 011 ES  20250424 Handle StopConditionException through ErrorManager.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
**
**   0. Attribution Requirement.
**
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
**
**   1. No License To Use Trademarks.
**
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
**
**   2. No Misrepresentation of Affiliation.
**
**     You may not represent yourself as Golden Code Development Corporation or FWD.
**
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
**
**   3. No Misrepresentation of Source or Origin.
**
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.persist.orm;

import java.sql.*;
import java.util.*;
import java.util.logging.*;

import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.ErrorManager;
import com.goldencode.p2j.util.logging.CentralLogger;

/**
 * The purpose of this class is to manage database savepoints in the context of application
 * blocks with transaction properties, such that the state of in-memory DMOs can be kept in
 * sync with the database, as full and sub-transactions are committed or rolled back.
 * <p>
 * An instance of this class is created when an application block with a full transaction
 * property is executed. If and when an associated database transaction is created, the {@code
 * SavepointManager} instance is installed in the active database session. The instantiation of
 * this class and the creation of a database transaction do not necessarily have to occur
 * simultaneously. In fact, a database transaction may not be created at all. A database-level
 * transaction is only begun the first time a record buffer for the associated database is used
 * (i.e., its scope is opened). This lazy creation of a database transaction is intended to
 * prevent the overhead of creating unused database transactions every time an application-level
 * transaction is opened.
 * <p>
 * When created at an application full transaction, this object is registered for {@link
 * com.goldencode.p2j.util.Commitable Commitable} and {@link com.goldencode.p2j.util.Finalizable
 * Finalizable} callbacks. It must again be registered for these callbacks at each sub-transaction
 * block encountered. The registration for these callbacks is the responsibility of the {@link
 * com.goldencode.p2j.persist.BufferManager BufferManager}. It is also the {@code BufferManager}
 * which determines when a database transaction needs to be opened.
 * <p>
 * When a database transaction is opened and the savepoint manager installed in the session, the
 * latter needs to be activated with a call to {@link #setSession(Session)}. Once activated, this
 * object will track any changes made to DMOs, lazily creating savepoints as necessary when a change
 * is made. It will track application persistence state which needs to be kept in sync with database
 * create, update, and delete activity. The lazy creation of savepoints only when a change has been
 * made prevents round trips to the database to set and release savepoints, if there is no database
 * activity to be rolled back in a given subtransaction block.
 * <p>
 * Undoable and NO-UNDO operations are treated differently. For the former, this class ensures that
 * the state of in-memory records is left in the state they were in at the time a rolled-back block
 * was entered, while rolling back a corresponding savepoint in the database. For the latter, this
 * class ensures that any rollback of NO-UNDO changes at the database is negated, by re-applying any
 * SQL operations which were rolled back at the database, after the sub-transaction or full transaction
 * completes.
 */
public class SavepointManager
implements Commitable,
           Finalizable,
           DmoState
{
   /** Logger */
   private static final CentralLogger log = CentralLogger.get(SavepointManager.class.getName());
   
   /** Debug logging level */
   private static final boolean debug = log.isLoggable(Level.FINE);
   
   /** Stack of block states which coincide with transaction block scopes */
   private final Deque<Block> blocks = new ArrayDeque<>();
   
   /** Stack of block states needed for LazyUndoable rollback to work */
   private final Deque<Block> rollbackBlocks = new ArrayDeque<>();
   
   /** Database session used for savepoint processing */
   private Session session = null;
   
   /** State of the transaction block which currently is active, if any */
   private Block activeBlock = null;
   
   /** The current parent block, if any (will be {@code null} at a full transaction block) */
   private Block parentBlock = null;
   
   /**
    * Default constructor.
    */
   public SavepointManager()
   {
   }
   
   /**
    * Register for transaction manager notifications for the current block. This is invoked
    * before the block is entered for sub-transactions, but lazily as the block is entered for
    * full transactions.
    * <p>
    * We register for {@code Finalizable} and {@code Commitable} notifications.
    * 
    * @param   txHelper
    *          Transaction manager helper object with which we register for notifications.
    * @param   fullTx
    *          {@code true} if this is a full-transaction.
    * @param    blockDepth
    *           zero-based depth of target block, starting from the outermost block. <code>0</code> indicates 
    *           the global block.
    */
   public void registerBlockHooks(TransactionManager.TransactionHelper txHelper, boolean fullTx, int blockDepth)
   {
      if (debug)
      {
         debug("registering hooks; #blocks: " + blocks.size());
      }
      
      if (fullTx)
      {
         txHelper.registerTransactionCommit(this, true);
      }
      else
      {
         txHelper.registerCommit(this);
      }
      
      txHelper.registerFinalizableAt(blockDepth, this);
      
      prepareBlock();
   }
   
   /**
    * Deregister the current instance from the transaction manager. This method is called when the
    * {@link TxWrapper} instance that created the current instance is cleaned up and the transaction
    * is initiated by TRANSACTION-MODE AUTO, the Finalizable is removed from the global block.
    * 
    * @param   txHelper
    *          Transaction manager helper object with which we register for notifications.
    */
   public void deregisterGlobalBlockHooks(TransactionManager.TransactionHelper txHelper)
   {
      if (debug)
      {
         debug("deregistering hooks; #blocks: " + blocks.size());
      }
      
      txHelper.deregisterGlobalFinalizable(this);
   }
   
   /**
    * The block has finished. Clean up.
    */
   @Override
   public void finished()
   {
      if (debug)
      {
         debug("finished");
      }
      
      cleanupBlock();
   }
   
   /**
    * No-op delete hook.
    */
   @Override
   public void deleted()
   {
      if (debug)
      {
         debug("deleted");
      }
   }
   
   /**
    * Prepare to iterate through another pass of a looping block. Clean up the block from the previous loop
    * iteration and prepare it for a new iteration.
    */
   @Override
   public void iterate()
   {
      if (debug)
      {
         debug("iterate");
      }
      
      cleanupBlock();
      prepareBlock();
   }
   
   /**
    * Retry a block after handling a condition. Clean up the block from the aborted attempt to process it
    * and prepare it for a retry.
    */
   @Override
   public void retry()
   {
      if (debug)
      {
         debug("retry");
      }
      
      cleanupBlock();
      prepareBlock();
   }
   
   /**
    * No-op validation hook.
    * 
    * @param   transaction
    *          Not used.
    * @param   aggressiveFlush
    *          Not used.
    */
   @Override
   public void validate(boolean transaction, boolean aggressiveFlush)
   {
      if (debug)
      {
         debug("validate");
      }
   }
   
   /**
    * Commit the active block, which entails releasing any savepoint and rolling up tracked
    * resources.
    * 
    * @param   transaction
    *          {@code true} if the current block is a full transaction; {@code false} if it is
    *          a sub-transaction.
    */
   @Override
   public void commit(boolean transaction)
   {
      if (debug)
      {
         debug("commit; active = " + activeBlock);
      }
      
      try
      {
         if (activeBlock != null)
         {
            Block endingBlock = activeBlock;
            
            if (transaction)
            {
               // notify of full transaction commit, so tracked DMOs can release their change
               // sets for garbage collection
               endingBlock.commit();
            }
            else
            {
               // a subtransaction block will have a non-null parent block; roll up all tracked
               // information for the ending block to it
               parentBlock.rollUp(endingBlock);
               
               if (session != null && endingBlock.savepoint != null)
               {
                  // nothing more is needed at the database for the ending block's savepoint, so
                  // release it (not applicable at full transaction scope)
                  session.releaseSavepoint(endingBlock.savepoint);
               }
            }
         }
      }
      catch (PersistenceException exc)
      {
         log.log(Level.SEVERE, "Commit error!", exc);
         
         ErrorManager.handleStopException(new StopConditionException(exc));
      }
   }
   
   /**
    * Roll back the active block, which entails rolling back any savepoint, rolling up tracked
    * resources, and marking any tracked undoable records as stale.
    * 
    * @param   transaction
    *          {@code true} if the current block is a full transaction; {@code false} if it is
    *          a sub-transaction.
    */
   @Override
   public void rollback(boolean transaction)
   {
      if (debug)
      {
         debug("rollback; active = " + activeBlock);
      }
      
      Block endingBlock = activeBlock;
      
      try
      {
         if (activeBlock != null && session != null)
         {
            // notify the FastFind cache that the changed tables need to be invalidated
            endingBlock.invalidateFastFind(session.getDatabase());
            
            // it is possible that the ending block has had no savepoint set, if no DMO changes were
            // made in the ending block or in more deeply nested blocks
            boolean rollback = endingBlock.savepoint != null;
            
            if (transaction || rollback)
            {
               // apply all pending blocks needed for a correct LazyUndoable rollback
               while(!rollbackBlocks.isEmpty())
               {
                  rollbackBlocks.removeLast().rollback();
               }
               // roll back all in-memory DMO changes made in the ending block and more deeply nested
               // blocks
               endingBlock.rollback();
            }
            
            // nothing more is needed at the database for the ending block's savepoint, so
            // release it, if available (not applicable at full transaction scope)
            if (!transaction && rollback)
            {
               session.rollbackSavepoint(endingBlock.savepoint);
            }
         }
      }
      catch (PersistenceException exc)
      {
         log.log(Level.SEVERE, "Rollback error!", exc);
         
         ErrorManager.handleStopException(new StopConditionException(exc));
      }
      finally
      {
         if (transaction && endingBlock != null &&
             endingBlock.changed != null        && 
             !endingBlock.changed.isEmpty())
         {
            for (BaseRecord dmo : endingBlock.changed)
            {
               dmo.updateState(null, TRACKED, false);
            }
         }
      }
   }
   
   /**
    * Install the given session into this savepoint manager.
    * 
    * @param   session
    *          Database session used for savepoint processing.
    */
   void setSession(Session session)
   {
      this.session = session;
      
      if (debug)
      {
         debug("activate");
      }
   }
   
   /**
    * Get the zero-based transaction block nesting level, where 0 indicates the current block is a full
    * transaction block, 1 indicates the current block is the first level of nested subtransaction, and so
    * on. Note that this includes only blocks with transaction properties; no-transaction blocks which may
    * be nested between blocks with transaction properties or ignored for purposes of this count.
    * 
    * @return  The current block's transaction nesting level.
    */
   int getTxNestingLevel()
   {
      return blocks.size() - 1;
   }
   
   /**
    * Begin tracking an undoable record as needing savepoint processing. Only record states are
    * stored. On rollback, they will be marked STALE, so the persistence framework can refresh
    * them lazily on first use after the rollback.
    * <p>
    * A record is tracked when a change (create/update/delete) has been made to it and that
    * change has been persisted to the database.
    * 
    * @param   dmo
    *          Record to be tracked.
    * 
    * @throws  PersistenceException
    *          if there is an error lazily setting savepoints in the active block and any ancestor
    *          blocks which do not yet have savepoints assigned.
    */
   void trackUndoable(BaseRecord dmo)
   throws PersistenceException
   {
      // we shouldn't be getting a record to track before savepoint manager has been activated
      assert(session != null);
      
      if (session != null && activeBlock != null)
      {
         lazilySetSavepoints(false);
         
         if (activeBlock.changed == null)
         {
            activeBlock.changed = Collections.newSetFromMap(new IdentityHashMap<>());
         }
         if (activeBlock.changed.add(dmo))
         {
            // reset changeset for given txLevel
            dmo.resetChangeSet(activeBlock.txLevel);
            dmo.updateState(session, TRACKED, true);
         }
      }
   }
   
   /**
    * Set a savepoint in the active block and in every previous block which has not yet had one set,
    * except for the outermost (which represents the full transaction block).
    * <p>
    * We do this lazily, when an actual change is made, rather than as each block is created. This avoids
    * setting "empty" savepoints which never contain updates, thereby preventing many unnecessary round
    * trips to the database.
    * 
    * @param   exceptLast
    *          If {@code true}, do not set up the very last block, instead return a lambda code which is
    *          prepared to do that. This allows a complete intermediary, short-leved sub-transaction to be
    *          executed before the savepoint for this blolck to be set up.
    * 
    * @return  a {@code PersistenceCode} implementation which will set up the last block with a savepoint.
    */
   PersistenceCode lazilySetSavepoints(boolean exceptLast)
   throws PersistenceException
   {
      if (activeBlock == null || activeBlock.txLevel == 0 || blocks.size() == 1)
      {
         // quick out if no active block or savepoint already created or full transaction block
         return null;
      }
      
      // savepoints need to be set in the order in which the blocks were entered, but it usually will be
      // faster to iterate from the head of the deque, so we copy just the blocks, if any, which do not
      // already have a savepoint to a temporary array and use it to walk forward from the outermost
      // block without a savepoint
      
      int start = -1;
      int len = blocks.size();
      Block[] array = null;
      
      // find the outermost sub-transaction block which does not yet have a savepoint set
      Iterator<Block> iter = blocks.iterator();
      for (int i = len - 1; iter.hasNext(); i--)
      {
         Block next = iter.next();
         
         if (next.txLevel == 0 || next.savepoint != null)
         {
            // we have reached the full transaction block or a sub-transaction block which already has
            // a savepoint set; terminate the loop
            break;
         }
         
         if (array == null)
         {
            array = new Block[len];
         }
         array[i] = next;
         start = i;
      }
      
      // set a savepoint in each block which did not already have one
      if (array == null)
      {
         return null;
      }
      
      for (int i = start; i < (exceptLast ? len - 1 : len); i++)
      {
         setSavepoint(array[i]);
      }
      
      if (exceptLast)
      {
         Block[] finalArray = array;
         return () -> setSavepoint(finalArray[len - 1]);
      }
      
      return null;
   }
   
   /**
    * Prepare a new block and make it the active block. If this is a sub-transaction, set a
    * database savepoint at this time. Otherwise, prepare the block without a savepoint, because
    * the full transaction will be used instead (external to this class) for database-level
    * commit or rollback.
    */
   private void prepareBlock()
   {
      parentBlock = blocks.peek();
      activeBlock = new Block(blocks.size());
      blocks.push(activeBlock);
      // pop a next block for rollback, as it will not be needed anymore
      if (!rollbackBlocks.isEmpty())
      {
         rollbackBlocks.pop();
      }
      
      if (debug)
      {
         debug("pushed block; active = " + activeBlock);
      }
   }
   
   /**
    * End of block processing, which entails discarding the currently active block, re-applying any rolled
    * back changes to a NO-UNDO temporary table, and reactivating the parent block, if any, for further
    * processing. Any commit or rollback hook called after this method is invoked will operate on the
    * reactivated block.
    */
   private void cleanupBlock()
   {
      // discard the current block, which has finished running
      Block lastActiveBlock = activeBlock;
      Block popped = blocks.pop();
      
      // store block for further use for rollback of LazyUndoables
      rollbackBlocks.push(popped);
      
      // reactivate the next block in the stack (if any)
      activeBlock = blocks.isEmpty() ? null : blocks.pop();
      parentBlock = blocks.isEmpty() ? null : blocks.peek();
      if (activeBlock != null)
      {
         blocks.push(activeBlock);
      }
      
      if (debug)
      {
         debug("cleanupBlock; discarded = " + popped + "; active = " + activeBlock);
      }
      
      if (activeBlock == null)
      {
         // full transaction is finished, deactivate savepoint manager (i.e., remove session)
         this.session = null;
         // release all associated rollback blocks
         rollbackBlocks.clear();
      }
   }
   
   /**
    * Set a new savepoint in the database and associate it with the given block.
    * 
    * @param   block
    *          Block with which the savepoint is associated.
    * 
    * @throws  PersistenceException
    *          if there is a database error setting the savepoint.
    */
   private void setSavepoint(Block block)
   throws PersistenceException
   {
      Savepoint sp = session.setSavepoint();
      block.setSavepoint(sp);
      
      if (debug)
      {
         debug("setSavepoint in block " + block);
      }
   }
   
   /**
    * Log a debug message if FINE logging is enabled for this class.
    * 
    * @param   message
    *          Message text.
    */
   private void debug(String message)
   {
      log.log(Level.FINE, "[" + (session != null ? session.getDatabase() : "?") + "] " + message);
   }
   
   /**
    * Allows to execute a piece of code which may throw a {@code PersistenceException} by a foreign object.
    * The receiver of such code will be aware that a persistence-related exception might be thworn during the
    * execution of the code.
    */
   public interface PersistenceCode
   {
      /**
       * The code to be executed.
       * 
       * @throws  PersistenceException
       *          The exception which may be thrown.
       */
      void run()
      throws PersistenceException;
   }
   
   /**
    * Block state corresponding with full and sub-transactions, for the purpose of tracking updated records
    * and their states.
    */
   private static class Block
   {
      /** Transaction nesting level of this block (0 = full transaction, 1 = first sub-transaction, etc.) */
      private final int txLevel;
      
      /** Records which have been changed in the scope of this block */
      private Set<BaseRecord> changed = null;
      
      /** Database savepoint for a sub-transaction block ({@code null} for full transaction */
      private Savepoint savepoint = null;
      
      /**
       * Constructor.
       */
      Block(int txLevel)
      {
         this.txLevel = txLevel;
      }
      
      /**
       * Set this block's savepoint.
       * 
       * @param   savepoint
       *          Database savepoint to be stored for this block. Must be {@code null} if the
       *          current block represents a full transaction.
       */
      void setSavepoint(Savepoint savepoint)
      {
         this.savepoint = savepoint;
      }
      
      /**
       * Add all changed, undoable records stored in the {@code from} {@code Block} into the current block.
       * 
       * @param   from
       *          Block from which to copy information into this block.
       */
      void rollUp(Block from)
      {
         if (from.changed != null && !from.changed.isEmpty())
         {
            for (BaseRecord dmo : new HashSet<>(from.changed))
            {
               dmo.rollUpChanges(txLevel);
            }
            if (changed == null)
            {
               changed = Collections.newSetFromMap(new IdentityHashMap<>());
            }
            changed.addAll(from.changed);
         }
      }
      
      /**
       * Notify all changed, undoable records that the current transaction has been committed.
       */
      void commit()
      {
         if (changed == null || changed.isEmpty())
         {
            return;
         }
         
         // this method should only be invoked at full transaction commit
         assert (txLevel == 0);
         
         for (BaseRecord dmo : new HashSet<>(changed))
         {
            dmo.updateState(null, TRACKED, false);
            dmo.commit();
         }
      }
      
      /**
       * Roll back changes to undoable records.
       */
      void rollback()
      throws PersistenceException
      {
         if (txLevel > 0 && savepoint == null)
         {
            // no DMOs were updated within this block, so nothing to undo
            return;
         }
         
         if (changed != null && !changed.isEmpty())
         {
            for (BaseRecord dmo : new HashSet<>(changed))
            {
               dmo.rollback(txLevel);
            }
         }
      }
      
      /**
       * Notify the FastFind cache that the changed tables need to be invalidated.
       * 
       * @param   database
       *          The instance of the database to work with.
       */
      void invalidateFastFind(Database database)
      {
         if (changed == null || changed.isEmpty())
         {
            return; // quick out
         }
         
         // all changed records belongs to same database. Detecting the database type (_temp or permanent)
         // on the fly, by checking the first record's metadata
         Boolean isTemp = null;
         Set<Long> dmoSet = new HashSet<>();
         
         for (BaseRecord dmo : new HashSet<>(changed))
         {
            DmoMeta dmoMeta = dmo._recordMeta().getDmoMeta();
            if (isTemp == null)
            {
               isTemp = dmoMeta.isTempTable();
            }
            long tableId = FastFindCache.combine(dmoMeta.getId(),
                                                 isTemp ? ((TempRecord) dmo)._multiplex() : null); 
            dmoSet.add(tableId);
         }
         
         FastFindCache.getInstance(isTemp, database).invalidate(dmoSet);
      }
      
      /**
       * Get a debug text representation of this object.
       * 
       * @return  Text representation of state.
       */
      public String toString()
      {
         String spid = "?";
         
         if (savepoint != null)
         {
            try
            {
               spid = String.valueOf(savepoint.getSavepointId());
            }
            catch (SQLException exc)
            {
            }
         }
         
         return "[" + spid + " " + (txLevel == 0 ? "fullTx" : ("subTx@" + txLevel)) + "]";
      }
   }
}