Implement CRM_Utils_Cache_Tiered
authorTim Otten <totten@civicrm.org>
Mon, 21 Jan 2019 09:59:48 +0000 (01:59 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 30 Jan 2019 00:47:08 +0000 (16:47 -0800)
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
CRM/Utils/Cache/Tiered.php [new file with mode: 0644]
tests/phpunit/E2E/Cache/TieredTest.php [new file with mode: 0644]

index eb134d136fedd9146aa44da1d4d6d66d13906cac..2824b80e61123bf000145ac573bf12aec4bebad4 100644 (file)
@@ -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 (file)
index 0000000..04fc470
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 5                                                  |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2019                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC (c) 2004-2019
+ */
+
+/**
+ * Class CRM_Utils_Cache_Tiered
+ *
+ * `Tiered` implements a hierarchy of fast and slow caches. For example, you
+ * might have a configuration in which:
+ *
+ *   - A local/in-memory array caches info for up to 1 minute (60s).
+ *   - A Redis cache retains info for up to 10 minutes (600s).
+ *   - A SQL cache retains info for up to 1 hour (3600s).
+ *
+ * Cached data will be written to all three tiers. When reading, you'll hit the
+ * fastest available tier.
+ *
+ * The example would be created with:
+ *
+ * $cache = new CRM_Utils_Cache_Tiered([
+ *   new CRM_Utils_Cache_ArrayCache(...),
+ *   new CRM_Utils_Cache_Redis(...),
+ *   new CRM_Utils_Cache_SqlGroup(...),
+ * ], [60, 600, 3600]);
+ *
+ * Note:
+ *  - Correctly implementing PSR-16 leads to a small amount of CPU+mem overhead.
+ *    If you need an extremely high number of re-reads within a thread and can live
+ *    with only two tiers, try CRM_Utils_Cache_ArrayDecorator or
+ *    CRM_Utils_Cache_FastArrayDecorator instead.
+ *  - With the exception of unit-testing, you should not access the underlying
+ *    tiers directly. The data-format may be different than your expectation.
+ */
+class CRM_Utils_Cache_Tiered implements CRM_Utils_Cache_Interface {
+
+  use CRM_Utils_Cache_NaiveMultipleTrait; // TODO Consider native implementation.
+
+  /**
+   * @var array
+   *   Array(int $tierNum => 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 (file)
index 0000000..ac31a41
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 5                                                  |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2019                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License along with this program; if not, contact CiviCRM LLC       |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * Verify that CRM_Utils_Cache_Tiered complies with PSR-16.
+ *
+ * @group e2e
+ */
+class E2E_Cache_TieredTest extends E2E_Cache_CacheTestCase {
+  const TOLERANCE = 5;
+
+  /**
+   * @var CRM_Utils_Cache_ArrayCache
+   */
+  protected $a, $b;
+
+  protected function tearDown() {
+    if (function_exists('timecop_return')) {
+      timecop_return();
+    }
+    parent::tearDown();
+  }
+
+  public function createSimpleCache($maxTimeouts = [86400]) {
+    return new CRM_Utils_Cache_Tiered([
+      $this->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);
+  }
+
+}