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;
      }
   }
}