Jake Vanderwerf
2026-08-29 f859e64a57c624ff2daa2b21355f65a49fd4807d
inc/managers/Cache.php
@@ -9,21 +9,78 @@
{
   private string $group;
   private int $ttl;
   private static array $timestamps = [];
   private const TS_GROUP = 'jvb_http_ts';
   private const CONNECTIONS_OPTION = 'jvb_cache_connections';
   private static ?array $connections = null;
   protected array $tags = [];
   private static array $instances = [];
   private bool $hasRedis;
   private bool $varyByAuth = false;
   private bool $varyByUser = false;
   private function __construct(string $group, int $ttl)
   private array $index=[];
   private ?string $target = null;
   private function __construct(string $group, ?string $target = null, int $ttl = WEEK_IN_SECONDS)
   {
      if (!is_null($target) && empty($this->index)) {
         $this->target = null;
         $this->index = get_option(BASE."cache_{$target}_index",[]);
      }
      $this->group = $group;
      $this->ttl   = $ttl;
      $this->hasRedis = (bool) wp_using_ext_object_cache();
      $this->logGroup();
      $this->hasRedis = (bool)wp_using_ext_object_cache();
   }
   protected function logGroup():void
   {
      $changed = false;
      $groups = get_option(BASE."cache_groups", []);
      if (!array_key_exists($this->group, $groups)) {
         $groups[$this->group] = [
            'auth'   => $this->varyByAuth,
            'user'   => $this->varyByUser,
            'target'=> $this->target,
            'ttl' => $this->ttl,
         ];
         $changed = true;
      }
      if ($groups[$this->group]['auth'] && !$this->varyByAuth) {
         error_log('[Cache]Group '.$this->group.' previously defined as varying by auth, but is not set. Setting it now.');
         $this->auth();
      }
      if ($groups[$this->group]['user'] && !$this->varyByUser) {
         error_log('[Cache]Group '.$this->group.' previously defined as varying by user, but is not set. Setting it now.');
         $this->user();
      }
      if (is_null($this->target) && !is_null($groups[$this->group]['target'])) {
         error_log('[Cache]Group '.$this->group. ' previously defined with target '.$groups[$this->group]['target'].'... Setting it now.');
         $this->target = $groups[$this->group]['target'];
      } elseif ($this->target !== $groups[$this->group]['target']) {
         error_log('[Cache]WARNING Group previously defined with target '.$groups[$this->group]['target'].' - but is currently defined with '.$this->target. '. You may want to look into this.');
      }
      if ($this->ttl !== $groups[$this->group]['ttl']) {
         error_log('[Cache]Group '. $this->group. ' previously defined with ttl '.$groups[$this->group]['ttl']. ' - but is currently defined with '. $this->ttl);
         $groups[$this->group]['ttl'] = $this->ttl;
         $changed = true;
      }
      if ($this->varyByAuth && !$groups[$this->group]['auth']) {
         $groups[$this->group]['auth'] = true;
         $changed = true;
      }
      if ($this->varyByUser && !$groups[$this->group]['user']) {
         $groups[$this->group]['user'] = true;
         $changed = true;
      }
      if ($this->target !== $groups[$this->group]['target']) {
         $groups[$this->group]['target'] = $this->target;
         $changed = true;
      }
      if ($changed) {
         update_option(BASE."cache_groups", $groups, false);
      }
   }
   public static function registerHooks(): void
@@ -43,9 +100,9 @@
      add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
      // Term meta updates
      add_action('updated_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
      add_action('added_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
      add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 2);
//    add_action('updated_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
//    add_action('added_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
//    add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 2);
      // User updates
      add_action('profile_update', [self::class, 'onUserChange'], 10, 2);
@@ -53,43 +110,76 @@
      add_action('deleted_user', [self::class, 'onUserDelete']);
      // User meta updates
      add_action('updated_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
      add_action('added_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
      add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 2);
//    add_action('updated_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
//    add_action('added_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
//    add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 2);
   }
   /* ---------------------------------------------------------------------
    * Factory
    * ------------------------------------------------------------------- */
   public function auth(): static
   {
      $this->varyByAuth = true;
      return $this;
   }
   public static function for(string $group, int $ttl = HOUR_IN_SECONDS): self
   public function user(): static
   {
      $this->varyByUser = true;
      return $this;
   }
   private function resolveKey(int|string $key): string
   {
      if ($this->varyByUser) {
         return $key . ':u' . get_current_user_id();
      }
      if ($this->varyByAuth) {
         return $key . ':' . (is_user_logged_in() ? 'auth' : 'guest');
      }
      return (string) $key;
   }
   public static function for(string $group, ?string $target = null, int $ttl = HOUR_IN_SECONDS): self
   {
      $group = sanitize_key($group);
      if (!isset(self::$instances[$group])) {
         self::$instances[$group] = new self($group, $ttl);
         self::$instances[$group] = new self($group, $target, $ttl);
      }
      return self::$instances[$group];
   }
   /**
    * To help facilitate cache flushing on object updates, we track the keys that are used for a particular id
    * Generally, $this->target will either be 'post', 'user', 'term', or 'custom'
    * @param int $ID
    * @param string $key
    * @return void
    */
   protected function trackID(int $ID, string $key):void
   {
      if (is_null($this->target)) {
         return;
      }
      if (!array_key_exists($ID, $this->index[$this->target])) {
         $this->index[$ID] = [];
      }
      if (!array_key_exists($this->group, $this->index[$ID])) {
         $this->index[$ID][$this->group] = [];
      }
      if (!in_array($key, $this->index[$ID][$this->group])) {
         $this->index[$this->target][$ID][$this->group][] = $key;
         update_option(BASE."cache_{$this->target}_index", $this->index, false);
      }
   }
   /* ---------------------------------------------------------------------
    * Core operations
    * ------------------------------------------------------------------- */
   public function remember(int|string|array $key, callable $callback, ?int $ttl = null): mixed
   {
      if (is_array($key)) {
         $key = $this->generateKey($key);
      }
      if (!empty($this->tags)) {
         return $this->rememberTagged(
            $key,
            $this->tags,
            $callback,
            $ttl
         );
      }
      $key = $this->generateKey($key);
      $value = $this->get($key);
@@ -100,49 +190,60 @@
      $value = $callback();
      if ($value !== null && $value !== false) {
         $this->set($key, $value);
         $this->set($key, $value, $ttl);
      }
      return $value;
   }
   public function get(int|string|array $id): mixed
   protected function transientKey(string $key):string
   {
      if (is_array($id)) {
         $id = $this->generateKey($id);
      }
      if ($this->hasRedis) {
         $value = wp_cache_get($id, $this->group);
      } else {
         $value = get_transient("jvb_{$this->group}_{$id}");
      }
      return $value;
      return BASE."{$this->group}_{$key}";
   }
   public function set(int|string|array $id, mixed $value, ?int $ttl = null): void
   public function get(int|string|array $key): mixed
   {
      if (is_array($id)) {
         $id = $this->generateKey($id);
      }
      $ttl = $ttl ?? $this->ttl;
      $key = $this->generateKey($key, false);
      $value = $this->hasRedis ?
         wp_cache_get($key, $this->group) :
         get_transient($this->transientKey($key));
      return $value['value']??false;
   }
   public function getTimestamp(int|string|array $key): mixed
   {
      $key = $this->generateKey($key, false);
      $value = $this->hasRedis ?
         wp_cache_get($key, $this->group) :
         get_transient($this->transientKey($key));
      return $value['modified']??false;
   }
   public function set(int|string|array $key, mixed $value, ?int $ttl = null): void
   {
      $key = $this->generateKey($key);
      $ttl = $ttl??$this->ttl;
      $store = [
         'value'  => $value,
         'modified'  => time()
      ];
      if ($this->hasRedis) {
         wp_cache_set($id, $value, $this->group, $ttl);
         wp_cache_set($key, $store, $this->group, $ttl);
      } else {
         set_transient("jvb_{$this->group}_{$id}", $value, $ttl);
         set_transient($this->transientKey($key), $store, $ttl);
      }
   }
   public function forget(int|string|array $id): void
   public function forget(int|string|array $key): void
   {
      if (is_array($id)) {
         $id = $this->generateKey($id);
      }
      $key = $this->generateKey($key);
      if ($this->hasRedis) {
         wp_cache_delete($id, $this->group);
         wp_cache_delete($key, $this->group);
      } else {
         delete_transient("jvb_{$this->group}_{$id}");
         delete_transient($this->transientKey($key));
      }
   }
   public function flush(): void
@@ -158,265 +259,53 @@
      }
   }
   /* ---------------------------------------------------------------------
    * Invalidation
    * ------------------------------------------------------------------- */
   public static function invalidateItem(string $group, int|string|array $id): void
   public static function clearPost(int $ID):void
   {
      if (is_array($id)) {
         $id = self::for($group)->generateKey($id);
      $index = get_option(BASE."cache_post_index", []);
      if (!array_key_exists($ID, $index)) {
         return;
      }
      $group = sanitize_key($group);
      if (wp_using_ext_object_cache()) {
         wp_cache_delete($id, $group);
      } else {
         delete_transient("jvb_{$group}_{$id}");
      }
      self::touch($group);
      foreach (self::connections()[$group] ?? [] as $conn) {
         $target = $conn['target'] ?? $conn; // Backwards compat if still string
         $flush = $conn['flush'] ?? false;
         if ($flush) {
            // Flush entire target group
            self::invalidateGroup($target);
         } else {
            // Just delete this item ID
            if (wp_using_ext_object_cache()) {
               wp_cache_delete($id, $target);
            } else {
               delete_transient("jvb_{$target}_{$id}");
            }
            self::touch($target);
      foreach ($index[$ID] as $group => $keys) {
         $cache = self::for($group, 'post');
         foreach ($keys as $key) {
            $cache->forget($key);
         }
      }
      self::invalidateByTag($group, $id);
   }
   public static function clearUser(int $ID):void
   {
      $index = get_option(BASE."cache_user_index", []);
      if (!array_key_exists($ID, $index)) {
         return;
      }
      foreach ($index[$ID] as $group => $keys) {
         $cache = self::for($group, 'user');
         foreach ($keys as $key) {
            $cache->forget($key);
         }
      }
   }
   public static function clearTerm(int $ID):void
   {
      $index = get_option(BASE."cache_term_index", []);
      if (!array_key_exists($ID, $index)) {
         return;
      }
      foreach ($index[$ID] as $group => $keys) {
         $cache = self::for($group, 'term');
         foreach ($keys as $key) {
            $cache->forget($key);
         }
      }
   }
   public static function invalidateGroup(string $group): void
   {
      $group = sanitize_key($group);
      if (wp_using_ext_object_cache()) {
         wp_cache_flush_group($group);
      } else {
         $instance = self::for($group);
         $instance->clearGroupTransients();
      }
      self::touch($group);
      foreach (self::connections()[$group] ?? [] as $conn) {
         $target = $conn['target'] ?? $conn; // Backwards compat
         // When flushing entire source group, always flush connected targets
         // (regardless of flush flag - we don't know which items to delete)
         if (wp_using_ext_object_cache()) {
            wp_cache_flush_group($target);
         } else {
            $instance = self::for($target);
            $instance->clearGroupTransients();
         }
         self::touch($target);
      }
   }
   public static function touch(string $group): int
   {
      $group = sanitize_key($group);
      $time  = time();
      if (wp_using_ext_object_cache()) {
         wp_cache_set($group, $time, self::TS_GROUP, WEEK_IN_SECONDS);
      } else {
         set_transient('jvb_ts_' . $group, $time, WEEK_IN_SECONDS);
      }
      self::$timestamps[$group] = $time;
      return $time;
   }
   public static function lastModified(string|array $groups): int
   {
      if (is_array($groups)) {
         return max(array_map([self::class, 'lastModified'], $groups));
      }
      $group = sanitize_key($groups);
      if (isset(self::$timestamps[$group])) {
         return self::$timestamps[$group];
      }
      if (wp_using_ext_object_cache()) {
         $ts = (int) wp_cache_get($group, self::TS_GROUP);
      } else {
         $ts = (int) get_transient('jvb_ts_' . $group);
      }
      if (!$ts) {
         $ts = time();
         if (wp_using_ext_object_cache()) {
            wp_cache_set($group, $ts, self::TS_GROUP, WEEK_IN_SECONDS);
         } else {
            set_transient('jvb_ts_' . $group, $ts, WEEK_IN_SECONDS);
         }
      }
      return self::$timestamps[$group] = $ts;
   }
   public function getLastModifiedForTags(array $tags): ?int
   {
      if (!$this->hasRedis) {
         return null;
      }
      $redis = self::redis();
      if (!$redis) {
         return null;
      }
      $lastModified = 0;
      foreach ($tags as $tag) {
         $ts = $redis->get("jvb:tag:{$tag}:lastModified");
         if ($ts) {
            $lastModified = max($lastModified, (int) $ts);
         }
      }
      return $lastModified ?: null;
   }
   /****************************************************
    * CONNECTIONS
    ****************************************************/
   private static function connections(): array
   {
      if (self::$connections === null) {
         self::$connections = get_option(self::CONNECTIONS_OPTION, []);
      }
      return self::$connections;
      self::for($group)->flush();
   }
   public function connect(string $source, bool $flush = false): self
   {
      $source = sanitize_key($source);
      $target = $this->group;
      $all = self::connections();
      $all[$source] ??= [];
      $before = count($all[$source]);
      // Add the connection
      $all[$source][] = ['target' => $target, 'flush' => $flush];
      // Remove duplicates by serializing for comparison
      $all[$source] = array_values(array_unique($all[$source], SORT_REGULAR));
      // Only update if something actually changed
      if (count($all[$source]) !== $before) {
         update_option(self::CONNECTIONS_OPTION, $all, false);
         self::$connections = $all;
      }
      return $this;
   }
   /****************************************************
    * REDIS
    ****************************************************/
   private static function redis(): ?\Redis
   {
      global $wp_object_cache;
      return $wp_object_cache instanceof \WP_Object_Cache
      && isset($wp_object_cache->redis)
         ? $wp_object_cache->redis
         : null;
   }
   /**
    * Remember with tags for complex invalidation scenarios
    *
    * Example: Cache user favorites tagged by each post ID
    * When any post updates, this cache entry auto-invalidates
    *
    * @param int|string|array $key Cache key
    * @param array $tags Array of [group, id] pairs: [['post', 123], ['user', 456]]
    * @param callable $callback Function to generate value if cache miss
    * @return mixed Cached or generated value
    */
   public function rememberTagged(
      int|string|array $key,
      array $tags,
      callable $callback,
      ?int $ttl = null
   ): mixed {
      if (is_array($key)) {
         $id = $this->generateKey($key);
      }
      $tags = array_unique(array_merge(
         $this->getTags(),
         array_map('sanitize_key', $tags)
      ));
      $value = wp_cache_get($key, $this->group);
      if ($value !== false) {
         return $value;
      }
      $value = $callback();
      if ($value === null || $value === false) {
         return $value;
      }
      wp_cache_set($key, $value, $this->group, $this->ttl);
      if ($redis = self::redis()) {
         foreach ($tags as [$tagGroup, $tagId]) {
            $redis->sAdd("tag:$tagGroup:$tagId", "{$this->group}:$key");
         }
      }
      return $value;
   }
   private static function invalidateByTag(string $group, int|string|array $id): void
   {
      if (is_array($id)) {
         $id = self::for($group)->generateKey($id);
      }
      if (!$redis = self::redis()) {
         return;
      }
      $key = "tag:$group:$id";
      $targets = $redis->sMembers($key);
      foreach ($targets as $target) {
         [$group, $id] = explode(':', $target, 2);
         wp_cache_delete($id, $group);
      }
      $redis->del($key);
   }
   public function tag(string $tag): static
   {
      $this->tags[] = sanitize_key($tag);
      return $this;
   }
   public function getTags(): array
   {
      return array_unique($this->tags);
   }
   /****************************************************
    * TRANSIENT HELPER
    ****************************************************/
@@ -424,8 +313,8 @@
   {
      global $wpdb;
      $pattern = '_transient_jvb_' . $this->group . '_%';
      $timeout_pattern = '_transient_timeout_jvb_' . $this->group . '_%';
      $pattern = '_transient_' .BASE. $this->group . '%';
      $timeout_pattern = '_transient_timeout_' . BASE . $this->group . '%';
      // Remove LIMIT to avoid table locks, add retry for deadlocks
      $attempts = 0;
@@ -465,13 +354,13 @@
      }
//    error_log('[Clearing cache for post change: '.$postId.']');
      self::invalidateItem('post', $postId);
      self::clearPost($postId);
   }
   public static function onPostDelete(int $postId): void
   {
//    error_log('[Clearing cache for post delete: '.$postId.']');
      self::invalidateItem('post', $postId);
      self::clearPost($postId);
   }
// public static function onPostMetaChange(int $metaId, int $objectId): void
@@ -491,26 +380,26 @@
    ****************************************************/
   public static function onTermChange(int $termId, int $ttId, string $taxonomy): void
   {
//    error_log('[Clearing cache for term change: '.$termId.']');
      self::invalidateItem('taxonomy', $termId);
      error_log('[Clearing cache for term change: '.$termId.']');
      self::clearTerm($termId);
   }
   public static function onTermDelete(int $termId): void
   {
//    error_log('[Clearing cache for term delete: '.$termId.']');
      self::invalidateItem('taxonomy', $termId);
      self::clearTerm($termId);
   }
   public static function onTermMetaChange(int $metaId, int $objectId): void
   {
//    error_log('[Clearing cache for term meta change: '.$objectId.']');
      self::invalidateItem('taxonomy', $objectId);
      self::clearTerm($objectId);
   }
   public static function onTermMetaDelete(array $metaIds, int $objectId): void
   {
//    error_log('[Clearing cache for term meta delete: '.$objectId.']');
      self::invalidateItem('taxonomy', $objectId);
      self::clearTerm($objectId);
   }
   /****************************************************
@@ -519,25 +408,25 @@
   public static function onUserChange(int $userId): void
   {
//    error_log('[Clearing cache for user change: '.$userId.']');
      self::invalidateItem('user', $userId);
      self::clearUser($userId);
   }
   public static function onUserDelete(int $userId): void
   {
//    error_log('[Clearing cache for user delete: '.$userId.']');
      self::invalidateItem('user', $userId);
      self::clearUser($userId);
   }
   public static function onUserMetaChange(int $metaId, int $objectId): void
   {
//    error_log('[Clearing cache for user meta change: '.$objectId.']');
      self::invalidateItem('user', $objectId);
      self::clearUser($objectId);
   }
   public static function onUserMetaDelete(array $metaIds, int $objectId): void
   {
//    error_log('[Clearing cache for user meta delete: '.$objectId.']');
      self::invalidateItem('user', $objectId);
      self::clearUser($objectId);
   }
   /***************************************************
    * UTILITY
@@ -546,13 +435,33 @@
    * Generate a cache key from parameters
    * Useful for caching based on multiple variables
    *
    * @param array $params Key-value pairs that uniquely identify this cache entry
    * @param int|string|array $key Key-value pairs that uniquely identify this cache entry
    * @return string MD5 hash of sorted parameters
    */
   public function generateKey(array $params): string
   public function generateKey(int|string|array $key, bool $trackIt = true): string
   {
      ksort($params);
      return md5(serialize($params));
      $trackID = false;
      $ID = false;
      if (!is_null($this->target) && is_int($key)) {
         //Likely an item id. Store it for reference
         $trackID = true;
         $ID = $key;
      }
      if (is_array($key)) {
         ksort($key);
         if (!is_null($this->target) && array_key_exists('id', $key)) {
            $trackID = true;
            $ID = $key['id'];
         }
         $key = md5(serialize($key));
      }
      $key = $this->resolveKey($key);
      if ($trackIt && !is_null($this->target) && $ID && $trackID) {
         $this->trackID($ID, $key);
      }
      return $key;
   }
   /**
@@ -563,90 +472,25 @@
    */
   public static function flushAll(): int
   {
      $all = self::connections();
      $groups = [];
      // Collect all unique groups from connections
      foreach ($all as $source => $targets) {
         $groups[$source] = true;
         foreach ($targets as $conn) {
            $target = $conn['target'] ?? $conn;
            $groups[$target] = true;
         }
      }
      // Add any instantiated groups not in connections
      foreach (array_keys(self::$instances) as $group) {
         $groups[$group] = true;
      }
      // Flush each group
      $groups = get_option(BASE."cache_groups", []);
      $count = 0;
      foreach (array_keys($groups) as $group) {
         self::invalidateGroup($group);
         $count++;
      foreach ($groups as $group => $conf) {
         $cache = Cache::for($group, $conf['target']??null, $conf['ttl']);
         $cache->flush();
         $count ++ ;
         usleep(50000); // Wait 50ms before retry
      }
      // Also flush timestamp cache
      if (wp_using_ext_object_cache()) {
         wp_cache_flush_group(self::TS_GROUP);
      }
      // Clear in-memory caches
      self::$timestamps = [];
      return $count;
   }
   /**
    * Get all cache groups and their connections for admin display
    * Get all cache groups
    *
    * @return array Format: ['group' => ['connects_to' => [...], 'connected_from' => [...]]]
    * @return array
    */
   public static function getAllGroups(): array
   {
      $connections = self::connections();
      $groups = [];
      // Build bidirectional view
      foreach ($connections as $source => $targets) {
         if (!isset($groups[$source])) {
            $groups[$source] = ['connects_to' => [], 'connected_from' => []];
         }
         foreach ($targets as $conn) {
            $target = $conn['target'] ?? $conn;
            $flush = $conn['flush'] ?? false;
            // Source connects to target
            $groups[$source]['connects_to'][] = [
               'group' => $target,
               'flush' => $flush
            ];
            // Target is connected from source
            if (!isset($groups[$target])) {
               $groups[$target] = ['connects_to' => [], 'connected_from' => []];
            }
            $groups[$target]['connected_from'][] = [
               'group' => $source,
               'flush' => $flush
            ];
         }
      }
      // Add any instantiated groups not in connections
      foreach (array_keys(self::$instances) as $group) {
         if (!isset($groups[$group])) {
            $groups[$group] = ['connects_to' => [], 'connected_from' => []];
         }
      }
      return $groups;
   }
   public function hasRedis():bool
   {
      return $this->hasRedis;
      return get_option(BASE."cache_groups", []);
   }
}