* @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved * @license LICENSE MIT */ class Memcached implements KeyValueStore { /** * @var \Memcached */ protected $client; public function __construct(\Memcached $client) { $this->client = $client; } /** * {@inheritdoc} */ public function get($key, &$token = null) { /** * Wouldn't it be awesome if I just used the obvious method? * * I'm going to use getMulti() instead of get() because the latter is * flawed in earlier versions, where it was known to mess up some * operations that are followed by it (increment/decrement have been * reported, also seen it make CAS return result unreliable) * * @see https://github.com/php-memcached-dev/php-memcached/issues/21 */ $values = $this->getMulti(array($key), $tokens); if (!isset($values[$key])) { $token = null; return false; } $token = $tokens[$key]; return $values[$key]; } /** * {@inheritdoc} */ public function getMulti(array $keys, array &$tokens = null) { $tokens = array(); if (empty($keys)) { return array(); } $keys = array_map(array($this, 'encode'), $keys); if (defined('\Memcached::GET_EXTENDED')) { $return = $this->client->getMulti($keys, \Memcached::GET_EXTENDED); $this->throwExceptionOnClientCallFailure($return); foreach ($return as $key => $value) { // once PHP<5.5 support is dropped, just use array_column $tokens[$key] = $value['cas']; $return[$key] = $value['value']; } } else { $return = $this->client->getMulti($keys, $tokens); $this->throwExceptionOnClientCallFailure($return); } $keys = array_map(array($this, 'decode'), array_keys($return)); $return = array_combine($keys, $return); // HHVMs getMulti() returns null instead of empty array for no results, // so normalize that $tokens = $tokens ?: array(); $tokens = array_combine($keys, $tokens); return $return ?: array(); } /** * {@inheritdoc} */ public function set($key, $value, $expire = 0) { if ($this->deleteIfExpired($key, $expire)) { return true; } $key = $this->encode($key); return $this->client->set($key, $value, $expire); } /** * {@inheritdoc} */ public function setMulti(array $items, $expire = 0) { if (empty($items)) { return array(); } $keys = array_keys($items); if ($this->deleteIfExpired($keys, $expire)) { return array_fill_keys($keys, true); } if (defined('HHVM_VERSION')) { $nums = array_filter(array_keys($items), 'is_numeric'); if (!empty($nums)) { return $this->setMultiNumericItemsForHHVM($items, $nums, $expire); } } $keys = array_map(array($this, 'encode'), array_keys($items)); $items = array_combine($keys, $items); $success = $this->client->setMulti($items, $expire); $keys = array_map(array($this, 'decode'), array_keys($items)); return array_fill_keys($keys, $success); } /** * {@inheritdoc} */ public function delete($key) { $key = $this->encode($key); return $this->client->delete($key); } /** * {@inheritdoc} */ public function deleteMulti(array $keys) { if (empty($keys)) { return array(); } if (!method_exists($this->client, 'deleteMulti')) { /** * HHVM didn't always support deleteMulti, so I'll hack around it by * setting all items expired. * I could also delete() all items one by one, but that would * probably take more network requests (this version always takes 2). * * @see http://docs.hhvm.com/manual/en/memcached.deletemulti.php */ $values = $this->getMulti($keys); $keys = array_map(array($this, 'encode'), array_keys($values)); $this->client->setMulti(array_fill_keys($keys, ''), time() - 1); $return = array(); foreach ($keys as $key) { $key = $this->decode($key); $return[$key] = array_key_exists($key, $values); } return $return; } $keys = array_map(array($this, 'encode'), $keys); $result = (array) $this->client->deleteMulti($keys); $keys = array_map(array($this, 'decode'), array_keys($result)); $result = array_combine($keys, $result); /* * Contrary to docs (http://php.net/manual/en/memcached.deletemulti.php) * deleteMulti returns an array of [key => true] (for successfully * deleted values) and [key => error code] (for failures) * Pretty good because I want an array of true/false, so I'll just have * to replace the error codes by falses. */ foreach ($result as $key => $status) { $result[$key] = true === $status; } return $result; } /** * {@inheritdoc} */ public function add($key, $value, $expire = 0) { $key = $this->encode($key); $success = $this->client->add($key, $value, $expire); if ($success) { $this->deleteIfExpired($key, $expire); } return $success; } /** * {@inheritdoc} */ public function replace($key, $value, $expire = 0) { $key = $this->encode($key); $success = $this->client->replace($key, $value, $expire); if ($success) { $this->deleteIfExpired($key, $expire); } return $success; } /** * {@inheritdoc} */ public function cas($token, $key, $value, $expire = 0) { if (!is_float($token) && !is_int($token)) { return false; } $key = $this->encode($key); $success = $this->client->cas($token, $key, $value, $expire); if ($success) { $this->deleteIfExpired($key, $expire); } return $success; } /** * {@inheritdoc} */ public function increment($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } /* * Not doing \Memcached::increment because that one: * * needs \Memcached::OPT_BINARY_PROTOCOL == true * * is prone to errors after a flush ("merges" with pruned data) in at * least some particular versions of Memcached */ return $this->doIncrement($key, $offset, $initial, $expire); } /** * {@inheritdoc} */ public function decrement($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } /* * Not doing \Memcached::decrement for the reasons described in: * @see increment() */ return $this->doIncrement($key, -$offset, $initial, $expire); } /** * {@inheritdoc} */ public function touch($key, $expire) { if ($this->deleteIfExpired($key, $expire)) { return true; } /** * HHVM doesn't support touch. * * @see http://docs.hhvm.com/manual/en/memcached.touch.php * * PHP does, but only with \Memcached::OPT_BINARY_PROTOCOL == true, * and even then, it appears to be buggy on particular versions of * Memcached. * * I'll just work around it! */ $value = $this->get($key, $token); return $this->cas($token, $key, $value, $expire); } /** * {@inheritdoc} */ public function flush() { return $this->client->flush(); } /** * {@inheritdoc} */ public function getCollection($name) { return new Collection($this, $name); } /** * Shared between increment/decrement: both have mostly the same logic * (decrement just increments a negative value), but need their validation * split up (increment won't accept negative values). * * @param string $key * @param int $offset * @param int $initial * @param int $expire * * @return int|bool */ protected function doIncrement($key, $offset, $initial, $expire) { $value = $this->get($key, $token); if (false === $value) { $success = $this->add($key, $initial, $expire); return $success ? $initial : false; } if (!is_numeric($value) || $value < 0) { return false; } $value += $offset; // value can never be lower than 0 $value = max(0, $value); $key = $this->encode($key); $success = $this->client->cas($token, $key, $value, $expire); return $success ? $value : false; } /** * Encode a key for use on the wire inside the memcached protocol. * * We encode spaces and line breaks to avoid protocol errors. We encode * the other control characters for compatibility with libmemcached * verify_key. We leave other punctuation alone, to maximise backwards * compatibility. * * @see https://github.com/wikimedia/mediawiki/commit/be76d869#diff-75b7c03970b5e43de95ff95f5faa6ef1R100 * @see https://github.com/wikimedia/mediawiki/blob/master/includes/libs/objectcache/MemcachedBagOStuff.php#L116 * * @param string $key * * @return string * * @throws InvalidKey */ protected function encode($key) { $regex = '/[^\x21\x22\x24\x26-\x39\x3b-\x7e]+/'; $key = preg_replace_callback($regex, function ($match) { return rawurlencode($match[0]); }, $key); if (strlen($key) > 255) { throw new InvalidKey("Invalid key: $key. Encoded Memcached keys can not exceed 255 chars."); } return $key; } /** * Decode a key encoded with encode(). * * @param string $key * * @return string */ protected function decode($key) { // matches %20, %7F, ... but not %21, %22, ... // (=the encoded versions for those encoded in encode) $regex = '/%(?!2[1246789]|3[0-9]|3[B-F]|[4-6][0-9A-F]|5[0-9A-E])[0-9A-Z]{2}/i'; return preg_replace_callback($regex, function ($match) { return rawurldecode($match[0]); }, $key); } /** * Memcached seems to not timely purge items the way it should when * storing it with an expired timestamp, so we'll detect that and * delete it (instead of performing the already expired operation). * * @param string|string[] $key * @param int $expire * * @return bool True if expired */ protected function deleteIfExpired($key, $expire) { if ($expire < 0 || ($expire > 2592000 && $expire < time())) { $this->deleteMulti((array) $key); return true; } return false; } /** * Numerical strings turn into integers when used as array keys, and * HHVM (used to) reject(s) such cache keys. * * @see https://github.com/facebook/hhvm/pull/7654 * * @param int $expire * * @return array */ protected function setMultiNumericItemsForHHVM(array $items, array $nums, $expire = 0) { $success = array(); $nums = array_intersect_key($items, array_fill_keys($nums, null)); foreach ($nums as $k => $v) { $success[$k] = $this->set((string) $k, $v, $expire); } $remaining = array_diff_key($items, $nums); if ($remaining) { $success += $this->setMulti($remaining, $expire); } return $success; } /** * Will throw an exception if the returned result from a Memcached call * indicates a failure in the operation. * The exception will contain debug information about the failure. * * @param mixed $result * * @throws OperationFailed */ protected function throwExceptionOnClientCallFailure($result) { if (false !== $result) { return; } throw new OperationFailed($this->client->getResultMessage(), $this->client->getResultCode()); } }