CRM-18692 issue warning about empty smart group cache rather than crash the server
[civicrm-core.git] / CRM / Contact / BAO / GroupContactCache.php
index 8cbea8e0e82aad7b15789a7b6a210ee854df8476..2ba71e2d77e731823424f59a71f3599226f63f8e 100644 (file)
@@ -34,6 +34,21 @@ class CRM_Contact_BAO_GroupContactCache extends CRM_Contact_DAO_GroupContactCach
 
   static $_alreadyLoaded = array();
 
+  /**
+   * Get a list of caching modes.
+   *
+   * @return array
+   */
+  public static function getModes() {
+    return array(
+      // Flush expired caches in response to user actions.
+      'opportunistic' => ts('Opportunistic Flush'),
+
+      // Flush expired caches via background cron jobs.
+      'deterministic' => ts('Cron Flush'),
+    );
+  }
+
   /**
    * Check to see if we have cache entries for this group.
    *
@@ -195,17 +210,6 @@ AND    g.refresh_date IS NULL
     }
   }
 
-  /**
-   * Fill the group contact cache if it is empty.
-   *
-   * Do this by the expensive operation of loading all groups. Call sparingly.
-   */
-  public static function fillIfEmpty() {
-    if (!CRM_Core_DAO::singleValueQuery("SELECT COUNT(id) FROM civicrm_group_contact_cache")) {
-      self::loadAll();
-    }
-  }
-
   /**
    * Build the smart group cache for a given group.
    *
@@ -290,6 +294,12 @@ WHERE  id IN ( $groupIDs )
    * cache date, i.e. the removal is not done if the group was recently
    * loaded into the cache.
    *
+   * In fact it turned out there is little overlap between the code when group is passed in
+   * and group is not so it makes more sense as separate functions.
+   *
+   * @todo remove last call to this function from outside the class then make function protected,
+   * enforce groupID as an array & remove non group handling.
+   *
    * @param int $groupID
    *   the groupID to delete cache entries, NULL for all groups.
    * @param bool $onceOnly
@@ -333,7 +343,7 @@ WHERE  id IN ( $groupIDs )
     if (!isset($groupID)) {
       if ($smartGroupCacheTimeout == 0) {
         $query = "
-TRUNCATE civicrm_group_contact_cache
+DELETE FROM civicrm_group_contact_cache
 ";
         $update = "
 UPDATE civicrm_group g
@@ -410,13 +420,24 @@ WHERE  id = %1
    * This function should be called via the opportunistic or deterministic cache refresh function to make the intent
    * clear.
    */
-  protected static function refreshCaches() {
-    if (self::isRefreshAlreadyInitiated()) {
+  protected static function flushCaches() {
+    try {
+      $lock = self::getLockForRefresh();
+    }
+    catch (CRM_Core_Exception $e) {
+      // Someone else is kindly doing the refresh for us right now.
       return;
     }
-
     $params = array(1 => array(self::getCacheInvalidDateTime(), 'String'));
-
+    // @todo this is consistent with previous behaviour but as the first query could take several seconds the second
+    // could become inaccurate. It seems to make more sense to fetch them first & delete from an array (which would
+    // also reduce joins). If we do this we should also consider how best to iterate the groups. If we do them one at
+    // a time we could call a hook, allowing people to manage the frequency on their groups, or possibly custom searches
+    // might do that too. However, for 2000 groups that's 2000 iterations. If we do all once we potentially create a
+    // slow query. It's worth noting the speed issue generally relates to the size of the group but if one slow group
+    // is in a query with 500 fast ones all 500 get locked. One approach might be to calculate group size or the
+    // number of groups & then process all at once or many query runs depending on what is found. Of course those
+    // preliminary queries would need speed testing.
     CRM_Core_DAO::executeQuery(
       "
         DELETE gc
@@ -427,15 +448,18 @@ WHERE  id = %1
       $params
     );
 
+    // Clear these out without resetting them because we are not building caches here, only clearing them,
+    // so the state is 'as if they had never been built'.
     CRM_Core_DAO::executeQuery(
       "
         UPDATE civicrm_group g
-        SET    cache_date = null,
-        refresh_date = NOW()
+        SET    cache_date = NULL,
+        refresh_date = NULL
         WHERE  g.cache_date <= %1
       ",
       $params
     );
+    $lock->release();
   }
 
   /**
@@ -446,16 +470,24 @@ WHERE  id = %1
    *   2) a mysql lock. This works fine as long as CiviMail is not running, or if mysql is version 5.7+
    *
    * Where these 2 locks fail we get 2 processes running at the same time, but we have at least minimised that.
+   *
+   * @return \Civi\Core\Lock\LockInterface
+   * @throws \CRM_Core_Exception
    */
-  protected static function isRefreshAlreadyInitiated() {
-    static $invoked = FALSE;
-    if ($invoked) {
-      return TRUE;
+  protected static function getLockForRefresh() {
+    if (!isset(Civi::$statics[__CLASS__])) {
+      Civi::$statics[__CLASS__] = array('is_refresh_init' => FALSE);
+    }
+
+    if (Civi::$statics[__CLASS__]['is_refresh_init']) {
+      throw new CRM_Core_Exception('A refresh has already run in this process');
     }
     $lock = Civi::lockManager()->acquire('data.core.group.refresh');
-    if (!$lock->isAcquired()) {
-      return TRUE;
+    if ($lock->isAcquired()) {
+      Civi::$statics[__CLASS__]['is_refresh_init'] = TRUE;
+      return $lock;
     }
+    throw new CRM_Core_Exception('Mysql lock unavailable');
   }
 
   /**
@@ -463,12 +495,10 @@ WHERE  id = %1
    *
    * Sites that do not run the smart group clearing cron job should refresh the caches under an opportunistic mode, akin
    * to a poor man's cron. The user session will be forced to wait on this so it is less desirable.
-   *
-   * @return bool
    */
-  public static function opportunisticCacheRefresh() {
-    if (Civi::settings()->get('contact_smart_group_display') == 'opportunistic') {
-      self::refreshCaches();
+  public static function opportunisticCacheFlush() {
+    if (Civi::settings()->get('smart_group_cache_refresh_mode') == 'opportunistic') {
+      self::flushCaches();
     }
   }
 
@@ -476,10 +506,8 @@ WHERE  id = %1
    * Do a forced cache refresh.
    *
    * This function is appropriate to be called by system jobs & non-user sessions.
-   *
-   * @return bool
    */
-  public static function deterministicCacheRefresh() {
+  public static function deterministicCacheFlush() {
     if (self::smartGroupCacheTimeout() == 0) {
       CRM_Core_DAO::executeQuery("TRUNCATE civicrm_group_contact_cache");
       CRM_Core_DAO::executeQuery("
@@ -487,7 +515,7 @@ WHERE  id = %1
         SET cache_date = null, refresh_date = null");
     }
     else {
-      self::refreshCaches();
+      self::flushCaches();
     }
   }
 
@@ -802,7 +830,7 @@ ORDER BY   gc.contact_id, g.children
    * @return string
    */
   public static function getCacheInvalidDateTime() {
-    return date('Ymdhis', strtotime("-" . self::smartGroupCacheTimeout() . " Minutes"));
+    return date('YmdHis', strtotime("-" . self::smartGroupCacheTimeout() . " Minutes"));
   }
 
   /**
@@ -813,7 +841,7 @@ ORDER BY   gc.contact_id, g.children
    * @return string
    */
   public static function getRefreshDateTime() {
-    return date('Ymdhis', strtotime("+ " . self::smartGroupCacheTimeout() . " Minutes"));
+    return date('YmdHis', strtotime("+ " . self::smartGroupCacheTimeout() . " Minutes"));
   }
 
 }