/ */ class P4Cms_Cache_Backend_MemcachedTagged extends Zend_Cache_Backend implements Zend_Cache_Backend_ExtendedInterface { /** * Default Server Values */ const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 11211; const DEFAULT_WEIGHT = 1; /** * Available options * * (array) servers : * an array of memcached server(s) ; each memcached server is described by an associative array : * 'host' => (string) : the dns or ip address of the memcached server * 'port' => (int) : the port of the memcached server * 'weight' => (int) : number of buckets to create for this server which in turn control its * probability of it being selected. The probability is relative to the total * weight of all servers. * (array) client : * if using the memcached php extension @see http://php.net/manual/memcached.constants.php for valid * options and their associated values. default options are set for Distribution, Hash and Libketama. * if using the memcache php extension the only option is 'compression' which can have the value 0 or * MEMCACHE_COMPRESSED (0 being the default). */ protected $_options = array( 'namespace' => null, 'servers' => array( array( 'host' => self::DEFAULT_HOST, 'port' => self::DEFAULT_PORT, 'weight' => self::DEFAULT_WEIGHT, ) ), 'client' => array() ); protected $_memcache = null; /** * Constructor * * @param array $options associative array of options * @throws Zend_Cache_Exception if neither the memcached or memcache extensions are present * @return void */ public function __construct(array $options = array()) { parent::__construct($options); // for convenience users can specify a single server's settings as the direct // value of 'servers'; we normalize it here to always be an array of servers. if (isset($this->_options['servers'])) { $value = $this->_options['servers']; if (isset($value['host'])) { $value = array($value); } $this->setOption('servers', $value); } // select the optimal backend; throw if we can find neither if (extension_loaded('memcached')) { $this->_memcache = new Memcached; // add in our default options if they are not already present // we cannot define these when $_options is declared as the constants // won't exist if the memcached extension isn't loaded. $this->_options['client'] += array( Memcached::OPT_DISTRIBUTION => Memcached::DISTRIBUTION_CONSISTENT, Memcached::OPT_HASH => Memcached::HASH_MD5, Memcached::OPT_LIBKETAMA_COMPATIBLE => true ); // apply the client options to our instance foreach ($this->_options['client'] as $id => $value) { $this->_memcache->setOption($id, $value); } } else if (extension_loaded('memcache')) { $this->_memcache = new Memcache; // provide a default for the only supported option which is compression // this will be applied each time our _set method is used. $this->_options['client'] += array( 'compression' => 0 ); } else { Zend_Cache::throwException('The memcached or memcache php extension must be loaded to use this backend!'); } // setup memcached servers foreach ($this->_options['servers'] as $server) { if (!isset($server['host'])) { continue; } // add in default values if port/weight are not specified $server += array( 'port' => self::DEFAULT_PORT, 'weight' => self::DEFAULT_WEIGHT ); $this->_memcache->addServer($server['host'], $server['port'], $server['weight']); } } /** * Returns the cached value for the given ID or false if no cache entry exists. * * @param string $id Cache id to retrieve * @param boolean $doNotTestCacheValidity Has no effect on this backend * @return string|false cached data or false */ public function load($id, $doNotTestCacheValidity = false) { $entry = $this->_get($id); if (isset($entry['data'])) { return $entry['data']; } return false; } /** * Checks if the given ID has a cached value or not. * Note this will pull down the entries value in the process. * * @param string $id Cache id * @return int|false "last modified" timestamp (int) if available otherwise false */ public function test($id) { $entry = $this->_get($id); // if this looks like a valid entry (has data) and // has a modified date we are happy; return it. if (isset($entry['data'], $entry['modified'])) { return (int)$entry['modified']; } // the item was expired or invalid, fail out return false; } /** * Save some data to cache, will replace any existing entry of the same ID . * * Note: $data is always a "string" (serialization is done by the core not by the backend) * * @param string $data The value to cache * @param string $id Cache id * @param array $tags Array of strings, the cache record will be tagged by each string entry * @param int|bool|null $specificLifetime Number of seconds to keep entry for or 0/null for unlimited or false * to use the default lifetime * @return bool True if entry was cached, false otherwise */ public function save($data, $id, $tags = array(), $specificLifetime = false) { // memcached supports a max data size of 1 meg; don't bother // pushing anything larger over the wire as it will fail. if (strlen($data) > 1024*1024) { $this->_log("MemcachedTagged::save() skipped, data over 1 meg"); return false; } $lifetime = $this->getLifetime($specificLifetime); $entry = array( 'data' => $data, 'modified' => time(), 'lifetime' => $lifetime ); // if no tags have been specified just store the entry details directly // on its specified id. // if tags are present, we instead store the list of tags on the passed // id and locate the entry under a new 'taggedId' we generate. if (empty($tags)) { $result = $this->_set($id, $entry, $lifetime); } else { $taggedId = $this->_makeTaggedId($id, $tags, true); $result = $taggedId && $this->_set($id, array('tags' => $tags), $lifetime); $result = $result && $this->_set($taggedId, $entry, $lifetime); } if ($result === false) { $this->_log("MemcachedTagged::save() failed"); } return $result; } /** * Remove a cache entry by id * * @param string $id Cache id * @return boolean True if entry was removed, false if it didn't exist */ public function remove($id) { return $this->_memcache->delete($id); } /** * Clean some cache records * * Available modes are : * Zend_Cache::CLEANING_MODE_ALL (default) => remove all cache entries ($tags is not used) * Zend_Cache::CLEANING_MODE_OLD => not supported * Zend_Cache::CLEANING_MODE_MATCHING_TAG => not supported * Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG => not supported * Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG => remove cache entries matching any given tags * ($tags can be an array of strings or a single string) * * @param string $mode Cleaning mode * @param array $tags Array of tags * @return bool True if no problem * @throws Zend_Cache_Exception */ public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()) { switch ($mode) { case Zend_Cache::CLEANING_MODE_ALL: return $this->_memcache->flush(); break; case Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG: // increment the tag counters for the passed tags to invalidate any // related entries. increment will only succeed if there is already // an entry for the given id. any failures are moot as that, most // likely, means the tag is already expired foreach ($tags as $tag) { $this->_memcache->increment($this->_makeTagCounterId($tag)); } break; case Zend_Cache::CLEANING_MODE_OLD: $this->_log("MemcachedTagged::clean() : CLEANING_MODE_OLD is unsupported"); break; case Zend_Cache::CLEANING_MODE_MATCHING_TAG: case Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG: $this->_log("MemcachedTagged::clean() : The MATCHING_TAG and NOT_MATCHING_TAG modes are unsupported"); break; default: Zend_Cache::throwException('Invalid mode for clean() method'); break; } return false; } /** * Returns true if automatic cleaning is available for the backend * * @return bool Always returns false for this type of backend */ public function isAutomaticCleaningAvailable() { return false; } /** * Return an array of stored cache ids. * The operation is not supported by this backend. * * @return array Always returns an empty array, unsupported */ public function getIds() { $this->_log("MemcachedTagged::getIds() : unsupported by this backend"); return array(); } /** * Return an array of stored tags * The operation is not supported by this backend. * * @return array Always returns an empty array, unsupported */ public function getTags() { $this->_log("MemcachedTagged::getTags() : unsupported by this backend"); return array(); } /** * Return an array of stored cache ids which match given tags * The operation is not supported by this backend. * * @param array $tags array of tags * @return array Always returns an empty array, unsupported */ public function getIdsMatchingTags($tags = array()) { $this->_log("MemcachedTagged::getIdsMatchingTags() : unsupported by this backend"); return array(); } /** * Return an array of stored cache ids which don't match given tags * The operation is not supported by this backend. * * @param array $tags array of tags * @return array Always returns an empty array, unsupported */ public function getIdsNotMatchingTags($tags = array()) { $this->_log("MemcachedTagged::getIdsNotMatchingTags() : unsupported by this backend"); return array(); } /** * Return an array of stored cache ids which match any given tags * The operation is not supported by this backend. * * @param array $tags array of tags * @return array Always returns an empty array, unsupported */ public function getIdsMatchingAnyTags($tags = array()) { $this->_log("MemcachedTagged::getIdsMatchingAnyTags() : unsupported by this backend"); return array(); } /** * Return the filling percentage of the backend storage * * @return int an number between 0 and 100 * @throws Zend_Cache_Exception */ public function getFillingPercentage() { $results = $this->_memcache->getStats(); if ($results === false) { return 0; } $totalSize = null; $totalUsed = null; foreach ($results as $server => $stats) { if ($stats === false) { $this->_log("cannot get stat from " . $server); continue; } $size = $stats['limit_maxbytes']; $used = $stats['bytes']; // don't allow usage to exceed max size for a given server if ($used > $size) { $used = $size; } $totalSize += $size; $totalUsed += $used; } if ($totalSize === null || $totalUsed === null) { Zend_Cache::throwException('Cannot determine filling percentage'); } return (int) (100 * ($totalUsed / $totalSize)); } /** * Return an array of metadatas for the given cache id * * The array must include these keys : * - expire : the expire timestamp * - tags : a string array of tags * - mtime : timestamp of last modification time * * @param string $id cache id * @return array array of metadatas (false if the cache id is not found) */ public function getMetadatas($id) { $entry = $this->_get($id); if (isset($entry['modified'], $entry['lifetime'], $entry['tags'])) { return array( 'expire' => $entry['modified'] + $entry['lifetime'], 'tags' => $entry['tags'], 'mtime' => $entry['modified'] ); } return false; } /** * Give (if possible) an extra lifetime to the given cache id * * @param string $id cache id * @param int $extraLifetime number of seconds to add to the current lifetime * @return bool true if ok false otherwise */ public function touch($id, $extraLifetime) { $entry = $this->_get($id); if (isset($entry['data'], $entry['modified'], $entry['lifetime'])) { $newLifetime = $entry['lifetime'] - (time() - $entry['modified']) + $extraLifetime; if ($newLifetime <= 0) { return false; } return $this->save($entry['data'], $id, $entry['tags'], $newLifetime); } return false; } /** * Return an associative array of capabilities (bools) of the backend * * - automatic_cleaning not supported * - tags supported (though only partially in reality) * - expired_read not supported * - priority not supported * - infinite_lifetime not supported * - get_list not supported * * @return array associative of with capabilities */ public function getCapabilities() { return array( 'automatic_cleaning' => false, 'tags' => true, 'expired_read' => false, 'priority' => false, 'infinite_lifetime' => false, 'get_list' => false ); } /** * This method takes care of applying the namespace, if * present, as a prefix to the passed id. If no namespace * is in use the id is simply returned unchaned. * * @param string $id the id to prefix if needed * @return string the id prefix to use, blank if none */ protected function _namespaceId($id) { $namespace = $this->_options['namespace']; if (!$namespace) { return $id; } return md5($this->_options['namespace']) . '-' . $id; } /** * Helper method to store a value; the two memcached extensions we * support have slightly different argument handling and this method * normalizes them. * * @param string $key The key/id to store under * @param mixed $value The value to store * @param int|null $lifetime optional - number of seconds to keep the item for * 0 indicates no limit (though it can still be removed * should memcached get full). * @return bool true on success false otherwise */ protected function _set($key, $value, $lifetime = 0) { // ensure the id is namespaced if needed $key = $this->_namespaceId($key); // if the lifetime is greater than 30 days it has to be represented as // a unix time; do that conversion here. if ($lifetime > 30*24*60*60) { $lifetime = time() + $lifetime; } // zend cache uses null to mean forever; convert to 0 for memcached if ($lifetime === null) { $lifetime = 0; } if ($this->_memcache instanceof Memcached) { return $this->_memcache->set($key, $value, $lifetime); } // deal with a memcache backend which expects an extra 'flags' param return $this->_memcache->set($key, $value, $this->_options['client']['compression'], $lifetime); } /** * Get all data and metadata for the given ID from memcached. * If the entry utilized tags when it was stored this method will take * care of resolving the tag counter(s) to get the actual data value back. * * If successful the returned array will have the following layout: * array ( * 'data' => , * 'tags' => , * 'modified' => , * 'lifetime' => * ) * * @param string $id The item id to retrieve * @return array|bool The item data & metadata or false */ protected function _get($id) { $directData = $this->_memcache->get($this->_namespaceId($id)); // if we don't receive a properly formed array bail out if (!is_array($directData) || (!isset($directData['data']) && !isset($directData['tags']))) { return false; } // if data is stored directly on this id simply return result if (isset($directData['data'])) { return $directData + array('tags' => array()); } // if we cannot determine the tagged id, a tag counter has // expired or something is amiss; bail out $taggedId = $this->_makeTaggedId($id, $directData['tags']); if (!$taggedId) { return false; } $taggedData = $this->_memcache->get($this->_namespaceId($taggedId)); // if we still don't have data it is a failure if (!isset($taggedData['data'])) { return false; } // made it all the way; return the combined tag and data details return array_merge($directData, $taggedData); } /** * We create 'counters' in memcached for every tag we encounter. This * method will translate from a tag to the associated tag counter ID. * * @param string $tag the tag to get an ID for * @return string the memcached ID associated with the passed tag */ protected function _makeTagCounterId($tag) { return '_tag-' . md5($tag); } /** * Given an id and a list of tags, returns the 'tagged' id under which * the actual data will be stored. Optionally creates the 'tag counters' * needed for this id if the force param is specified. * * @param string $id the plain id to utilize * @param array $tags string based tags that we want to apply to the id * @param bool $force normally we fail if any tag counters are missing, pass * true to cause any missing tag counters to be created * @return bool|string the tagged id or false */ protected function _makeTaggedId($id, $tags, $force = false) { // we only do tagged ids for tagged content if (empty($tags)) { return false; } $tagCounterIds = array(); foreach ($tags as $tag) { $tagCounterIds[] = $this->_makeTagCounterId($tag); } // read out all of the tag counters from memcached, use getMulti if possible if (method_exists($this->_memcache, 'getMulti')) { $tagCounts = $this->_memcache->getMulti($tagCounterIds); } else { $tagCounts = $this->_memcache->get($tagCounterIds); } // deal with getting a non array or only partial result back $tagCounts = is_array($tagCounts) ? $tagCounts : array(); if (count($tagCounts) != count($tagCounterIds)) { // if we don't have force set; we have to give up at this point if (!$force) { return false; } // find the missing tags $missingTags = array_diff($tagCounterIds, array_keys($tagCounts)); // use the current time as the value for all missing tag counters. // if we always started at 1 it would have two issues: // - one is a terribly obvious choice which makes it somewhat boring // - if a tag counter expires and we restore it to 1 we could erroneously // resurrect associated entries which should really be expired as well. $missingTagCounts = array_fill_keys(array_values($missingTags), time()); // ensure we include the missing tags in the full list $tagCounts += $missingTagCounts; // attempt to add all the missing tags foreach ($missingTagCounts as $tagId => $value) { // if the add fails likely someone beat us to it, // try and get the value they used if (!$this->_memcache->add($tagId, $value)) { $tagCounts[$tagId] = $this->_memcache->get($tagId); // if we cannot read out the value give up if (!$tagCounts[$tagId]) { return false; } } } } // ensure the tag counts are alphabetically ordered uksort($tagCounts, 'strnatcmp'); // generate a 'tagged' id to lookup the actual data return '_tagged-' . $id . '-'. md5(serialize($tagCounts)); } }