Java tutorial
/** * Copyright (C) 2013 Oodlemud Inc. (developers@oodlemud.com) * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.oodlemud.appengine.counter.service; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang3.StringUtils; import com.google.appengine.api.capabilities.CapabilitiesService; import com.google.appengine.api.capabilities.CapabilitiesServiceFactory; import com.google.appengine.api.capabilities.Capability; import com.google.appengine.api.capabilities.CapabilityStatus; import com.google.appengine.api.memcache.MemcacheService; import com.google.appengine.api.memcache.MemcacheService.IdentifiableValue; import com.google.appengine.api.memcache.MemcacheService.SetPolicy; import com.google.appengine.api.memcache.MemcacheServiceException; import com.google.appengine.api.memcache.MemcacheServiceFactory; import com.google.appengine.api.taskqueue.Queue; import com.google.appengine.api.taskqueue.QueueFactory; import com.google.appengine.api.taskqueue.TaskOptions; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import com.googlecode.objectify.Key; import com.googlecode.objectify.ObjectifyService; import com.googlecode.objectify.VoidWork; import com.googlecode.objectify.Work; import com.oodlemud.appengine.counter.Counter; import com.oodlemud.appengine.counter.CounterBuilder; import com.oodlemud.appengine.counter.data.CounterData; import com.oodlemud.appengine.counter.data.CounterData.CounterStatus; import com.oodlemud.appengine.counter.data.CounterShardData; /** * A durable implementation of a {@link ShardedCounterService} that provides * counter increment, decrement, and delete functionality. This implementation * is is backed by one or more Datastore "shard" entities which each hold a * discrete count in order to provide for high throughput. When aggregated, the * sum total of all CounterShard entity counts is the value of the counter. See * the google link below for more details on Sharded Counters in appengine. Note * that CounterShards cannot go negative, and there is no difference between a * counter being "zero" and a counter not existing.<br/> * <br/> * Note that this implementation is capable of incrementing/decrementing various * counter shard counts but does not automatically increase or reduce the * <b>number</b> of shards for a given counter in response to load.<br/> * <br/> * All datastore operations are performed using Objectify.<br/> * <br/> * <br/> * <b>Incrementing a Counter</b><br/> * When incrementing, a random shard is selected to prevent a single shard from * being written to too frequently.<br/> * <br/> * <b>Decrementing a Counter</b><br/> * This implementation does not support negative counts, so CounterShard counts * can not go below zero. Thus, when decrementing, a random shard is selected to * prevent a single shard from being written to too frequently. However, if a * particular shard cannot be decremented then other shards are tried until all * shards have been tried. If no shard can be decremented, then the decrement * function is considered complete, even though nothing was decremented. Because * of this, it is possible that a request to reduce a counter by more than its * available count will succeed with a lesser count having been reduced. * <b>Getting the Count</b><br/> * Aggregate Counter counter lookups are first attempted using Memcache. If the * counter value is not in the cache, then the shards are read from the * datastore and accumulated to reconstruct the current count. This operation * has a cost of O(numShards), or O(N). Increase the number of shards to improve * counter increment throughput, but beware that this has a cost - it makes * counter lookups from the Datastore more expensive.<br/> * <br/> * <b>Throughput</b><br/> * As an upper-bound calculation of throughput and shard-count, the Psy * "Gangham Style" youtube video (arguably one of the most viral videos of all * time) reached 750m views in approximately 60 days. If that video's * 'hit-counter' was using appengine-counter as its underlying implementation, * then the counter would have needed to sustain an increment rate of 145 * updates per second for 60 days. Since each CounterShard could have provided * up to 5 updates per second (this seems to be the average indicated by the * appengine team and in various documentation), then the counter would have * required at least 29 CounterShard entities, which in the grand scheme of the * Appengine Datastore seems prett small. In reality, a counter with this much * traffic would not need to be highly consistent, but it could have been using * appengine-counter.<br/> * <br/> * <b>Future Improvements</b><br/> * <ul> * <li><b>CounterShard Expansion</b>: A shard-expansion mechanism can be * envisioned to increase the number of CounterShard entities for a particular * Counter when load increases to a specified amount for a given Counter.</li> * <li><b>CounterShard Contraction</b>: A shard-reduction mechanism can be * envisioned to aggregate multiple shards (and their counts) into fewer shards * to improve datastore counter lookup performance when Counter load falls below * some threshold.</li> * <li><b>Counter Reset</b>: Reset a counter to zero by resetting all counter * shards 'counts' to zero. This would need to be, by nature of this * implementation, async.</li> * </ul> * * @see "https://developers.google.com/appengine/articles/sharding_counters" * @author David Fuelling <dfuelling@oodlemud.com> */ public class ShardedCounterServiceImpl implements ShardedCounterService { private static final Logger logger = Logger.getLogger(ShardedCounterServiceImpl.class.getName()); // Helper constant for counterName keys. public static final String COUNTER_NAME = "counterName"; /** * A random number generating, for distributing writes across shards. */ protected final Random generator = new Random(); protected final MemcacheService memcacheService; protected final CapabilitiesService capabilitiesService; protected final ShardedCounterServiceConfiguration config; // ///////////////////////////// // Constructors // ///////////////////////////// /** * Default Constructor for Dependency-Injection that uses * {@link MemcacheServiceFactory} to construct the {@link MemcacheService} * and {@link CapabilitiesServiceFactory} to construct the * {@link CapabilitiesService}. dependency for this service. */ public ShardedCounterServiceImpl() { this(MemcacheServiceFactory.getMemcacheService(), CapabilitiesServiceFactory.getCapabilitiesService()); } /** * Default Constructor for Dependency-Injection that uses a default number * of counter shards (set to 1) and a default configuration per * {@link ShardedCounterServiceConfiguration#defaultConfiguration}. * * @param memcacheService * @param capabilitiesService */ public ShardedCounterServiceImpl(final MemcacheService memcacheService, final CapabilitiesService capabilitiesService) { this(memcacheService, capabilitiesService, ShardedCounterServiceConfiguration.defaultConfiguration()); } /** * Default Constructor for Dependency-Injection. * * @param memcacheService * @param capabilitiesService * @param config The configuration for this service */ public ShardedCounterServiceImpl(final MemcacheService memcacheService, final CapabilitiesService capabilitiesService, final ShardedCounterServiceConfiguration config) { Preconditions.checkNotNull(memcacheService, "Invalid memcacheService!"); Preconditions.checkNotNull(capabilitiesService, "Invalid capabilitiesService!"); Preconditions.checkNotNull(config); this.memcacheService = memcacheService; this.capabilitiesService = capabilitiesService; this.config = config; Preconditions.checkArgument(config.getNumInitialShards() > 0, "Number of Shards for a new CounterData must be greater than 0!"); if (config.getRelativeUrlPathForDeleteTaskQueue() != null) { // The relativeUrlPathForDeleteTaskQueue may be null, but if // it's non-null, then it must not be blank. Preconditions.checkArgument(!StringUtils.isBlank(config.getRelativeUrlPathForDeleteTaskQueue()), "Must be null (for the Default Queue) or a non-blank String!"); } } // ///////////////////////////// // Retreival Functions // ///////////////////////////// /** * The cache will expire after {@code defeaultExpiration} seconds, so the * counter will be accurate after a minute because it performs a load from * the datastore. * * @param counterData * @return */ @Override public Counter getCounter(final String counterName) { Preconditions.checkArgument(!StringUtils.isBlank(counterName), "CounterData Names may not be null, blank, or empty!"); // We always load the CounterData from the Datastore (or its Objectify // cache), but we sometimes return the cached count value. CounterData counterData = this.getOrCreateCounterData(counterName); // If the counter is DELETING, then its count is always 0! if (CounterStatus.DELETING == counterData.getCounterStatus()) { return new CounterBuilder(counterData).withCount(0L).build(); } String memCacheKey = this.assembleCounterKeyforMemcache(counterName); Long cachedCounterCount = null; if (this.isMemcacheAvailable()) { cachedCounterCount = (Long) memcacheService.get(memCacheKey); } if (cachedCounterCount != null) { // ///////////////////////////////////// // The count was found in memcache, so return it. // ///////////////////////////////////// if (getLogger().isLoggable(Level.FINE)) { getLogger().log(Level.FINE, "Cache Hit for Counter Named \"" + counterName + "\": value=" + cachedCounterCount); } return new CounterBuilder(counterData).withCount(cachedCounterCount).build(); } else { // ///////////////////////////////////// // The count was NOT found in memcache! // ///////////////////////////////////// if (getLogger().isLoggable(Level.FINE)) { getLogger().log(Level.FINE, "Cache Miss for CounterData Named \"" + counterName + "\": value=" + cachedCounterCount + ". Checking Datastore instead!"); getLogger().log(Level.FINE, "Aggregating counts from " + counterData.getNumShards() + " CounterDataShards for CounterData named '" + counterData.getCounterName() + "'!"); } // /////////////////// // Assemble a List of CounterShardData Keys to retrieve in parallel! List<Key<CounterShardData>> keysToLoad = Lists.newArrayList(); for (int i = 0; i < counterData.getNumShards(); i++) { Key<CounterShardData> counterShardKey = CounterShardData.key(counterData.getCounterName(), i); keysToLoad.add(counterShardKey); } long sum = 0; // For added performance, we could spawn multiple threads to wait // for each value to be returned from the DataStore, and then // aggregate that way. However, the simple summation below is not // very expensive, so creating multiple threads to get each value // would probably be overkill. Just let objectify do this for us, // even though we have to wait for all entities to return before // summation begins. // No TX - get is Strongly consistent by default, and we will exceed // the TX limit for high-shard-count counters if we try to do this // in a TX. Map<Key<CounterShardData>, CounterShardData> counterShardDatasMap = ObjectifyService.ofy() .transactionless().load().keys(keysToLoad); Collection<CounterShardData> counterShardDatas = counterShardDatasMap.values(); for (CounterShardData counterShardData : counterShardDatas) { if (counterShardData != null) { sum += counterShardData.getCount(); } } if (getLogger().isLoggable(Level.FINE)) { getLogger().log(Level.FINE, "The Datastore is reporting a count of " + sum + " for CounterData \"" + counterData.getCounterName() + "\" count. Resetting memcache count to " + sum + " for this counter name"); } if (this.isMemcacheAvailable()) { memcacheService.put(memCacheKey, new Long(sum), config.getDefaultExpiration(), SetPolicy.SET_ALWAYS); } return new CounterBuilder(counterName).withCount(sum).build(); } } // ///////////////////////////// // Increment Functions // ///////////////////////////// @Override public Counter increment(String counterName) { return this.increment(counterName, 1L); } @Override public Counter increment(String counterName, long amount) { return this.increment(counterName, amount, true); } @Override public Counter increment(final String counterName, final long amount, boolean isolated) { // /////////// // Precondition Checks Preconditions.checkArgument(!StringUtils.isBlank(counterName)); Preconditions.checkArgument(amount > 0, "CounterData increments must be positive numbers!"); // Create the Work to be done for this increment, which will be done // inside of a TX. See // https://developers.google.com/appengine/docs/java/datastore/transactions#Java_Isolation_and_consistency final Work<Long> atomicIncrementShardWork = new Work<Long>() { // NOTE: In order for this to work properly, the CounterShardData // must be gotten, created, and updated all in the same transaction // in order to remain consistent (in other words, it must be // atomic). @Override public Long run() { CounterData counterData = getOrCreateCounterData(counterName); if (counterData.getCounterStatus() == CounterStatus.DELETING) { throw new RuntimeException("Can't increment counter \"" + counterName + "\" because it is currently being deleted!"); } // Find how many shards are in this counter. final int currentNumShards = counterData.getNumShards(); // Choose the shard randomly from the available shards. final int shardNumber = generator.nextInt(currentNumShards); Key<CounterShardData> counterShardDataKey = CounterShardData.key(counterName, shardNumber); // Load the Shard from the DS. CounterShardData counterShardData = ObjectifyService.ofy().load().key(counterShardDataKey).now(); if (counterShardData == null) { // Create it in the Datastore counterShardData = new CounterShardData(counterName, shardNumber); ObjectifyService.ofy().save().entity(counterShardData).now(); } // Increment the count by {amount} counterShardData.setCount(counterShardData.getCount() + amount); if (getLogger().isLoggable(Level.FINE)) { getLogger().log(Level.FINE, "Saving CounterShardData" + shardNumber + " for CounterData \"" + counterName + "\" with count " + counterShardData.getCount()); } // Persist the updated value. ObjectifyService.ofy().save().entity(counterShardData).now(); return new Long(amount); } }; // /////////// // Take Off! Long amountIncrementedInTx = new Long(0L); // Perform the increment inside of its own, isolated transaction. if (isolated) { amountIncrementedInTx = ObjectifyService.ofy().transactNew(atomicIncrementShardWork); } // Perform the increment inside of the existing transaction, if any. else { amountIncrementedInTx = ObjectifyService.ofy().transact(atomicIncrementShardWork); } // We use the "amountIncrementedInTx" to pause this thread until the // work inside of "atomicIncrementShardWork" completes. This is because // we don't want to increment memcache (below) until after that point. // ///////////////// // Increment this counter in memcache atomically, with retry until it // succeeds (with some governor). If this fails, it's ok // because memcache is merely a cache of the actual count data, and will // eventually become accurate when the cache is reloaded. // ///////////////// incrementMemcacheAtomic(counterName, amountIncrementedInTx.longValue()); // return #getCount because this will either return the memcache value // or get the actual count from the Datastore, which will do the same // thing. return getCounter(counterName); } // ///////////////////////////// // Decrementing Functions // ///////////////////////////// @Override public Counter decrement(String counterName) { return this.decrement(counterName, 1L); } @Override public Counter decrement(final String counterName, final long amount) { // /////////// // Precondition Checks Preconditions.checkNotNull(counterName); Preconditions.checkArgument(!StringUtils.isBlank(counterName)); // /////////// // CounterData Status Checks CounterData counterData = getOrCreateCounterData(counterName); // Find how many shards are in this counter. final int currentNumShards = counterData.getNumShards(); long totalAmountDecremented = 0L; // Try a random shard at first -- this will generally work, but if it // fails, then the code below it will kick-in, which is less // efficient since it scans through all of the shards and will generally // bias towards the lower-numbered shards since the counter starts at 0. // An improvement to reduce this bias would be to pick a random shard, // then scan up and down from there. // Choose the shard randomly from the available shards. final int randomShardNum = generator.nextInt(currentNumShards); Key<CounterShardData> randomCounterShardDataKey = CounterShardData.key(counterName, randomShardNum); DecrementShardWork decrementShardTask = new DecrementShardWork(counterName, randomCounterShardDataKey, amount); Long lAmountDecrementedInTx = ObjectifyService.ofy().transactNew(decrementShardTask); long amountDecrementedInTx = lAmountDecrementedInTx == null ? 0L : lAmountDecrementedInTx.longValue(); totalAmountDecremented = amountDecrementedInTx; long amountLeftToDecrement = amount - amountDecrementedInTx; if (amountLeftToDecrement > 0) { // Try to decrement an amount from one shard at a time in a serial // fashion so that two shards aren't decremented at the // same time. for (int i = 0; i < counterData.getNumShards(); i++) { final Key<CounterShardData> sequentialCounterShardDataKey = CounterShardData.key(counterName, i); if (sequentialCounterShardDataKey.equals(randomCounterShardDataKey)) { // This shard has already been decremented, so don't try // again, but keep trying other shards. continue; } // Try to decrement amountLeftToDecrement decrementShardTask = new DecrementShardWork(counterName, sequentialCounterShardDataKey, amountLeftToDecrement); lAmountDecrementedInTx = ObjectifyService.ofy().transactNew(decrementShardTask); amountDecrementedInTx = lAmountDecrementedInTx == null ? 0L : lAmountDecrementedInTx.longValue(); totalAmountDecremented += amountDecrementedInTx; amountLeftToDecrement -= amountDecrementedInTx; } } // ///////////////// // Increment this counter in memcache atomically, with retry until it // succeeds (with some governor). If this fails, it's ok // because memcache is merely a cache of the actual count data, and will // eventually become accurate when the cache is reloaded. // ///////////////// incrementMemcacheAtomic(counterName, (totalAmountDecremented * -1L)); // return #getCount because this will either return the memcache value // or get the actual count from the Datastore, which will do the same // thing. return getCounter(counterName); } /** * An implementation of {@link Work} that decrements a specific * {@link CounterShardData} by a specified amount. {@link CounterShardData} * entities may not go below zero, so the amount returned by this callable * may be less than the requested amount. */ final class DecrementShardWork implements Work<Long> { private final String counterName; private final Key<CounterShardData> counterShardKey; private final long requestedDecrementAmount; /** * Required args Constructor * * @param counterShardKey * @param isolated * @param allowNegativeShardCounts */ public DecrementShardWork(final String counterName, final Key<CounterShardData> counterShardKey, final long requestedDecrementAmount) { Preconditions.checkArgument(!StringUtils.isBlank(counterName)); Preconditions.checkNotNull(counterShardKey); Preconditions.checkArgument(requestedDecrementAmount >= 0, "Cannot decrement with a negative number!"); this.counterName = counterName; this.counterShardKey = counterShardKey; this.requestedDecrementAmount = requestedDecrementAmount; } /** * Attempt to decrement a particular CounterShardData by the * {@code decrementAmount}, or something less if the shard does not have * enough count to fulfill the entire decrement request. Note that * CounterShardData counts are not permitted to go negative! */ @Override public Long run() { CounterData counterData = getOrCreateCounterData(counterName); if (counterData.getCounterStatus() == CounterStatus.DELETING) { throw new RuntimeException( "Can't increment counter \"" + counterName + "\" because it is currently being deleted!"); } // Load the appropriate Shard CounterShardData counterShardData = ObjectifyService.ofy().load().key(counterShardKey).now(); if (counterShardData == null) { // Nothing was decremented! We don't create shards on a // decrement operation! return new Long(0L); } // This is the amount to decrement by. It may be reduced if // the shard doesn't have enough count! long decrementAmount = computeLargestDecrementAmountForShard(counterShardData.getCount(), requestedDecrementAmount); // /////////////////////////////// // We have adjusted the decrementAmount, but it's possible // it's still 0, in which case we should short-circuit the // datastore update and just return 0. // /////////////////////////////// if (decrementAmount <= 0) { if (getLogger().isLoggable(Level.FINE)) { getLogger().fine("Unable to Decrement CounterShardData (" + counterShardKey + ") with count " + counterShardData.getCount() + " and requestedDecrementAmount of " + requestedDecrementAmount); } return new Long(0); } else { counterShardData.setCount(counterShardData.getCount() - decrementAmount); if (getLogger().isLoggable(Level.FINE)) { getLogger().fine("Saving Decremented CounterShardData (" + counterShardKey + ") with count " + counterShardData.getCount() + " after requestedDecrementAmount of " + requestedDecrementAmount + " and actual decrementAmount of " + decrementAmount); } // Persist the updated value, but wait for it to // complete! ObjectifyService.ofy().save().entity(counterShardData).now(); return new Long(decrementAmount); } } /** * Returns a decrement amount that is either zero, or a positive long * amount that a particular CounterShard can be reduced by. * * @param counterShardData * @param decrementAmount * @return */ @VisibleForTesting protected long computeLargestDecrementAmountForShard(long counterShardCount, long decrementAmount) { if (counterShardCount - decrementAmount < 0) { // The 'delta' is the difference between the current // count, // and the requested decrement. long delta = counterShardCount - decrementAmount; // If the delta is negative, then reduce the // decrementAmount so that the counterShard doesn't go // below 0. if (delta < 0L) { // reduce the decrement by the delta decrementAmount -= Math.abs(delta); // One final check, which is probably redundant if (decrementAmount < 0L) { decrementAmount = 0L; } } } return decrementAmount; } }; // ///////////////////////////// // Counter Deletion Functions // ///////////////////////////// @Override public void delete(final String counterName) { Preconditions.checkNotNull(counterName); Preconditions.checkArgument(!StringUtils.isBlank(counterName)); // Delete the main counter in a new TX... ObjectifyService.ofy().transactNew(new VoidWork() { @Override public void vrun() { // Load in a TX so that two threads don't mark the counter as // deleted at the same time. Key<CounterData> counterDataKey = CounterData.key(counterName); final CounterData counterData = ObjectifyService.ofy().load().key(counterDataKey).now(); if (counterData == null) { // Nothing to delete... return; } Queue queue; if (config.getDeleteCounterShardQueueName() == null) { queue = QueueFactory.getDefaultQueue(); } else { queue = QueueFactory.getQueue(config.getDeleteCounterShardQueueName()); } // The TaskQueue will delete the counter once all shards are // deleted. counterData.setCounterStatus(CounterStatus.DELETING); // Call this Async so that the rest of the thread can // continue. Everything will block till commit is called. ObjectifyService.ofy().save().entity(counterData); // Transactionally enqueue this task to the path specified // in the constructor (if this is null, then the default // queue will be used). TaskOptions taskOptions = TaskOptions.Builder.withParam(COUNTER_NAME, counterName); if (config.getRelativeUrlPathForDeleteTaskQueue() != null) { taskOptions = taskOptions.url(config.getRelativeUrlPathForDeleteTaskQueue()); } // Kick off a Task to delete the Shards for this CounterData // and the CounterData itself, but only if the TX succeeds queue.add(taskOptions); } }); } @Override public void onTaskQueueCounterDeletion(final String counterName) { // Load in a TX so that two threads don't mark the counter as // deleted at the same time. Key<CounterData> counterDataKey = CounterData.key(counterName); final CounterData counterData = ObjectifyService.ofy().load().key(counterDataKey).now(); if (counterData == null) { getLogger().severe("While attempting to delete CounterData named \"" + counterName + "\", no CounterData was found in the Datastore!"); // Nothing to delete...perhaps another task already did the // deletion? return; } else if (counterData.getCounterStatus() != CounterStatus.DELETING) { throw new RuntimeException("Can't delete a counter \"" + counterName + "\" because it is currently no in the DELETING state!"); } // Assemble a list of CounterShard keys, and delete them all in a batch! Collection<Key<CounterShardData>> counterShardDataKeys = Lists.newArrayList(); for (int i = 0; i < counterData.getNumShards(); i++) { Key<CounterShardData> counterShardDataKey = CounterShardData.key(counterName, i); counterShardDataKeys.add(counterShardDataKey); } // No TX needed, and no need to wait. ObjectifyService.ofy().transactionless().delete().keys(counterShardDataKeys).now(); // Delete the CounterData itself...No TX needed, and no need to wait. ObjectifyService.ofy().transactionless().delete().key(counterData.getTypedKey()).now(); // Clear Memcache if (isMemcacheAvailable()) { memcacheService.delete(counterName); } } // ////////////////////////////////// // Protected Helpers // ////////////////////////////////// /** * Helper method to get (or create and then get) a {@link CounterData} from * the Datastore with a given name. The result of this function is * guaranteed to be non-null if no exception is thrown. * * @param counterName * @return * @throws NullPointerException in the case where no CounterData could be * loaded from the Datastore. */ @VisibleForTesting protected CounterData getOrCreateCounterData(final String counterName) { final Key<CounterData> counterKey = CounterData.key(counterName); // Do this in a new TX to avoid XG transaction limits, and to ensure // that if two threads with different config default shard values don't // stomp on each other. If two threads conflict with each other, one // will win and create the CounterData, and the other thread will retry // and return the loaded CounterData. return ObjectifyService.ofy().transactNew(new Work<CounterData>() { @Override public CounterData run() { CounterData counterData = ObjectifyService.ofy().load().key(counterKey).now(); if (counterData == null) { counterData = new CounterData(counterName, config.getNumInitialShards()); ObjectifyService.ofy().save().entity(counterData).now(); } return counterData; } }); } /** * Increment the memcache version of the named-counter by {@code amount} * (positive or negative) in an atomic fashion. Use memcache as a * Semaphore/Mutex, and retry up to 10 times if other threads are attempting * to update memcache at the same time. If nothing is in Memcache when this * function is called, then do nothing because only #getCounter should "put" * a value to memcache. * * @param counterName * @param amount * @return The new count of this counter as reflected by memcache */ @VisibleForTesting protected Optional<Long> incrementMemcacheAtomic(final String counterName, final long amount) { // Memcache update did not succeed! if (!isMemcacheAvailable()) { return Optional.absent(); } // Get the cache counter at a current point in time. String memCacheKey = this.assembleCounterKeyforMemcache(counterName); int numRetries = 10; while (numRetries > 0) { try { IdentifiableValue identifiableCounter = memcacheService.getIdentifiable(memCacheKey); // See Javadoc about a null identifiableCounter. If it's null, // then the named counter doesn't exist in memcache. if (identifiableCounter == null || (identifiableCounter != null && identifiableCounter.getValue() == null)) { if (getLogger().isLoggable(Level.FINE)) { getLogger().fine( "No identifiableCounter was found in Memcache. Unable to Atomically increment for CounterName \"" + counterName + "\". Memcache will be populated on the next called to getCounter()!"); } // This will return an absent value. Only #getCounter should // "put" a value to memcache. break; } // If we get here, the count exists in memcache, so it can be // atomically incremented. Long cachedCounterAmount = (Long) identifiableCounter.getValue(); long newMemcacheAmount = cachedCounterAmount.longValue() + amount; if (newMemcacheAmount < 0) { newMemcacheAmount = 0; } if (getLogger().isLoggable(Level.FINE)) { getLogger().fine("Just before Atomic Increment of " + amount + ", Memcache has value " + identifiableCounter.getValue()); } if (memcacheService.putIfUntouched(counterName, identifiableCounter, new Long(newMemcacheAmount), config.getDefaultExpiration())) { if (getLogger().isLoggable(Level.FINE)) { getLogger().fine("memcacheService.putIfUntouched SUCCESS! with value " + newMemcacheAmount); } // If we get here, the put succeeded... return Optional.of(new Long(newMemcacheAmount)); } else { if (getLogger().isLoggable(Level.WARNING)) { getLogger().log(Level.WARNING, "Unable to update memcache counter atomically. Retrying " + numRetries + " more times..."); } } } catch (MemcacheServiceException mse) { // Check and post-decrement the numRetries counter in one step if (numRetries-- > 0) { if (getLogger().isLoggable(Level.WARNING)) { getLogger().log(Level.WARNING, "Unable to update memcache counter atomically. Retrying " + numRetries + " more times...", mse); } // Keep trying... continue; } else { // Evict the counter here, and let the next call to // getCounter populate memcache getLogger().log(Level.SEVERE, "Unable to update memcache counter atomically, with no more allowed retries. Evicting counter named " + counterName + " from the cache!", mse); memcacheService.delete(memCacheKey); break; } } } // The increment did not work... return Optional.absent(); } /** * Assembles a CounterKey for Memcache * * @param counterName * @return */ @VisibleForTesting protected String assembleCounterKeyforMemcache(String counterName) { return counterName; } /** * @return {@code true} if Memcache is usable; {@code false} otherwise. */ @VisibleForTesting protected boolean isMemcacheAvailable() { CapabilityStatus capabilityStatus = this.capabilitiesService.getStatus(Capability.MEMCACHE).getStatus(); return capabilityStatus == CapabilityStatus.ENABLED; } /** * @return */ protected Logger getLogger() { return logger; } }