<?php
|
namespace JVBase\managers;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class Cache
|
{
|
private string $group;
|
private int $ttl;
|
|
private static array $instances = [];
|
private bool $hasRedis;
|
private bool $varyByAuth = false;
|
private bool $varyByUser = false;
|
|
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->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);
|
}
|
|
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", []);
|
}
|
}
|