<?php
|
namespace JVBase\managers;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Manages HTTP cache timestamps and relationship-based invalidation
|
*
|
* Data caching: Use wrapper methods or wp_cache_get/set directly
|
* HTTP caching: This class manages timestamps for ETag/Last-Modified headers
|
*/
|
class CacheManager
|
{
|
private const CONNECTIONS_OPTION = BASE.'cache_connections';
|
private static ?array $connections_cache = null; // Cache in memory
|
private string $prefix = BASE;
|
private string $group;
|
private int $cache_ttl;
|
private static ?bool $use_object_cache = null;
|
private static array $instances = []; // Cache instances per type
|
private static array $http_timestamps = []; // Request-level memory cache
|
private static ?CacheManager $singleton = null;
|
|
/**
|
* Private constructor - use for() factory method instead
|
*/
|
private function __construct(string $group, ?int $ttl = null)
|
{
|
$this->group = jvbNoBase($group);
|
$this->cache_ttl = $ttl ?: 3600;
|
|
if (is_null(static::$use_object_cache)) {
|
static::$use_object_cache = wp_using_ext_object_cache();
|
}
|
|
add_action('init', [$this, 'registerHooks']);
|
}
|
|
/**
|
* Get singleton instance (for general cache operations)
|
* For type-specific operations, use for() or forUser() instead
|
*/
|
public static function getInstance(): self
|
{
|
if (self::$singleton === null) {
|
self::$singleton = new self('global', HOUR_IN_SECONDS);
|
}
|
return self::$singleton;
|
}
|
|
/**
|
* Get all cache connections (public accessor)
|
*
|
* @return array Array of cache group connections
|
*/
|
public static function getAllConnections(): array
|
{
|
return self::getConnections();
|
}
|
|
/**
|
* Get all registered cache groups
|
*
|
* @return array List of cache group names
|
*/
|
public static function getAllGroups(): array
|
{
|
$connections = self::getConnections();
|
return array_keys($connections);
|
}
|
/**
|
* Register WordPress hooks for automatic cache invalidation
|
* Call this once during plugin initialization
|
*/
|
public static function registerHooks(): void
|
{
|
// Post updates (all post types including core)
|
add_action('save_post', [self::class, 'onPostSave'], 10, 2);
|
add_action('delete_post', [self::class, 'onPostDelete']);
|
// Meta updates (will catch MetaManager updates)
|
add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
|
add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
|
add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
|
// transition_post_status?
|
|
// Term updates (all taxonomies)
|
add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
|
add_action('create_term', [self::class, 'onTermSave'], 10, 3);
|
add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
|
|
// Term meta updates
|
add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
|
add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
|
add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
|
|
// User updates
|
add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
|
add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
|
add_action('deleted_user', [self::class, 'onUserDelete']);
|
|
// User meta updates
|
add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
|
add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
|
add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
|
}
|
|
/**
|
* Get or create a cache manager instance for a content type
|
*
|
* @param string $type Content type (tattoo, style, etc.)
|
* @param int|null $ttl Optional TTL override
|
* @return self Fluent interface
|
*/
|
public static function for(string $type, ?int $ttl = null): self
|
{
|
$type = jvbNoBase($type);
|
$key = $type . ($ttl ? "_{$ttl}" : '');
|
|
if (!isset(self::$instances[$key])) {
|
self::$instances[$key] = new self($type, $ttl);
|
}
|
|
return self::$instances[$key];
|
}
|
|
/**
|
* Get cache manager for a specific user
|
* Each user gets their own cache group for complete isolation
|
*
|
* @param int $user_id User ID
|
* @param int|null $ttl Optional TTL
|
* @return self
|
*/
|
public static function forUser(int $user_id, ?int $ttl = null): self
|
{
|
return self::for("user_{$user_id}", $ttl);
|
}
|
|
/**
|
* Get HTTP cache timestamp for content type(s)
|
* Used for ETag and Last-Modified header generation
|
*
|
* @param string|array $types Single type or array of types
|
* @return int Latest timestamp (Unix time)
|
*/
|
public static function getTimestamp(string|array $types): int
|
{
|
// Multiple types - return latest
|
if (is_array($types)) {
|
$latest = 0;
|
foreach ($types as $type) {
|
$timestamp = self::getTimestamp($type);
|
if ($timestamp > $latest) {
|
$latest = $timestamp;
|
}
|
}
|
return $latest ?: time();
|
}
|
|
$type = jvbNoBase($types);
|
|
// Check request-level cache
|
if (isset(self::$http_timestamps[$type])) {
|
return self::$http_timestamps[$type];
|
}
|
|
// Load from cache (Redis or transient - wp_cache handles it)
|
$timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
|
|
// Cache in memory for this request
|
self::$http_timestamps[$type] = $timestamp;
|
|
return $timestamp;
|
}
|
|
/**
|
* Update HTTP cache timestamp (marks content as modified)
|
*
|
* @param string $type Content type
|
* @return int The new timestamp
|
*/
|
public static function updateTimestamp(string $type): int
|
{
|
$type = jvbNoBase($type);
|
$timestamp = time();
|
|
// Store (Redis or transient - wp_cache handles it)
|
wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
|
|
// Update request cache
|
self::$http_timestamps[$type] = $timestamp;
|
|
do_action('jvb_http_timestamp_updated', $type, $timestamp);
|
|
return $timestamp;
|
}
|
|
/**
|
* Invalidate cache for a content type
|
*
|
* @param string $type Content type to invalidate
|
* @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
|
* @param bool $flush_connections Whether to flush connected caches
|
* @return void
|
*/
|
public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
|
{
|
$type = jvbNoBase($type);
|
|
// Update HTTP timestamp
|
self::updateTimestamp($type);
|
|
// If specific keys provided, only delete those
|
if ($specific_keys !== null) {
|
$instance = self::for($type);
|
if (is_array($specific_keys)) {
|
foreach ($specific_keys as $key) {
|
$instance->delete($key);
|
}
|
} else {
|
$instance->delete($specific_keys);
|
}
|
} else {
|
// Flush the entire group
|
if (function_exists('wp_cache_flush_group')) {
|
wp_cache_flush_group($type);
|
} else {
|
wp_cache_flush();
|
}
|
}
|
|
// Flush connected caches
|
if ($flush_connections) {
|
self::for($type)->connections();
|
}
|
|
do_action('jvb_cache_invalidated', $type);
|
}
|
|
/**
|
* Invalidate only specific keys for a type (doesn't flush group or update timestamp)
|
* Use this when you want surgical cache invalidation
|
*
|
* @param string $type Content type
|
* @param string|array $keys Key(s) to delete
|
* @return void
|
*/
|
public static function invalidateKeys(string $type, string|array $keys): void
|
{
|
$instance = self::for($type);
|
|
if (is_array($keys)) {
|
foreach ($keys as $key) {
|
$instance->delete($key);
|
}
|
} else {
|
$instance->delete($keys);
|
}
|
}
|
|
/**
|
* Fluent instance method to invalidate this cache type
|
*
|
* @param string|array|null $specific_keys Optional specific key(s)
|
* @param bool $flush_connections Whether to flush connected caches
|
* @return self For chaining
|
*/
|
public function invalidate($specific_keys = null, bool $flush_connections = true): self
|
{
|
self::invalidateAll($this->group, $specific_keys, $flush_connections);
|
return $this;
|
}
|
|
/**
|
* Get the HTTP timestamp for this instance's type
|
*
|
* @return int
|
*/
|
public function timestamp(): int
|
{
|
return self::getTimestamp($this->group);
|
}
|
|
/**
|
* Update the HTTP timestamp for this instance's type
|
*
|
* @return self For chaining
|
*/
|
public function touch(): self
|
{
|
self::updateTimestamp($this->group);
|
return $this;
|
}
|
|
/**
|
* Get a value from the cache
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param string|null $group The group to get from. Defaults to current group
|
* @return mixed
|
*/
|
public function get(string|array $key, ?string $group = null): mixed
|
{
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
$value = wp_cache_get($cache_key, $group);
|
|
// Fallback to transient if no external object cache
|
if ($value === false && !wp_using_ext_object_cache()) {
|
$value = get_transient($group . '_' . $cache_key);
|
}
|
|
return $value;
|
}
|
|
/**
|
* Store a value in cache
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param mixed $value The Value to set
|
* @param int|null $ttl The ttl (defaults to current set ttl)
|
* @param string|null $group The group to add cache to (defaults to current group))
|
* @return bool
|
*/
|
public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool
|
{
|
$ttl = $ttl ?: $this->cache_ttl;
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
self::updateTimestamp($this->group);
|
|
// Try object cache first
|
$result = wp_cache_set($cache_key, $value, $group, $ttl);
|
|
// If no external object cache, also store in transient for persistence
|
if (!wp_using_ext_object_cache()) {
|
set_transient($group . '_' . $cache_key, $value, $ttl);
|
}
|
|
return $result;
|
}
|
/**
|
* Delete a cached value
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param string|null $group The group to delete from (defaults to current group)
|
* @return bool
|
*/
|
public function delete(string|array $key, ?string $group = null): bool
|
{
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
$result = wp_cache_delete($cache_key, $group);
|
|
// Also delete transient if no external object cache
|
if (!wp_using_ext_object_cache()) {
|
delete_transient($group . '_' . $cache_key);
|
}
|
|
return $result;
|
}
|
|
|
/**
|
* Clear all cache for this group
|
* @return bool
|
*/
|
public function clear(): bool
|
{
|
try {
|
if (function_exists('wp_cache_flush_group')) {
|
wp_cache_flush_group($this->group);
|
}
|
|
// Clear transients for this group if no external object cache
|
if (!wp_using_ext_object_cache()) {
|
$this->clearGroupTransients();
|
}
|
|
self::updateTimestamp($this->group);
|
return true;
|
} catch (\Exception $e) {
|
return false;
|
}
|
}
|
|
/**
|
* Clear all transients for this cache group
|
*/
|
private function clearGroupTransients(): void
|
{
|
global $wpdb;
|
|
$pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
|
$timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
|
|
$wpdb->query(
|
$wpdb->prepare(
|
"DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
|
$pattern,
|
$timeout_pattern
|
)
|
);
|
}
|
|
/**
|
* Helper to generateKey from array if applicable
|
* @param string|array $key
|
* @return string
|
*/
|
private function normalizeKey(string|array $key): string
|
{
|
return is_array($key) ? $this->generateKey($key) : $key;
|
}
|
|
/**
|
* Generate a cache key from parameters
|
* @param array $params An array of key/values that differentiates this cache item from others
|
* @return string
|
*/
|
public function generateKey(array $params): string
|
{
|
// Sort params for consistent key generation
|
ksort($params);
|
return md5(serialize($params));
|
}
|
|
/**
|
* The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found.
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param callable $callback The callback to generate the value for this key
|
* @param int|null $ttl The time-to-live for the cache. Defaults to constructor
|
* @param string|null $group The group to save cache to. Defaults to constructor
|
* @return mixed
|
*/
|
public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed
|
{
|
$group = $group ?: $this->group;
|
$ttl = $ttl ?: $this->cache_ttl;
|
$key = $this->normalizeKey($key);
|
|
$value = $this->get($key, $group);
|
|
if ($value === false) {
|
$value = $callback();
|
if ($value !== false && $value !== null) {
|
$this->set($key, $value, $ttl, $group);
|
}
|
}
|
|
return $value;
|
}
|
|
/**
|
* Build the cache key
|
* @param string $key
|
* @return string
|
*/
|
private function buildKey(string $key): string
|
{
|
return $this->prefix . $key;
|
}
|
|
/**
|
* Get instance group name (for debugging)
|
*/
|
public function getGroup(): string
|
{
|
return $this->group;
|
}
|
|
|
/***************************************************************************
|
* CONNECTIONS
|
* Connect to other caches by instantiating and defining connection
|
* Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
|
* Where: $type = content / taxonomy / user
|
* $scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
|
* $keyPattern = ??
|
***************************************************************************/
|
/**
|
* Define a connection between cache groups
|
* Connected caches will have their ID-based keys deleted when this cache invalidates
|
*
|
* @param string $type Grand overview ('post', 'taxonomy', 'user')
|
* @param string $scope Type-specific constant, user role, or 'id'
|
* @return self For chaining
|
*/
|
public function connectTo(string $type, string $scope = 'id'): self
|
{
|
//TODO: Handle connect to where $type === 'all'
|
$connections = self::getConnections();
|
|
if (!isset($connections[$this->group])) {
|
$connections[$this->group] = [];
|
}
|
|
$new_connection = [
|
'parent' => $type,
|
'scope' => $scope
|
];
|
|
// Check if already exists
|
foreach ($connections[$this->group] as $existing) {
|
if ($existing === $new_connection) {
|
return $this;
|
}
|
}
|
|
$connections[$this->group][] = $new_connection;
|
update_option(self::CONNECTIONS_OPTION, $connections, false);
|
self::$connections_cache = $connections;
|
|
return $this;
|
}
|
|
/**
|
* Get all registered connections (cached for performance)
|
*
|
* @param bool $refresh Force refresh from database
|
* @return array
|
*/
|
private static function getConnections(bool $refresh = false): array
|
{
|
if (self::$connections_cache === null || $refresh) {
|
self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
|
}
|
|
return self::$connections_cache;
|
}
|
|
/**
|
* Flush all caches connected to this one
|
*
|
* @return self For chaining
|
*/
|
public function connections(): self
|
{
|
$all_connections = self::getConnections();
|
|
foreach ($all_connections as $cache_group => $connections) {
|
foreach ($connections as $conn) {
|
if ($this->matchesConnection($conn)) {
|
$this->flushConnection($cache_group, $conn);
|
}
|
}
|
}
|
|
return $this;
|
}
|
|
/**
|
* Check if this cache group matches a connection definition
|
*/
|
private function matchesConnection(array $connection): bool
|
{
|
$parent = $connection['parent'] ?? '';
|
$scope = $connection['scope'] ?? 'id';
|
|
// Grand overview match
|
if ($this->group === $parent) {
|
return true;
|
}
|
|
// Type-specific match
|
if ($scope !== 'id') {
|
if ($this->group === jvbNoBase($scope)) {
|
return true;
|
}
|
|
// Check constants
|
if ($parent === 'post' && defined('JVB_CONTENT')) {
|
return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
|
}
|
|
if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
|
return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
|
}
|
}
|
|
// ID-specific match: 'user_123' matches 'user' + 'id'
|
if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
|
return true;
|
}
|
|
return false;
|
}
|
|
/**
|
* Flush a connected cache group
|
* For ID-specific connections, deletes the specific ID key
|
* For type/overview connections, flushes entire group
|
*/
|
private function flushConnection(string $cache_group, array $connection): void
|
{
|
$scope = $connection['scope'] ?? 'id';
|
|
// ID-specific: delete specific key
|
if ($scope === 'id') {
|
$id = $this->extractIdFromGroup();
|
|
if ($id !== null) {
|
self::invalidateKeys($cache_group, $id);
|
return;
|
}
|
}
|
|
// Type/overview: flush entire group
|
self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
|
}
|
|
/**
|
* Extract ID from group name like 'user_123' -> '123'
|
*
|
* @return string|null
|
*/
|
private function extractIdFromGroup(): ?string
|
{
|
if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
|
return $matches[1];
|
}
|
|
return null;
|
}
|
|
/**
|
* Register multiple connections at once
|
*/
|
public static function registerConnections(array $connections): void
|
{
|
$existing = self::getConnections();
|
$changed = false;
|
|
foreach ($connections as $cache_group => $configs) {
|
if (!isset($existing[$cache_group])) {
|
$existing[$cache_group] = [];
|
}
|
|
foreach ($configs as $config) {
|
$duplicate = false;
|
foreach ($existing[$cache_group] as $existing_config) {
|
if ($existing_config === $config) {
|
$duplicate = true;
|
break;
|
}
|
}
|
|
if (!$duplicate) {
|
$existing[$cache_group][] = $config;
|
$changed = true;
|
}
|
}
|
}
|
|
if ($changed) {
|
update_option(self::CONNECTIONS_OPTION, $existing, false);
|
self::$connections_cache = $existing;
|
}
|
}
|
|
/**
|
* Handle post save/update
|
*/
|
public static function onPostSave(int $post_id, \WP_Post $post): void
|
{
|
// Skip revisions and autosaves
|
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
|
return;
|
}
|
|
$post_type = jvbNoBase($post->post_type);
|
|
// Invalidate post type cache
|
self::invalidateAll($post_type);
|
|
// Invalidate specific post cache
|
self::invalidateAll($post_id);
|
// Clear WordPress core post object cache
|
clean_post_cache($post_id);
|
}
|
|
/**
|
* Handle post deletion
|
*/
|
public static function onPostDelete(int $post_id): void
|
{
|
$post = get_post($post_id);
|
if (!$post) {
|
return;
|
}
|
|
$post_type = jvbNoBase($post->post_type);
|
|
self::invalidateAll($post_type);
|
self::invalidateAll($post_id);
|
// Clear WordPress core post object cache
|
clean_post_cache($post_id);
|
}
|
|
/**
|
* Handle term save/update
|
*/
|
public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
|
{
|
// Clear WordPress core term cache
|
clean_term_cache($term_id, $taxonomy);
|
$taxonomy = jvbNoBase($taxonomy);
|
|
// Invalidate taxonomy cache
|
self::invalidateAll($taxonomy);
|
|
// Invalidate specific term cache
|
self::invalidateAll($term_id);
|
}
|
|
/**
|
* Handle term deletion
|
*/
|
public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
|
{
|
// Clear WordPress core term cache
|
clean_term_cache($term_id, $taxonomy);
|
$taxonomy = jvbNoBase($taxonomy);
|
|
self::invalidateAll($taxonomy);
|
self::invalidateAll($term_id);
|
}
|
|
/**
|
* Handle user update
|
*/
|
public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
|
{
|
// Invalidate user-specific cache
|
self::invalidateAll($user_id);
|
|
// Invalidate user role caches if roles changed
|
if ($old_user_data) {
|
$user = get_userdata($user_id);
|
if ($user && $user->roles !== $old_user_data->roles) {
|
foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
|
self::invalidateAll($role);
|
}
|
}
|
}
|
// Clear WordPress core user cache
|
clean_user_cache($user_id);
|
}
|
|
/**
|
* Handle user deletion
|
*/
|
public static function onUserDelete(int $user_id): void
|
{
|
self::invalidateAll($user_id);
|
// Clear WordPress core user cache
|
clean_user_cache($user_id);
|
}
|
|
/**
|
* Handle post meta updates
|
*/
|
public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$post = get_post($post_id);
|
if (!$post) {
|
return;
|
}
|
|
self::onPostSave($post_id, $post);
|
}
|
public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$post = get_post($post_id);
|
if (!$post) {
|
return;
|
}
|
|
self::onPostSave($post_id, $post);
|
}
|
|
/**
|
* Handle term meta updates
|
*/
|
public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$term = get_term($term_id);
|
if (!$term || is_wp_error($term)) {
|
return;
|
}
|
|
self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
|
}
|
|
public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$term = get_term($term_id);
|
if (!$term || is_wp_error($term)) {
|
return;
|
}
|
|
self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
|
}
|
|
/**
|
* Handle user meta updates
|
*/
|
public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$user = get_userdata($user_id);
|
if (!$user) {
|
return;
|
}
|
|
self::onUserUpdate($user_id, null);
|
}
|
|
public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
|
{
|
if (!str_starts_with($meta_key, BASE)) {
|
return;
|
}
|
|
$user = get_userdata($user_id);
|
if (!$user) {
|
return;
|
}
|
|
self::onUserUpdate($user_id, null);
|
}
|
}
|