From 65e95c28179666b7d690425230bb00131941f60b Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Fri, 30 Mar 2018 07:48:39 +0200 Subject: [PATCH 1/2] TTL skeleton with expiration queueing using SplPriorityQueue --- README.md | 5 +++- src/ArrayCache.php | 56 ++++++++++++++++++++++++++++++++++------ src/CacheInterface.php | 3 ++- tests/ArrayCacheTest.php | 43 ++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6770ec1..67874f4 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,13 @@ This example fetches the value of the key `foo` and passes it to the `var_dump` function. You can use any of the composition provided by [promises](https://github.com/reactphp/promise). +If the key `foo` does not exist or when the TTL has passed, the promise will +be fulfilled with `null` as value. On any error it will also resolve with `null`. + #### set() ```php -$cache->set('foo', 'bar'); +$cache->set('foo', 'bar', 60); ``` This example eventually sets the value of the key `foo` to `bar`. If it diff --git a/src/ArrayCache.php b/src/ArrayCache.php index 7d6f75f..83f3e34 100644 --- a/src/ArrayCache.php +++ b/src/ArrayCache.php @@ -3,11 +3,18 @@ namespace React\Cache; use React\Promise; +use SplPriorityQueue; class ArrayCache implements CacheInterface { private $limit; private $data = array(); + private $expires = array(); + + /** + * @var SplPriorityQueue + */ + private $expiresQueue; /** * The `ArrayCache` provides an in-memory implementation of the [`CacheInterface`](#cacheinterface). @@ -39,10 +46,16 @@ class ArrayCache implements CacheInterface public function __construct($limit = null) { $this->limit = $limit; + $this->expiresQueue = new SplPriorityQueue(); + $this->expiresQueue->setExtractFlags(SplPriorityQueue::EXTR_BOTH); } public function get($key, $default = null) { + if (array_key_exists($key, $this->expires)) { + $this->garbageCollection(); + } + if (!array_key_exists($key, $this->data)) { return Promise\resolve($default); } @@ -51,28 +64,55 @@ public function get($key, $default = null) $value = $this->data[$key]; unset($this->data[$key]); $this->data[$key] = $value; - return Promise\resolve($value); } - public function set($key, $value) + public function set($key, $value, $ttl = null) { + $expires = null; + + if (is_int($ttl)) { + $this->expires[$key] = microtime(true) + $ttl; + $this->expiresQueue->insert($key, 0 - $this->expires[$key]); + } + // unset before setting to ensure this entry will be added to end of array unset($this->data[$key]); $this->data[$key] = $value; - // ensure size limit is not exceeded or remove first entry from array - if ($this->limit !== null && count($this->data) > $this->limit) { - reset($this->data); - unset($this->data[key($this->data)]); - } + $this->garbageCollection(); return Promise\resolve(true); } public function remove($key) { - unset($this->data[$key]); + unset($this->data[$key], $this->expires[$key]); + $this->garbageCollection(); return Promise\resolve(true); } + + private function garbageCollection() + { + // ensure size limit is not exceeded or remove first entry from array + while ($this->limit !== null && count($this->data) > $this->limit) { + reset($this->data); + unset($this->data[key($this->data)]); + } + + if ($this->expiresQueue->count() === 0) { + return; + } + + $this->expiresQueue->rewind(); + do { + $run = false; + $item = $this->expiresQueue->current(); + if ((int)substr((string)$item['priority'], 1) <= microtime(true)) { + $this->expiresQueue->extract(); + $run = true; + unset($this->data[$item['data']], $this->expires[$item['data']]); + } + } while ($run && $this->expiresQueue->count() > 0); + } } diff --git a/src/CacheInterface.php b/src/CacheInterface.php index 3832fac..36968e0 100644 --- a/src/CacheInterface.php +++ b/src/CacheInterface.php @@ -34,9 +34,10 @@ public function get($key, $default = null); * * @param string $key * @param mixed $value + * @param float|null $ttl * @return PromiseInterface Returns a promise which resolves to true on success of false on error */ - public function set($key, $value); + public function set($key, $value, $ttl = null); /** * Remove an item from the cache, returns a promise which resolves to true on success or diff --git a/tests/ArrayCacheTest.php b/tests/ArrayCacheTest.php index bacf448..4134711 100644 --- a/tests/ArrayCacheTest.php +++ b/tests/ArrayCacheTest.php @@ -152,4 +152,47 @@ public function testGetWithLimitedSizeWillUpdateLRUInfo() $this->cache->get('bar')->then($this->expectCallableOnceWith(null)); $this->cache->get('baz')->then($this->expectCallableOnceWith('3')); } + + /** @test */ + public function getWithinTtl() + { + $this->cache + ->set('foo', 'bar', 100); + + + $success = $this->createCallableMock(); + $success + ->expects($this->once()) + ->method('__invoke') + ->with('bar'); + + $this->cache + ->get('foo') + ->then( + $success, + $this->expectCallableNever() + ); + } + + /** @test */ + public function getAfterTtl() + { + $this->cache + ->set('foo', 'bar', 1); + + sleep(2); + + $success = $this->createCallableMock(); + $success + ->expects($this->once()) + ->method('__invoke') + ->with(null); + + $this->cache + ->get('foo') + ->then( + $success, + $this->expectCallableNever() + ); + } } From 1879cb0f4b0d202e824fa2f3d3af38291fc902a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 23 May 2018 18:41:14 +0200 Subject: [PATCH 2/2] Use sorted array to store TTL and improve memory consumption --- README.md | 25 +++++++++----- src/ArrayCache.php | 70 ++++++++++++++++------------------------ src/CacheInterface.php | 28 +++++++++++++--- tests/ArrayCacheTest.php | 60 +++++++++++++++++----------------- 4 files changed, 96 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 67874f4..81808ab 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,13 @@ provide alternate implementations. #### get() -The `get(string $key, mixed $default = null): PromiseInterfae` method can be used to +The `get(string $key, mixed $default = null): PromiseInterface` method can be used to retrieve an item from the cache. This method will resolve with the cached value on success or with the given `$default` value when no item can be found or when an error occurs. +Similarly, an expired cache item (once the time-to-live is expired) is +considered a cache miss. ```php $cache @@ -53,20 +55,27 @@ This example fetches the value of the key `foo` and passes it to the `var_dump` function. You can use any of the composition provided by [promises](https://github.com/reactphp/promise). -If the key `foo` does not exist or when the TTL has passed, the promise will -be fulfilled with `null` as value. On any error it will also resolve with `null`. - #### set() +The `set(string $key, mixed $value, ?float $ttl = null): PromiseInterface` method can be used to +store an item in the cache. + +This method will resolve with `true` on success or `false` when an error +occurs. If the cache implementation has to go over the network to store +it, it may take a while. + +The optional `$ttl` parameter sets the maximum time-to-live in seconds +for this cache item. If this parameter is omitted (or `null`), the item +will stay in the cache for as long as the underlying implementation +supports. Trying to access an expired cache item results in a cache miss, +see also [`get()`](#get). + ```php $cache->set('foo', 'bar', 60); ``` This example eventually sets the value of the key `foo` to `bar`. If it -already exists, it is overridden. To provide guarantees as to when the cache -value is set a promise is returned. The promise will fulfill with `true` on success -or `false` on error. If the cache implementation has to go over the network to store -it, it may take a while. +already exists, it is overridden. #### remove() diff --git a/src/ArrayCache.php b/src/ArrayCache.php index 83f3e34..8512a1f 100644 --- a/src/ArrayCache.php +++ b/src/ArrayCache.php @@ -3,7 +3,6 @@ namespace React\Cache; use React\Promise; -use SplPriorityQueue; class ArrayCache implements CacheInterface { @@ -11,11 +10,6 @@ class ArrayCache implements CacheInterface private $data = array(); private $expires = array(); - /** - * @var SplPriorityQueue - */ - private $expiresQueue; - /** * The `ArrayCache` provides an in-memory implementation of the [`CacheInterface`](#cacheinterface). * @@ -46,14 +40,13 @@ class ArrayCache implements CacheInterface public function __construct($limit = null) { $this->limit = $limit; - $this->expiresQueue = new SplPriorityQueue(); - $this->expiresQueue->setExtractFlags(SplPriorityQueue::EXTR_BOTH); } public function get($key, $default = null) { - if (array_key_exists($key, $this->expires)) { - $this->garbageCollection(); + // delete key if it is already expired => below will detect this as a cache miss + if (isset($this->expires[$key]) && $this->expires[$key] < microtime(true)) { + unset($this->data[$key], $this->expires[$key]); } if (!array_key_exists($key, $this->data)) { @@ -64,23 +57,38 @@ public function get($key, $default = null) $value = $this->data[$key]; unset($this->data[$key]); $this->data[$key] = $value; + return Promise\resolve($value); } public function set($key, $value, $ttl = null) { - $expires = null; + // unset before setting to ensure this entry will be added to end of array (LRU info) + unset($this->data[$key]); + $this->data[$key] = $value; - if (is_int($ttl)) { + // sort expiration times if TTL is given (first will expire first) + unset($this->expires[$key]); + if ($ttl !== null) { $this->expires[$key] = microtime(true) + $ttl; - $this->expiresQueue->insert($key, 0 - $this->expires[$key]); + asort($this->expires); } - // unset before setting to ensure this entry will be added to end of array - unset($this->data[$key]); - $this->data[$key] = $value; - - $this->garbageCollection(); + // ensure size limit is not exceeded or remove first entry from array + if ($this->limit !== null && count($this->data) > $this->limit) { + // first try to check if there's any expired entry + // expiration times are sorted, so we can simply look at the first one + reset($this->expires); + $key = key($this->expires); + + // check to see if the first in the list of expiring keys is already expired + // if the first key is not expired, we have to overwrite by using LRU info + if ($key === null || $this->expires[$key] > microtime(true)) { + reset($this->data); + $key = key($this->data); + } + unset($this->data[$key], $this->expires[$key]); + } return Promise\resolve(true); } @@ -88,31 +96,7 @@ public function set($key, $value, $ttl = null) public function remove($key) { unset($this->data[$key], $this->expires[$key]); - $this->garbageCollection(); - return Promise\resolve(true); - } - private function garbageCollection() - { - // ensure size limit is not exceeded or remove first entry from array - while ($this->limit !== null && count($this->data) > $this->limit) { - reset($this->data); - unset($this->data[key($this->data)]); - } - - if ($this->expiresQueue->count() === 0) { - return; - } - - $this->expiresQueue->rewind(); - do { - $run = false; - $item = $this->expiresQueue->current(); - if ((int)substr((string)$item['priority'], 1) <= microtime(true)) { - $this->expiresQueue->extract(); - $run = true; - unset($this->data[$item['data']], $this->expires[$item['data']]); - } - } while ($run && $this->expiresQueue->count() > 0); + return Promise\resolve(true); } } diff --git a/src/CacheInterface.php b/src/CacheInterface.php index 36968e0..da46cae 100644 --- a/src/CacheInterface.php +++ b/src/CacheInterface.php @@ -11,6 +11,8 @@ interface CacheInterface * * This method will resolve with the cached value on success or with the * given `$default` value when no item can be found or when an error occurs. + * Similarly, an expired cache item (once the time-to-live is expired) is + * considered a cache miss. * * ```php * $cache @@ -29,13 +31,29 @@ interface CacheInterface public function get($key, $default = null); /** - * Store an item in the cache, returns a promise which resolves to true on success or - * false on error. + * Stores an item in the cache. + * + * This method will resolve with `true` on success or `false` when an error + * occurs. If the cache implementation has to go over the network to store + * it, it may take a while. + * + * The optional `$ttl` parameter sets the maximum time-to-live in seconds + * for this cache item. If this parameter is omitted (or `null`), the item + * will stay in the cache for as long as the underlying implementation + * supports. Trying to access an expired cache item results in a cache miss, + * see also [`get()`](#get). + * + * ```php + * $cache->set('foo', 'bar', 60); + * ``` + * + * This example eventually sets the value of the key `foo` to `bar`. If it + * already exists, it is overridden. * * @param string $key - * @param mixed $value - * @param float|null $ttl - * @return PromiseInterface Returns a promise which resolves to true on success of false on error + * @param mixed $value + * @param ?float $ttl + * @return PromiseInterface Returns a promise which resolves to `true` on success or `false` on error */ public function set($key, $value, $ttl = null); diff --git a/tests/ArrayCacheTest.php b/tests/ArrayCacheTest.php index 4134711..a5a23df 100644 --- a/tests/ArrayCacheTest.php +++ b/tests/ArrayCacheTest.php @@ -153,46 +153,44 @@ public function testGetWithLimitedSizeWillUpdateLRUInfo() $this->cache->get('baz')->then($this->expectCallableOnceWith('3')); } - /** @test */ - public function getWithinTtl() + public function testGetWillResolveWithValueIfItemIsNotExpired() { - $this->cache - ->set('foo', 'bar', 100); + $this->cache = new ArrayCache(); + $this->cache->set('foo', '1', 10); - $success = $this->createCallableMock(); - $success - ->expects($this->once()) - ->method('__invoke') - ->with('bar'); + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + } - $this->cache - ->get('foo') - ->then( - $success, - $this->expectCallableNever() - ); + public function testGetWillResolveWithDefaultIfItemIsExpired() + { + $this->cache = new ArrayCache(); + + $this->cache->set('foo', '1', 0); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); } - /** @test */ - public function getAfterTtl() + public function testSetWillOverwritOldestItemIfNoEntryIsExpired() { - $this->cache - ->set('foo', 'bar', 1); + $this->cache = new ArrayCache(2); - sleep(2); + $this->cache->set('foo', '1', 10); + $this->cache->set('bar', '2', 20); + $this->cache->set('baz', '3', 30); - $success = $this->createCallableMock(); - $success - ->expects($this->once()) - ->method('__invoke') - ->with(null); + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + } - $this->cache - ->get('foo') - ->then( - $success, - $this->expectCallableNever() - ); + public function testSetWillOverwriteExpiredItemIfAnyEntryIsExpired() + { + $this->cache = new ArrayCache(2); + + $this->cache->set('foo', '1', 10); + $this->cache->set('bar', '2', 0); + $this->cache->set('baz', '3', 30); + + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + $this->cache->get('bar')->then($this->expectCallableOnceWith(null)); } }