# 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)) + "]";
          }
       }
    }