From e315f563be9f932eb8548b52824548d8801fdde7 Mon Sep 17 00:00:00 2001 From: Kamisama Date: Mon, 26 Aug 2013 18:26:33 -0400 Subject: [PATCH] Add MemcachedEngine --- lib/Cake/Cache/Engine/MemcachedEngine.php | 280 +++++++++ .../Case/Cache/Engine/MemcachedEngineTest.php | 570 ++++++++++++++++++ 2 files changed, 850 insertions(+) create mode 100755 lib/Cake/Cache/Engine/MemcachedEngine.php create mode 100755 lib/Cake/Test/Case/Cache/Engine/MemcachedEngineTest.php diff --git a/lib/Cake/Cache/Engine/MemcachedEngine.php b/lib/Cake/Cache/Engine/MemcachedEngine.php new file mode 100755 index 000000000..9729283ac --- /dev/null +++ b/lib/Cake/Cache/Engine/MemcachedEngine.php @@ -0,0 +1,280 @@ + 127.0.0.1. If an + * array MemcacheEngine will use them as a pool. + * - compress = boolean, default => false + * + * @var array + */ + public $settings = array(); + +/** + * Initialize the Cache Engine + * + * Called automatically by the cache frontend + * To reinitialize the settings call Cache::engine('EngineName', [optional] settings = array()); + * + * @param array $settings array of setting for the engine + * @return boolean True if the engine has been successfully initialized, false if not + */ + public function init($settings = array()) { + if (!class_exists('Memcached')) { + return false; + } + if (!isset($settings['prefix'])) { + $settings['prefix'] = Inflector::slug(APP_DIR) . '_'; + } + $settings += array( + 'engine' => 'Memcached', + 'servers' => array('127.0.0.1'), + 'compress' => false, + 'persistent' => true, + 'persistent_id' => 'mc', + 'login' => null, + 'password' => null, + ); + parent::init($settings); + + if (!is_array($this->settings['servers'])) { + $this->settings['servers'] = array($this->settings['servers']); + } + if (!isset($this->_Memcached)) { + $this->_Memcached = new Memcached($this->settings['persistent'] ? $this->settings['persistent_id'] : null); + $this->_setOptions(); + + if (!count($this->_Memcached->getServerList())) { + $servers = array(); + foreach ($this->settings['servers'] as $server) { + $servers[] = $this->_parseServerString($server); + } + + if (!$this->_Memcached->addServers($servers)) { + return false; + } + + if ($this->settings['login'] !== null && $this->settings['password'] !== null) { + $this->_Memcached->setSaslAuthData($this->settings['login'], $this->settings['password']); + } + } + + return true; + } + + return true; + } + +/** + * Settings the memcached instance + * + */ + protected function _setOptions() + { + $this->_Memcached->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true); + //$this->_Memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); + + if (Memcached::HAVE_IGBINARY) { + $this->_Memcached->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY); + } + + $this->_Memcached->setOption(Memcached::OPT_COMPRESSION, (bool)$this->settings['compress']); + } + +/** + * Parses the server address into the host/port. Handles both IPv6 and IPv4 + * addresses and Unix sockets + * + * @param string $server The server address string. + * @return array Array containing host, port + */ + protected function _parseServerString($server) { + if ($server[0] == 'u') { + return array($server, 0); + } + if (substr($server, 0, 1) == '[') { + $position = strpos($server, ']:'); + if ($position !== false) { + $position++; + } + } else { + $position = strpos($server, ':'); + } + $port = 11211; + $host = $server; + if ($position !== false) { + $host = substr($server, 0, $position); + $port = substr($server, $position + 1); + } + return array($host, (int)$port); + } + +/** + * Write data for key into cache. When using memcached as your cache engine + * remember that the Memcached pecl extension does not support cache expiry times greater + * than 30 days in the future. Any duration greater than 30 days will be treated as never expiring. + * + * @param string $key Identifier for the data + * @param mixed $value Data to be cached + * @param integer $duration How long to cache the data, in seconds + * @return boolean True if the data was successfully cached, false on failure + * @see http://php.net/manual/en/memcache.set.php + */ + public function write($key, $value, $duration) { + if ($duration > 30 * DAY) { + $duration = 0; + } + + return $this->_Memcached->set($key, $value, $duration); + } + +/** + * Read a key from the cache + * + * @param string $key Identifier for the data + * @return mixed The cached data, or false if the data doesn't exist, has expired, or if there was an error fetching it + */ + public function read($key) { + return $this->_Memcached->get($key); + } + +/** + * Increments the value of an integer cached key + * + * @param string $key Identifier for the data + * @param integer $offset How much to increment + * @return New incremented value, false otherwise + * @throws CacheException when you try to increment with compress = true + */ + public function increment($key, $offset = 1) { + return $this->_Memcached->increment($key, $offset); + } + +/** + * Decrements the value of an integer cached key + * + * @param string $key Identifier for the data + * @param integer $offset How much to subtract + * @return New decremented value, false otherwise + * @throws CacheException when you try to decrement with compress = true + */ + public function decrement($key, $offset = 1) { + return $this->_Memcached->decrement($key, $offset); + } + +/** + * Delete a key from the cache + * + * @param string $key Identifier for the data + * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed + */ + public function delete($key) { + return $this->_Memcached->delete($key); + } + +/** + * Delete all keys from the cache + * + * @param boolean $check + * @return boolean True if the cache was successfully cleared, false otherwise + */ + public function clear($check) { + if ($check) { + return true; + } + + $keys = $this->_Memcached->getAllKeys(); + + foreach($keys as $key) { + if (strpos($key, $this->settings['prefix']) === 0) { + $this->_Memcached->delete($key); + } + } + + return true; + } + +/** + * Returns the `group value` for each of the configured groups + * If the group initial value was not found, then it initializes + * the group accordingly. + * + * @return array + */ + public function groups() { + if (empty($this->_compiledGroupNames)) { + foreach ($this->settings['groups'] as $group) { + $this->_compiledGroupNames[] = $this->settings['prefix'] . $group; + } + } + + $groups = $this->_Memcached->getMulti($this->_compiledGroupNames); + if (count($groups) !== count($this->settings['groups'])) { + foreach ($this->_compiledGroupNames as $group) { + if (!isset($groups[$group])) { + $this->_Memcached->set($group, 1, 0); + $groups[$group] = 1; + } + } + ksort($groups); + } + + $result = array(); + $groups = array_values($groups); + foreach ($this->settings['groups'] as $i => $group) { + $result[] = $group . $groups[$i]; + } + + return $result; + } + +/** + * Increments the group value to simulate deletion of all keys under a group + * old values will remain in storage until they expire. + * + * @return boolean success + */ + public function clearGroup($group) { + return (bool)$this->_Memcached->increment($this->settings['prefix'] . $group); + } +} diff --git a/lib/Cake/Test/Case/Cache/Engine/MemcachedEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/MemcachedEngineTest.php new file mode 100755 index 000000000..f284c0461 --- /dev/null +++ b/lib/Cake/Test/Case/Cache/Engine/MemcachedEngineTest.php @@ -0,0 +1,570 @@ + + * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * @link http://book.cakephp.org/2.0/en/development/testing.html CakePHP(tm) Tests + * @package Cake.Test.Case.Cache.Engine + * @since CakePHP(tm) v 1.2.0.5434 + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +App::uses('Cache', 'Cache'); +App::uses('MemcachedEngine', 'Cache/Engine'); + +/** + * Class TestMemcachedEngine + * + * @package Cake.Test.Case.Cache.Engine + */ +class TestMemcachedEngine extends MemcachedEngine { + +/** + * public accessor to _parseServerString + * + * @param string $server + * @return array + */ + public function parseServerString($server) { + return $this->_parseServerString($server); + } + + public function setMemcached($memcached) { + $this->_Memcached = $memcached; + } + + public function getMemcached() { + return $this->_Memcached; + } + +} + +/** + * MemcachedEngineTest class + * + * @package Cake.Test.Case.Cache.Engine + */ +class MemcachedEngineTest extends CakeTestCase { + +/** + * setUp method + * + * @return void + */ + public function setUp() { + parent::setUp(); + $this->skipIf(!class_exists('Memcached'), 'Memcached is not installed or configured properly.'); + + $this->_cacheDisable = Configure::read('Cache.disable'); + Configure::write('Cache.disable', false); + Cache::config('memcached', array( + 'engine' => 'Memcached', + 'prefix' => 'cake_', + 'duration' => 3600 + )); + } + +/** + * tearDown method + * + * @return void + */ + public function tearDown() { + parent::tearDown(); + Configure::write('Cache.disable', $this->_cacheDisable); + Cache::drop('memcached'); + Cache::drop('memcached_groups'); + Cache::drop('memcached_helper'); + Cache::config('default'); + } + +/** + * testSettings method + * + * @return void + */ + public function testSettings() { + $settings = Cache::settings('memcached'); + unset($settings['serialize'], $settings['path']); + $expecting = array( + 'prefix' => 'cake_', + 'duration' => 3600, + 'probability' => 100, + 'servers' => array('127.0.0.1'), + 'persistent' => true, + 'persistent_id' => 'mc', + 'compress' => false, + 'engine' => 'Memcached', + 'login' => null, + 'password' => null, + 'groups' => array() + ); + $this->assertEquals($expecting, $settings); + } + +/** + * testCompressionSetting method + * + * @return void + */ + public function testCompressionSetting() { + $Memcached = new TestMemcachedEngine(); + $Memcached->init(array( + 'engine' => 'Memcached', + 'servers' => array('127.0.0.1:11211'), + 'compress' => false + )); + + $this->assertFalse($Memcached->getMemcached()->getOption(Memcached::OPT_COMPRESSION)); + + $MemcachedCompressed = new TestMemcachedEngine(); + $MemcachedCompressed->init(array( + 'engine' => 'Memcached', + 'servers' => array('127.0.0.1:11211'), + 'compress' => true + )); + + $this->assertTrue($MemcachedCompressed->getMemcached()->getOption(Memcached::OPT_COMPRESSION)); + } + +/** + * testSettings method + * + * @return void + */ + public function testMultipleServers() { + $servers = array('127.0.0.1:11211', '127.0.0.1:11222'); + $available = true; + $Memcached = new Memcached(); + + foreach ($servers as $server) { + list($host, $port) = explode(':', $server); + //@codingStandardsIgnoreStart + if (!$Memcached->addServer($host, $port)) { + $available = false; + } + //@codingStandardsIgnoreEnd + } + + $this->skipIf(!$available, 'Need memcached servers at ' . implode(', ', $servers) . ' to run this test.'); + + $Memcached = new MemcachedEngine(); + $Memcached->init(array('engine' => 'Memcached', 'servers' => $servers)); + + $settings = $Memcached->settings(); + $this->assertEquals($settings['servers'], $servers); + Cache::drop('dual_server'); + } + +/** + * test connecting to an ipv6 server. + * + * @return void + */ + public function testConnectIpv6() { + $Memcached = new MemcachedEngine(); + $result = $Memcached->init(array( + 'prefix' => 'cake_', + 'duration' => 200, + 'engine' => 'Memcached', + 'servers' => array( + '[::1]:11211' + ) + )); + $this->assertTrue($result); + } + +/** + * test non latin domains. + * + * @return void + */ + public function testParseServerStringNonLatin() { + $Memcached = new TestMemcachedEngine(); + $result = $Memcached->parseServerString('schülervz.net:13211'); + $this->assertEquals(array('schülervz.net', '13211'), $result); + + $result = $Memcached->parseServerString('sülül:1111'); + $this->assertEquals(array('sülül', '1111'), $result); + } + +/** + * test unix sockets. + * + * @return void + */ + public function testParseServerStringUnix() { + $Memcached = new TestMemcachedEngine(); + $result = $Memcached->parseServerString('unix:///path/to/memcachedd.sock'); + $this->assertEquals(array('unix:///path/to/memcachedd.sock', 0), $result); + } + +/** + * testReadAndWriteCache method + * + * @return void + */ + public function testReadAndWriteCache() { + Cache::set(array('duration' => 1), null, 'memcached'); + + $result = Cache::read('test', 'memcached'); + $expecting = ''; + $this->assertEquals($expecting, $result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('test', $data, 'memcached'); + $this->assertTrue($result); + + $result = Cache::read('test', 'memcached'); + $expecting = $data; + $this->assertEquals($expecting, $result); + + Cache::delete('test', 'memcached'); + } + +/** + * testExpiry method + * + * @return void + */ + public function testExpiry() { + Cache::set(array('duration' => 1), 'memcached'); + + $result = Cache::read('test', 'memcached'); + $this->assertFalse($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'memcached'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'memcached'); + $this->assertFalse($result); + + Cache::set(array('duration' => "+1 second"), 'memcached'); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'memcached'); + $this->assertTrue($result); + + sleep(3); + $result = Cache::read('other_test', 'memcached'); + $this->assertFalse($result); + + Cache::config('memcached', array('duration' => '+1 second')); + + $result = Cache::read('other_test', 'memcached'); + $this->assertFalse($result); + + Cache::config('memcached', array('duration' => '+29 days')); + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('long_expiry_test', $data, 'memcached'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('long_expiry_test', 'memcached'); + $expecting = $data; + $this->assertEquals($expecting, $result); + + Cache::config('memcached', array('duration' => 3600)); + } + +/** + * testDeleteCache method + * + * @return void + */ + public function testDeleteCache() { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'memcached'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'memcached'); + $this->assertTrue($result); + } + +/** + * testDecrement method + * + * @return void + */ + public function testDecrement() { + $result = Cache::write('test_decrement', 5, 'memcached'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'memcached'); + $this->assertEquals(4, $result); + + $result = Cache::read('test_decrement', 'memcached'); + $this->assertEquals(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'memcached'); + $this->assertEquals(2, $result); + + $result = Cache::read('test_decrement', 'memcached'); + $this->assertEquals(2, $result); + + Cache::delete('test_decrement', 'memcached'); + } + +/** + * test decrementing compressed keys + * + * @return void + */ + public function testDecrementCompressedKeys() { + Cache::config('compressed_memcached', array( + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + 'servers' => array('127.0.0.1:11211'), + 'compress' => true + )); + + $result = Cache::write('test_decrement', 5, 'compressed_memcached'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'compressed_memcached'); + $this->assertEquals(4, $result); + + $result = Cache::read('test_decrement', 'compressed_memcached'); + $this->assertEquals(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'compressed_memcached'); + $this->assertEquals(2, $result); + + $result = Cache::read('test_decrement', 'compressed_memcached'); + $this->assertEquals(2, $result); + + Cache::delete('test_decrement', 'compressed_memcached'); + } + +/** + * testIncrement method + * + * @return void + */ + public function testIncrement() { + $result = Cache::write('test_increment', 5, 'memcached'); + $this->assertTrue($result); + + $result = Cache::increment('test_increment', 1, 'memcached'); + $this->assertEquals(6, $result); + + $result = Cache::read('test_increment', 'memcached'); + $this->assertEquals(6, $result); + + $result = Cache::increment('test_increment', 2, 'memcached'); + $this->assertEquals(8, $result); + + $result = Cache::read('test_increment', 'memcached'); + $this->assertEquals(8, $result); + + Cache::delete('test_increment', 'memcached'); + } + +/** + * test incrementing compressed keys + * + * @return void + */ + public function testIncrementCompressedKeys() { + Cache::config('compressed_memcached', array( + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + 'servers' => array('127.0.0.1:11211'), + 'compress' => true + )); + + $result = Cache::write('test_increment', 5, 'compressed_memcached'); + $this->assertTrue($result); + + $result = Cache::increment('test_increment', 1, 'compressed_memcached'); + $this->assertEquals(6, $result); + + $result = Cache::read('test_increment', 'compressed_memcached'); + $this->assertEquals(6, $result); + + $result = Cache::increment('test_increment', 2, 'compressed_memcached'); + $this->assertEquals(8, $result); + + $result = Cache::read('test_increment', 'compressed_memcached'); + $this->assertEquals(8, $result); + + Cache::delete('test_increment', 'compressed_memcached'); + } + +/** + * test that configurations don't conflict, when a file engine is declared after a memcached one. + * + * @return void + */ + public function testConfigurationConflict() { + Cache::config('long_memcached', array( + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + 'servers' => array('127.0.0.1:11211'), + )); + Cache::config('short_memcached', array( + 'engine' => 'Memcached', + 'duration' => '+1 seconds', + 'servers' => array('127.0.0.1:11211'), + )); + Cache::config('some_file', array('engine' => 'File')); + + $this->assertTrue(Cache::write('duration_test', 'yay', 'long_memcached')); + $this->assertTrue(Cache::write('short_duration_test', 'boo', 'short_memcached')); + + $this->assertEquals('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); + $this->assertEquals('boo', Cache::read('short_duration_test', 'short_memcached'), 'Value was not read %s'); + + sleep(1); + $this->assertEquals('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); + + sleep(2); + $this->assertFalse(Cache::read('short_duration_test', 'short_memcached'), 'Cache was not invalidated %s'); + $this->assertFalse(Cache::read('duration_test', 'long_memcached'), 'Value did not expire %s'); + + Cache::delete('duration_test', 'long_memcached'); + Cache::delete('short_duration_test', 'short_memcached'); + } + +/** + * test clearing memcached. + * + * @return void + */ + public function testClear() { + Cache::config('memcached2', array( + 'engine' => 'Memcached', + 'prefix' => 'cake2_', + 'duration' => 3600 + )); + + Cache::write('some_value', 'cache1', 'memcached'); + $result = Cache::clear(true, 'memcached'); + $this->assertTrue($result); + $this->assertEquals('cache1', Cache::read('some_value', 'memcached')); + + Cache::write('some_value', 'cache2', 'memcached2'); + $result = Cache::clear(false, 'memcached'); + $this->assertTrue($result); + $this->assertFalse(Cache::read('some_value', 'memcached')); + $this->assertEquals('cache2', Cache::read('some_value', 'memcached2')); + + Cache::clear(false, 'memcached2'); + } + +/** + * test that a 0 duration can successfully write. + * + * @return void + */ + public function testZeroDuration() { + Cache::config('memcached', array('duration' => 0)); + $result = Cache::write('test_key', 'written!', 'memcached'); + + $this->assertTrue($result); + $result = Cache::read('test_key', 'memcached'); + $this->assertEquals('written!', $result); + } + +/** + * test that durations greater than 30 days never expire + * + * @return void + */ + public function testLongDurationEqualToZero() { + $memcached = new TestMemcachedEngine(); + $memcached->settings['compress'] = false; + + $mock = $this->getMock('Memcached'); + $memcached->setMemcached($mock); + $mock->expects($this->once()) + ->method('set') + ->with('key', 'value', 0); + + $value = 'value'; + $memcached->write('key', $value, 50 * DAY); + } + +/** + * Tests that configuring groups for stored keys return the correct values when read/written + * Shows that altering the group value is equivalent to deleting all keys under the same + * group + * + * @return void + */ + public function testGroupReadWrite() { + Cache::config('memcached_groups', array( + 'engine' => 'Memcached', + 'duration' => 3600, + 'groups' => array('group_a', 'group_b'), + 'prefix' => 'test_' + )); + Cache::config('memcached_helper', array( + 'engine' => 'Memcached', + 'duration' => 3600, + 'prefix' => 'test_' + )); + $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); + $this->assertEquals('value', Cache::read('test_groups', 'memcached_groups')); + + Cache::increment('group_a', 1, 'memcached_helper'); + $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::write('test_groups', 'value2', 'memcached_groups')); + $this->assertEquals('value2', Cache::read('test_groups', 'memcached_groups')); + + Cache::increment('group_b', 1, 'memcached_helper'); + $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::write('test_groups', 'value3', 'memcached_groups')); + $this->assertEquals('value3', Cache::read('test_groups', 'memcached_groups')); + } + +/** + * Tests that deleteing from a groups-enabled config is possible + * + * @return void + */ + public function testGroupDelete() { + Cache::config('memcached_groups', array( + 'engine' => 'Memcached', + 'duration' => 3600, + 'groups' => array('group_a', 'group_b') + )); + $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); + $this->assertEquals('value', Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::delete('test_groups', 'memcached_groups')); + + $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + } + +/** + * Test clearing a cache group + * + * @return void + */ + public function testGroupClear() { + Cache::config('memcached_groups', array( + 'engine' => 'Memcached', + 'duration' => 3600, + 'groups' => array('group_a', 'group_b') + )); + + $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); + $this->assertTrue(Cache::clearGroup('group_a', 'memcached_groups')); + $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + + $this->assertTrue(Cache::write('test_groups', 'value2', 'memcached_groups')); + $this->assertTrue(Cache::clearGroup('group_b', 'memcached_groups')); + $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + } +}