From 3768d7a07ff87848f87dc2aed605a71ec8143e27 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 21 Jan 2019 01:59:48 -0800 Subject: [PATCH] Implement CRM_Utils_Cache_Tiered Before ------ * No way to daisy-chain caches to form a cache hierarchy * ArrayCache::reobjectify() would fail to reobjectify objects in an array, which seems against the spirit of PSR-16 After ----- * You can create a cache hierarchy with `new CRM_Utils_Cache_Tiered([$fastCache, $mediumCache, $slowCache])` * ArrayCache::reobjectify() will reobjectify if it detects an object directly in an array Note: To ensure that TTL data is respected consistently regardless of how the tiers behave and the order in which they are used, the TTL/expiration must be stored extra times. --- CRM/Utils/Cache/ArrayCache.php | 12 +- CRM/Utils/Cache/Tiered.php | 186 ++++++++++++++++++++++ tests/phpunit/E2E/Cache/TieredTest.php | 205 +++++++++++++++++++++++++ 3 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 CRM/Utils/Cache/Tiered.php create mode 100644 tests/phpunit/E2E/Cache/TieredTest.php diff --git a/CRM/Utils/Cache/ArrayCache.php b/CRM/Utils/Cache/ArrayCache.php index eb134d136f..2824b80e61 100644 --- a/CRM/Utils/Cache/ArrayCache.php +++ b/CRM/Utils/Cache/ArrayCache.php @@ -118,7 +118,17 @@ class CRM_Utils_Cache_Arraycache implements CRM_Utils_Cache_Interface { } private function reobjectify($value) { - return is_object($value) ? unserialize(serialize($value)) : $value; + if (is_object($value)) { + return unserialize(serialize($value)); + } + if (is_array($value)) { + foreach ($value as $p) { + if (is_object($p)) { + return unserialize(serialize($value)); + } + } + } + return $value; } /** diff --git a/CRM/Utils/Cache/Tiered.php b/CRM/Utils/Cache/Tiered.php new file mode 100644 index 0000000000..04fc470016 --- /dev/null +++ b/CRM/Utils/Cache/Tiered.php @@ -0,0 +1,186 @@ + int $seconds). + */ + protected $maxTimeouts; + + /** + * @var array + * List of cache instances, with fastest/closest first. + * Array(int $tierNum => CRM_Utils_Cache_Interface). + */ + protected $tiers; + + /** + * CRM_Utils_Cache_Tiered constructor. + * @param array $tiers + * List of cache instances, with fastest/closest first. + * Must be indexed numerically (0, 1, 2...). + * @param array $maxTimeouts + * A list of maximum timeouts for each cache-tier. + * There must be at least one value in this array. + * If timeouts are omitted for slower tiers, they are filled in with the last value. + * @throws CRM_Core_Exception + */ + public function __construct($tiers, $maxTimeouts = [86400]) { + $this->tiers = $tiers; + $this->maxTimeouts = []; + + foreach ($tiers as $k => $tier) { + $this->maxTimeouts[$k] = isset($maxTimeouts[$k]) + ? $maxTimeouts[$k] + : $this->maxTimeouts[$k - 1]; + } + + for ($far = 1; $far < count($tiers); $far++) { + $near = $far - 1; + if ($this->maxTimeouts[$near] > $this->maxTimeouts[$far]) { + throw new \CRM_Core_Exception("Invalid configuration: Near cache #{$near} has longer timeout than far cache #{$far}"); + } + } + } + + public function set($key, $value, $ttl = NULL) { + if ($ttl !== NULL & !is_int($ttl) && !($ttl instanceof DateInterval)) { + throw new CRM_Utils_Cache_InvalidArgumentException("Invalid cache TTL"); + } + foreach ($this->tiers as $tierNum => $tier) { + /** @var CRM_Utils_Cache_Interface $tier */ + $effTtl = $this->getEffectiveTtl($tierNum, $ttl); + $expiresAt = CRM_Utils_Date::convertCacheTtlToExpires($effTtl, $this->maxTimeouts[$tierNum]); + if (!$tier->set($key, [0 => $expiresAt, 1 => $value], $effTtl)) { + return FALSE; + } + } + return TRUE; + } + + public function get($key, $default = NULL) { + $nack = CRM_Utils_Cache::nack(); + foreach ($this->tiers as $readTierNum => $tier) { + /** @var CRM_Utils_Cache_Interface $tier */ + $wrapped = $tier->get($key, $nack); + if ($wrapped !== $nack && $wrapped[0] >= CRM_Utils_Time::getTimeRaw()) { + list ($parentExpires, $value) = $wrapped; + // (Re)populate the faster caches; and then return the value we found. + for ($i = 0; $i < $readTierNum; $i++) { + $now = CRM_Utils_Time::getTimeRaw(); + $effExpires = min($parentExpires, $now + $this->maxTimeouts[$i]); + $this->tiers[$i]->set($key, [0 => $effExpires, 1 => $value], $effExpires - $now); + } + return $value; + } + } + return $default; + } + + public function delete($key) { + foreach ($this->tiers as $tier) { + /** @var CRM_Utils_Cache_Interface $tier */ + $tier->delete($key); + } + return TRUE; + } + + public function flush() { + return $this->clear(); + } + + public function clear() { + foreach ($this->tiers as $tier) { + /** @var CRM_Utils_Cache_Interface $tier */ + if (!$tier->clear()) { + return FALSE; + } + } + return TRUE; + } + + public function has($key) { + $nack = CRM_Utils_Cache::nack(); + foreach ($this->tiers as $tier) { + /** @var CRM_Utils_Cache_Interface $tier */ + $wrapped = $tier->get($key, $nack); + if ($wrapped !== $nack && $wrapped[0] > CRM_Utils_Time::getTimeRaw()) { + return TRUE; + } + } + return FALSE; + } + + protected function getEffectiveTtl($tierNum, $ttl) { + if ($ttl === NULL) { + return $this->maxTimeouts[$tierNum]; + } + else { + return min($this->maxTimeouts[$tierNum], $ttl); + } + } + +} diff --git a/tests/phpunit/E2E/Cache/TieredTest.php b/tests/phpunit/E2E/Cache/TieredTest.php new file mode 100644 index 0000000000..ac31a41639 --- /dev/null +++ b/tests/phpunit/E2E/Cache/TieredTest.php @@ -0,0 +1,205 @@ +a = CRM_Utils_Cache::create([ + 'name' => 'e2e tiered test a', + 'type' => ['ArrayCache'], + ]), + $this->b = CRM_Utils_Cache::create([ + 'name' => 'e2e tiered test b', + 'type' => ['ArrayCache'], + ]) + ], $maxTimeouts); + } + + public function testDoubleLifeWithDelete() { + $this->assertFalse($this->a->has('foo')); + $this->assertFalse($this->b->has('foo')); + $this->assertEquals('dfl-1', $this->a->get('foo', 'dfl-1')); + $this->assertEquals('dfl-2', $this->b->get('foo', 'dfl-2')); + + $this->cache->set('foo', 100); + + $this->assertTrue($this->a->has('foo')); + $this->assertTrue($this->b->has('foo')); + $this->assertEquals(100, $this->a->get('foo', 'dfl-1')[1]); + $this->assertEquals(100, $this->b->get('foo', 'dfl-2')[1]); + $this->assertEquals($this->a->get('foo'), $this->b->get('foo')); + + $this->cache->set('foo', 200); + + $this->assertTrue($this->a->has('foo')); + $this->assertTrue($this->b->has('foo')); + $this->assertEquals(200, $this->a->get('foo', 'dfl-1')[1]); + $this->assertEquals(200, $this->b->get('foo', 'dfl-2')[1]); + $this->assertEquals($this->a->get('foo'), $this->b->get('foo')); + + $this->cache->delete('foo'); + + $this->assertFalse($this->a->has('foo')); + $this->assertFalse($this->b->has('foo')); + $this->assertEquals('dfl-1', $this->a->get('foo', 'dfl-1')); + $this->assertEquals('dfl-2', $this->b->get('foo', 'dfl-2')); + } + + public function testDoubleLifeWithClear() { + $this->assertFalse($this->a->has('foo')); + $this->assertFalse($this->b->has('foo')); + $this->assertEquals('dfl-1', $this->a->get('foo', 'dfl-1')); + $this->assertEquals('dfl-2', $this->b->get('foo', 'dfl-2')); + + $this->cache->set('foo', 100); + + $this->assertTrue($this->a->has('foo')); + $this->assertTrue($this->b->has('foo')); + $this->assertEquals(100, $this->a->get('foo', 'dfl-1')[1]); + $this->assertEquals(100, $this->b->get('foo', 'dfl-2')[1]); + $this->assertEquals($this->a->get('foo'), $this->b->get('foo')); + + $this->cache->clear(); + + $this->assertFalse($this->a->has('foo')); + $this->assertFalse($this->b->has('foo')); + $this->assertEquals('dfl-1', $this->a->get('foo', 'dfl-1')); + $this->assertEquals('dfl-2', $this->b->get('foo', 'dfl-2')); + } + + public function testTieredTimeout_default() { + $start = CRM_Utils_Time::getTimeRaw(); + $this->cache = $this->createSimpleCache([100, 1000]); + + $this->cache->set('foo', 'bar'); + $this->assertApproxEquals($start + 100, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + + // Simulate expiration & repopulation in nearest tier. + + $this->a->clear(); + $this->assertApproxEquals(NULL, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + + $this->assertEquals('bar', $this->cache->get('foo')); + $this->assertApproxEquals($start + 100, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + } + + public function testTieredTimeout_explicitLow() { + $start = CRM_Utils_Time::getTimeRaw(); + $this->cache = $this->createSimpleCache([100, 1000]); + + $this->cache->set('foo', 'bar', 50); + $this->assertApproxEquals($start + 50, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 50, $this->b->getExpires('foo'), self::TOLERANCE); + + // Simulate expiration & repopulation in nearest tier. + + $this->a->clear(); + $this->assertApproxEquals(NULL, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 50, $this->b->getExpires('foo'), self::TOLERANCE); + + $this->assertEquals('bar', $this->cache->get('foo')); + $this->assertApproxEquals($start + 50, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 50, $this->b->getExpires('foo'), self::TOLERANCE); + } + + public function testTieredTimeout_explicitMedium() { + $start = CRM_Utils_Time::getTimeRaw(); + $this->cache = $this->createSimpleCache([100, 1000]); + + $this->cache->set('foo', 'bar', 500); + $this->assertApproxEquals($start + 100, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 500, $this->b->getExpires('foo'), self::TOLERANCE); + + // Simulate expiration & repopulation in nearest tier. + + $this->a->clear(); + $this->assertApproxEquals(NULL, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 500, $this->b->getExpires('foo'), self::TOLERANCE); + + $this->assertEquals('bar', $this->cache->get('foo')); + $this->assertApproxEquals($start + 100, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 500, $this->b->getExpires('foo'), self::TOLERANCE); + } + + public function testTieredTimeout_explicitHigh_lateReoad() { + $start = CRM_Utils_Time::getTimeRaw(); + $this->cache = $this->createSimpleCache([100, 1000]); + + $this->cache->set('foo', 'bar', 5000); + $this->assertApproxEquals($start + 100, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + + // Simulate expiration & repopulation in nearest tier. + + $this->a->clear(); + $this->assertApproxEquals(NULL, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + + function_exists('timecop_return') ? timecop_travel(time() + self::TOLERANCE) : sleep(self::TOLERANCE); + + $this->assertEquals('bar', $this->cache->get('foo')); + $this->assertApproxEquals($start + 100 + self::TOLERANCE, $this->a->getExpires('foo'), self::TOLERANCE); + $this->assertApproxEquals($start + 1000, $this->b->getExpires('foo'), self::TOLERANCE); + } + + /** + * Assert that two numbers are approximately equal. + * + * @param int|float $expected + * @param int|float $actual + * @param int|float $tolerance + * @param string $message + */ + public function assertApproxEquals($expected, $actual, $tolerance, $message = NULL) { + if ($message === NULL) { + $message = sprintf("approx-equals: expected=[%.3f] actual=[%.3f] tolerance=[%.3f]", $expected, $actual, $tolerance); + } + $this->assertTrue(abs($actual - $expected) < $tolerance, $message); + } + +} -- 2.25.1