UniqueTracker.java

/*
** Module   : UniqueTracker.java
** Abstract : Tracks records for testing whether the unique index constraints are broken.
**
** Copyright (c) 2020-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 OM  20200110 Created initial version.
**     ECF 20200211 Changes to integrate with DMO validation and flushing.
**     CA  20200622 Small performance improvement - use IdentityHashMap if the key is java.lang.Class.
** 002 OM  20201002 Use DmoMeta cached information instead of map lookups.
** 003 CA  20220210 Removed Commitable.rollbackPending and its notifications, as there is no actual 
**                  implementation of this method.
**     OM  20221005 Do not save deleted transient records for rollback. Added UniqueIndex.toString() method.
**     TJD 20221122 Reset rollback flag on commit
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 CA  20240507 Unique index validation must validate only dirty indexes, even for NEW records, if we are
**                  not in flush mode.
** 006 TJD 20240123 Java 17 compatibility updates.
** 007 CA  20240924 Do not push scopes to 'changes' and 'deletes' until the scope is used.
*/

/*
** 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.BitSet;
import java.util.List;
import java.util.concurrent.locks.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.Record;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import org.apache.commons.lang3.tuple.*;

/**
 * Implements a tracker for records in unique indices in uncommitted transactions. This enables
 * a proactive, synchronized check to ensure the insertion or update of a record will not cause
 * a unique constraint violation, with respect to any uncommitted changes across FWD sessions.
 * Otherwise, such a failure would not occur until the latter of two transactions containing a
 * conflicting insert/update were committed. This would be too late in terms of legacy
 * compatibility, since the 4GL reports this failure at the moment of the index update, not at
 * the eventual transaction commit.
 * <p>
 * The overall structure of the {@code UniqueTracker} architecture is one map instance per unique
 * index, per table/DMO type. The maps are keyed by {@code Key} which emulate the P4GL
 * constraints from the point of view of the unique indexes. The values mapped are the {@code id}s
 * of the records.
 * <p>
 * The exposed API allows to:
 * <ul>
 *    <li>add new {@code Record} to tracking;</li>
 *    <li>test whether a specified {@code Record} is tracked;</li>
 *    <li>remove a specified {@code Record} (or using its {@code id}) from tracking;</li>
 * </ul>
 * 
 * The tracker also keeps track of the transactions and all elements associated with a transaction
 * are processed when the transaction is committed (or rolled back).
 */
public class UniqueTracker
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(UniqueTracker.class);
   
   /** Cache of DMO implementation classes to instances of this class */
   private static final Map<Class<? extends BaseRecord>, UniqueTracker> instances =
      new IdentityHashMap<>();
   
   /** Context local state for this session's uncommitted transaction data */
   private static final ContextLocal<Context> context = new ContextLocal<Context>()
   {
      @Override protected Context initialValue() { return new Context(); }
      @Override protected void cleanup(Context context) { context.cleanup(true); }
   };
   
   /**
    * The list of {@code UniqueIndex} for a {@code DataModelObject}. When an {@code UniqueTracker}
    * is created the DMO class is analyzed and the list of unique indexes is extracted. For each
    * index there an {@code UniqueIndex} is created and added to this array.
    */
   private final UniqueIndex[] uniqueIndexes;
   
   /**
    * Default constructor, used only by no-op subclass.
    * 
    * @see  #instantiate(DmoMeta)
    */
   protected UniqueTracker()
   {
      this.uniqueIndexes = null;
   }
   
   /**
    * Creates a new tracker for a designated {@code BaseRecord}. The {@code dmoClass} is used to
    * extract the unique indexes and create an optimized internal data structure.
    *
    * @param   dmoInfo
    *          The DMO meta information for DMO implementation class.
    */
   private UniqueTracker(DmoMeta dmoInfo)
   {
      RecordMeta rm = dmoInfo.recordMeta;
      BitSet[] uniqueBits = rm.getUniqueIndices();
      List<UniqueIndex> indexList = new ArrayList<>(5);
      for (BitSet b : uniqueBits)
      {
         indexList.add(new UniqueIndex(b));
      }
      
      uniqueIndexes = indexList.toArray(new UniqueIndex[indexList.size()]);
   }
   
   /**
    * Get the context-local helper object which is the local session's access to the {@code
    * UniqueTracker} API.
    * 
    * @return  Context-local helper object.
    */
   public static Context getContext()
   {
      return context.get();
   }
   
   /**
    * Get a {@code UniqueTracker} for a specified {@code DataModelObject}. If this is the first
    * call for a specified class, a new instance is created.
    * 
    * @param   dmoInfo
    *          The DMO meta information for DMO implementation class.
    *
    * @return  The {@code  UniqueTracker} for the specified {@code DataModelObject}.
    */
   public static UniqueTracker getInstance(DmoMeta dmoInfo)
   {
      Class<? extends Record> dmoClass = dmoInfo.getImplementationClass();
      UniqueTracker ut = instances.get(dmoClass);
      if (ut == null)
      {
         ut = instantiate(dmoInfo);
         instances.put(dmoClass, ut);
      }
      return ut;
   }
   
   /**
    * Instantiate the appropriate form of this class for the given DMO implementation class.
    * This returns a no-op version for use with temp-table DMOs, and fully functioning version
    * for persistent table DMOs. Temp-tables operate only in a single context. Therefore,
    * validating against the database is sufficient to detect any unique constraint violation.
    *
    * @param   dmoInfo
    *          The DMO meta information for DMO implementation class.
    * 
    * @return  A new instance of this class, possibly with some methods neutered.
    */
   private static UniqueTracker instantiate(DmoMeta dmoInfo)
   {
      if (dmoInfo.isTempTable())
      {
         // temp-tables use a no-op implementation of this class
         return new UniqueTracker()
         {
            @Override
            protected final Token lockAndChange(BaseRecord dmo, Context context, String bufferName, boolean flush) 
            { return null; }
            @Override
            protected final Token lockAndDelete(BaseRecord dmo, Context context) { return null; }
            @Override
            protected final void rollbackChange(Token token) { }
            @Override
            protected final void unlock(Token token) { }
         };
      }
      
      return new UniqueTracker(dmoInfo);
   }
   
   /**
    * Check the given record's state and update the minimal, necessary unique index(es), based
    * on any dirty properties in the record, or update all unique indexes in the case that
    * {@code dmo} has been newly created and was not previously persisted.
    * <p>
    * If the data within {@code dmo} violates an existing unique constraint, an error is
    * raised. Any changes made up to that point are rolled back automatically.
    * 
    * @param   dmo
    *          Record whose state is driving an update to this tracker.
    * @param   context
    *          Uncommitted data managed for the current context.
    * @param   bufferName
    *          The name of the buffer. It is used when printing the error message, if any.
    * @param   flush
    *          Whether to flush the record; {@code false} to validate only.
    * 
    * @return  A token which contains information about the update operation. This token must
    *          be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
    *          in order to commit or roll back the update, respectively, and to ensure any
    *          locks acquired by the update are released.
    * 
    * @throws  ValidationException
    *          if the data in {@code dmo} represent a unique constraint violation within any
    *          session's uncommitted transactions.
    */
   protected Token lockAndChange(BaseRecord dmo, Context context, String bufferName, boolean flush)
   throws ValidationException
   {
      if (context.pendingChangesScopes == 0 && context.changes.scopes() <= 0)
      {
         LOG.severe("", new IllegalStateException("Empty scopes in UniqueTracker"));
         return null;
      }
      
      // figure out which indexes need to be locked for update; for a newly created record, a new
      // entry must be created for each unique index; otherwise, just dirty indices are updated
      
      boolean full = flush && dmo.checkState(DmoState.NEW);
      
      // affected pairs of Integers to Keys; the left element represents the position of
      // the affected UniqueIndex in uniqueIndexes array, the right is the old value to be reset
      // on rollback, or null if this operation represents a new insert
      List<Pair<Integer, UniqueIndex.Key>> affected = new LinkedList<>();
      
      Long id = dmo.primaryKey();
      
      try
      {
         BitSet dirty = dmo.getDirtyProps();
         
         int len = uniqueIndexes.length;
         for (int i = 0; i < len; i++)
         {
            UniqueIndex ui = uniqueIndexes[i];
            
            // only update UniqueIndex if we are indexing a new record or if one of its fields
            // has been touched
            if (full || dirty.intersects(ui.fieldMap))
            {
               // obtain lock; may block here
               ui.lock();
               
               // if an entry for this record exists in this UniqueIndex, remove it before
               // trying to add new Key
               UniqueIndex.Key oldVal = ui.entries.remove(id);
               if (oldVal != null)
               {
                  ui.records.remove(oldVal, id);
               }
               
               // remember changes in case rollback is needed; left element of pair is position
               // of affected UniqueIndex instance in uniqueIndexes array; right element is old
               // value of Key (null in full case)
               affected.add(Pair.of(i, oldVal));
               
               // attempt to store new Key, based on record's data
               UniqueIndex.Key newVal = ui.createKey(dmo);
               if (ui.records.containsKey(newVal))
               {
                  reportUniqueConstraintViolation(dmo, newVal, bufferName);
               }
               
               // track unique key in shared data structures
               ui.records.put(newVal, id);
               ui.entries.put(id, newVal);
               
               // track unique key in context-local data structure
               while (context.pendingChangesScopes > 0)
               {
                  context.changes.pushScope();
                  context.pendingChangesScopes--;
               }
               context.changes.add(false, newVal);
            }
         }
      }
      catch (ValidationException exc)
      {
         rollbackChange(id, affected);
         
         throw exc;
      }
      
      return new Token(id, affected);
   }
   
   /**
    * The given DMO has been deleted in the current transaction. If a unique index already is
    * being tracked for this DMO, it indicates that the DMO previously was inserted or updated
    * in the same transaction. In this case, remove its earlier unique index entry, but
    * remember it in case we need to roll back this delete later. In the event there is no
    * unique index already being tracked, it indicates that the record deleted was not
    * previously inserted or updated during the current transaction. In this case, we store
    * {@code null} for the key in the {@code deletes} we are tracking. Rollback code must
    * tolerate this {@code null}.
    * <p>
    * This operation performs no validation, as no unique constraint can be violated by a
    * delete.
    * 
    * @param   dmo
    *          DMO which was deleted.
    * @param   context
    *          Uncommitted data managed for the current context.
    *          
    * @return  A token which contains information about the update operation. This token must
    *          be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
    *          in order to commit or roll back the update, respectively, and to ensure any
    *          locks acquired by the update are released.
    */
   protected Token lockAndDelete(BaseRecord dmo, Context context)
   {
      // affected pairs of Integers and Keys; the left element represents the position of the
      // affected UniqueIndex in uniqueIndexes array, the right is the old value to be reset
      // on rollback, or null if this operation represents the delete of a record not previously
      // inserted or updated in this transaction
      List<Pair<Integer, UniqueIndex.Key>> affected = new LinkedList<>();
      
      Long id = dmo.primaryKey();
      
      int len = uniqueIndexes.length;
      for (int i = 0; i < len; i++)
      {
         UniqueIndex ui = uniqueIndexes[i];
         
         // obtain lock; may block here
         ui.lock();
         
         // if an entry for this record exists in this UniqueIndex, remove it
         UniqueIndex.Key oldVal = ui.entries.remove(id);
         if (oldVal != null)
         {
            ui.records.remove(oldVal, id);
         }
         
         // remember changes in case rollback is needed; left element of pair is position of affected
         // UniqueIndex instance in uniqueIndexes array; right element is old value of Key (null in the
         // case of the delete of a record not previously inserted or updated in this transaction)
         affected.add(Pair.of(i, oldVal));
         
         if (!dmo.checkState(DmoState.NEW))
         {
            // track pair of record ID and old unique key in context-local data structure; null key
            // has same meaning as above
            // NOTE: do this only for non transient record. The transient records were not yet saved so there
            //       is no point in restoring them.
            while (context.pendingDeletesScopes > 0)
            {
               context.deletes.pushScope();
               context.pendingDeletesScopes--;
            }
            context.deletes.add(false, Pair.of(id, oldVal));
         }
      }
      
      return new Token(id, affected);
   }
   
   /**
    * Complete the operation begun by {@link #lockAndChange} or by
    * {@link #lockAndDelete(BaseRecord, Context)}, using the token returned by the respective method.
    * A commit is no more than the absence of a rollback and an unlocking of any {@code UniqueIndex}
    * instances locked by the update or delete.
    * <p>
    * This method should be invoked when no unique constraint violation has occurred during a record
    * validation event.
    * 
    * @param   token
    *          A token returned by {@link #lockAndChange} or by {@link
    *          #lockAndDelete(BaseRecord, Context)}, used to unlock any {@code UniqueIndex} instances
    *          instances locked by those methods.
    */
   protected void unlock(Token token)
   {
      if (token == null || token.affected.isEmpty())
      {
         // no resources are locked
         return;
      }
      
      for (Pair<Integer, UniqueIndex.Key> pair : token.affected)
      {
         int i = pair.getLeft();
         uniqueIndexes[i].unlock();
      }
   }
   
   /**
    * Roll back an update operation on zero or more unique keys in this tracker, presumably in response
    * to a unique constraint violation either generated by this tracker or which occurred during a database
    * insert or update.
    * <p>
    * This method should be invoked when a unique constraint violation has occurred during a record
    * validation event.
    * 
    * @param   token
    *          A token returned by {@link #lockAndChange}, used to rollback
    *          any partial or full updates made and to unlock any {@code UniqueIndex} instances locked by
    *          the update.
    */
   protected void rollbackChange(Token token)
   {
      rollbackChange(token.id, token.affected);
   }
   
   /**
    * Roll back an update operation on zero or more unique keys in this tracker, presumably in
    * response to a unique constraint violation either generated by this tracker or which
    * occurred during a database insert or update.
    * 
    * @param   id
    *          Primary key of the record for which the update operation was performed.
    * @param   affected
    *          A list of pairs of {@code UniqueIndex} positions in the {@code uniqueIndexes}
    *          array and corresponding {@code Key} instances to be restored (or {@code
    *          null} if the operation represented a new record insert).
    */
   private void rollbackChange(Long id, List<Pair<Integer, UniqueIndex.Key>> affected)
   {
      try
      {
         Iterator<Pair<Integer, UniqueIndex.Key>> iter = affected.iterator();
         while (iter.hasNext())
         {
            Pair<Integer, UniqueIndex.Key> pair = iter.next();
            
            // access UniqueIndex instance
            int i = pair.getLeft();
            UniqueIndex ui = uniqueIndexes[i];
            
            // remove new mappings, if any
            UniqueIndex.Key newVal = ui.entries.remove(id);
            if (newVal != null)
            {
               ui.records.remove(newVal);
            }
            
            // restore old mappings, if any
            UniqueIndex.Key oldVal = pair.getRight();
            if (oldVal != null)
            {
               ui.entries.put(id, oldVal);
               ui.records.put(oldVal, id);
            }
            
            // unlock the UniqueIndex
            ui.unlock();
            
            // remove the pair, to prevent attempting rollback/unlock more than once
            iter.remove();
         }
      }
      finally
      {
         // unlock all affected resources, in case we exited the try block abnormally; no-op
         // if already unlocked above
         unlock(affected);
      }
   }
   
   /**
    * Unlock the {@code UniqueIndex} instances specified in the given list of pairs.
    * 
    * @param   affected
    *          A list of zero or more pairs of {@code UniqueIndex} positions in the {@code
    *          uniqueIndexes} array and {@code Key} instances. The {@code UniqueIndex}
    *          instances at the given positions are to be unlocked.
    */
   private void unlock(List<Pair<Integer, UniqueIndex.Key>> affected)
   {
      for (Pair<Integer, UniqueIndex.Key> pair : affected)
      {
         int i = pair.getLeft();
         uniqueIndexes[i].unlock();
      }
   }
   
   /**
    * Report a unique constraint violation using the expected, legacy error message.
    * 
    * @param   dmo
    *          Record which violated constraint.
    * @param   key
    *          Unique index key which contains the non-unique data.
    * @param   bufferName
    *          The name of the buffer. It is used when printing the error message, if any.
    * 
    * @throws  ValidationException
    *          always; describes the unique constraint violation.
    */
   private void reportUniqueConstraintViolation(BaseRecord dmo, UniqueIndex.Key key, String bufferName)
   throws ValidationException
   {
      BitSet uIndex = new BitSet();
      for (int i : key.fieldPos)
      {
         uIndex.set(i);
      }
      
      String msg = Validation.getFailUniqueIndexMessage(uIndex, bufferName, dmo);
      throw new ValidationException(msg, 132);
   }
   
   /**
    * Context-local API through which {@code UniqueTracker} functionality should be accessed.
    */
   public static class Context
   implements Finalizable,
              Commitable
   {
      /** Scoped list of uncommitted insert/update changes local to this context */
      private final ScopedList<UniqueIndex.Key> changes = new ScopedList<>();
      
      /** The number of pending scopes to be added to {@link #changes}. */
      private int pendingChangesScopes = 0;
      
      /** Scoped list of uncommitted deletes local to this context */
      private final ScopedList<Pair<Long, UniqueIndex.Key>> deletes = new ScopedList<>();
      
      /** The number of pending scopes to be added to {@link #deletes}. */
      private int pendingDeletesScopes = 0;
      
      /** Whether to reverse apply changes/deletes for the innermost scope at end of life */
      private boolean rollback = false;
      
      /** Whether the innermost scope represents a full (vs. sub) transaction */
      private boolean fullTx = false;
      
      public void register(TransactionManager.TransactionHelper txHelper, int blockDepth)
      {
         txHelper.registerFinalizable(this, false);
         txHelper.registerCommit(this);
         
//         log(">>> entry");
         beginTxScope();
//         log("<<< entry");
      }
      
      /**
       * Check the given record's state and update the minimal, necessary unique index(es), based
       * on any dirty properties in the record, or update all unique indexes in the case that
       * {@code dmo} has been newly created and was not previously persisted.
       * <p>
       * If the data within {@code dmo} violates an existing unique constraint, an error is
       * raised. Any changes made up to that point are rolled back automatically.
       *
       * @param   tracker
       *          The unique tracker associated with the affected table.
       * @param   dmo
       *          Record whose state is driving an update to this tracker.
       * @param   bufferName
       *          The name of the buffer. It is used when printing the error message, if any.
       * @param   flush
       *          Whether to flush the record; {@code false} to validate only.
       *          
       * @return  A token which contains information about the update operation. This token must
       *          be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
       *          in order to commit or roll back the update, respectively, and to ensure any
       *          locks acquired by the update are released.
       * 
       * @throws  ValidationException
       *          if the data in {@code dmo} represent a unique constraint violation within any
       *          session's uncommitted transactions.
       */
      public Token lockAndChange(UniqueTracker tracker, BaseRecord dmo, String bufferName, boolean flush)
      throws ValidationException
      {
         return tracker.lockAndChange(dmo, this, bufferName, flush);
      }
      
      /**
       * The given DMO has been deleted in the current transaction. If a unique index already is
       * being tracked for this DMO, it indicates that the DMO previously was inserted or updated
       * in the same transaction. In this case, remove its earlier unique index entry, but
       * remember it in case we need to roll back this delete later. In the event there is no
       * unique index already being tracked, it indicates that the record deleted was not
       * previously inserted or updated during the current transaction. In this case, we store
       * {@code null} for the key in the {@code deletes} we are tracking. Rollback code must
       * tolerate this {@code null}.
       * <p>
       * This operation performs no validation, as no unique constraint can be violated by a
       * delete.
       * 
       * @param   tracker
       *          The unique tracker associated with the affected table.
       * @param   dmo
       *          DMO which was deleted.
       *          
       * @return  A token which contains information about the update operation. This token must
       *          be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
       *          in order to commit or roll back the update, respectively, and to ensure any
       *          locks acquired by the update are released.
       */
      public Token lockAndDelete(UniqueTracker tracker, BaseRecord dmo)
      {
         return tracker.lockAndDelete(dmo, this);
      }
      
      /**
       * Roll back an update operation on zero or more unique keys in this tracker, presumably
       * in response to a unique constraint violation either generated by this tracker or which
       * occurred during a database insert or update.
       * <p>
       * This method should be invoked when a unique constraint violation has occurred during a
       * record validation event.
       * 
       * @param   tracker
       *          The unique tracker associated with the affected table.
       * @param   token
       *          A token returned by {@link #lockAndChange}, used
       *          to roll back any partial or full updates made and to unlock any {@code UniqueIndex}
       *          instances locked by the update.
       */
      public void rollbackChange(UniqueTracker tracker, Token token)
      {
         tracker.rollbackChange(token);
      }
      
      /**
       * Complete the operation begun by {@link #lockAndChange}
       * or by {@link #lockAndDelete(UniqueTracker, BaseRecord)}, using the token returned by the
       * respective method. A commit is no more than the absence of a rollback and an unlocking
       * of any {@code UniqueIndex} instances locked by the update or delete.
       * <p>
       * This method should be invoked when no unique constraint violation has occurred during a
       * record validation event.
       * 
       * @param   token
       *          A token returned by {@link #lockAndChange} or by
       *          {@link #lockAndDelete(UniqueTracker, BaseRecord)}, used to unlock any {@code
       *          UniqueIndex} instances locked by those methods.
       */
      public void unlock(UniqueTracker tracker, Token token)
      {
         tracker.unlock(token);
      }
      
      /**
       * Callback when a looping block is iterated, before business logic in the block is
       * executed. End the current scope and begin a new one.
       */
      @Override
      public void iterate()
      {
//         log(">>> iterate");
         endTxScope();
         beginTxScope();
//         log("<<< iterate");
      }
      
      /**
       * Callback when a block is retried, before business logic in the block is executed.
       * End the current scope and begin a new one.
       */
      @Override
      public void retry()
      {
//         log(">>> retry");
         endTxScope();
         beginTxScope();
//         log("<<< retry");
      }
      
      /**
       * Callback when a block is finished. End the current scope.
       */
      @Override
      public void finished()
      {
//         log(">>> finished");
         endTxScope();
//         log("<<< finished");
      }
      
      /**
       * No-op block callback.
       */
      @Override
      public void deleted()
      {
      }
      
      /**
       * Callback when a full or sub-transaction block is committed.
       * 
       * @param   fullTx
       *          {@code true} if the block is a full transaction block; {@code false} if it is
       *          a sub-transaction block.
       */
      @Override
      public void commit(boolean fullTx)
      {
//         log("commit");
         this.fullTx = fullTx;
         this.rollback = false;
      }
      
      /**
       * Callback when a full or sub-transaction block is rolled back.
       * 
       * @param   fullTx
       *          {@code true} if the block is a full transaction block; {@code false} if it is
       *          a sub-transaction block.
       */
      @Override
      public void rollback(boolean fullTx)
      {
//         log("rollback");
         this.fullTx = fullTx;
         this.rollback = true;
      }

      /**
       * No-op callback.
       */
      @Override
      public void validate(boolean transaction, boolean aggressiveFlush)
      {
      }
      
      /*
      // temporary for debugging
      private final Set<String> entries = new LinkedHashSet<>();
      private final Set<String> finishes = new LinkedHashSet<>();
      
      private void log(String msg)
      {
         Throwable t = new Throwable();
         StackTraceElement[] ste = t.getStackTrace();
         
         String elem = "UNINITIALIZED";
         
         if (ste != null)
         {
            for (int i = 0; i < ste.length; i++)
            {
               StackTraceElement e = ste[i];
               String className = e.getClassName();
               if (className.startsWith("com.goldencode.hotel."))
               {
                  elem = e.toString();
                  
                  if (msg.startsWith(">>> entry"))
                  {
                     entries.add(elem);
                  }
                  else if (msg.startsWith(">>> finished"))
                  {
                     finishes.add(elem);
                  }
                  
                  msg += (" " + elem);
                  
                  break;
               }
            }
         }
         int count = changes.scopes();
         
         if (count == 0 && msg.startsWith(">>>"))
         {
            entries.clear();
            finishes.clear();
            LOG.finer();
            LOG.finer("---------------------------- BEGIN ----------------------------");
         }
         
         LOG.finer("#" + count + " --> " + msg);
         
         if (count == 0 && msg.startsWith("<<<"))
         {
            LOG.finer("----------------------------- END -----------------------------");
            LOG.finer();
            
            Set<String> unmatched = new LinkedHashSet<>(finishes);
            unmatched.removeAll(entries);
            if (!unmatched.isEmpty())
            {
               LOG.finer("finished() calls unmatched by entry() calls:");
               for (String e : unmatched)
               {
                  LOG.finer(e);
               }
               LOG.finer();
            }
         }
      }
      */
      
      /**
       * Begin a transaction scope (full or sub-). This event causes new scopes to be pushed
       * onto the scoped lists of changes and deletes.
       */
      private void beginTxScope()
      {
         // changes.pushScope();
         pendingChangesScopes++;
         // deletes.pushScope();
         pendingDeletesScopes++;
      }
      
      /**
       * End a transaction scope (full or sub-), resulting in a commit or rollback of any changes
       * or deletes in the context's current, uncommitted database transaction, with respect to
       * the {@code UniqueTracker} instances active for this context.
       * <p>
       * A commit simply rolls up any changes/deletes for the current sub-transaction scope to
       * the enclosing scope, if any. A rollback reverse applies the changes/deletes for the current
       * current (sub-)transaction scope. Either way, the current scope is popped.
       */
      private void endTxScope()
      {
         if (pendingChangesScopes == 0 && changes.scopes() <= 0)
         {
            LOG.severe("", new IllegalStateException("Imbalanced Scopes in UniqueTracker"));
            return;
         }
         
         try
         {
            if (fullTx)
            {
               cleanup(false);
            }
            else if (rollback)
            {
               // roll back insert/update changes by removing each of them from their index
               if (pendingChangesScopes == 0)
               {
                  for (UniqueIndex.Key key : changes.sublistAtScope(0))
                  {
                     key.removeFromIndex();
                  }
               }
                  
               if (pendingDeletesScopes == 0)
               {
                  // roll back deletes by adding any deleted unique index keys  back to their index;
                  // if the key is null, it indicates the delete being rolled back was of a record
                  // not inserted or updated in this transaction, so do not try to re-insert in this
                  // case
                  for (Pair<Long, UniqueIndex.Key> pair : deletes.sublistAtScope(0))
                  {
                     Long id = pair.getLeft();
                     UniqueIndex.Key key = pair.getRight();
                     if (key != null)
                     {
                        // if key was not null, it indicates the delete that we are rolling back
                        // was of a record we were tracking, so it had been inserted and/or updated
                        // in the current transaction previous to the delete.
                        key.addToIndex(id);
                        
                        // if the key was null, the delete being rolled back was of a record not
                        // otherwise touched by the current transaction; therefore, the rollback of
                        // the delete already is reflected in this session's version of the
                        // database, so we do not need to track it separately as uncommitted data
                     }
                  }
               }
            }
            else
            {
               // on sub-transaction commit, reversible changes need to roll up to parent scope
               if (pendingChangesScopes == 0)
               {
                  changes.rollUp();
               }
               
               if (pendingDeletesScopes == 0)
               {
                  deletes.rollUp();
               }
            }
         }
         finally
         {
            if (pendingChangesScopes == 0)
            {
               changes.popScope();
            }
            else
            {
               pendingChangesScopes--;
            }
            
            if (pendingDeletesScopes == 0)
            {
               deletes.popScope();
            }
            else
            {
               pendingDeletesScopes--;
            }
         }
      }
      
      /**
       * Clean up as a block is finishing its processing. Optionally cleanup as the current
       * context is exiting.
       * 
       * @param   exit
       *          {@code true} if the context is exiting, not just the current block. This
       *          represents an emergency cleanup.
       */
      private void cleanup(boolean exit)
      {
         // TODO: make lock/unlock more efficient
         for (UniqueIndex.Key key : changes)
         {
            key.removeFromIndex();
         }
         
         // TODO: is this sufficient cleanup on context end? Don't we need to rollback this
         // context's impact on shared resources?
         if (exit)
         {
            pendingChangesScopes = 0;
            while (changes.scopes() > 0)
            {
               changes.popScope();
            }
            
            pendingDeletesScopes = 0;
            while (deletes.scopes() > 0)
            {
               deletes.popScope();
            }
         }
      }
   }
   
   /**
    * An object which holds information about the set of {@code UniqueIndex} objects locked and
    * updated by an operation on a particular {@code UniqueTracker} instance. A token is created
    * by {@link UniqueTracker#lockAndChange} or {@link
    * UniqueTracker#lockAndDelete(BaseRecord, Context)}, and must subsequently be passed to
    * {@link UniqueTracker#unlock(Token)} or {@link UniqueTracker#rollbackChange(Token)}.
    * The latter method uses the token to roll back the changes made in an update or delete
    * operation. Both methods use the token to unlock any locked {@code UniqueIndex} instances.
    */
   public static class Token
   {
      /** Primary key of record indexed by a unit of work */
      private final Long id;
      
      /** Pairs of {@code UniqueIndex} offsets and old {@code Key} values */
      private final List<Pair<Integer, UniqueIndex.Key>> affected;
      
      /**
       * Constructor.
       * 
       * @param   id
       *          Primary key of the record involved in the operation described by this token.
       * @param   affected
       *          Pairs of {@code UniqueIndex} offsets in their array within the tracker, and
       *          {@code Key} instances which were affected by the operation.
       */
      private Token(long id, List<Pair<Integer, UniqueIndex.Key>> affected)
      {
         this.id = id;
         this.affected = affected;
      }
   }
   
   /**
    * The data related to a specified unique index. This holds all the unique records added for
    * an index of a specific {@code DMO}.
    */
   private static class UniqueIndex
   {
      /** Mutex lock */
      private final ReentrantLock lock = new ReentrantLock();
      
      /**
       * The set of fields of the {@code BaseRecord} which comprise the index. The order is
       * irrelevant and this array uses the 'natural' order in which the fields were declared at
       * the definition of the table.
       */
      private final int[] fieldPos;
      
      /** Bitset whereby set bits describe the components of the index by position in the data */
      private final BitSet fieldMap;
      
      /**
       * The set of unique records, keyed by {@code Key}, mapping to the original
       * {@code id} of the record.
       */
      private final Map<Key, Long> records = new HashMap<>();
      
      /** Unique index keys mapped by primary keys */
      private final Map<Long, Key> entries = new HashMap<>();
      
      /**
       * Creates a new structure that contains data related to an unique index of a table.
       * 
       * @param   fieldMap
       *          The bitmap of the index, that is, the bits corresponding to field component of
       *          the index.
       */
      public UniqueIndex(BitSet fieldMap)
      {
         this.fieldMap = fieldMap;
         
         // convert from BitSet to an array of the offsets of the component fields. The size of
         // the array is the length of index component. We can now access the k field directly
         // using record.data[fieldPos[k]].
         this.fieldPos = fieldMap.stream().toArray();
      }
      
      /**
       * Lock this resource exclusively.
       */
      void lock()
      {
         lock.lock();
      }
      
      /**
       * Release the lock on this resource.
       */
      void unlock()
      {
         lock.unlock();
      }
      
      /**
       * Create a {@code Key} for the given record within the current index. This is just
       * a wrapper for the {@code Key} constructor with access to its implicit {@code
       * UniqueIndex.this} reference.
       * 
       * @param   dmo
       *          Database record.
       * 
       * @return  {@code Key} instance.
       */
      Key createKey(BaseRecord dmo)
      {
         return new Key(this, dmo.data);
      }
      
      /**
       * The key used for tracking a record within an unique index.
       */
      private class Key
      {
         /** The snapshot of the record at the moment of indexing. */
         private final Object[] snapshot;
         
         /**
          * The fields that the unique index is compounded of. Having the offsets in an int array
          * is faster than computing them bitwise from a {@code BitSet}. 
          */
         private final int[] fieldPos;
         
         /** The pre-computed {@code hash} code */
         private final int hash;
         
         /** 
          * Flags whether the record has {@code null} values for fields components of this unique
          * index. In this case the record is unique.
          */
         private boolean hasNulls;
         
         /**
          * Creates a new, immutable {@code Key} object. For performance reasons, the 
          * {@code hash} code will be computed only once and the {@code hasNulls} will also be
          * evaluated.
          * 
          * @param   uniqueIndex
          *          The components of the index, by their position in {@code data} array.
          * @param   data
          *          The values of the fields in the record (a reference will be saved, not
          *          copied).
          */
         public Key(UniqueIndex uniqueIndex, Object[] data)
         {
            snapshot = data;
            fieldPos = uniqueIndex.fieldPos;
            hash = computeHash();
         }
         
         /**
          * Test for equality with a specified object. The test is done using the 4GL uniqueness
          * semantics: two records are unique from the point of view of an unique index if the
          * compound fields (their index in {@code data} array is specified in the {@code
          * fieldPos}) must be different except when one of them is {@code null}, in which case
          * the records are unique.
          *
          * @param   o
          *          The object to be tested against.
          *
          * @return  {@code true} if and only if the two keys are equals from the point of view of
          *          the 4GL uniqueness semantics. 
          */
         @Override
         public boolean equals(Object o)
         {
            // basic tests:
            
            // identity:
            if (this == o)
            {
               return true;
            }
            
            // null comparison
            if (o == null)
            {
               return false;
            }
            
            // just in case this was skipped: check hashes:
            Key that = (Key) o;
            if (this.hash != that.hash)
            {
               return false;
            }
            
            // in P4GL 'unique' semantics, a record with an unknown (null) field as index component
            // will make the record unique; this intentionally departs from the implementations of
            // hashCode and equals being consistent, to ensure separate Key instances are
            // stored if all else is the same, except that one or both contain null
            if (this.hasNulls || that.hasNulls)
            {
               return false;
            }
            
            // check for equality of fields in index:
            int len = fieldPos.length;
            for (int i = 0; i < len; i++)
            {
               int position = fieldPos[i];
               if (!Objects.equals(this.snapshot[position], that.snapshot[position]))
//               if (!this.snapshot[position].equals(that.snapshot[position]))
               {
                  return false;
               }
            }
            
            return true;
         }
         
         /**
          * Composes and return a short description of this object which includes the values of the indexed
          * fields.
          * 
          * @return  a short description of this object.
          */
         @Override
         public String toString()
         {
            StringBuilder sb = new StringBuilder("Kuid{");
            for (int i = 0; i < fieldPos.length; i++)
            {
               if (i != 0)
               {
                  sb.append(":");
               }
               sb.append(snapshot[fieldPos[i]]);
            }
            sb.append('}');
            return sb.toString();
         }
         
         /**
          * Obtain the hash code for this unique key.
          * 
          * @return  the hash code for this unique key.
          */
         @Override
         public int hashCode()
         {
            return hash;
         }
         
         /**
          * Add this key to its enclosing {@code UniqueIndex}. The index is locked before the
          * operation and released after.
          * 
          * @param   id
          *          Primary key to associate with this key.
          */
         void addToIndex(Long id)
         {
            lock();
            
            try
            {
               records.put(this, id);
               entries.put(id, this);
            }
            finally
            {
               unlock();
            }
         }
         
         /**
          * Remove this key from its enclosing {@code UniqueIndex}. The index is locked before
          * the operation and released after.
          */
         void removeFromIndex()
         {
            lock();
            
            try
            {
               Long id = records.remove(this);
               if (id != null)
               {
                  entries.remove(id);
               }
            }
            finally
            {
               unlock();
            }
         }
         
         /**
          * Computes the hash code for this unique key. Only the compound fields of the current index
          * from the record are taken into consideration, the other are ignored. 
          * 
          * @return  the hash code for this unique key.
          */
         private int computeHash()
         {
            int res = 0;
            int len = fieldPos.length;
            for (int i = 0; i < len; i++)
            {
               int position = fieldPos[i];
               Object o = snapshot[position];
               if (o == null)
               {
                  hasNulls = true;
               }
               res = res * 37 + (o == null ? 97 : o.hashCode());
            }
            
            return res;
         }
      }
   }
}