DirtyShareContext.java

/*
** Module   : DirtyShareContext.java
** Abstract : The implementation of DirtyShareContext
**
** Copyright (c) 2004-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- --JPRM-- -----------------------------------Description-----------------------------------
** 001 ECF 20080420   @38626 Created initial version. Client context's
**                           primary bridge to the DirtyShareManager.
**                           Manages dirty state specific to the current
**                           context.
** 002 ECF 20080610   @38682 Added safety logic to prevent NPEs.
** 003 ECF 20080612   @38719 Fixed getDirtyInfo(). If an uncommitted
**                           delete was made before the backing table in
**                           the dirty database was created, we could
**                           attempt a query nonetheless and bomb.
** 004 ECF 20080627   @39073 Minor optimization. Avoid collection empty
**                           check is some cases.
** 005 CA  20080716   @39122 Renamed method lockUniqueIndexes to 
**                           lockAllIndexes - this will lock all the 
**                           indexes for a certain entity. Modified the
**                           delete(...) and rollbackInsert(...) methods
**                           so the index locking can be disabled.
** 006 ECF 20080815   @39542 Added support for global change events. These
**                           are events that represent index-changing
**                           inserts, updates, or deletes, which are
**                           processed by sessions other than the session
**                           in which the event originated.
** 007 ECF 20081211   @40869 Fixed delete()/rollbackInsert() defect. The
**                           deletion of a record which was inserted in
**                           the same transaction rolls back the insert,
**                           but does not track the record as deleted.
**                           This caused a problem when the insert itself
**                           was later rolled back. Such records are now
**                           tracked via the deadInserts set.
** 008 ECF 20090428   @42129 Added isLocalInsert(). Reports whether a record
**                           is being tracked as newly inserted by this
**                           context.
** 009 ECF 20090513   @42173 Modified update() method. Need to pass new lock
**                           parameter to modified DirtyShareManager update()
**                           method.
** 010 ECF 20090601   @42569 Added support for Progress isolation leak quirk.
**                           Added new methods.
** 011 ECF 20090610   @42639 Modified updateSnapshots() method. Now returns
**                           list of DMOs affected and tracks entities whose
**                           uncommitted changes have been leaked. The list of
**                           such entities is now used during cleanup.
** 012 ECF 20090611   @42661 Optimized updateSnapshots(). In a long-running
**                           transaction with many updates, it was doing much
**                           unnecessary work. We now track which DMOs need to
**                           have their snapshots updated and refresh only
**                           these.
** 013 CA  20090721   @43285 Fixed rollback of dirty records, when a record is
**                           in dead insert mode - the changes map should not 
**                           be checked in this case.
** 014 ECF 20090721   @43307 Modified delete(). If the record being deleted is
**                           already being tracked for changes, it must be
**                           removed from the changes and dirtySnapshots
**                           collections.
** 015 ECF 20090819   @43692 Added new update() variant. Used when it is known
**                           that the target DMO already is being tracked by
**                           the dirty share manager.
** 016 ECF 20110405          Fixed dirty change leak. Remove record identifier
**                           from deadInserts set upon insert to avoid error
**                           during rollbackInsert() for same record later.
**                           Otherwise, a record is leaked in the dirty share
**                           manager for the life of the server process. Also
**                           fixed (separate) minor bug in rollbackInsert()
**                           which was not removing dirtySnapshot identifiers
**                           when their corresponding records were removed
**                           from dirty share tracking.
** 017 ECF 20131028          Import change.
** 018 ECF 20140821          Changed insert API to allow insertion of unvalidated, newly created
**                           records.
** 019 OM  20160307          Added support for EXTENTs in update() methods. Updated signature of
**                           getDirtyInfo().
** 020 ECF 20160608          Removed unnecessary intern calls.
** 021 ECF 20160917          Added ignoreLocal parameter to isDirtyDelete.
** 022 ECF 20171217          Prevent error trying to merge null DMOs in updateSnapshots.
**         20171219          Avoid using incorrect DMO in update during new record creation.
** 023 ECF 20180719          Fixed defect in optimization in getDirtyInfo.
** 024 ECF 20200901          New ORM implementation.
** 025 OM  20200929          Reset the temporary NEW flag after record is saved to dirty database.
**     OM  20210419          After a record is successfully inserted in database table, the buffer storing it
**                           is removed from early inserts list of dirty context.
**     OM  20221103          New class names for FQLPreprocessor, FQLExpression, FQLBundle, and FQLCache.
** 026 AL2 20230822          Removed readOnly parameter.
** 027 AL2 20240319          Extended isEntityDirty to allow checking only the current context.
** 028 TJD 20240123          Java 17 compatibility updates
** 029 TJD 20230421          Allow reuse of DELETED and then UPDATED dirty share records
**     TJD 20230508          Updates needed to support dirty records leaks to other sessions on FIND
**     TJD 20230724          Migrate earlyPublishEntities from List to Set to improve performance
**     TJD 20240109          Fixes and updates to cross section DirtyShare
**     TJD 20240308          DirtyShare optimizations and simplifications
**     TJD 20240318          Support for cases with released records
**     TJD 20240604          Support for cases with records modified after initial flushing
**     TJD 20240619          Fix intra session earlyInserts leaks
**     AL2 20240903          Added dirtyData as a marker on the data that was actually changed.
** 030 AL2 20241021          Adapt getDirtyInfo for CAN-FIND cases in which the buffer is not loaded with the
**                           actual DMO as a result of the query execution.
** 031 OM  20241128          Multi-tenant runtime support: selected the proper persistence context, eventually
**                           based on [sharedDb] parameter.
** 032 AL2 20250110          Use entity and DMO instead of buffer when updating.
** 033 ES  20250426          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.dirty;

import java.util.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.Record;
import com.goldencode.p2j.persist.event.*;
import com.goldencode.p2j.persist.lock.*;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

/**
 * Default implementation of a client context's primary interface to store
 * uncommitted index updates for sharing with other contexts, and to read
 * uncommitted index updates made by other contexts.  This is intended to
 * support compatibility with Progress' peculiar transaction isolation
 * behavior of immediately sharing updates to indexed fields across user
 * sessions, but deferring the sharing of updates to non-indexed fields until
 * the enclosing transaction is committed.
 * <p>
 * This implementation maintains state specific to the current context, in
 * order both to filter information appropriately, and to be able to clean up
 * shared data when the current context's transactions are completed.
 * 
 * @see  DirtyShareManager
 * @see  DefaultDirtyShareManager
 */
public final class DirtyShareContext
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(DirtyShareContext.class.getName());
   
   /** Object which manages dirty reads for the database */
   private final DirtyShareManager manager;
   
   /** Identifiers of records inserted by this context */
   private Set<RecordIdentifier<String>> inserts = null;
   
   /** Identifiers of records leaked by this context */
   private Set<RecordIdentifier<String>> leaks = null;
   
   /** Identifiers of records inserted by this context, which deletes have rolled back */
   private Set<RecordIdentifier<String>> deadInserts = null;
   
   /** Buffers holding newly created records, which have not yet had all their indices updated */
   private Map<RecordIdentifier<String>, RecordBuffer> earlyInserts = null;
   
   /** Map of entity names to sets of IDs inserted/updated by this context */
   private Map<String, Set<Long>> changes = null;
   
   /** Map of entity names to sets of IDs deleted by this context */
   private Map<String, Set<Long>> deletes = null;
   
   /** Set of IDs of DMOs changed since last updateSnapshots() call */
   private Set<RecordIdentifier<String>> dirtySnapshots = null;
   
   /**
    * Constructor which stores a reference to the dirty share manager for the
    * associated database.
    * 
    * @param   manager
    *          Dirty share manager for the associated database.
    */
   DirtyShareContext(DirtyShareManager manager)
   {
      this.manager = manager;
   }
   
   /**
    * Register with a global event clearinghouse an interest in insert,
    * delete, and update events which affect the given DMO entity.
    * 
    * @param   entity
    *          DMO entity for which the caller is interested in tracking
    *          change.
    * 
    * @return  A unique identifier for the registrant, assigned by the event
    *          clearinghouse, used later to collect events and deregister.
    * 
    * @see     #getGlobalEvents(long, long)
    * @see     #deregisterForGlobalEvents(long)
    */
   public Long registerForGlobalEvents(String entity)
   {
      return manager.registerForGlobalEvents(entity);
   }
   
   /**
    * Deregister with a global event clearinghouse an interest in insert,
    * delete, and update events which affect a particular DMO type.
    * 
    * @param   registerID
    *          A unique identifier for the registrant being deregistered.
    * 
    * @see     #getGlobalEvents(long, long)
    * @see     #registerForGlobalEvents(String)
    */
   public void deregisterForGlobalEvents(long registerID)
   {
      manager.deregisterForGlobalEvents(registerID);
   }
   
   /**
    * Retrieve any new events that have been collected by a global event
    * clearinghouse, representing inserts, deletes, and updates made to
    * record types for which the caller previously has registered interest.
    * 
    * @param   registerID
    *          A unique identifier for an object which has registered interest
    *          in record-changing events.
    * @param   lastID
    *          A unique identifier representing the last event retrieved by
    *          the caller.  If less than 0, it is assumed no events have yet
    *          been retrieved since the caller registered interest in these
    *          events.
    * 
    * @return  Array of applicable, new events, or <code>null</code> if no
    *          such events have been collected.
    * 
    * @throws  EventRegistrationException
    *          if <code>registerID</code> is invalid or has expired.
    */
   public GlobalChangeEvent[] getGlobalEvents(long registerID, long lastID)
   throws EventRegistrationException
   {
      return manager.getGlobalEvents(registerID, lastID);
   }
   
   /**
    * Lock or unlock all indexes associated with the given DMO entity.
    * 
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation
    *          class associated with a table being tracked for uncommitted
    *          changes.
    * @param   lockType
    *          Type of lock to acquire, or <code>NONE</code> to release
    *          existing locks.  <code>NO_WAIT</code> variants will be treated
    *          as regular, blocking variants.
    * 
    * @throws  PersistenceException
    *          if there is an error querying JDBC metadata while retrieving
    *          index information.
    */
   public void lockAllIndexes(String entity, LockType lockType)
   throws PersistenceException
   {
      manager.lockAllIndexes(entity, lockType);
   }
   
   /**
    * Begin tracking a newly inserted record in the dirty database.
    * <p>
    * This record is removed when the adding context's current transaction is committed or rolled back.
    * 
    * @param   buffer
    *          Record buffer responsible for the insert.
    * @param   dmo
    *          Record to be tracked.
    * @param   lock
    *          {@code true} to acquire write locks to insert the record;
    *          {@code false} to assume write locks are acquired by calling code.
    * @param   dirtyData
    *          The data that was changed and caused this update. This is explicitly sent
    *          because sometimes we can't infer the dirty props from the DMO anymore.
    * 
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public void insert(RecordBuffer buffer, Record dmo, boolean lock, DirtyData dirtyData)
   throws PersistenceException
   {
      if (dmo == null)
      {
         // no current buffer, no-op
         return;
      }
      String entity = buffer.getEntityName();
      Long id = dmo.primaryKey();
      RecordIdentifier<String> ident = new RecordIdentifier<>(entity, id);
      
      // make a copy of the DMO to share with other contexts
      Record copy = dmo.snapshot();
      
      // if record comes from record nursery and needs validation mark it as such
      if (dmo.needsValidation())
      {
         if (earlyInserts == null)
         {
            earlyInserts = new HashMap<>();
         }
         
         earlyInserts.putIfAbsent(ident, buffer);
      }
      
      if (deadInserts != null)
      {
         // Remove this record from the dead inserts set if we were tracking
         // it as such;  this will prevent any problems if we later have to
         // roll back this insert.
         deadInserts.remove(ident);
      }
      
      // store in database
      manager.insert(entity, copy, dmo.getIndexState(), lock, dirtyData);
      
      // Track insert in local context.
      if (inserts == null)
      {
         inserts = new HashSet<>();
      }
      inserts.add(ident);
      trackChange(entity, id);
      
      // the NEW flag was added above to instruct the [Persister] that this is a new record for this database
      // and it needs to be inserted, not updated, but it was not cleared because the second parameter of
      // [session.save(copy, false);] prevented the flag to be unset. Doing this now:
      copy.updateState(null, DmoState.NEW, false);
   }
   
   /**
    * Indicate whether the specified record is being tracked as newly inserted
    * by this context.
    * 
    * @param   ident
    *          Record identifier of the record to be checked.
    * 
    * @return  <code>true</code> if this record is newly inserted by this
    *          context, else <code>false</code>.
    */
   public boolean isTracked(RecordIdentifier<String> ident)
   {
      if (changes != null && changes.containsKey(ident.getTable()))
      {
         if (changes.get(ident.getTable()).contains(ident.getRecordID())) 
         {
            return true;
         }
      }
      
      return manager.isTracked(ident.getTable(), ident.getRecordID());
   }
   
   /**
    * If the specified record is an unvalidated insert created by a buffer local to this context,
    * get the buffer which created it.
    * 
    * @param   ident
    *          Record identifier of the record to be checked.
    * 
    * @return  Creating buffer, or <code>null</code> if the record is not a locally created,
    *          unvalidated insert.
    */
   public RecordBuffer getCreatingBuffer(RecordIdentifier<String> ident)
   {
      return earlyInserts != null ? earlyInserts.get(ident) : null;
   }
   
   /**
    * Rollback the insert of a record previously introduced with the {@link
    * #insert(RecordBuffer, Record, boolean, DirtyData)} method.
    * 
    * @param   ident
    *          Identifier for the target record, which encapsulates its entity
    *          name and primary key.
    * @param   lock
    *          <code>true</code> to acquire write locks;
    *          <code>false</code> to assume write locks are acquired by
    *          calling code.
    * 
    * @throws  IllegalStateException
    *          if the record was not inserted in this context during the
    *          current transaction
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public void rollbackInsert(RecordIdentifier<String> ident, boolean lock)
   throws PersistenceException
   {
      // if the target record was already deleted from the dirty context, then
      // the changes map check is not relevant (it may be empty if only one
      // record is in the dirty context and this is a dead record)
      if ((deadInserts == null || !deadInserts.contains(ident)) && 
          (changes == null || changes.isEmpty() || 
           inserts == null || !inserts.contains(ident)))
      {
         throw new IllegalStateException(
            "No dirty entry for '" + ident + "';  cannot roll back insert");
      }
      
      try
      {
         if (deadInserts != null && deadInserts.remove(ident))
         {
            // This insert occurred within the existing transaction, and was
            // rolled back by a subsequent delete within the transaction, so
            // it already has been removed from the dirty share manager.  We
            // cannot remove it again as this will cause an error.
            
            if (deadInserts.isEmpty())
            {
               deadInserts = null;
            }
            
            return;
         }
         
         // Remove from local context insert tracking.
         inserts.remove(ident);
         if (earlyInserts != null)
         {
            earlyInserts.remove(ident);
         }
         
         // Remove from database.
         manager.rollbackInsert(ident, lock);
      }
      finally
      {
         // Remove from local context change tracking.
         String entity = ident.getTable();
         Long id = ident.getRecordID();
         Set<Long> ids = changes.get(entity);
         if (ids != null && ids.remove(id))
         {
            if (ids.isEmpty())
            {
               changes.remove(entity);
            }
            dirtySnapshots.remove(ident);
         }
      }
   }
   
   /**
    * Notify this object that a record was inserted into database and should not be tracked any more in
    * {@code earlyInserts}.
    * 
    * @param   id
    *          The identifier of the record.
    */
   public void recordInserted(RecordIdentifier<String> id)
   {
      if (earlyInserts != null && !earlyInserts.isEmpty())
      {
         earlyInserts.remove(id);
      }
   }
   
   /**
    * Track an update to one or more indexed, DMO properties, as indicated by the 
    * {@code properties}, {@code extIndexes} and {@code values} arguments.
    * <p>
    * Any record added to the dirty database as a result of this method is removed when the adding
    * context's current transaction is committed or rolled back.
    * <p>
    * Assumes appropriate indexes have been locked by the caller.
    *
    * @param   entity
    *          The entity table for which the DMO is provided
    * @param   dmo
    *          The DMO that should be registered as updated to the dirty-share mechanism.
    * @param   transientIndexValidated
    *          {@code true} if the update was caused by an index validation of a transient record.
    * @param   dirtyData
    *          The data that was changed and caused this update. This is explicitly sent
    *          because sometimes we can't infer the dirty props from the DMO anymore.
    *
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public void update(String entity,
                      Record dmo,
                      boolean transientIndexValidated,
                      DirtyData dirtyData)
   throws PersistenceException
   {
      if (dmo == null)
      {
    	  // no-op
    	  return;
      }
      
      Long id = dmo.primaryKey();
      
      // determine whether the dirty share manager is already tracking changes to this record from
      // a previous insert or update.  If not, begin tracking the record, then update it. This set
      // of calls is assumed to be "atomic", since we must have an exclusive lock on the record.
      if (manager.isTracked(entity, id))
      {
         manager.update(entity,
                        id,
                        transientIndexValidated,
                        false,
                        dirtyData);
      }
      else
      {
         // get a snapshot of a current record if not yet tracked and start tracking it
         // not sure if this ever will be called as most cases are handled by insert now.
         Record copy = dmo.snapshot();
         copy.updateState(null, DmoState.NEW, true); // the record is new relative to DIRTY database
         
         manager.update(entity,
                        copy,
                        false,
                        dirtyData);
         copy.updateState(null, DmoState.NEW, false);
      }
      
      // Track update in local context.
      trackChange(entity, id);
   }
   
   /**
    * Mark a record as having been deleted in an uncommitted transaction, or
    * unmark a record which previously was so marked.
    * <p>
    * If marking a record as deleted, that was earlier inserted by the current
    * context, rollback the insert, but do not mark the record as deleted.
    * <p>
    * A record is unmarked when the adding context's current transaction is
    * committed or rolled back.
    * 
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation
    *          class associated with a table being tracked for uncommitted
    *          changes.
    * @param   id
    *          Primary key of deleted record.
    * @param   rollback
    *          If <code>false</code>, the target record is marked as deleted;
    *          if <code>true</code>, it is unmarked.
    * @param   lock
    *          <code>true</code> to acquire write locks to delete the record;
    *          <code>false</code> to assume write locks are acquired by
    *          calling code.
    * 
    * @throws  PersistenceException
    *          if a database error occurs rolling back an earlier insert.
    */
   public void delete(String entity, Long id, boolean rollback, boolean lock)
   throws PersistenceException
   {
      RecordIdentifier<String> ident = new RecordIdentifier<>(entity, id);
      
      if (!rollback)
      {
         // Special case 1:  if we are deleting a record we previously
         // inserted in the same transaction, remove the record, but do not
         // update the shared deleted records state.
         if (inserts != null && inserts.contains(ident))
         {
            rollbackInsert(ident, lock);
            if (deadInserts == null)
            {
               deadInserts = new HashSet<>(8);
            }
            deadInserts.add(ident);
            
            return;
         }
         
         // Special case 2:  if we are deleting a record we previously
         // updated in the same transaction, remove the record, but still
         // update the shared deleted records state.
         if (changes != null)
         {
            Set<Long> changed = changes.get(entity);
            if (changed != null && changed.remove(id))
            {
               if (changed.isEmpty())
               {
                  changes.remove(entity);
               }
            }
         }
         if (dirtySnapshots != null)
         {
            dirtySnapshots.remove(ident);
         }
      }
      
      // Update shared state.
      try
      {
         manager.delete(entity, id, rollback, lock);
      }
      finally
      {
         // Track state locally, so we can clean up at end of transaction.
         if (rollback)
         {
            if (deletes != null)
            {
               Set<Long> ids = deletes.get(entity);
               if (ids != null)
               {
                  if (ids.remove(id) && ids.isEmpty())
                  {
                     deletes.remove(entity);
                  }
               }
            }
         }
         else
         {
            Set<Long> ids = null;
            if (deletes == null)
            {
               deletes = new HashMap<>();
            }
            else
            {
               ids = deletes.get(entity);
            }
            if (ids == null)
            {
               ids = new HashSet<>();
               deletes.put(entity, ids);
            }
            ids.add(id);
         }
      }
   }
   
   /**
    * Indicate whether there are currently any uncommitted changes (inserts,
    * updates, or deletes) in the table represented by the given DMO entity
    * name.
    * 
    * @param   entity
    *          Fully qualified name of the DMO entity to be checked.
    * 
    * @return  <code>true</code> if there any pending, uncommitted changes in
    *          the backing table, else <code>false</code>.
    */
   public boolean isEntityDirty(String entity)
   {
      return isEntityDirty(entity, false);
   }
   
   /**
    * Indicate whether there are currently any uncommitted changes (inserts,
    * updates, or deletes) in the table represented by the given DMO entity
    * name.
    * 
    * @param   entity
    *          Fully qualified name of the DMO entity to be checked.
    * @param   localOnly
    *          Check for the dirty status only in the current context.
    * 
    * @return  <code>true</code> if there any pending, uncommitted changes in
    *          the backing table, else <code>false</code>.
    */
   public boolean isEntityDirty(String entity, boolean localOnly)
   {
      if (localOnly)
      {
         if (changes != null && changes.containsKey(entity))
         {
            return true;
         }
         else if (deletes != null && deletes.containsKey(entity))
         {
            return true;
         }
         
         return false;
      }
      else
      {
         int dirtyState = manager.getEntityDirtyState(entity);
         return (dirtyState != DirtyInfo.NONE);
      }
   }
   
   /**
    * Indicate whether the record specified by the given entity and primary key has
    * been deleted within an uncommitted transaction within a context, optionally
    * ignoring deletes in the current context.
    * 
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation
    *          class associated with a table being tracked for uncommitted
    *          changes.
    * @param   id
    *          Primary key of the record to be checked.
    * @param   ignoreLocal
    *          Ignore a delete if it occurred in the current context and only report
    *          whether a delete of the given record occurred in a different context.
    * 
    * @return  {@code true} if the specified record has been deleted, else {@code false}.
    */
   public boolean isDirtyDelete(String entity, Long id, boolean ignoreLocal)
   {
      if (!manager.isDeleted(entity, id))
      {
         return false;
      }
      
      if (ignoreLocal)
      {
         // It is marked deleted in the shared space, but if this context is the
         // one that deleted it, return false.
         Set<Long> deleted = null;
         if (deletes != null)
         {
            deleted = deletes.get(entity);
         }
         
         return (deleted == null || !deleted.contains(id));
      }
      
      return true;
   }
   
   /**
    * Retrieve from the dirty database the record specified by the given
    * entity and primary key, if it exists, and if it was not put there by the
    * current context.
    * 
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation
    *          class associated with a table being tracked for uncommitted
    *          changes.
    * @param   id
    *          Primary key of the record to be retrieved.
    * 
    * @return  Target record if it exists in the dirty database, and was not
    *          put there by the current context, else <code>null</code>.
    * 
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public Record getDirtyDMO(String entity, Long id)
   throws PersistenceException
   {
      // If we're the context making the change, we don't need the dirty
      // copy, since it's the same as the one we have in our current session.
      if (changes != null)
      {
         Set<Long> changed = changes.get(entity);
         if (changed != null && changed.contains(id))
         {
            return null;
         }
      }
      
      return manager.getDirtyDMO(entity, id);
   }
   
   /**
    * Execute an HQL query and return the results as a list.  If the query returns multiple
    * results per row, each element in the list will be an array of {@code Object}s.
    * <p>
    * No locking is attempted.
    *
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation class associated
    *          with a table being tracked for uncommitted changes. Should be {@code null}
    *          for a multi-table query.
    * @param   fql
    *          FQL query statement. This method makes no assumptions as to the types of object(s) returned.
    * @param   params
    *          Substitution values for the query. If none, this should be an empty array.
    * @param   maxResults
    *          The maximum number of elements to be returned in the list. If this value is
    *          non-positive, no upper limit is applied.
    * @param   startOffset
    *          The 0-based offset of the first record to retrieve.  If this value is non-positive,
    *          an offset of 0 is used by default.
    *
    * @return  A list of results, or {@code null} if no result was found.
    *
    * @throws  PersistenceException
    *          if there was an error executing the query.
    */
   public <T> List<T> list(String entity,
                           String fql,
                           Object[] params,
                           int maxResults,
                           int startOffset)
   throws PersistenceException
   {
      // if a specific entity is provided, check its dirty state to provide a quick out for the
      // common case
      if (entity != null)
      {
         int dirtyState = manager.getEntityDirtyState(entity);
         if ((dirtyState & DirtyInfo.CHANGE) == DirtyInfo.NONE)
         {
            return null;
         }
      }
      
      // query the dirty share manager
      return manager.list(fql, params, maxResults, startOffset);
   }
   
   /**
    * Given certain search criteria and possibly a potential DMO match in the primary database,
    * report any significant information currently being tracked for uncommitted transactions,
    * which might override data (or the lack thereof) found in the primary database.
    * <p>
    * If a {@link DirtyInfo} object is returned by this method, it will contain overriding
    * information, such as a dirty DMO which matched the search criteria, whether that record was
    * newly inserted, or the fact that the candidate record found in the primary search has been
    * deleted or modified in an uncommitted transaction.  A {@code null} return indicates no
    * overriding information was available.
    * 
    * @param   buffer
    *          Record buffer associated with the current query.
    * @param   index
    *          UID of index which governs the ordering of data in the current query.
    * @param   queryType
    *          Type of query.
    * @param   bundle
    *          An object containing one or more FQL statements to be executed against the dirty
    *          database to find a record which matches the current query criteria.  The list is in
    *          order of most specific to least specific search criteria.
    * @param   params
    *          Query substitution parameters which match placeholders in the most specific FQL
    *          statement.
    * @param   found
    *          DMO, if any, found in the primary database, which matches the current query criteria.
    * @param   byRowid
    *          The predicate of the query does a lookup for ROWID/RECID of the table. In case of
    *          transient records they are accessible without the need of having a full matched
    *          index.
    * @param   bufferLoadedByQuery
    *          The buffer associated with the current query is going to be loaded by the query.
    *          This doesn't happen for CAN-FIND, that uses the buffer only to reference the entity, not
    *          for actual loading.
    *
    * @return  An object which summarizes the findings, or {@code null} if no uncommitted change
    *          information was available for the given arguments.
    *
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public DirtyInfo getDirtyInfo(RecordBuffer buffer,
                                 int index,
                                 int queryType,
                                 FQLBundle bundle,
                                 Object[] params,
                                 Record found,
                                 boolean byRowid,
                                 boolean bufferLoadedByQuery)
   throws PersistenceException
   {
      String entity = buffer.getEntityName();
      int dirtyState = manager.getEntityDirtyState(entity);
      
      // quick out (hopefully the common case): if there are no changes pending for the target
      // entity, we can bypass the check altogether
      if (dirtyState == DirtyInfo.NONE)
      {
         return null;
      }
      
      Long foundID = (found != null ? found.primaryKey() : null);
      
      // if we found a record in the primary database with a unique find, and we already are
      // tracking that record for changes/insert in this context (and it is validated), the
      // primary database in this session already reflects reality, and we don't need to check
      // the dirty database
      if (foundID != null && queryType == QueryConstants.UNIQUE && changes != null)
      {
         Set<Long> ids = changes.get(entity);
         if (ids != null && ids.contains(foundID) &&
             (earlyInserts == null || !earlyInserts.containsKey(new RecordIdentifier<>(entity, foundID))))
         {
            return null;
         }
      }
      
      // has the record found in primary database been deleted?
      // if record was deleted and changed afterwards we should continue here
      if ((dirtyState & DirtyInfo.DELETE) == DirtyInfo.DELETE  &&
         ((dirtyState & DirtyInfo.CHANGE) != DirtyInfo.CHANGE) &&
          foundID != null                                      &&
          isDirtyDelete(entity, foundID, false))
      {
         DirtyInfo info = new DirtyInfo();
         info.setDeleted();
         
         return info;
      }
      
      DirtyInfo info = null;
      
      if ((dirtyState & DirtyInfo.CHANGE) == DirtyInfo.CHANGE)
      {
         List<String> statements = bundle.getDirtyStatements();
         
         info = manager.getDirtyInfo(entity, foundID, index, statements, params, byRowid);
         
         if (info == null)
         {
            return null;
         }
         
         Record dirtyDMO = info.getDirtyDMO();
         if (dirtyDMO != null && changes != null)
         {
            // ignore validated changes that are driven by this context; they are already
            // reflected in the primary database
            Long id = dirtyDMO.primaryKey();
            RecordIdentifier<String> ident = new RecordIdentifier<>(entity, id);
            Set<Long> changed = changes.get(entity);
            // if dmo is changed in the current context
            if (changed != null &&
                changed.contains(id))
            {
               // first check whether the dirty DMO represents an unvalidated record being managed by this
               // context; if so, replace the copy generated by the dirty database with the actual
               // record being managed for the primary database.
               if (earlyInserts != null && earlyInserts.containsKey(ident))
               {
                  RecordBuffer creatingBuffer = earlyInserts.get(ident);
                  // if DMO was created by a different buffer replace DirtyInfo with local version
                  if ((!bufferLoadedByQuery || creatingBuffer != buffer) && creatingBuffer.isTransient())
                  {
                     Record localDMO = creatingBuffer.getCurrentRecord();
                     info.setInserted();
                     info.setLocal();
                     info.setDirtyDMOs(localDMO, localDMO);
                  }
                  else
                  {
                     // ignore DirtyInfo result in other case
                     return null;
                  }
               }
               // otherwise if we have a candidate and it exists in DirtyShare database ignore DirtyInfo,
               // our candidate shall prevail
               else if (foundID != null && info.isModified())
               {
                  return null;
               }
 
            }
         }
      }
      
      return info;
   }
   
   /**
    * Clean up all information contributed to the dirty share manager by this context for the current
    * transaction.  This method is called when a full database transaction has been committed or rolled back
    * against the primary database.  It is also invoked when the client context ends.  It removes all records
    * it added to the dirty database for new inserts, updates, leaks and deletes.
    * 
    * @throws  StopConditionException
    *          if a critical database error occurs that would leave the dirty database in an improper state.
    */
   public void cleanup()
   {
      try
      {
         if (changes != null)
         {
            if (leaks != null && inserts != null)
            {
               inserts.addAll(leaks);
            }
            manager.cleanupChanges(changes, inserts);
         }
         if (deletes != null)
         {
            manager.cleanupDeletes(deletes);
         }
      }
      catch (PersistenceException exc)
      {
         ErrorManager.handleStopException(new StopConditionException(exc));
      }
      finally
      {
         leaks = null;
         changes = null;
         inserts = null;
         deadInserts = null;
         earlyInserts = null;
         deletes = null;
         dirtySnapshots = null;
      }
   }
   
   /**
    * Remember that the record represented by the given entity and primary key
    * ID has been modified in the current context.  This is necessary for
    * proper cleanup at the end of the current transaction.
    * 
    * @param   entity
    *          Name of an entity, which is the name of the DMO implementation
    *          class associated with a table being tracked for uncommitted
    *          changes.
    * @param   id
    *          Primary key ID of the modified record.
    */
   private void trackChange(String entity, Long id)
   {
      if (changes == null)
      {
         changes = new HashMap<>();
      }
      
      if (dirtySnapshots == null)
      {
         dirtySnapshots = new HashSet<>();
      }

      Set<Long> ids = changes.computeIfAbsent(entity, k -> new HashSet<>());
      ids.add(id);
      
      RecordIdentifier<String> ident = new RecordIdentifier<String>(entity, id);
      dirtySnapshots.add(ident);
   }
   
   /**
    * Overwrite the state of the records in the dirty database which match the
    * IDs of all DMOs for the given entities, which have been modified by the
    * current context.
    * <p>
    * This is necessary to support a quirk/bug in Progress which compromises
    * transaction isolation by leaking uncommitted changes (even non-indexed
    * data) under certain conditions.
    * 
    * @param   entities
    *          List of entities, which are the names of the DMO implementation
    *          classes associated with tables being tracked for uncommitted
    *          changes.  Only those records which match these entities will
    *          have their current state leaked.
    * @param   persistence
    *          Persistence service object to be used to retrieved the latest
    *          version of the target records in the current context.
    * 
    * @return  List of all DMOs whose snapshots were updated with the dirty
    *          share manager.  The list may be empty, but will not be
    *          <code>null</code>.
    * 
    * @throws  PersistenceException
    *          if there is any database error.
    */
   public List<Record> updateSnapshots(Set<String> entities, Persistence persistence)
   throws PersistenceException
   {
      
      List<Record> allDMOs = null;
      if (changes == null || dirtySnapshots == null)
      {
         // Nothing to update.
         return Collections.emptyList();
      }
      
      for (String entity : entities)
      {
         Set<Long> changed = changes.get(entity);
         if (changed == null || changed.isEmpty())
         {
            continue;
         }
         
         if (allDMOs == null)
         {
            allDMOs = new ArrayList<>();
         }
         Set<Record> entityDMOs = new HashSet<>();
         Iterator<Long> iter = changed.iterator();
         while(iter.hasNext())
         {
            Long id = iter.next();
            RecordIdentifier<String> ident = new RecordIdentifier<>(entity, id);
            Record dmo = null;
            
            if ((earlyInserts == null || !earlyInserts.containsKey(ident)) &&
                 dirtySnapshots.remove(ident))
            {
               // get record from local cache if stored or made visible 
               dmo = persistence.quickLoad(ident, Persistence.SHARED_CTX);
            }
            else if (dirtySnapshots.remove(ident))
            {
               // get record from earlyInserts
               RecordBuffer buffer = earlyInserts.get(ident);
               dmo = buffer.getCurrentRecord();
               if (dmo == null)
               {
                  // early inserts record is released and cant do anything about it, try next one
                  continue;
               }
               IndexState is = dmo.getIndexState();
               // but if given record do not have all indices complete do not dirty leak it
               if (!is.isEmpty())
               {
                  dmo = null;
               }
            }
            if (dmo != null)
            {
               if (leaks == null)
               {
                  leaks = new HashSet<>();
               }
               // leak that dirty DMO
               leaks.add(ident);
               entityDMOs.add(dmo);
               // continue updating list of DMOs to be potentially evicted
               allDMOs.add(dmo);
            }
         }
         if (!entityDMOs.isEmpty())
         {
            manager.updateSnapshots(entity, entityDMOs);
         }
      }
      if (allDMOs == null)
      {
         allDMOs = Collections.emptyList();
      }
      
      return allDMOs;
   }
}