package com.googlecode.objectify.cache;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.memcache.ErrorHandlers;
import com.google.appengine.api.memcache.Expiration;
import com.google.appengine.api.memcache.IMemcacheServiceFactory;
import com.google.appengine.api.memcache.MemcacheService.CasValues;
import com.google.appengine.api.memcache.MemcacheService.IdentifiableValue;
import com.google.appengine.spi.ServiceFactoryFactory;
import lombok.EqualsAndHashCode;
import lombok.extern.java.Log;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
/**
* <p>This is the facade used by Objectify to cache entities in the MemcacheService.</p>
*
* <p>Entity cacheability and expiration are determined by a {@code CacheControl} object.
* In addition, hit/miss statistics are tracked in a {@code MemcacheStats}.</p>
*
* <p>In order to guarantee cache synchronization, getAll() *must* be able to return
* an IdentifiableValue, even for entries not present in the cache. Because empty cache
* values cannot be made into IdentifiableValue, we immediately replace them with a
* null value and refetch (null is a valid cache value). If this refetch doesn't work,
* we treat the key as uncacheable for the duration of the request.</p>
*
* <p>The values put in memcache are Key -> Entity, except for negative cache entries,
* which are Key -> String (the value NEGATIVE).</p>
*
* @author Jeff Schnitzer <jeff@infohazard.org>
*/
@Log
public class EntityMemcache
{
/**
* A bucket represents memcache information for a particular Key. It might have an entity,
* it might be a negative cache result, it might be empty.
*
* Buckets can be hash keys; they hash to their Key value.
*/
@EqualsAndHashCode(of="key")
public class Bucket
{
/** Identifies the bucket */
private Key key;
/**
* If null, this means the key is uncacheable (possibly because the cache is down).
* If not null, the IV holds the Entity or NEGATIVE or EMPTY.
*/
private IdentifiableValue iv;
/**
* The Entity to store in this bucket in a put(). Can be null to indicate a negative cache
* result. The Entity key *must* match the bucket key.
*/
private Entity next;
/**
* Crate a bucket with an uncacheable key. Same as this(key, null).
*/
public Bucket(Key key)
{
this(key, null);
}
/**
* @param iv can be null to indicate an uncacheable key
*/
public Bucket(Key key, IdentifiableValue iv)
{
this.key = key;
this.iv = iv;
}
/** */
public Key getKey() { return this.key; }
/** @return true if we can cache this bucket; false if the key isn't cacheable or the memcache was down when we created the bucket */
public boolean isCacheable() { return this.iv != null; }
/** @return true if this is a negative cache result */
public boolean isNegative() { return this.isCacheable() && NEGATIVE.equals(iv.getValue()); }
/**
* "Empty" means we don't know the value - it could be null, it could be uncacheable, or we could have some
* really weird unknown data in the cache. Basically, anything other than "yes we have an entity/negative"
* is considered empty.
*
* @return true if this is empty or uncacheable or something other than a nice entity or negative result.
*/
public boolean isEmpty()
{
return !this.isCacheable() || (!this.isNegative() && !(iv.getValue() instanceof Entity));
}
/** Get the entity stored at this bucket, possibly the one that was set */
public Entity getEntity() {
if (iv != null && iv.getValue() instanceof Entity)
return (Entity)iv.getValue();
else
return null;
}
/**
* Prepare the value that will be set in memcache in the next putAll().
* Null (or not calling this method) will put a negative result in the cache.
*/
public void setNext(Entity value)
{
this.next = value;
}
/**
* @return the actual value we should store in memcache based on the next value, ie possibly NEGATIVE
*/
private Object getNextToStore()
{
return (this.next == null) ? NEGATIVE : this.next;
}
}
/**
* The value stored in the memcache for a negative cache result.
*/
public static final String NEGATIVE = "NEGATIVE";
/** */
KeyMemcacheService memcache;
KeyMemcacheService memcacheWithRetry;
MemcacheStats stats;
CacheControl cacheControl;
/**
* Creates a memcache which caches everything without expiry and doesn't record statistics.
*/
public EntityMemcache(String namespace)
{
this(namespace, new CacheControl() {
@Override
public Integer getExpirySeconds(Key key) { return 0; }
});
}
/**
* Creates a memcache which doesn't record stats
*/
public EntityMemcache(String namespace, CacheControl cacheControl)
{
this(namespace, cacheControl, new MemcacheStats() {
@Override public void recordHit(Key key) { }
@Override public void recordMiss(Key key) { }
});
}
/**
*/
public EntityMemcache(String namespace, CacheControl cacheControl, MemcacheStats stats)
{
this(namespace, cacheControl, stats, ServiceFactoryFactory.getFactory(IMemcacheServiceFactory.class));
}
public EntityMemcache(
String namespace,
CacheControl cacheControl,
MemcacheStats stats,
IMemcacheServiceFactory memcacheServiceFactory)
{
this.memcache = new KeyMemcacheService(memcacheServiceFactory.getMemcacheService(namespace));
this.memcache.setErrorHandler(ErrorHandlers.getConsistentLogAndContinue(Level.SEVERE));
this.memcacheWithRetry = new KeyMemcacheService(
MemcacheServiceRetryProxy.createProxy(memcacheServiceFactory.getMemcacheService(namespace)));
this.stats = stats;
this.cacheControl = cacheControl;
}
/**
* Sets the error handler for the non-retry memcache object.
*/
@SuppressWarnings("deprecation")
public void setErrorHandler(com.google.appengine.api.memcache.ErrorHandler handler) {
this.memcache.setErrorHandler(handler);
}
/**
* <p>Gets the Buckets for the specified keys. A bucket is built around an IdentifiableValue so you can
* putAll() them without the risk of overwriting other threads' changes. Buckets also hide the
* underlying details of storage for negative, empty, and uncacheable results.</p>
*
* <p>Note that worst case (a cold cache), obtaining each bucket might require three memcache requests:
* a getIdentifiable() which returns null, a put(EMPTY), and another getIdentifiable(). Since
* there is no batch getIdentifiable(), this is *per key*.</p>
*
* <p>When keys are uncacheable (per CacheControl) or the memcache is down, you will still get an empty
* bucket back. The bucket will have null IdentifiableValue so we can identify it as uncacheable.</p>
*
* @return the buckets requested. Buckets will never be null. You will always get a bucket for every key.
*/
public Map<Key, Bucket> getAll(Iterable<Key> keys)
{
Map<Key, Bucket> result = new HashMap<>();
// Sort out the ones that are uncacheable
Set<Key> potentials = new HashSet<>();
for (Key key: keys)
{
if (cacheControl.getExpirySeconds(key) == null)
result.put(key, new Bucket(key));
else
potentials.add(key);
}
Map<Key, IdentifiableValue> ivs;
try {
ivs = this.memcache.getIdentifiables(potentials);
} catch (Exception ex) {
// This should really only be a problem if the serialization format for an Entity changes,
// or someone put a badly-serializing object in the cache underneath us.
log.log(Level.WARNING, "Error obtaining cache for " + potentials, ex);
ivs = new HashMap<>();
}
// Figure out cold cache values
Map<Key, Object> cold = new HashMap<>();
for (Key key: potentials)
if (ivs.get(key) == null)
cold.put(key, null);
if (!cold.isEmpty())
{
// The cache is cold for those values, so start them out with nulls that we can make an IV for
this.memcache.putAll(cold);
try {
Map<Key, IdentifiableValue> ivs2 = this.memcache.getIdentifiables(cold.keySet());
ivs.putAll(ivs2);
} catch (Exception ex) {
// At this point we should just not worry about it, the ivs will be null and uncacheable
}
}
// Now create the remaining buckets
for (Key key: keys)
{
// iv might still be null, which is ok - that means uncacheable
IdentifiableValue iv = ivs.get(key);
Bucket buck = (iv == null) ? new Bucket(key) : new Bucket(key, iv);
result.put(key, buck);
if (buck.isEmpty())
this.stats.recordMiss(buck.getKey());
else
this.stats.recordHit(buck.getKey());
}
return result;
}
/**
* Update a set of buckets with new values. If collisions occur, resets the memcache value to null.
*
* @param updates can have null Entity values, which will record a negative cache result. Buckets must have
* been obtained from getAll().
*/
public void putAll(Collection<Bucket> updates)
{
Set<Key> good = this.cachePutIfUntouched(updates);
if (good.size() == updates.size())
return;
// Figure out which ones were bad
List<Key> bad = new ArrayList<>();
for (Bucket bucket: updates)
if (!good.contains(bucket.getKey()))
bad.add(bucket.getKey());
if (!bad.isEmpty())
{
// So we had some collisions. We need to reset these back to null, but do it in a safe way - if we
// blindly set null something already null, it will break any putIfUntouched() which saw the first null.
// This could result in write contention starving out a real write. The solution is to only reset things
// that are not already null.
Map<Key, Object> cached = this.cacheGetAll(bad);
// Remove the stuff we don't care about
Iterator<Object> it = cached.values().iterator();
while (it.hasNext())
{
Object value = it.next();
if (value == null)
it.remove();
}
this.empty(cached.keySet());
}
}
/**
* Revert a set of keys to the empty state. Will loop on this several times just in case
* the memcache write fails - we don't want to leave the cache in a nasty state.
*/
public void empty(Iterable<Key> keys)
{
Map<Key, Object> updates = new HashMap<>();
for (Key key: keys)
if (cacheControl.getExpirySeconds(key) != null)
updates.put(key, null);
this.memcacheWithRetry.putAll(updates);
}
/**
* Put buckets in the cache, checking for cacheability and collisions.
* @return the set of keys that were *successfully* put without collision
*/
private Set<Key> cachePutIfUntouched(Iterable<Bucket> buckets)
{
Map<Key, CasValues> payload = new HashMap<>();
for (Bucket buck: buckets)
{
if (!buck.isCacheable())
continue;
Integer expirySeconds = cacheControl.getExpirySeconds(buck.getKey());
if (expirySeconds == null)
continue;
Expiration expiration = expirySeconds == 0 ? null : Expiration.byDeltaSeconds(expirySeconds);
payload.put(buck.getKey(), new CasValues(buck.iv, buck.getNextToStore(), expiration));
}
return this.memcache.putIfUntouched(payload);
}
/**
* Bulk get on keys, getting the raw objects
*/
private Map<Key, Object> cacheGetAll(Collection<Key> keys)
{
try {
return this.memcache.getAll(keys);
} catch (Exception ex) {
// Some sort of serialization error, just wipe out the values
log.log(Level.WARNING, "Error fetching values from memcache, deleting keys", ex);
this.memcache.deleteAll(keys);
return new HashMap<>();
}
}
/**
* Basically a list comprehension of the keys for convenience.
*/
public static Set<Key> keysOf(Iterable<Bucket> buckets)
{
Set<Key> keys = new HashSet<>();
for (Bucket buck: buckets)
keys.add(buck.getKey());
return keys;
}
}