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