diff --git a/Config/bootstrap.php b/Config/bootstrap.php index 4b9b566..434041f 100644 --- a/Config/bootstrap.php +++ b/Config/bootstrap.php @@ -3,4 +3,5 @@ // Bootstrap for CakePHP plugin loading // Loads required classes and all engines in the plugin App::uses('FileEngine', 'Cache/Engine'); +require_once(dirname(__FILE__) . '/../src/CacheEnginesHelper.php'); require_once(dirname(__FILE__) . '/../src/Engines.php'); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbfb5b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM php:7.1-fpm-alpine +RUN apk update && apk add build-base +RUN apk add zlib-dev git zip libmcrypt-dev \ + && docker-php-ext-install zip \ + && docker-php-ext-install mcrypt \ + && docker-php-ext-enable mcrypt +RUN curl -sS https://getcomposer.org/installer | php \ + && mv composer.phar /usr/local/bin/ \ + && ln -s /usr/local/bin/composer.phar /usr/local/bin/composer +COPY . /app +WORKDIR /app +ENV PATH="~/.composer/vendor/bin:./vendor/bin:${PATH}" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..af3b143 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +help: ## This command + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.DEFAULT_GOAL := help + +build: ## Builds the image using docker-compose + docker-compose build --no-cache cakephp-cache-engines +start: ## Run the application in the background + docker-compose up -d +start-build: ## Build the application and run application + docker-compose up -d --force-recreate --remove-orphans +stop: ## Stop application + @docker-compose stop +run-composer-install: ## Run composer install + docker-compose exec -T cakephp-cache-engines \ + composer install +run-unit-tests: ## Run unit tests + docker-compose exec -T cakephp-cache-engines \ + /app/bin/phpunit test diff --git a/README.md b/README.md index 551887d..2037d87 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ This CakePHP plugin provides some additional cache engines that can be used by C We currently provide three cache engines: -1. RedisTreeCacheEngine: Redis based cache that supports managing keys using wildcards -2. FileTreeCacheEngine: Local filesystem based cache that supports managing keys using wildcards +1. RedisTreeCacheEngine: Redis based cache that supports managing keys using wildcards and cache key 'parents'. +2. FileTreeCacheEngine: Local filesystem based cache that supports managing keys using wildcards and cache key 'parents'. 3. FallBackCacheEngine: Allows you to define two cache engines; the first engine is used as the primary cache engine. The second cache engine is used only if the primary fails. diff --git a/composer.json b/composer.json index 45293d7..9774261 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ }, "require-dev": { "phpunit/phpunit": "4.8.*", - "cakephp/cakephp": "2.8.*", + "cakephp/cakephp": "2.10.22", "m6web/redis-mock": "v2.8.*" }, "config": { diff --git a/doc/TESTING.md b/doc/TESTING.md index 2cb88d8..d39fe16 100644 --- a/doc/TESTING.md +++ b/doc/TESTING.md @@ -16,4 +16,12 @@ You are now ready to run the tests: ```bash $ bin/phpunit test -``` \ No newline at end of file +``` + +The tests can also be ran using Docker with the following commands: + +```bash + $ make start-build + $ make run-composer-install + $ make run-unit-tests +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a95070 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.7" +services: + cakephp-cache-engines: + build: + context: . + dockerfile: Dockerfile + network: host + container_name: cakephp-cache-engines + restart: always + volumes: + - ./:/app:rw diff --git a/src/CacheEnginesHelper.php b/src/CacheEnginesHelper.php new file mode 100644 index 0000000..d6b9c0c --- /dev/null +++ b/src/CacheEnginesHelper.php @@ -0,0 +1,121 @@ + [ + * 'parent_cache_key_1' + * ], + * 'cache_key_2' => [ + * 'parent_cache_key_2' + * ] + * ] + * );` + * + * Writing to a specific cache config: + * + * `Cache::write('cached_data', $data, 'long_term');` + * + * @param string $key Identifier for the data + * @param mixed $value Data to be cached - anything except a resource + * @param string $config Optional string configuration name to write to + * Defaults to 'default' + * @param string|array $parentKey Parent key that data is a dependent child of + * @return bool True if the data was successfully cached, false on failure + */ + public static function writeWithParent( + $key, + $value, + $config = 'default', + $parentKey = '' + ) { + $settings = Cache::settings($config); + + if (empty($settings)) { + return false; + } + if (!Cache::isInitialized($config)) { + return false; + } + $key = Cache::engine($config)->key($key); + + if (!$key || is_resource($value)) { + return false; + } + + $success = false; + if (method_exists(Cache::engine($config), 'writeWithParent')) { + $success = Cache::engine($config)->writeWithParent( + $settings['prefix'] . $key, + $value, + $settings['duration'], + $parentKey + ); + } + Cache::set(null, $config); + if ($success === false && $value !== '') { + trigger_error( + __d( + 'cake_dev', + "%s cache was unable to write '%s' to %s cache", + $config, + $key, + Cache::$_engines[$config]->settings['engine'] + ), + E_USER_WARNING + ); + } + return $success; + } +} diff --git a/src/FallbackEngine.php b/src/FallbackEngine.php index 6a18c9f..546eff3 100644 --- a/src/FallbackEngine.php +++ b/src/FallbackEngine.php @@ -71,6 +71,21 @@ public function write($key, $value, $duration) } } + /** + * Write data for key into a cache engine with one or more 'parent'. + * + * @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 + * @param string|array $parentKey Parent key that data is a dependent child of + * @return bool True if the data was successfully cached, false on failure + * @throws Exception + */ + public function writeWithParent($key, $value, $duration, $parentKey = '') + { + return Cache::engine($this->activeCache)->writeWithParent($key, $value, $duration, $parentKey); + } + public function read($key) { try { @@ -130,4 +145,9 @@ protected function fallback($setPrimary = false) $this->activeCache = $this->secondaryConfig; } } + + public function key($key) + { + return Cache::engine($this->activeCache)->key($key); + } } diff --git a/src/FileTreeEngine.php b/src/FileTreeEngine.php index f7891eb..eb09a9d 100644 --- a/src/FileTreeEngine.php +++ b/src/FileTreeEngine.php @@ -88,6 +88,25 @@ public function write($key, $data, $duration) } + /** + * 'Parents' are not supported by the FileTreeEngine. + * This method performs same action as `write`. + * + * This method exists to gracefully degrade when using + * this engine as a fallback to the RedisTreeEngine. + * + * @param string $key + * @param mixed $data + * @param int $duration + * @param string|array $parentKey Unused. + * @return bool + * @throws Exception + */ + public function writeWithParent($key, $data, $duration, $parentKey = '') + { + return $this->write($key, $data, $duration); + } + public function delete($key) { @@ -151,4 +170,4 @@ public function key($key) return str_replace(array(DS, '/', '.', '<', '>', '?', ':', '|', ' ', '"'), '_', $key); } -} +} \ No newline at end of file diff --git a/src/RedisTreeEngine.php b/src/RedisTreeEngine.php index 3d29d8a..8f2d36d 100644 --- a/src/RedisTreeEngine.php +++ b/src/RedisTreeEngine.php @@ -31,6 +31,8 @@ class RedisTreeEngine extends CacheEngine */ public $settings = array(); + private $_childSetKeySuffix = ':child_keys'; + /** * Initialize the Cache Engine * @@ -114,7 +116,6 @@ public function key($key) return $key; } - /** * Write data for key into cache. * @@ -126,48 +127,70 @@ public function key($key) */ public function write($key, $value, $duration) { - - // Cake's Redis cache engine sets a default prefix of null. We'll need to handle both - // a prefix configured by the user or left as null. - if (strpos($key, '[') !== false && substr($key, -1) == ']') { - $keys = $this->parseMultiKey($key); - - if (count($keys) != count($value)) { - throw new Exception('Num keys != num values.'); - } - $key_vals = array_combine($keys, $value); - - return $this->_mwrite($key_vals, $duration); - } - return $this->_write($key, $value, $duration); } + /** + * Write data for key into a cache engine with one or more 'parent'. + * + * @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 + * @param string|array $parentKey parent key that data is a dependent child of + * @return bool True if the data was successfully cached, false on failure + * @throws Exception + */ + public function writeWithParent($key, $value, $duration, $parentKey = '') + { + return $this->_write($key, $value, $duration, $parentKey); + } + /** * Internal multi-val write. * @param $key_value_array * @param $duration + * @param string|array $parentKey Parent key that data is a dependent child of. + * If provided array is one dimensional or a string + * then the parent key(s) is applied to all keys. + * If provided array is two dimensional the parent + * keys are applied only to the key specified via + * the respective index. * @return */ - private function _mwrite($key_value_array, $duration) + private function _mwrite($key_value_array, $duration, $parentKey = '') { foreach ($key_value_array as $key => &$value) { if (!is_int($value)) { $value = serialize($value); } - } unset($value); - if ($duration === 0) { - return $this->redis->mset($key_value_array); - } + $keys = array_keys($key_value_array); - //note that there is no "msetex" in redis! must do this in a more convoluted way: $this->redis->multi(); - foreach ($key_value_array as $key => $value) { - $this->redis->setex($key, $duration, $value); + if (!empty($parentKey)) { + if (!is_array($parentKey)) { + $parentKey = [$parentKey]; + } + foreach ($parentKey as $k => $pk) { + if (is_array($pk)) { + foreach ($pk as $keySpecificParentKey) { + $this->_writeChildRelationship($keySpecificParentKey, $k); + } + } else { + $this->_writeChildRelationship($pk, ...$keys); + } + } + } + if ($duration === 0) { + $this->redis->mset($key_value_array); + } else { + // note that there is no "msetex" in redis! must do this in a more convoluted way: + foreach ($key_value_array as $key => $value) { + $this->redis->setex($key, $duration, $value); + } } return $this->redis->exec(); @@ -178,20 +201,59 @@ private function _mwrite($key_value_array, $duration) * @param $key * @param $value * @param $duration + * @param string|array $parentKey Parent key that data is a dependent child of * @return */ - private function _write($key, $value, $duration) + private function _swrite($key, $value, $duration, $parentKey = '') { if (!is_int($value)) { $value = serialize($value); } + $this->redis->multi(); + if (!empty($parentKey)) { + if (!is_array($parentKey)) { + $parentKey = [$parentKey]; + } + foreach ($parentKey as $pk) { + $this->_writeChildRelationship($pk, $key); + } + } if ($duration === 0) { - return $this->redis->set($key, $value); + $this->redis->set($key, $value); + } else { + $this->redis->setex($key, $duration, $value); } + return $this->redis->exec(); + } - return $this->redis->setex($key, $duration, $value); + /** + * Internal write. + * Write data for key into a cache engine with or without parents. + * + * @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 + * @param string|array $parentKey Optional parent key that data is a dependent child of + * @return bool True if the data was successfully cached, false on failure + * @throws Exception + */ + private function _write($key, $value, $duration, $parentKey = '') + { + // Cake's Redis cache engine sets a default prefix of null. We'll need to handle both + // a prefix configured by the user or left as null. + if (strpos($key, '[') !== false && substr($key, -1) == ']') { + $keys = $this->parseMultiKey($key); + + if (count($keys) != count($value)) { + throw new Exception('Num keys != num values.'); + } + $key_vals = array_combine($keys, $value); + return $this->_mwrite($key_vals, $duration, $parentKey); + } + + return $this->_swrite($key, $value, $duration, $parentKey); } /** @@ -311,6 +373,9 @@ public function delete($key) if (strpos($key, '[') !== false && substr($key, -1) == ']') { $keys = $this->parseMultiKey($key); + // dedupe keys before deletion + $keys = array_values(array_unique($keys)); + return $this->_mdelete($keys); } @@ -325,29 +390,31 @@ public function delete($key) */ private function _mdelete($keys) { - $finalKeys = array(); + $finalKeys = []; foreach ($keys as $key) { // keys() is an expensive call; only call it if we need to (i.e. if there actually is a wildcard); // the chars "?*[" seem to be the right ones to listen for according to: http://redis.io/commands/KEYS if (preg_match('/[\?\*\[]/', $key)) { - if ($this->supportsScan) { $currKeys = array(); foreach (new Iterator\Keyspace($this->redis, $key, $this->scanCount) as $currKey) { $currKeys[] = $currKey; } $finalKeys = array_merge($finalKeys, $currKeys); - } - else { + } else { $finalKeys = array_merge($finalKeys, $this->redis->keys($key)); } - } - else { + } else { $finalKeys[] = $key; + $childKeys = $this->_getChildKeys($key); + $finalKeys = array_merge($finalKeys, $childKeys); } } + // dedupe keys before deletion + $finalKeys = array_values(array_unique($finalKeys)); + // Check if there are any key to delete if (!empty($finalKeys)) { return $this->redis->del($finalKeys); @@ -371,15 +438,18 @@ private function _delete($key) foreach (new Iterator\Keyspace($this->redis, $key, $this->scanCount) as $currKey) { $keys[] = $currKey; } - } - else { + } else { $keys = $this->redis->keys($key); } - } - else { + } else { $keys = array($key); + $childKeys = $this->_getChildKeys($key); + $keys = array_merge($keys, $childKeys); } + // dedupe keys before deletion + $keys = array_values(array_unique($keys)); + // Check if there are any key to delete if (!empty($keys)) { return $this->redis->del($keys); @@ -407,8 +477,7 @@ public function clear($check = false) foreach (new Iterator\Keyspace($this->redis, $this->settings['prefix'] . '*', $this->scanCount) as $currKey) { $keys[] = $currKey; } - } - else { + } else { $keys = $this->redis->keys($this->settings['prefix'] . '*'); } $this->redis->del($keys); @@ -467,4 +536,52 @@ protected function parseMultiKey($key) return $keys; } + + /** + * Get the key used to store the set of child keys. + * + * @param string $parentKey The key to get the child set key for + * + * @return string The child set's key + */ + private function _getChildSetKey($parentKey) + { + $key = $parentKey . $this->_childSetKeySuffix; + if (!empty($this->settings['prefix']) && strpos($key, $this->settings['prefix']) !== 0) { + $key = $this->settings['prefix'] . $key; + } + return $key; + } + + /** + * Record a key(s) dependent association to another key. + * + * @param string $parentKey The key the children as associated to + * @param string ...$childKeys The children to associate + * + * @return int number of keys added to set + */ + private function _writeChildRelationship($parentKey, ...$childKeys) + { + $setKey = $this->_getChildSetKey($parentKey); + return $this->redis->sadd($setKey, ...$childKeys); + } + + /** + * Get the child keys of a given key. + * + * @param string $parentKey The key the children are associated to + * + * @return array The child keys, including the child set key. + */ + private function _getChildKeys($parentKey) + { + $setKey = $this->_getChildSetKey($parentKey); + if ($this->redis->type($setKey) === 'set') { + $keys = $this->redis->smembers($setKey); + array_push($keys, $setKey); + return $keys; + } + return []; + } } diff --git a/test/CacheEnginesHelper.php b/test/CacheEnginesHelper.php new file mode 100644 index 0000000..28a9c61 --- /dev/null +++ b/test/CacheEnginesHelper.php @@ -0,0 +1,61 @@ +getAdapter('Predis\Client', true); + CacheMock::config( + 'RedisTree', + [ + 'engine' => 'RedisTreeMock', + 'duration' => 4 + ] + ); + CacheMock::setEngine('RedisTree', $redisMock); + + $key = 'CacheEnginesHelper:testWriteWithParent:1'; + $parentKeys = [ + 'CacheEnginesHelper:TestParent:10', + 'CacheEnginesHelper:TestParent:20' + ]; + $value = date('Y-m-d h:m'); + CacheEnginesHelper::writeWithParent($key, $value, $this->cache, $parentKeys); + $this->assertEquals($value, CacheMock::read($key, $this->cache)); + } + + public function testWriteWithParentTriggerError() + { + // use FileEngine, which does not support writeWithParent + CacheMock::config( + 'File', + [ + 'engine' => 'File', + 'duration' => 4 + ] + ); + + $key = 'CacheEnginesHelper:testWriteWithParent:1'; + $parentKeys = [ + 'CacheEnginesHelper:TestParent:10', + 'CacheEnginesHelper:TestParent:20' + ]; + $value = date('Y-m-d h:m'); + try { + CacheEnginesHelper::writeWithParent( + $key, + $value, + 'File', + $parentKeys + ); + } catch (PHPUnit_Framework_Error $e) { + $this->assertTrue(true); + } + } +} diff --git a/test/RedisTreeEngineTest.php b/test/RedisTreeEngineTest.php index a9a6d2f..7816024 100644 --- a/test/RedisTreeEngineTest.php +++ b/test/RedisTreeEngineTest.php @@ -206,4 +206,155 @@ public function testClear() $this->assertNull(CacheMock::read($otherKey, $this->cache), 'Key not deleted'); } + + + public function testWriteWithParentReadDeleteWithSharedParent() + { + + $key1 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithSharedParent:1'; + $key2 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithSharedParent:2'; + $multiKey = '[' . $key1 . ',' . $key2 . ']'; + + $parentKey = 'RedisTreeEngine:TestParent:-1'; + + $value1 = date('Y-m-d h:m') . ':1'; + $value2 = date('Y-m-d h:m') . ':2'; + $values = array( + $value1, + $value2 + ); + + CacheEnginesHelper::writeWithParent($multiKey, $values, $this->cache, $parentKey); + + $multiVal = CacheMock::read($multiKey, $this->cache); + + $this->assertInternalType('array', $multiVal); + $this->assertEquals(2, count($multiVal)); + $first = $multiVal[0]; + $this->assertEquals($first, $value1); + $second = $multiVal[1]; + $this->assertEquals($second, $value2); + + CacheMock::delete($parentKey, $this->cache); + $this->assertNull(CacheMock::read($key1, $this->cache), 'Key 1 is deleted'); + $this->assertNull(CacheMock::read($key2, $this->cache), 'Key 2 is deleted'); + } + + + public function testWriteWithParentReadDeleteWithSharedParents() + { + + $key1 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithSharedParents:1'; + $key2 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithSharedParents:2'; + $multiKey = '[' . $key1 . ',' . $key2 . ']'; + + $parentKeys = [ + 'RedisTreeEngine:TestParent:1', + 'RedisTreeEngine:TestParent:2' + ]; + + $value1 = date('Y-m-d h:m') . ':1'; + $value2 = date('Y-m-d h:m') . ':2'; + $values = array( + $value1, + $value2 + ); + + CacheEnginesHelper::writeWithParent($multiKey, $values, $this->cache, $parentKeys); + + $multiVal = CacheMock::read($multiKey, $this->cache); + + $this->assertInternalType('array', $multiVal); + $this->assertEquals(2, count($multiVal)); + $first = $multiVal[0]; + $this->assertEquals($first, $value1); + $second = $multiVal[1]; + $this->assertEquals($second, $value2); + + CacheMock::delete($parentKeys[0], $this->cache); + $this->assertNull(CacheMock::read($key1, $this->cache), 'Key 1 is deleted'); + $this->assertNull(CacheMock::read($key2, $this->cache), 'Key 2 is deleted'); + } + + + public function testWriteWithParentReadDeleteWithUnqiueParents() + { + + $key1 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithUnqiueParents:1'; + $key2 = 'RedisTreeEngine:testWriteWithParentReadDeleteWithUnqiueParents:2'; + $multiKey = '[' . $key1 . ',' . $key2 . ']'; + + $parentKeys = [ + $key1 => [ + 'RedisTreeEngine:TestParent:10', + 'RedisTreeEngine:TestParent:11' + ], + $key2 => [ + 'RedisTreeEngine:TestParent:21', + 'RedisTreeEngine:TestParent:22', + ] + ]; + + $value1 = date('Y-m-d h:m') . ':1'; + $value2 = date('Y-m-d h:m') . ':2'; + $values = array( + $value1, + $value2 + ); + + CacheEnginesHelper::writeWithParent($multiKey, $values, $this->cache, $parentKeys); + + $multiVal = CacheMock::read($multiKey, $this->cache); + + $this->assertInternalType('array', $multiVal); + $this->assertEquals(2, count($multiVal)); + $first = $multiVal[0]; + $this->assertEquals($first, $value1); + $second = $multiVal[1]; + $this->assertEquals($second, $value2); + + CacheMock::delete($parentKeys[$key2][1], $this->cache); + $this->assertNotNull(CacheMock::read($key1, $this->cache), 'Key 1 is not deleted'); + $this->assertNull(CacheMock::read($key2, $this->cache), 'Key 2 is deleted'); + } + + + public function testWriteWithParentDeleteWithParent() + { + + $key1 = 'RedisTreeEngine:testWriteWithParentDeleteWithParent:1'; + $parentKey = 'RedisTreeEngine:TestParent:30'; + $value = date('Y-m-d h:m'); + CacheEnginesHelper::writeWithParent($key1, $value, $this->cache, $parentKey); + $this->assertEquals($value, CacheMock::read($key1, $this->cache)); + + $key2 = 'RedisTreeEngine:testWriteWithParentDeleteWithParent:2'; + $value2 = date('Y-m-d h:m'); + CacheMock::write($key2, $value2, $this->cache); + + CacheMock::delete($parentKey, $this->cache); + $this->assertNull(CacheMock::read($key1, $this->cache), 'Key 1 is deleted'); + $this->assertNotNull(CacheMock::read($key2, $this->cache), 'Key 2 is not deleted'); + } + + + public function testWriteWithParentDeleteWithParents() + { + $key = 'RedisTreeEngine:testWriteWithParentDeleteWithParents:1'; + $parentKeys = [ + 'RedisTreeEngine:TestParent:40', + 'RedisTreeEngine:TestParent:50' + ]; + $value = date('Y-m-d h:m'); + CacheEnginesHelper::writeWithParent($key, $value, $this->cache, $parentKeys); + $this->assertEquals($value, CacheMock::read($key, $this->cache)); + + $key2 = 'RedisTreeEngine:testWriteWithParentDeleteWithParents:2'; + $value2 = date('Y-m-d h:m'); + CacheMock::write($key2, $value2, $this->cache); + + CacheMock::delete($parentKeys[1], $this->cache); + $this->assertNull(CacheMock::read($key, $this->cache), 'Key is deleted'); + $this->assertNotNull(CacheMock::read($key2, $this->cache), 'Key 2 is not deleted'); + } } diff --git a/test/bootstrap.php b/test/bootstrap.php index 875b850..b031ebb 100644 --- a/test/bootstrap.php +++ b/test/bootstrap.php @@ -15,7 +15,9 @@ require_once(dirname(__FILE__) . '/../vendor/cakephp/cakephp/lib/Cake/Cache/CacheEngine.php'); require_once(dirname(__FILE__) . '/../vendor/cakephp/cakephp/lib/Cake/Cache/Engine/FileEngine.php'); + // Load all engines +require_once(dirname(__FILE__) . '/../src/CacheEnginesHelper.php'); require_once(dirname(__FILE__) . '/../src/Engines.php'); // Load mock classes for unit tests require_once(dirname(__FILE__) . '/RedisTreeMockEngine.php');