<?php
|
namespace JVBase\managers;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class Cache
|
{
|
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 function __construct(string $group, int $ttl)
|
{
|
$this->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;
|
}
|
}
|