index)) { $this->target = null; $this->index = get_option(BASE."cache_{$target}_index",[]); } $this->group = $group; $this->ttl = $ttl; $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 { // Post updates (all post types including core) add_action('save_post', [self::class, 'onPostChange'], 10, 2); add_action('delete_post', [self::class, 'onPostDelete']); // Post meta updates, now handled via Meta.php? // add_action('updated_post_meta', [self::class, 'onPostMetaChange'], 10, 2); // add_action('added_post_meta', [self::class, 'onPostMetaChange'], 10, 2); // add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 2); // Term updates (all taxonomies) add_action('edited_term', [self::class, 'onTermChange'], 10, 3); add_action('create_term', [self::class, 'onTermChange'], 10, 3); 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); // User updates add_action('profile_update', [self::class, 'onUserChange'], 10, 2); add_action('user_register', [self::class, 'onUserChange'], 10, 1); 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); } public function auth(): static { $this->varyByAuth = true; return $this; } 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, $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 { $key = $this->generateKey($key); $value = $this->get($key); if ($value !== false) { return $value; } $value = $callback(); if ($value !== null && $value !== false) { $this->set($key, $value, $ttl); } return $value; } protected function transientKey(string $key):string { return BASE."{$this->group}_{$key}"; } public function get(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['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($key, $store, $this->group, $ttl); } else { set_transient($this->transientKey($key), $store, $ttl); } } public function forget(int|string|array $key): void { $key = $this->generateKey($key); if ($this->hasRedis) { wp_cache_delete($key, $this->group); } else { delete_transient($this->transientKey($key)); } } public function flush(): void { if ($this->hasRedis) { if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group($this->group); } else { wp_cache_flush(); } } else { $this->clearGroupTransients(); } } public static function clearPost(int $ID):void { $index = get_option(BASE."cache_post_index", []); if (!array_key_exists($ID, $index)) { return; } foreach ($index[$ID] as $group => $keys) { $cache = self::for($group, 'post'); foreach ($keys as $key) { $cache->forget($key); } } } 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 { self::for($group)->flush(); } /**************************************************** * TRANSIENT HELPER ****************************************************/ private function clearGroupTransients(): void { global $wpdb; $pattern = '_transient_' .BASE. $this->group . '%'; $timeout_pattern = '_transient_timeout_' . BASE . $this->group . '%'; // Remove LIMIT to avoid table locks, add retry for deadlocks $attempts = 0; $max_attempts = 3; while ($attempts < $max_attempts) { $result = $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s", $pattern, $timeout_pattern ) ); // Success or non-deadlock error if ($result !== false || !str_contains($wpdb->last_error, 'Deadlock')) { break; } $attempts++; if ($attempts < $max_attempts) { usleep(50000); // Wait 50ms before retry } } } /**************************************************** * HOOKS ****************************************************/ /**************************************************** * HOOKS - Posts ****************************************************/ public static function onPostChange(int $postId, \WP_Post $post): void { if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) { return; } // error_log('[Clearing cache for post change: '.$postId.']'); self::clearPost($postId); } public static function onPostDelete(int $postId): void { // error_log('[Clearing cache for post delete: '.$postId.']'); self::clearPost($postId); } // public static function onPostMetaChange(int $metaId, int $objectId): void // { //// error_log('[Clearing cache for post meta change: '.$objectId.']'); // self::invalidateItem('post', $objectId); // } // // public static function onPostMetaDelete(array $metaIds, int $objectId): void // { //// error_log('[Clearing cache for post meta delete: '.$objectId.']'); // self::invalidateItem('post', $objectId); // } /**************************************************** * HOOKS - Terms ****************************************************/ public static function onTermChange(int $termId, int $ttId, string $taxonomy): void { 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::clearTerm($termId); } public static function onTermMetaChange(int $metaId, int $objectId): void { // error_log('[Clearing cache for term meta change: '.$objectId.']'); self::clearTerm($objectId); } public static function onTermMetaDelete(array $metaIds, int $objectId): void { // error_log('[Clearing cache for term meta delete: '.$objectId.']'); self::clearTerm($objectId); } /**************************************************** * HOOKS - Users ****************************************************/ public static function onUserChange(int $userId): void { // error_log('[Clearing cache for user change: '.$userId.']'); self::clearUser($userId); } public static function onUserDelete(int $userId): void { // error_log('[Clearing cache for user delete: '.$userId.']'); self::clearUser($userId); } public static function onUserMetaChange(int $metaId, int $objectId): void { // error_log('[Clearing cache for user meta change: '.$objectId.']'); self::clearUser($objectId); } public static function onUserMetaDelete(array $metaIds, int $objectId): void { // error_log('[Clearing cache for user meta delete: '.$objectId.']'); self::clearUser($objectId); } /*************************************************** * UTILITY **************************************************/ /** * Generate a cache key from parameters * Useful for caching based on multiple variables * * @param int|string|array $key Key-value pairs that uniquely identify this cache entry * @return string MD5 hash of sorted parameters */ public function generateKey(int|string|array $key, bool $trackIt = true): string { $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; } /** * Nuclear option: Flush ALL registered cache groups * Use for debugging or after major updates * * @return int Number of groups flushed */ public static function flushAll(): int { $groups = get_option(BASE."cache_groups", []); $count = 0; foreach ($groups as $group => $conf) { $cache = Cache::for($group, $conf['target']??null, $conf['ttl']); $cache->flush(); $count ++ ; usleep(50000); // Wait 50ms before retry } return $count; } /** * Get all cache groups * * @return array */ public static function getAllGroups(): array { return get_option(BASE."cache_groups", []); } }