ChangeSet.java
/*
** Module : ChangeSet.java
** Abstract : Tracks a record's changes during a transaction to enable in-memory rollback of data and state
**
** Copyright (c) 2020-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 ECF 20200602 Created initial version. Tracks a record's changes during a transaction and rolls back
** data and state at full and sub-transaction levels.
** ECF 20211230 Added missing javadoc.
** CA 20220308 In rollback(), allow any nested sub-tx changes to be applied.
** 002 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 003 CA 20240422 For a NEW temp record which was not flushed and is being deleted or rolled back in the
** transaction, the allocated primary key row (which is an empty template) to reserve this
** primary key, needs to be explicitly removed.
** 004 AL2 20240517 Don't remove CACHED flag if the DMO is still loaded.
** 005 TJD 20230303 Allow resetting changeset state and changeset changes roll-up
** TJD 20230705 Make sure resetted changesets fit into structures
** TJD 20231019 fix resetChangeset function name, remove unused variable
** TJD 20240926 Revert AL2 20240517 change, as its causing regression
** 006 LS 20241118 Changed rollUpChanges() to also propagate the changes.
** 007 AL2 20250113 Reintroduce record in the dirty-share context if delete was rollback-ed.
** 008 LS 20250219 Reintroduced AL2 20240517 change.
*/
/*
** 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.util.*;
import java.util.logging.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.dirty.DirtyShareContext;
import com.goldencode.p2j.persist.dirty.DirtyShareFactory;
import com.goldencode.p2j.util.logging.*;
/**
* This object manages the rollback of in-memory DMO data and state to match legacy semantics. Data and state
* change events are logged as they occur, to allow the rollback of these actions to occur at the same full
* and sub-transaction block levels. It is necessary to manage these changes in memory, rather than relying
* on the state of the database to restore the state of a DMO, because not all legacy state is captured in
* the database. Also, the legacy rules of when changes to a record are flushed to the database may mean
* that certain state changes are not represented in the database until a more deeply nested sub-transaction
* scope than when those changes are realized in the FWD server.
* <p>
* A {@link BaseRecord} instance references zero or one {@code ChangeSet} instances. An instance is only
* associated with a {@code BaseRecord} during an active, application-level transaction. During that
* transaction, and any sub-transactions it contains, changes to the record are recorded in the change set.
* These include both state events, such as the record being created, deleted, validated, and flushed to
* the database, as well as the updates of individual DMO properties. Each change is tracked by the
* transaction nesting level in which it occurred, where 0 represents the full transaction, 1 represents the
* first level of nested sub-transaction, and so on.
* <p>
* Records which have had data or state changes are tracked by the {@link SavepointManager}. When a rollback
* event occurs, the savepoint manager rolls back the associated database savepoint, and notifies each
* tracked DMO to roll back its changes to the appropriate transaction nesting level.
* <p>
* The implementation of this class is biased toward high performance update tracking, at the cost of some
* memory. Rollback processing, being the exceptional case rather than the common case, requires more work
* to be performed, but it should also be quite fast in most cases.
*/
class ChangeSet
implements DmoState
{
/** Logger */
private static final CentralLogger log = CentralLogger.get(ChangeSet.class.getName());
/** Types of change events which are logged in change set for rollback processing */
static interface Event
{
public static final int NONE = 0x00;
public static final int CREATE = 0x01;
public static final int INSERT = 0x02;
public static final int UPDATE = 0x04;
public static final int DELETE = 0x08;
public static final int VALIDATE = 0x10;
}
/** Default initial number of subtransaction levels recorded by the change set */
private static final int INITIAL_SIZE = 16;
/** Number of subtransaction levels to increment when growing changes array */
private static final int INCREMENT = INITIAL_SIZE / 2;
/** Session under which this change set was created */
private final Session session;
/** Transaction ID associated with this change set */
private final int txId;
/** Baseline snapshot of data before first change is made */
private final Object[] baselineData;
/** Baseline state before first change is made */
private final int baselineState;
/** Two dimensional array representing data changes across nested sub-transactions */
private Object[][] changes = null;
/** Bitsets corresponding with the data changes array, indicating which data elements have changed */
private BitSet[] marked = null;
/** Record states corresponding with the data changes array */
private int[] states = null;
/** Least deeply nested (outermost) sub-transaction level at which a change is made */
int outer = -1;
/** Most deeply nested (innermost) sub-transaction level at which a change is made */
int inner = -1;
/**
* Constructor.
*
* @param session
* Session under which this change set was created.
* @param txId
* ID for the transaction associated with this change set.
* @param data
* DMO's data at the moment of the first state or data change. A baseline snapshot of this
* data is made to enable rollback later.
* @param state
* Baseline state before first change is made.
* @param capacity
* Anticipated maximum number of transaction nesting levels to record. It will be increased
* dynamically later, if this capacity is exceeded.
*/
ChangeSet(Session session, int txId, Object[] data, int state, int capacity)
{
this.session = session;
this.txId = txId;
int len = data.length;
this.baselineData = new Object[len];
System.arraycopy(data, 0, this.baselineData, 0, len);
this.baselineState = state;
ensureCapacity(capacity);
}
/**
* Verify that this change set was created for the given session and transaction.
*
* @param session
* Database session.
* @param txId
* Transaction ID.
*
* @return {@code true} if the given session and transaction ID match that of this change set, else
* {@code false}.
*/
boolean verify(Session session, int txId)
{
return this.session == session && this.txId == txId;
}
/**
* Log a record state change.
*
* @param state
* Bitfield of states to be logged.
* @param txLevel
* Transaction nesting level at which event occurred.
*/
void logStateChange(int state, int txLevel)
{
prepare(txLevel);
states[txLevel] = state;
}
/**
* Log a data change.
*
* @param txLevel
* The transaction nesting level at which the change occurred.
* @param offset
* Index of the changed data element in the record's data array.
* @param datum
* The new value of the data element. The original revision of the datum will be recorded in
* the baseline snapshot, and intermediate revisions may be recorded in earlier transaction
* nesting levels in the change set.
*/
void logDataChange(int txLevel, int offset, Object datum)
{
prepare(txLevel);
changes[txLevel][offset] = datum;
marked[txLevel].set(offset);
}
/**
* Perform a full or sub-transaction level rollback of the given record to its state as it was immediately
* before the block of the specified transaction nesting level was entered.
*
* @param txLevel
* The nesting level of sub-transactions of the block being rolled back, where 0 represents
* the full transaction block, and a positive number represents that number of nested
* sub-transactions. The record will be rolled back to its state as it was immediately before
* this block was entered.
* @param dmo
* Record to be rolled back.
*/
void rollback(int txLevel, BaseRecord dmo)
throws PersistenceException
{
if (changes == null || txLevel > inner)
{
// TODO: what is appropriate here?
return;
}
// live data to be updated by rollback
Object[] liveData = dmo.data;
// gather the positions of all the data elements that were changed at this block and in more
// deeply nested blocks and clear those data structures after collecting this information
BitSet touched = new BitSet(baselineData.length);
for (int i = txLevel; i <= inner; i++)
{
// the bitset representing changed data elements (if any) at this transaction nesting level
BitSet next = marked[i];
// add the bits representing the changed values in this rolled back transaction level to the
// aggregate bitset of elements which need to be rolled back
touched.or(next);
// release the object references in the rolled back change set level for garbage collection
for (int j = next.nextSetBit(0); j >= 0; j = next.nextSetBit(j + 1))
{
// null out the corresponding data element in this transaction nesting level
changes[i][j] = null;
// clear the corresponding bit for this transaction nesting level
next.clear(j);
}
}
// starting at the rolled back block's parent (if any), work our way outward, overlaying data
// elements with the most recent changes we find
for (int i = txLevel - 1; i >= outer && !touched.isEmpty(); i--)
{
// bitset representing elements changed at this transaction nesting level
BitSet next = marked[i];
if (next.isEmpty())
{
continue;
}
// get the intersection of bits which represent data elements needing rollback and those (if
// any) changed at this transaction nesting level
BitSet copy = (BitSet) next.clone();
copy.and(touched);
if (copy.isEmpty())
{
continue;
}
// roll back to the most recent version of the data element found at this transaction nesting
// level
for (int j = copy.nextSetBit(0); j >= 0; j = copy.nextSetBit(j + 1))
{
// overlay the live data
liveData[j] = changes[i][j];
// remember that we've rolled back this data element
touched.clear(j);
}
}
// if any touched elements remain to be rolled back, get the values from the baseline snapshot
if (!touched.isEmpty())
{
for (int j = touched.nextSetBit(0); j >= 0; j = touched.nextSetBit(j + 1))
{
liveData[j] = baselineData[j];
}
}
// roll back state
try
{
RecordState liveState = dmo.state;
if (dmo.checkState(NEW) && !dmo.checkState(DELETED) && dmo instanceof TempRecord)
{
// remove the slot from MultiplexedScanIndex
DirectAccessHelper.clearPrimaryKey(session, (TempRecord) dmo);
}
int startingState = liveState.state;
// reset live state to baseline state
liveState.state = baselineState;
if ((baselineState & CACHED) != CACHED && (startingState & CACHED) == CACHED)
{
if (dmo.isInUse())
{
// the DMO is in use so we can't evict it; make sure it is still marked as cached
liveState.state |= CACHED;
}
else
{
// rolled back DMO must be removed from session cache
session.evict(dmo);
}
}
else if ((baselineState & CACHED) == CACHED && (startingState & CACHED) != CACHED)
{
// rolled back DMO must be added back to the session cache
session.associate(dmo);
}
if ((baselineState & DELETED) != DELETED && (startingState & DELETED) == DELETED)
{
// revive record in dirty-share as well
Database database = this.session.getDatabase();
DirtyShareContext dirtyContext = DirtyShareFactory.getContextInstance(database);
if (dirtyContext != null)
{
dirtyContext.delete(dmo.getClass().getName(), dmo.primaryKey(), true, true);
}
}
}
catch (StaleRecordException exc)
{
// not a fatal error, we just were not able to re-associate a stale record with the session cache
if (log.isLoggable(Level.FINE))
{
log.log(Level.FINE, exc.getMessage(), exc);
}
}
catch (DirectAccessException e)
{
throw new PersistenceException(e);
}
finally
{
// clear flags for rolled back state
for (int i = inner; i >= txLevel; i--)
{
// clear stored state
states[i] = DEFAULT;
}
}
}
/**
* Prepare for a log entry into the change set by updating the outermost and innermost transaction
* nesting levels at which changes occur, and by ensuring the internal data structures have enough
* capacity to record information for the given transaction nesting level.
*
* @param txLevel
* Current transaction nesting level.
*/
private void prepare(int txLevel)
{
outer = outer >= 0 ? Math.min(outer, txLevel) : txLevel;
inner = inner >= 0 ? Math.max(inner, txLevel) : txLevel;
ensureCapacity(txLevel + 1);
}
/**
* Ensure that this object's internal data structures are sufficiently sized to handle the given
* capacity. If the required capacity exceeds the current capacity, capacity is dynamically
* increased.
*
* @param capacity
* Required maximum number of nested transaction levels to be tracked.
*/
private void ensureCapacity(int capacity)
{
int len = this.changes != null ? this.changes.length : INITIAL_SIZE;
if (capacity <= len && this.changes != null)
{
// the current data structures are sufficient to handle the required capacity
return;
}
// increment target size until it meets or exceeds the required capacity
int size = len;
while (size < capacity)
{
size += INCREMENT;
}
int width = baselineData.length;
Object[][] changes = new Object[size][width];
BitSet[] active = new BitSet[size];
int[] states = new int[size];
if (this.changes != null)
{
System.arraycopy(this.changes, 0, changes, 0, len);
System.arraycopy(this.marked, 0, active, 0, len);
System.arraycopy(this.states, 0, states, 0, len);
for (int i = len; i < active.length; i++)
{
active[i] = new BitSet(width);
}
}
else
{
for (int i = 0; i < active.length; i++)
{
active[i] = new BitSet(width);
}
}
this.changes = changes;
this.marked = active;
this.states = states;
}
/**
* Perform a sub-transaction level ChangeSet state reset of a given record to its state as it was
* immediately before the block of the specified transaction nesting level was entered.
*
* @param txLevel
* The nesting level of sub-transactions of the block being reset, where 0 represents
* the full transaction block, and a positive number represents that number of nested
* sub-transactions.
*/
void resetTxLevel(int txLevel)
{
prepare(txLevel);
int width = baselineData.length;
states[txLevel] = DEFAULT;
marked[txLevel] = new BitSet(width);
}
/**
* Propagate information about changed to upper transaction level. This is needed to perform rollback
* correctly.
*
* @param txLevel
* The nesting level of sub-transactions of the block being rolled up back, where 0 represents
* the full transaction block, and a positive number represents that number of nested
* sub-transactions. The record's changed fields information will be rolled back to
* upper transaction level/block.
*/
public void rollUpChanges(int txLevel)
{
prepare(txLevel);
// propagate change marks down and changes
marked[txLevel].or(marked[txLevel+1]);
BitSet next = marked[txLevel + 1];
for (int j = next.nextSetBit(0); j >= 0; j = next.nextSetBit(j + 1))
{
changes[txLevel][j] = changes[txLevel + 1][j];
changes[txLevel + 1][j] = null;
}
}
}