/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco 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 Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.cache;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.cache.TransactionStats.OpType;
import org.alfresco.repo.tenant.TenantService;
import org.alfresco.repo.tenant.TenantUtil;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState;
import org.alfresco.repo.transaction.TransactionListener;
import org.alfresco.util.EqualsHelper;
import org.alfresco.util.PropertyCheck;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.transaction.support.TransactionSynchronizationManager;

/**
 * A 2-level cache that maintains both a transaction-local cache and
 * wraps a non-transactional (shared) cache.
 * <p>
 * It uses the <b>shared</b> <tt>SimpleCache</tt> for it's per-transaction
 * caches as these can provide automatic size limitations, etc.
 * <p>
 * Instances of this class <b>do not require a transaction</b>.  They will work
 * directly with the shared cache when no transaction is present.  There is
 * virtually no overhead when running out-of-transaction.
 * <p>
 * The first phase of the commit ensures that any values written to the cache in the
 * current transaction are not already superseded by values in the shared cache.  In
 * this case, the transaction is failed for concurrency reasons and will have to retry.
 * The second phase occurs post-commit.  We are sure that the transaction committed
 * correctly, but things may have changed in the cache between the commit and post-commit.
 * If this is the case, then the offending values are merely removed from the shared
 * cache.
 * <p>
 * When the cache is {@link #clear() cleared}, a flag is set on the transaction.
 * The shared cache, instead of being cleared itself, is just ignored for the remainder
 * of the tranasaction.  At the end of the transaction, if the flag is set, the
 * shared transaction is cleared <i>before</i> updates are added back to it.
 * <p>
 * Because there is a limited amount of space available to the in-transaction caches,
 * when either of these becomes full, the cleared flag is set.  This ensures that
 * the shared cache will not have stale data in the event of the transaction-local
 * caches dropping items.  It is therefore important to size the transactional caches
 * correctly.
 * 
 * @author Derek Hulley
 */
public class TransactionalCache<K extends Serializable, V extends Object>
        implements LockingCache<K, V>, TransactionListener, InitializingBean
{
    private static final String RESOURCE_KEY_TXN_DATA = "TransactionalCache.TxnData";
    
    private Log logger;
    private boolean isDebugEnabled;
    
    /** a name used to uniquely identify the transactional caches */
    private String name;
    /** enable/disable write through to the shared cache */
    private boolean disableSharedCache;
    /** the shared cache that will get updated after commits */
    private SimpleCache<Serializable, ValueHolder<V>> sharedCache;
    /** can the cached values be modified */
    private boolean isMutable;
    /** can values be compared using full equality checking */
    private boolean allowEqualsChecks;
    /** the maximum number of elements to be contained in the cache */
    private int maxCacheSize = 500;
    /** a unique string identifying this instance when binding resources */
    private String resourceKeyTxnData;
    /** Use of cacheStats is guarded by the cacheStatsEnabled flag */
    private CacheStatistics cacheStats;
    /** Enable collection of statistics? */
    private boolean cacheStatsEnabled = false;
    private boolean isTenantAware = true; // true if tenant-aware (default), false if system-wide
    
    /**
     * Public constructor.
     */
    public TransactionalCache()
    {
        logger = LogFactory.getLog(TransactionalCache.class);
        isDebugEnabled = logger.isDebugEnabled();
        disableSharedCache = false;
        isMutable = true;
        allowEqualsChecks = false;
    }
    
    /**
     * @see #setName(String)
     */
    public String toString()
    {
        return name;
    }
    
    public boolean equals(Object obj)
    {
        if (obj == this)
        {
            return true;
        }
        if (obj == null)
        {
            return false;
        }
        if (!(obj instanceof TransactionalCache<?, ?>))
        {
            return false;
        }
        @SuppressWarnings("rawtypes")
        TransactionalCache that = (TransactionalCache) obj;
        return EqualsHelper.nullSafeEquals(this.name, that.name);
    }
    
    public int hashCode()
    {
        return name.hashCode();
    }
    
    /**
     * Set the shared cache to use during transaction synchronization or when no transaction
     * is present.
     * 
     * @param sharedCache           underlying cache shared by transactions
     */
    public void setSharedCache(SimpleCache<Serializable, ValueHolder<V>> sharedCache)
    {
        this.sharedCache = sharedCache;
    }

    /**
     * Set whether values must be written through to the shared cache or not
     * 
     * @param disableSharedCache    <tt>true</tt> to prevent values from being written to
     *                              the shared cache
     */
    public void setDisableSharedCache(boolean disableSharedCache)
    {
        this.disableSharedCache = disableSharedCache;
    }

    /**
     * @param isMutable             <tt>true</tt> if the data stored in the cache is modifiable
     */
    public void setMutable(boolean isMutable)
    {
        this.isMutable = isMutable;
    }

    /**
     * Allow equality checking of values before they are written to the shared cache on
     * commit.  This allows some caches to bypass unnecessary cache updates when the
     * values remain unchanged.  Typically, this setting should be applied only to mutable
     * caches and only where the values being stored have a fast and reliable equality check.
     * 
     * @param allowEqualsChecks     <tt>true</tt> if value comparisons can be made between values
     *                              stored in the transactional cache and those stored in the
     *                              shared cache
     */
    public void setAllowEqualsChecks(boolean allowEqualsChecks)
    {
        this.allowEqualsChecks = allowEqualsChecks;
    }

    /**
     * Set the maximum number of elements to store in the update and remove caches.
     * The maximum number of elements stored in the transaction will be twice the
     * value given.
     * <p>
     * The removed list will overflow to disk in order to ensure that deletions are
     * not lost.
     * 
     * @param maxCacheSize          maximum number of items to be held in-transaction
     */
    public void setMaxCacheSize(int maxCacheSize)
    {
        this.maxCacheSize = maxCacheSize;
    }

    /**
     * Set the name that identifies this cache from other instances.
     */
    public void setName(String name)
    {
        this.name = name;
    }
    
    public void setTenantAware(boolean isTenantAware)
    {
        this.isTenantAware = isTenantAware;
    }
    
    public void setCacheStats(CacheStatistics cacheStats)
    {
        this.cacheStats = cacheStats;
    }

    public void setCacheStatsEnabled(boolean cacheStatsEnabled)
    {
        this.cacheStatsEnabled = cacheStatsEnabled;
    }

    /**
     * Ensures that all properties have been set
     */
    public void afterPropertiesSet() throws Exception
    {
        PropertyCheck.mandatory(this, "name", name);
        PropertyCheck.mandatory(this, "sharedCache", sharedCache);
        
        // generate the resource binding key
        resourceKeyTxnData = RESOURCE_KEY_TXN_DATA + "." + name;
        // Refine the log category
        logger = LogFactory.getLog(TransactionalCache.class.getName() + "." + name);
        isDebugEnabled = logger.isDebugEnabled();
        
        // Assign a 'null' cache if write-through is disabled
        if (disableSharedCache)
        {
            sharedCache = NullCache.getInstance();
        }
    }

    /**
     * To be used in a transaction only.
     */
    private TransactionData getTransactionData()
    {
        @SuppressWarnings("unchecked")
        TransactionData data = (TransactionData) AlfrescoTransactionSupport.getResource(resourceKeyTxnData);
        if (data == null)
        {
            data = new TransactionData();
            // create and initialize caches
            data.updatedItemsCache = new LRULinkedHashMap<Serializable, CacheBucket<V>>(23);
            data.removedItemsCache = new HashSet<Serializable>(13);
            data.lockedItemsCache = new HashSet<Serializable>(13);
            data.isReadOnly = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY;
            data.stats = new TransactionStats();

            // ensure that we get the transaction callbacks as we have bound the unique
            // transactional caches to a common manager
            // The synchronizations are not available after the txn is committed/rolled back
            // the resources are still stored in org.alfresco.util.transaction.TransactionSupportUtil
            if (TransactionSynchronizationManager.isSynchronizationActive())
            {
                AlfrescoTransactionSupport.bindListener(this);
            }
            AlfrescoTransactionSupport.bindResource(resourceKeyTxnData, data);
        }
        return data;
    }
    
    /**
     * @see #setDisableSharedCacheReadForTransaction(boolean)
     */
    public boolean getDisableSharedCacheReadForTransaction()
    {
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            TransactionData txnData = getTransactionData();
            return txnData.noSharedCacheRead;
        }
        else
        {
            return false;
        }
    }
    
    /**
     * Transaction-long setting to force all the share cache to be bypassed for the current transaction.
     * <p/>
     * This setting is like having a {@link NullCache null} {@link #setSharedCache(SimpleCache) shared cache},
     * but only lasts for the transaction.
     * <p/>
     * Use this when a read transaction <b>must</b> see consistent and current data i.e. go to the database.
     * While this is active, write operations will also not be committed to the shared cache.
     * 
     * @param noSharedCacheRead         <tt>true</tt> to avoid reading from the shared cache for the transaction
     */
    @SuppressWarnings("unchecked")
    public void setDisableSharedCacheReadForTransaction(boolean noSharedCacheRead)
    {
        TransactionData txnData = getTransactionData();

        // If we are switching on noSharedCacheRead mode, convert all existing reads and updates to avoid 'consistent
        // read' behaviour giving us a potentially out of date node already accessed
        if (noSharedCacheRead && !txnData.noSharedCacheRead)
        {
            txnData.noSharedCacheRead = noSharedCacheRead;
            String currentCacheRegion = TenantUtil.getCurrentDomain();
            for (Map.Entry<Serializable, CacheBucket<V>> entry : new ArrayList<Map.Entry<Serializable, CacheBucket<V>>>(
                    txnData.updatedItemsCache.entrySet()))
            {
                Serializable cacheKey = entry.getKey();
                K key = null;
                if (cacheKey instanceof CacheRegionKey)
                {
                    CacheRegionKey cacheRegionKey = (CacheRegionKey) cacheKey;
                    if (currentCacheRegion.equals(cacheRegionKey.getCacheRegion()))
                    {
                        key = (K) cacheRegionKey.getCacheKey();
                    }
                }
                else
                {
                    key = (K) cacheKey;
                }

                if (key != null)
                {
                    CacheBucket<V> bucket = entry.getValue();
                    // Simply 'forget' reads
                    if (bucket instanceof ReadCacheBucket)
                    {
                        txnData.updatedItemsCache.remove(cacheKey);
                    }
                    // Convert updates to removes
                    else if (bucket instanceof UpdateCacheBucket)
                    {
                        remove(key);
                    }
                    // Leave new entries alone - they can't have come from the shared cache
                }
            }
        }
    }
    
    /**
     * Checks the transactional removed and updated caches before checking the shared cache.
     */
    public boolean contains(K key)
    {
        Object value = get(key);
        if (value == null)
        {
            return false;
        }
        else
        {
            return true;
        }
    }
    
    /**
     * The keys returned are a union of the set of keys in the current transaction and
     * those in the backing cache.
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public Collection<K> getKeys()
    {
        Collection<Serializable> keys = null;
        // in-txn layering
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            keys = new HashSet<Serializable>(23);
            TransactionData txnData = getTransactionData();
            if (!txnData.isClearOn)
            {
                // the backing cache is not due for a clear
                Collection<K> backingKeys = (Collection<K>)sharedCache.getKeys();
                Collection<Serializable> backingCacheKeys = new HashSet<Serializable>(backingKeys.size());
                for (K backingKey : backingKeys)
                {
                    backingCacheKeys.add(getTenantAwareCacheKey(backingKey));
                }
                keys.addAll(backingCacheKeys);
            }
            // add keys
            keys.addAll(txnData.updatedItemsCache.keySet());
            // remove keys
            keys.removeAll(txnData.removedItemsCache);
        }
        else
        {
            // no transaction, so just use the backing cache
            keys = (Collection) sharedCache.getKeys();
        }
        
        Collection<K> cacheKeys = new HashSet<K>(keys.size());
        String currentCacheRegion = TenantUtil.getCurrentDomain();
        
        for (Serializable key : keys)
        {
            if (key instanceof CacheRegionKey)
            {
                CacheRegionKey cacheRegionKey = (CacheRegionKey)key;
                if (currentCacheRegion.equals(cacheRegionKey.getCacheRegion()))
                {
                    cacheKeys.add((K)cacheRegionKey.getCacheKey());
                }
            }
            else
            {
                cacheKeys.add((K)key);
            }
        }
        // done
        return cacheKeys;
    }
    
    /**
     * @see #getSharedCacheValue(SimpleCache, Serializable, TransactionStats)
     */
    public static <KEY extends Serializable, VAL> VAL getSharedCacheValue(SimpleCache<KEY, ValueHolder<VAL>> sharedCache, KEY key)
    {
        return getSharedCacheValue(sharedCache, key, null);
    }
    
    /**
     * Fetches a value from the shared cache.  If values were wrapped,
     * then they will be unwrapped before being returned.  If code requires
     * direct access to the wrapper object as well, then this call should not
     * be used.
     * <p>
     * If a TransactionStats instance is passed in, then cache access stats
     * are tracked, otherwise - if null is passed in then stats are not tracked.
     * 
     * @param key           the key
     * @return              Returns the value or <tt>null</tt>
     */
    @SuppressWarnings("unchecked")
    public static <KEY extends Serializable, VAL> VAL getSharedCacheValue(SimpleCache<KEY, ValueHolder<VAL>> sharedCache, KEY key, TransactionStats stats)
    {
        final long startNanos = stats != null ? System.nanoTime() : 0;
        Object possibleWrapper = sharedCache.get(key);
        final long endNanos = stats != null ? System.nanoTime() : 0;
        if (possibleWrapper == null)
        {
            if (stats != null)
            {
                stats.record(startNanos, endNanos, OpType.GET_MISS);
            }
            return null;
        }
        else if (possibleWrapper instanceof ValueHolder)
        {
            if (stats != null)
            {
                stats.record(startNanos, endNanos, OpType.GET_HIT);
            }
            ValueHolder<VAL> wrapper = (ValueHolder<VAL>) possibleWrapper;
            return wrapper.getValue();
        }
        else
        {
            if (stats != null)
            {
                stats.record(startNanos, endNanos, OpType.GET_MISS);
            }
            throw new IllegalStateException("All entries for TransactionalCache must be put using TransactionalCache.putSharedCacheValue.");
        }
    }
    
    /**
     * Values written to the backing cache need proper wrapping and unwrapping
     * 
     * @param sharedCache           the cache to operate on
     * @param key                   the key
     * @param value                 the value to wrap
     * 
     * @since 4.2.3
     */
    public static <KEY extends Serializable, VAL> void putSharedCacheValue(SimpleCache<KEY, ValueHolder<VAL>> sharedCache, KEY key, VAL value, TransactionStats stats)
    {
        ValueHolder<VAL> wrapper = new ValueHolder<VAL>(value);
        final long startNanos = System.nanoTime(); // TODO: enabled?
        sharedCache.put(key, wrapper);
        final long endNanos = System.nanoTime();
        if (stats != null)
        {
            stats.record(startNanos, endNanos, OpType.PUT);
        }
    }
    
    /**
     * @param txnData       the existing data associated with the transaction
     * @param key           a tenant-aware key
     * @return              <tt>true</tt> if the key is locked
     * 
     * @see HashSet#contains(Object)
     * @see HashSet#size()
     */
    private final boolean isValueLocked(TransactionData txnData, Serializable key)
    {
        /*
         * Locking will be very infrequent.  Calculating the hashcode of the key
         * and using it to determine whether the lockedItemsCache contains the key is an
         * unnecessary overhead; use the size() method for a slightly faster answer
         * in the bulk of cases.
         */
        return (txnData.lockedItemsCache.size() > 0 && txnData.lockedItemsCache.contains(key));
    }
    
    @Override
    public boolean isValueLocked(K keyIn)
    {
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            final Serializable key = getTenantAwareCacheKey(keyIn);
            TransactionData txnData = getTransactionData();
            return txnData.lockedItemsCache.contains(key);
        }
        else
        {
            // No transaction; we can't have locked it
            return false;
        }
    }

    @Override
    public void lockValue(K keyIn)
    {
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            final Serializable key = getTenantAwareCacheKey(keyIn);
            TransactionData txnData = getTransactionData();
            txnData.lockedItemsCache.add(key);
            return;
        }
        else
        {
            // No transaction; we can't lock it
            return;
        }
    }

    @Override
    public void unlockValue(K keyIn)
    {
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            final Serializable key = getTenantAwareCacheKey(keyIn);
            TransactionData txnData = getTransactionData();
            txnData.lockedItemsCache.remove(key);
            return;
        }
        else
        {
            // No transaction; we can't unlock it
            return;
        }
    }

    /**
     * Checks the per-transaction caches for the object before going to the shared cache.
     * If the thread is not in a transaction, then the shared cache is accessed directly.
     */
    public V get(K keyIn)
    {
        final Serializable key = getTenantAwareCacheKey(keyIn);
        
        boolean ignoreSharedCache = false;
        // are we in a transaction?
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            TransactionData txnData = getTransactionData();
            if (txnData.isClosed)
            {
                // This check could have been done in the first if block, but that would have added another call to the
                // txn resources.
            }
            else            // The txn is still active
            {
                if (!txnData.isClearOn)   // deletions cache only useful before a clear
                {
                    // check to see if the key is present in the transaction's removed items
                    if (txnData.removedItemsCache.contains(key))
                    {
                        // it has been removed in this transaction
                        if (isDebugEnabled)
                        {
                            logger.debug("get returning null - item has been removed from transactional cache: \n" +
                                    "   cache: " + this + "\n" +
                                    "   key: " + key);
                        }
                        return null;
                    }
                }
                
                // check for the item in the transaction's new/updated items
                CacheBucket<V> bucket = (CacheBucket<V>) txnData.updatedItemsCache.get(key);
                if (bucket != null)
                {
                    V value = bucket.getValue();
                    // element was found in transaction-specific updates/additions
                    if (isDebugEnabled)
                    {
                        logger.debug("Found item in transactional cache: \n" +
                                "   cache: " + this + "\n" +
                                "   key: " + key + "\n" +
                                "   value: " + value);
                    }
                    return value;
                }
                else if (txnData.isClearOn)
                {
                    // Can't store values in the current txn any more
                    ignoreSharedCache = true;
                }
                else if (txnData.noSharedCacheRead)
                {
                    // Explicitly told to ignore shared cache
                    ignoreSharedCache = true;
                }
                else
                {
                    // There is no in-txn entry for the key
                    // Use the value direct from the shared cache
                    V value = null;
                    if (cacheStatsEnabled)
                    {
                        value = TransactionalCache.getSharedCacheValue(sharedCache, key, txnData.stats);
                    }
                    else
                    {
                        // No stats tracking, pass in null TransactionStats
                        value = TransactionalCache.getSharedCacheValue(sharedCache, key, null);
                    }
                    bucket = new ReadCacheBucket<V>(value);
                    txnData.updatedItemsCache.put(key, bucket);
                    return value;
                }
            }
        }
        // no value found - must we ignore the shared cache?
        if (!ignoreSharedCache)
        {
            V value = TransactionalCache.getSharedCacheValue(sharedCache, key, null);
            // go to the shared cache
            if (isDebugEnabled)
            {
                logger.debug("No value found in transaction - fetching instance from shared cache: \n" +
                        "   cache: " + this + "\n" +
                        "   key: " + key + "\n" +
                        "   value: " + value);
            }
            return value;
        }
        else        // ignore shared cache
        {
            if (isDebugEnabled)
            {
                logger.debug("No value found in transaction and ignoring shared cache: \n" +
                        "   cache: " + this + "\n" +
                        "   key: " + key);
            }
            return null;
        }
    }

    /**
     * Goes direct to the shared cache in the absence of a transaction.
     * <p>
     * Where a transaction is present, a cache of updated items is lazily added to the
     * thread and the <tt>Object</tt> put onto that. 
     */
    public void put(K keyIn, V value)
    {
        final Serializable key = getTenantAwareCacheKey(keyIn);
        
        // are we in a transaction?
        if (AlfrescoTransactionSupport.getTransactionId() == null)  // not in transaction
        {
            // no transaction
            TransactionalCache.putSharedCacheValue(sharedCache, key, value, null);
            // done
            if (isDebugEnabled)
            {
                logger.debug("No transaction - adding item direct to shared cache: \n" +
                        "   cache: " + this + "\n" +
                        "   key: " + key + "\n" +
                        "   value: " + value);
            }
        }
        else  // transaction present
        {
            TransactionData txnData = getTransactionData();
            // Ensure that the cache isn't being modified
            if (txnData.isClosed)
            {
                if (isDebugEnabled)
                {
                    logger.debug(
                            "In post-commit add: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key + "\n" +
                            "   value: " + value);
                }
            }
            else if (isValueLocked(txnData, key))
            {
                // The key has been locked
                if (isDebugEnabled)
                {
                    logger.debug(
                            "Ignoring put after detecting locked key: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key + "\n" +
                            "   value: " + value);
                }
            }
            else
            {
                // we have an active transaction - add the item into the updated cache for this transaction
                // are we in an overflow condition?
                if (txnData.updatedItemsCache.hasHitSize())
                {
                    // overflow about to occur or has occured - we can only guarantee non-stale
                    // data by clearing the shared cache after the transaction.  Also, the
                    // shared cache needs to be ignored for the rest of the transaction.
                    txnData.isClearOn = true;
                    if (!txnData.haveIssuedFullWarning)
                    {
                        if (logger.isInfoEnabled())
                        {
                             Exception e = new Exception("Stack: ");
                             logger.info("Transactional update cache '" + name + "' is full (" + maxCacheSize + ").", e);
                        }
                        else if (logger.isWarnEnabled())
                        {
                            logger.warn("Transactional update cache '" + name + "' is full (" + maxCacheSize + ").");
                        }
                        txnData.haveIssuedFullWarning = true;
                    }
                }
                ValueHolder<V> existingValueHolder = txnData.noSharedCacheRead ? null : sharedCache.get(key);
                CacheBucket<V> bucket = null;
                if (existingValueHolder == null)
                {
                    // ALF-5134: Performance of Alfresco cluster less than performance of single node
                    // The 'null' marker that used to be inserted also triggered an update in the afterCommit
                    // phase; the update triggered cache invalidation in the cluster.  Now, the null cannot
                    // be verified to be the same null - there is no null equivalence
                    // 
                    // The value didn't exist before
                    bucket = new NewCacheBucket<V>(value);
                }
                else
                {
                    // Record the existing value as is
                    bucket = new UpdateCacheBucket<V>(existingValueHolder, value);
                }
                txnData.updatedItemsCache.put(key, bucket);
                // remove the item from the removed cache, if present
                txnData.removedItemsCache.remove(key);
                // done
                if (isDebugEnabled)
                {
                    logger.debug("In transaction - adding item direct to transactional update cache: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key + "\n" +
                            "   value: " + value);
                }
            }
        }
    }

    /**
     * Goes direct to the shared cache in the absence of a transaction.
     * <p>
     * Where a transaction is present, a cache of removed items is lazily added to the
     * thread and the <tt>Object</tt> put onto that. 
     */
    public void remove(K keyIn)
    {
        final Serializable key = getTenantAwareCacheKey(keyIn);
        
        // are we in a transaction?
        if (AlfrescoTransactionSupport.getTransactionId() == null)  // not in transaction
        {
            // no transaction
            sharedCache.remove(key);
            // done
            if (isDebugEnabled)
            {
                logger.debug("No transaction - removing item from shared cache: \n" +
                        "   cache: " + this + "\n" +
                        "   key: " + key);
            }
        }
        else  // transaction present
        {
            TransactionData txnData = getTransactionData();
            // Ensure that the cache isn't being modified
            if (txnData.isClosed)
            {
                if (isDebugEnabled)
                {
                    logger.debug(
                            "In post-commit remove: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key);
                }
            }
            else if (isValueLocked(txnData, key))
            {
                // The key has been locked
                if (isDebugEnabled)
                {
                    logger.debug(
                            "Ignoring remove after detecting locked key: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key);
                }
            }
            else
            {
                // is the shared cache going to be cleared?
                if (txnData.isClearOn)
                {
                    // don't store removals if we're just going to clear it all out later
                }
                else
                {
                    // are we in an overflow condition?
                    if (txnData.removedItemsCache.size() >= maxCacheSize)
                    {
                        // overflow about to occur or has occured - we can only guarantee non-stale
                        // data by clearing the shared cache after the transaction.  Also, the
                        // shared cache needs to be ignored for the rest of the transaction.
                        txnData.isClearOn = true;
                        if (!txnData.haveIssuedFullWarning)
                        {
                            if (logger.isInfoEnabled())
                            {
                                 Exception e = new Exception("Stack: ");
                                 logger.info("Transactional removal cache '" + name + "' is full (" + maxCacheSize + ").", e);
                            }
                            else if (logger.isWarnEnabled())
                            {
                                logger.warn("Transactional removal cache '" + name + "' is full (" + maxCacheSize + ").");
                            }
                            txnData.haveIssuedFullWarning = true;
                        }
                    }
                    else
                    {
                        // Create a bucket to remove the value from the shared cache
                        txnData.removedItemsCache.add(key);
                    }
                }
                // remove the item from the udpated cache, if present
                txnData.updatedItemsCache.remove(key);
                // done
                if (isDebugEnabled)
                {
                    logger.debug("In transaction - adding item direct to transactional removed cache: \n" +
                            "   cache: " + this + "\n" +
                            "   key: " + key);
                }
            }
        }
    }

    /**
     * Clears out all the caches.
     */
    public void clear()
    {
        // clear local caches
        if (AlfrescoTransactionSupport.getTransactionId() != null)
        {
            if (isDebugEnabled)
            {
                logger.debug("In transaction clearing cache: \n" +
                        "   cache: " + this + "\n" +
                        "   txn: " + AlfrescoTransactionSupport.getTransactionId());
            }
            
            TransactionData txnData = getTransactionData();
            // Ensure that the cache isn't being modified
            if (txnData.isClosed)
            {
                if (isDebugEnabled)
                {
                    logger.debug(
                            "In post-commit clear: \n" +
                            "   cache: " + this);
                }
            }
            else
            {
                // The shared cache must be cleared at the end of the transaction
                // and also serves to ensure that the shared cache will be ignored
                // for the remainder of the transaction.
                // We do, however, keep all locked values locked.
                txnData.isClearOn = true;
                txnData.updatedItemsCache.clear();
                txnData.removedItemsCache.clear();
            }
        }
        else            // no transaction
        {
            if (isDebugEnabled)
            {
                logger.debug("No transaction - clearing shared cache");
            }
            // clear shared cache
            sharedCache.clear();
        }
    }

    /**
     * NO-OP
     */
    public void flush()
    {
    }

    /**
     * NO-OP
     */
    public void beforeCompletion()
    {
    }

    /**
     * Merge the transactional caches into the shared cache
     */
    public void beforeCommit(boolean readOnly)
    {
        if (isDebugEnabled)
        {
            logger.debug("Processing before-commit");
        }
        
        TransactionData txnData = getTransactionData();
        try
        {
            if (txnData.isClearOn)
            {
                // clear shared cache
                final long startNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                sharedCache.clear();
                final long endNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                if (cacheStatsEnabled)
                {
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.CLEAR);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Clear notification recieved in commit - clearing shared cache");
                }
            }
            else
            {
                // transfer any removed items
                for (Serializable key : txnData.removedItemsCache)
                {
                    final long startNanos = System.nanoTime();
                    sharedCache.remove(key);
                    final long endNanos = System.nanoTime();
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.REMOVE);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Removed " + txnData.removedItemsCache.size() + " values from shared cache in commit");
                }
            }
            
            // transfer updates
            Set<Serializable> keys = (Set<Serializable>) txnData.updatedItemsCache.keySet();
            for (Map.Entry<Serializable, CacheBucket<V>> entry : (Set<Map.Entry<Serializable, CacheBucket<V>>>) txnData.updatedItemsCache.entrySet())
            {
                Serializable key = entry.getKey();
                CacheBucket<V> bucket = entry.getValue();
                bucket.doPreCommit(
                        sharedCache,
                        key, this.isMutable, this.allowEqualsChecks, txnData.isReadOnly);
            }
            if (isDebugEnabled)
            {
                logger.debug("Pre-commit called for " + keys.size() + " values.");
            }
        }
        catch (Throwable e)
        {
            throw new AlfrescoRuntimeException("Failed to transfer updates to shared cache", e);
        }
        finally
        {
            // Block any further updates
            txnData.isClosed = true;
        }
    }

    /**
     * Merge the transactional caches into the shared cache
     */
    public void afterCommit()
    {
        if (isDebugEnabled)
        {
            logger.debug("Processing after-commit");
        }
        
        TransactionData txnData = getTransactionData();
        try
        {
            if (txnData.isClearOn)
            {
                // clear shared cache
                final long startNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                sharedCache.clear();
                final long endNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                if (cacheStatsEnabled)
                {
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.CLEAR);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Clear notification recieved in commit - clearing shared cache");
                }
            }
            else
            {
                // transfer any removed items
                for (Serializable key : txnData.removedItemsCache)
                {
                    final long startNanos = System.nanoTime();
                    sharedCache.remove(key);
                    final long endNanos = System.nanoTime();
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.REMOVE);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Removed " + txnData.removedItemsCache.size() + " values from shared cache in commit");
                }
            }
            
            // transfer updates
            Set<Serializable> keys = (Set<Serializable>) txnData.updatedItemsCache.keySet();
            for (Map.Entry<Serializable, CacheBucket<V>> entry : (Set<Map.Entry<Serializable, CacheBucket<V>>>) txnData.updatedItemsCache.entrySet())
            {
                Serializable key = entry.getKey();
                CacheBucket<V> bucket = entry.getValue();
                try
                {
                    bucket.doPostCommit(
                            sharedCache,
                            key, this.isMutable, this.allowEqualsChecks, txnData.isReadOnly, txnData.stats);
                }
                catch (Exception e)
                {
                    // MNT-10486: NPE in NodeEntity during post-commit write through to shared cache
                    //              This try-catch is diagnostic in nature.  We need to know the names of the caches
                    //              and details of the values involved.
                    //              The causal exception will be rethrown.
                    throw new AlfrescoRuntimeException(
                            "CacheBucket postCommit transfer to shared cache failed: \n" +
                            "   Cache:      " + sharedCache + "\n" +
                            "   Key:        " + key + "\n" +
                            "   New Value:  " + bucket.getValue() + "\n" +
                            "   Cache Value:" + sharedCache.get(key),
                            e);
                }
            }
            if (isDebugEnabled)
            {
                logger.debug("Post-commit called for " + keys.size() + " values.");
            }
        }
        catch (Throwable e)
        {
            throw new AlfrescoRuntimeException("Failed to transfer updates to shared cache", e);
        }
        finally
        {
            removeCaches(txnData);
            // Aggregate this transaction's stats with centralised cache stats.
            if (cacheStatsEnabled)
            {
                cacheStats.add(name, txnData.stats);
            }
        }
    }

    /**
     * Transfers cache removals or clears.  This allows explicit cache cleanup to be propagated
     * to the shared cache even in the event of rollback - useful if the cause of a problem is
     * the shared cache value.
     */
    public void afterRollback()
    {
        TransactionData txnData = getTransactionData();
        try
        {
            if (txnData.isClearOn)
            {
                // clear shared cache
                final long startNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                sharedCache.clear();
                final long endNanos = cacheStatsEnabled ? System.nanoTime() : 0;
                if (cacheStatsEnabled)
                {
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.CLEAR);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Clear notification recieved in rollback - clearing shared cache");
                }
            }
            else
            {
                // transfer any removed items
                for (Serializable key : txnData.removedItemsCache)
                {
                    final long startNanos = System.nanoTime();
                    sharedCache.remove(key);
                    final long endNanos = System.nanoTime();
                    TransactionStats stats = txnData.stats;
                    stats.record(startNanos, endNanos, OpType.REMOVE);
                }
                if (isDebugEnabled)
                {
                    logger.debug("Removed " + txnData.removedItemsCache.size() + " values from shared cache in rollback");
                }
            }
        }
        catch (Throwable e)
        {
            throw new AlfrescoRuntimeException("Failed to transfer updates to shared cache", e);
        }
        finally
        {
            removeCaches(txnData);
            // Aggregate this transaction's stats with centralised cache stats.
            if (cacheStatsEnabled)
            {
                cacheStats.add(name, txnData.stats);
            }
        }
    }
    
    /**
     * Ensures that the transactional caches are removed from the common cache manager.
     * 
     * @param txnData the data with references to the the transactional caches
     */
    private void removeCaches(TransactionData txnData)
    {
        txnData.isClosed = true;
    }
    
    /**
     * Interface for the transactional cache buckets.  These hold the actual values along
     * with some state and behaviour around writing from the in-transaction caches to the
     * shared.
     * 
     * @author Derek Hulley
     */
    private interface CacheBucket<BV extends Object> extends Serializable
    {
        /**
         * @return                  Returns the bucket's value
         */
        BV getValue();
        /**
         * Flush the current bucket to the shared cache as far as possible.
         * 
         * @param sharedCache       the cache to flush to
         * @param key               the key that the bucket was stored against
         */
        public void doPreCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly);
        /**
         * Flush the current bucket to the shared cache as far as possible.
         * 
         * @param sharedCache       the cache to flush to
         * @param key               the key that the bucket was stored against
         */
        public void doPostCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly, TransactionStats stats);
    }
    
    /**
     * A bucket class to hold values for the caches.<br/>
     * 
     * @author Derek Hulley
     */
    private static class NewCacheBucket<BV> implements CacheBucket<BV>
    {
        private static final long serialVersionUID = -8536386687213957425L;
        
        private final BV value;
        public NewCacheBucket(BV value)
        {
            this.value = value;
        }
        public BV getValue()
        {
            return value;
        }
        public void doPreCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly)
        {
        }
        public void doPostCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly, TransactionStats stats)
        {
            ValueHolder<BV> sharedObjValueHolder = sharedCache.get(key);
            if (sharedObjValueHolder == null)
            {
                // Nothing has changed, write it through
                TransactionalCache.putSharedCacheValue(sharedCache, key, value, stats);
            }
            else if (!mutable)
            {
                // Someone else put the object there
                // The assumption is that the value will be correct because the values are immutable
                // Don't write it unnecessarily.
            }
            else if (allowEqualsCheck && EqualsHelper.nullSafeEquals(value, sharedObjValueHolder.getValue()))
            {
                // The value we want to write is the same as the one in the shared cache.
                // Don't write it unnecessarily.
            }
            else
            {
                // The shared value moved on in a way that was not possible to
                // validate.  We pessimistically remove the entry.
                sharedCache.remove(key);
            }
        }
    }
    
    /**
     * Data holder to keep track of a cached value's ID in order to detect stale
     * shared cache values.  This bucket assumes the presence of a pre-existing entry in
     * the shared cache.
     */
    private static class UpdateCacheBucket<BV> implements CacheBucket<BV>
    {
        private static final long serialVersionUID = 7885689778259779578L;
        
        private final BV value;
        private final ValueHolder<BV> originalValueHolder;
        public UpdateCacheBucket(ValueHolder<BV> originalValueHolder, BV value)
        {
            this.originalValueHolder = originalValueHolder;
            this.value = value;
        }
        public BV getValue()
        {
            return value;
        }
        public void doPreCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly)
        {
        }
        public void doPostCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly, TransactionStats stats)
        {
            ValueHolder<BV> sharedObjValueHolder = sharedCache.get(key);
            if (sharedObjValueHolder == null)
            {
                // Someone removed the value
                if (!mutable)
                {
                    // We can assume that our value is correct because it's immutable
                    TransactionalCache.putSharedCacheValue(sharedCache, key, value, stats);
                }
                else
                {
                    // The value is mutable, so we must behave pessimistically i.e. leave the shared cache empty
                }
            }
            else if (!mutable)
            {
                // We assume the configuration is correct and therefore, that we do not need to compare
                // the cached value with the updated value.  This applies to null as well.
            }
            else if (allowEqualsCheck && EqualsHelper.nullSafeEquals(value, sharedObjValueHolder.getValue()))
            {
                // The value we want to write is the same as the one in the shared cache.
                // Don't write it unnecessarily.
            }
            else if (EqualsHelper.nullSafeEquals(originalValueHolder, sharedObjValueHolder))
            {
                // The value in the cache did not change from what we observed before.
                // Update the value.
                TransactionalCache.putSharedCacheValue(sharedCache, key, value, stats);
            }
            else
            {
                // The shared value moved on in a way that was not possible to
                // validate.  We pessimistically remove the entry.
                sharedCache.remove(key);
            }
        }
    }
    
    /**
     * Data holder to represent data read from the shared cache.  It will not attempt to
     * update the shared cache.
     */
    private static class ReadCacheBucket<BV> implements CacheBucket<BV>
    {
        private static final long serialVersionUID = 7885689778259779578L;
        
        private final BV value;
        public ReadCacheBucket(BV value)
        {
            this.value = value;
        }
        public BV getValue()
        {
            return value;
        }
        public void doPreCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly)
        {
        }
        public void doPostCommit(
                SimpleCache<Serializable, ValueHolder<BV>> sharedCache,
                Serializable key,
                boolean mutable, boolean allowEqualsCheck, boolean readOnly, TransactionStats stats)
        {
        }
    }
    
    /** Data holder to bind data to the transaction */
    private class TransactionData
    {
        private LRULinkedHashMap<Serializable, CacheBucket<V>> updatedItemsCache;
        private Set<Serializable> removedItemsCache;
        private Set<Serializable> lockedItemsCache;
        private boolean haveIssuedFullWarning;
        private boolean isClearOn;
        private boolean isClosed;
        private boolean isReadOnly;
        private boolean noSharedCacheRead;
        private TransactionStats stats;
    }
    
    /**
     * Simple LRU based on {@link LinkedHashMap}
     * 
     * @author Derek Hulley
     * @since 3.4
     */
    private class LRULinkedHashMap<K1, V1> extends LinkedHashMap<K1, V1>
    {
        private static final long serialVersionUID = -4874684348174271106L;

        private LRULinkedHashMap(int initialSize)
        {
            super(initialSize);
        }
        private boolean hasHitSize()
        {
            return size() >= maxCacheSize;
        }
        /**
         * Remove the eldest entry if the size has reached the maximum cache size
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry<K1, V1> eldest)
        {
            return (size() > maxCacheSize);
        }
    }
    
    /**
     * Convert the key to a tenant-specific key if the cache is tenant-aware and
     * the current thread is running in the context of a tenant.
     * 
     * @param key           the key to convert
     * @return              a key that separates tenant-specific values
     */
    private Serializable getTenantAwareCacheKey(final K key)
    {
        if (isTenantAware)
        {
            final String tenantDomain = TenantUtil.getCurrentDomain();
            if (! tenantDomain.equals(TenantService.DEFAULT_DOMAIN))
            {
                return new CacheRegionKey(tenantDomain, key);
            }
            // drop through
        }
        return key;
    }
    
    public static class CacheRegionKey implements Serializable
    {
        private static final long serialVersionUID = -213050301938804468L;
        
        private final String cacheRegion;
        private final Serializable cacheKey;
        private final int hashCode;
        
        public CacheRegionKey(String cacheRegion, Serializable cacheKey)
        {
            this.cacheRegion = cacheRegion;
            this.cacheKey = cacheKey;
            this.hashCode = cacheRegion.hashCode() + cacheKey.hashCode();
        }
        
        public Serializable getCacheKey()
        {
            return cacheKey;
        }
        
        public String getCacheRegion()
        {
            return cacheRegion;
        }
        
        @Override
        public String toString()
        {
            return cacheRegion + (cacheRegion != "" ? "." : "") + cacheKey.toString();
        }
        
        @Override
        public boolean equals(Object obj)
        {
            if (this == obj)
            {
                return true;
            }
            else if (!(obj instanceof CacheRegionKey))
            {
                return false;
            }
            CacheRegionKey that = (CacheRegionKey) obj;
            return this.cacheRegion.equals(that.cacheRegion) && this.cacheKey.equals(that.cacheKey);
        }
        
        @Override
        public int hashCode()
        {
            return hashCode;
        }
    }

    /**
     * A wrapper object to carry object values, but forcing a straight equality check
     * based on a random integer only.  This is used in cases where cache values do NOT
     * have an adequate equals method and we expect serialization of objects.
     * 
     * @author Derek Hulley
     * @since 4.2.4
     */
    public static final class ValueHolder<V2> implements Serializable
    {
        private static final long serialVersionUID = -3462098329153772713L;
        
        /**
         * A random, positive integer since we only have to
         * prevent short-term duplication between values with the same keys.
         */
        private final int rand;
        private final V2 value;
        private ValueHolder(V2 value)
        {
            
            this.rand = (int) (Math.random() * Integer.MAX_VALUE);
            this.value = value;
        }
        public final V2 getValue()
        {
            return value;
        }
        @Override
        public final int hashCode()
        {
            return rand;
        }
        @SuppressWarnings("rawtypes")
        @Override
        public final boolean equals(Object obj)
        {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            ValueHolder other = (ValueHolder) obj;
            return this.rand == other.rand;
        }
        @Override
        public final String toString()
        {
            return "ValueHolder [value=" + value + "]";
        }
    }
}
