group = $group; $this->ttl = $ttl; $this->hasRedis = (bool) wp_using_ext_object_cache(); } 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 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); } /* --------------------------------------------------------------------- * Factory * ------------------------------------------------------------------- */ public static function for(string $group, int $ttl = HOUR_IN_SECONDS): self { $group = sanitize_key($group); if (!isset(self::$instances[$group])) { self::$instances[$group] = new self($group, $ttl); } return self::$instances[$group]; } /* --------------------------------------------------------------------- * 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 ); } $value = $this->get($key); if ($value !== false) { return $value; } $value = $callback(); if ($value !== null && $value !== false) { $this->set($key, $value); } return $value; } public function get(int|string|array $id): mixed { 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; } public function set(int|string|array $id, mixed $value, ?int $ttl = null): void { if (is_array($id)) { $id = $this->generateKey($id); } $ttl = $ttl ?? $this->ttl; if ($this->hasRedis) { wp_cache_set($id, $value, $this->group, $ttl); } else { set_transient("jvb_{$this->group}_{$id}", $value, $ttl); } } public function forget(int|string|array $id): void { if (is_array($id)) { $id = $this->generateKey($id); } if ($this->hasRedis) { wp_cache_delete($id, $this->group); } else { delete_transient("jvb_{$this->group}_{$id}"); } } 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(); } } /* --------------------------------------------------------------------- * Invalidation * ------------------------------------------------------------------- */ public static function invalidateItem(string $group, int|string|array $id): void { if (is_array($id)) { $id = self::for($group)->generateKey($id); } $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); } } self::invalidateByTag($group, $id); } 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; } 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 ****************************************************/ private function clearGroupTransients(): void { global $wpdb; $pattern = '_transient_jvb_' . $this->group . '_%'; $timeout_pattern = '_transient_timeout_jvb_' . $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::invalidateItem('post', $postId); } public static function onPostDelete(int $postId): void { // error_log('[Clearing cache for post delete: '.$postId.']'); self::invalidateItem('post', $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::invalidateItem('taxonomy', $termId); } public static function onTermDelete(int $termId): void { // error_log('[Clearing cache for term delete: '.$termId.']'); self::invalidateItem('taxonomy', $termId); } public static function onTermMetaChange(int $metaId, int $objectId): void { // error_log('[Clearing cache for term meta change: '.$objectId.']'); self::invalidateItem('taxonomy', $objectId); } public static function onTermMetaDelete(array $metaIds, int $objectId): void { // error_log('[Clearing cache for term meta delete: '.$objectId.']'); self::invalidateItem('taxonomy', $objectId); } /**************************************************** * HOOKS - Users ****************************************************/ public static function onUserChange(int $userId): void { // error_log('[Clearing cache for user change: '.$userId.']'); self::invalidateItem('user', $userId); } public static function onUserDelete(int $userId): void { // error_log('[Clearing cache for user delete: '.$userId.']'); self::invalidateItem('user', $userId); } public static function onUserMetaChange(int $metaId, int $objectId): void { // error_log('[Clearing cache for user meta change: '.$objectId.']'); self::invalidateItem('user', $objectId); } public static function onUserMetaDelete(array $metaIds, int $objectId): void { // error_log('[Clearing cache for user meta delete: '.$objectId.']'); self::invalidateItem('user', $objectId); } /*************************************************** * UTILITY **************************************************/ /** * 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 * @return string MD5 hash of sorted parameters */ public function generateKey(array $params): string { ksort($params); return md5(serialize($params)); } /** * 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 { $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 $count = 0; foreach (array_keys($groups) as $group) { self::invalidateGroup($group); $count++; } // 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 * * @return array Format: ['group' => ['connects_to' => [...], 'connected_from' => [...]]] */ 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; } }