From 5bee84b7d04c977a07fb31ca12ec1a96e24c1807 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 02:53:42 +0000 Subject: [PATCH 01/10] feat: align adapter safety, stampede control, and cache isolation tests Agent-Logs-Url: https://github.com/MaximillianGroupInc/StarCache/sessions/c740e11c-fc9b-483b-b759-09f1619c08e2 Co-authored-by: MaximillianGroup <34328348+MaximillianGroup@users.noreply.github.com> --- README.md | 22 +++- StarAssetMinifier.php | 38 +++++- StarCache.php | 138 ++++++++++++++++++++- StarCacheAdapter.php | 206 ++++++++++++++++++++++++++++--- StarPageCache.php | 6 + phpstan-bootstrap.php | 50 ++++++++ tests/UnitTests.php | 277 ++++++++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 68 +++++++++++ 8 files changed, 775 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index aadd41b..9738a32 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ wp starcache flush StarCache probes backends in the following order and uses the first one that responds successfully: -1. **Redis** – requires the `redis` PHP extension; reads `WP_REDIS_*` constants. +1. **Redis** – tries PhpRedis (`ext-redis`) first, then Predis (`\Predis\Client`) using `WP_REDIS_*` constants. 2. **Memcached** – requires the `memcached` PHP extension; reads `MEMCACHED_SERVERS`. 3. **Memcache** – requires the `memcache` PHP extension; reads `MEMCACHE_SERVER_HOST/PORT`. 4. **WordPress object cache** – always available; backed by APCu, file, or database @@ -163,6 +163,25 @@ responds successfully: The detected backend is exposed via `StarCacheAdapter::getBackend()` and shown in the WordPress admin bar for administrators. +`StarCacheAdapter::getBackendCapabilities()` provides runtime capability detection for: + +- PhpRedis availability +- Predis availability +- Memcached availability +- Memcache availability +- WordPress object-cache add/flush support +- WordPress object-cache persistence mode (`persistent` vs `runtime`) + +### Dangerous flush guard + +Global backend flush operations are blocked by default. To allow them explicitly: + +```php +define('STARCACHE_ALLOW_DANGEROUS_FLUSH', true); +``` + +Without this constant, `StarCacheAdapter::flush()` returns `false` and logs a warning. + --- ## Filters and hooks @@ -201,4 +220,3 @@ Contributions are welcome! Please see [CONTRIBUTING.md](contributing.md) for det If you encounter any issues or have questions, please open an issue on the [GitHub repository](https://github.com/MaximillianGroupInc/StarCache). - diff --git a/StarAssetMinifier.php b/StarAssetMinifier.php index 89af0db..a9a467b 100644 --- a/StarAssetMinifier.php +++ b/StarAssetMinifier.php @@ -229,12 +229,16 @@ public static function buildAssetFromCron(string $localPath, string $destPath, s $minified = ($type === 'css') ? self::minifyCss($source) : self::normalizeJs($source); - // Write atomically via temp file so partial writes are never visible. - $tmpPath = $destPath . '.tmp'; - if (file_put_contents($tmpPath, $minified) === false) { + // Write atomically via unique temp file so partial writes are never visible. + $tmpPath = $destPath . '.tmp.' . uniqid('', true); + if (file_put_contents($tmpPath, $minified, LOCK_EX) === false) { + return; + } + + if (!@rename($tmpPath, $destPath)) { + @unlink($tmpPath); return; } - rename($tmpPath, $destPath); } // ------------------------------------------------------------------------- @@ -314,6 +318,8 @@ private static function processAsset(\WP_Dependencies $deps, string $handle, str return; } + self::cleanupStaleHashedAssets($safeHandle, $fileName); + // Minified file already exists — swap src and bump the version so // browsers and CDNs re-fetch after any cache is cleared. $deps->registered[$handle]->src = $destUrl; @@ -413,4 +419,28 @@ private static function isEnabled(): bool } return (bool) apply_filters('starcache_minify_enabled', true); } + + /** + * Remove old hashed files for the same handle to keep cache growth bounded. + */ + private static function cleanupStaleHashedAssets(string $safeHandle, string $currentFileName): void + { + // Keep cleanup lightweight in frontend hot paths. + if (random_int(1, 20) !== 1) { + return; + } + + $pattern = self::$cacheDir . '/' . $safeHandle . '-*.min.{css,js}'; + $files = glob($pattern, GLOB_BRACE); + if (!$files) { + return; + } + + foreach ($files as $file) { + if (basename($file) === $currentFileName) { + continue; + } + @unlink($file); + } + } } diff --git a/StarCache.php b/StarCache.php index cf78157..6b444ac 100644 --- a/StarCache.php +++ b/StarCache.php @@ -50,6 +50,18 @@ class StarCache /** Cache group used for all data managed by this class. */ public const CACHE_GROUP = 'starcache_data'; + /** Internal envelope marker used by remember() payloads. */ + private const REMEMBER_ENVELOPE_V1 = 'starcache.remember.v1'; + + /** Default soft-TTL ratio for remember() entries. */ + private const DEFAULT_SOFT_TTL_RATIO = 0.8; + + /** Default lock TTL in seconds for stampede protection. */ + private const DEFAULT_REMEMBER_LOCK_TTL = 15; + + /** Max lock wait in milliseconds when stale-while-revalidate is disabled. */ + private const DEFAULT_REMEMBER_WAIT_MS = 200; + // ------------------------------------------------------------------ // Constructor // ------------------------------------------------------------------ @@ -180,15 +192,68 @@ public function star_flushReloadCachedData(string $reference, ?string $userId = */ public function star_remember(string $reference, callable $callback, int $ttl = 0, ?string $userId = null): mixed { - $found = false; - $cached = $this->star_getCachedData($reference, $userId, $found); - if ($found) { - return $cached; + $hardTtl = $ttl > 0 ? $ttl : self::CACHE_EXPIRATION_DYNAMIC; + $softTtl = $this->resolveSoftTtl($hardTtl); + $swr = (bool) apply_filters('starcache_remember_swr_enabled', true); + + $key = $this->buildKey($reference, $userId); + $group = $this->star_getUserGroup($reference, $userId); + $lockKey = $this->buildRememberLockKey($key); + + $hit = StarCacheAdapter::getWithFound($key, $group); + if ($hit['found']) { + $entry = $this->normalizeRememberEntry($hit['value'], $softTtl, $hardTtl); + if ($entry['isFresh']) { + return $entry['value']; + } + + if (StarCacheAdapter::add($lockKey, 1, self::DEFAULT_REMEMBER_LOCK_TTL, $group)) { + try { + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); + return $value; + } finally { + StarCacheAdapter::delete($lockKey, $group); + } + } + + if ($swr) { + return $entry['value']; + } + + $reloaded = $this->waitForRememberRefresh($key, $group); + if ($reloaded['found']) { + $latest = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); + return $latest['value']; + } } - $value = $callback(); - $this->star_setCachedDataWithTtl($value, $reference, $ttl ?: self::CACHE_EXPIRATION_DYNAMIC, $userId); + if (StarCacheAdapter::add($lockKey, 1, self::DEFAULT_REMEMBER_LOCK_TTL, $group)) { + try { + $race = StarCacheAdapter::getWithFound($key, $group); + if ($race['found']) { + $entry = $this->normalizeRememberEntry($race['value'], $softTtl, $hardTtl); + if ($entry['isFresh']) { + return $entry['value']; + } + } + + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); + return $value; + } finally { + StarCacheAdapter::delete($lockKey, $group); + } + } + + $reloaded = $this->waitForRememberRefresh($key, $group); + if ($reloaded['found']) { + $entry = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); + return $entry['value']; + } + $value = $callback(); + $this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl); return $value; } @@ -281,6 +346,67 @@ private function buildKey(string $reference, ?string $userId): string return StarCacheKey::build($reference, $userId, StarVersionStore::GROUP_OBJECTS); } + private function buildRememberLockKey(string $key): string + { + return 'remember_lock:' . $key; + } + + /** + * @return array{value:mixed,isFresh:bool} + */ + private function normalizeRememberEntry(mixed $raw, int $softTtl, int $hardTtl): array + { + if ( + is_array($raw) + && ($raw['marker'] ?? '') === self::REMEMBER_ENVELOPE_V1 + && array_key_exists('value', $raw) + ) { + $createdAt = (int) ($raw['created_at'] ?? 0); + $soft = max(1, (int) ($raw['soft_ttl'] ?? $softTtl)); + $hard = max($soft, (int) ($raw['hard_ttl'] ?? $hardTtl)); + $age = max(0, time() - $createdAt); + $fresh = $age < $soft; + + return ['value' => $raw['value'], 'isFresh' => $fresh && $age < $hard]; + } + + return ['value' => $raw, 'isFresh' => true]; + } + + private function storeRememberEntry(string $key, string $group, mixed $value, int $softTtl, int $hardTtl): bool + { + $payload = [ + 'marker' => self::REMEMBER_ENVELOPE_V1, + 'value' => $value, + 'created_at' => time(), + 'soft_ttl' => $softTtl, + 'hard_ttl' => $hardTtl, + ]; + return StarCacheAdapter::set($key, $payload, $hardTtl, $group); + } + + private function resolveSoftTtl(int $hardTtl): int + { + $default = max(1, (int) floor($hardTtl * self::DEFAULT_SOFT_TTL_RATIO)); + $softTtl = (int) apply_filters('starcache_remember_soft_ttl', $default, $hardTtl); + return max(1, min($softTtl, $hardTtl)); + } + + /** + * @return array{found:bool,value:mixed} + */ + private function waitForRememberRefresh(string $key, string $group): array + { + $waitMs = (int) apply_filters('starcache_remember_lock_wait_ms', self::DEFAULT_REMEMBER_WAIT_MS); + $waitMs = max(0, $waitMs); + if ($waitMs <= 0) { + return ['found' => false, 'value' => false]; + } + + usleep($waitMs * 1000); + return StarCacheAdapter::getWithFound($key, $group); + } + /** * Log errors via StarExceptionHandler when available, otherwise error_log(). * diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 16566e2..bf7941b 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -31,7 +31,9 @@ class StarCacheAdapter public const BACKEND_MEMCACHE = 'memcache'; public const BACKEND_WP = 'wp'; - /** @var \Redis|\Memcached|\Memcache|null */ + private const DEFAULT_GROUP = 'default'; + + /** @var \Redis|\Predis\Client|\Memcached|\Memcache|null */ private static $connection = null; /** @var string */ @@ -55,6 +57,9 @@ public static function init(): void if (self::tryRedis()) { return; } + if (self::tryPredis()) { + return; + } if (self::tryMemcached()) { return; } @@ -82,16 +87,35 @@ public static function getBackend(): string } /** - * Returns the raw connection object (Redis / Memcached / Memcache) or null - * when the WordPress object cache is used. + * Returns the raw connection object (backend-specific) or null when the + * WordPress object cache fallback is used. * - * @return \Redis|\Memcached|\Memcache|null + * @return object|null */ - public static function getConnection(): \Redis|\Memcached|\Memcache|null + public static function getConnection(): object|null { return self::$connection; } + /** + * Return backend capability information for operational visibility. + * + * @return array + */ + public static function getBackendCapabilities(): array + { + return [ + 'phpredis_available' => extension_loaded('redis'), + 'predis_available' => class_exists('\Predis\Client'), + 'memcached_available' => extension_loaded('memcached'), + 'memcache_available' => extension_loaded('memcache'), + 'wp_object_cache_add' => function_exists('wp_cache_add'), + 'wp_object_cache_flush' => function_exists('wp_cache_flush'), + 'wp_object_cache_mode' => self::detectWpObjectCacheMode(), + 'active_backend' => self::getBackend(), + ]; + } + /** * Returns true when OPcache is enabled and functioning. */ @@ -129,8 +153,9 @@ public static function getWithFound(string $key, string $group = ''): array try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - $value = self::$connection->get($key); - if ($value === false) { + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); + if ($value === false || $value === null) { return ['found' => false, 'value' => false]; } if (!is_string($value)) { @@ -143,7 +168,8 @@ public static function getWithFound(string $key, string $group = ''): array return ['found' => true, 'value' => $unserialized]; case self::BACKEND_MEMCACHED: - $value = self::$connection->get($key); + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); if ($value === false && self::$connection->getResultCode() === \Memcached::RES_NOTFOUND) { return ['found' => false, 'value' => false]; } @@ -160,7 +186,8 @@ public static function getWithFound(string $key, string $group = ''): array case self::BACKEND_MEMCACHE: // Memcache::get() returns false on miss AND when the stored value is literally // false. Values are stored serialized so a retrieved string is always a hit. - $value = self::$connection->get($key); // @phpstan-ignore-line + $storageKey = self::buildStorageKey($key, $group); + $value = self::$connection->get($storageKey); // @phpstan-ignore-line if ($value === false) { return ['found' => false, 'value' => false]; // cache miss (serialized values are strings, never false) } @@ -198,21 +225,37 @@ public static function set(string $key, mixed $value, int $expiration = 3600, st try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: + $storageKey = self::buildStorageKey($key, $group); $serialised = serialize($value); if ($expiration > 0) { - return (bool) self::$connection->setEx($key, $expiration, $serialised); + if (self::isPredisConnection()) { + return self::$connection->setex($storageKey, $expiration, $serialised) === 'OK'; + } + return (bool) self::$connection->setEx($storageKey, $expiration, $serialised); } - return (bool) self::$connection->set($key, $serialised); + if (self::isPredisConnection()) { + return self::$connection->set($storageKey, $serialised) === 'OK'; + } + return (bool) self::$connection->set($storageKey, $serialised); case self::BACKEND_MEMCACHED: // Serialize to mirror the Redis strategy and allow any PHP value // (including boolean false) to be stored and retrieved unambiguously. - return self::$connection->set($key, serialize($value), $expiration); + return self::$connection->set( + self::buildStorageKey($key, $group), + serialize($value), + $expiration + ); case self::BACKEND_MEMCACHE: // Memcache::set($key, $value, $flags, $expire) — 0 = no compression. // Serialize for the same reason as Memcached above. - return self::$connection->set($key, serialize($value), 0, $expiration); // @phpstan-ignore-line + return self::$connection->set( + self::buildStorageKey($key, $group), + serialize($value), + 0, + $expiration + ); // @phpstan-ignore-line default: return wp_cache_set($key, $value, $group, $expiration); @@ -234,13 +277,13 @@ public static function delete(string $key, string $group = ''): bool try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - return (bool) self::$connection->del($key); + return (bool) self::$connection->del(self::buildStorageKey($key, $group)); case self::BACKEND_MEMCACHED: - return self::$connection->delete($key); + return self::$connection->delete(self::buildStorageKey($key, $group)); case self::BACKEND_MEMCACHE: - return self::$connection->delete($key); + return self::$connection->delete(self::buildStorageKey($key, $group)); default: return wp_cache_delete($key, $group); @@ -256,10 +299,18 @@ public static function delete(string $key, string $group = ''): bool */ public static function flush(): bool { + if (!defined('STARCACHE_ALLOW_DANGEROUS_FLUSH') || STARCACHE_ALLOW_DANGEROUS_FLUSH !== true) { + self::logMessage('StarCacheAdapter::flush blocked. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true to enable.'); + return false; + } + try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - return (bool) self::$connection->flushAll(); + if (self::isPredisConnection()) { + return self::$connection->flushdb() === 'OK'; + } + return (bool) self::$connection->flushDB(); case self::BACKEND_MEMCACHED: return self::$connection->flush(); @@ -276,6 +327,52 @@ public static function flush(): bool } } + /** + * Atomically set only when absent where backend supports add/NX semantics. + * + * @param mixed $value + */ + public static function add(string $key, mixed $value, int $expiration = 30, string $group = ''): bool + { + try { + $storageKey = self::buildStorageKey($key, $group); + $serialised = serialize($value); + + switch (self::$detectedBackend) { + case self::BACKEND_REDIS: + if (self::isPredisConnection()) { + return self::$connection->set($storageKey, $serialised, 'EX', $expiration, 'NX') === 'OK'; + } + + $options = ['nx']; + if ($expiration > 0) { + $options['ex'] = $expiration; + } + $result = self::$connection->set($storageKey, $serialised, $options); + return $result === true || $result === 'OK'; + + case self::BACKEND_MEMCACHED: + return self::$connection->add($storageKey, $serialised, $expiration); + + case self::BACKEND_MEMCACHE: + return self::$connection->add($storageKey, $serialised, 0, $expiration); // @phpstan-ignore-line + + default: + if (function_exists('wp_cache_add')) { + return wp_cache_add($key, $value, $group, $expiration); + } + $hit = self::getWithFound($key, $group); + if ($hit['found']) { + return false; + } + return self::set($key, $value, $expiration, $group); + } + } catch (Exception $e) { + self::logError('StarCacheAdapter::add', $e); + return false; + } + } + /** * Close the underlying connection (no-op for WP cache). */ @@ -287,7 +384,13 @@ public static function close(): void try { switch (self::$detectedBackend) { case self::BACKEND_REDIS: - self::$connection->close(); + if (self::isPredisConnection()) { + if (method_exists(self::$connection, 'disconnect')) { + self::$connection->disconnect(); + } + } else { + self::$connection->close(); + } break; case self::BACKEND_MEMCACHED: case self::BACKEND_MEMCACHE: @@ -334,6 +437,38 @@ private static function tryRedis(): bool return true; } + private static function tryPredis(): bool + { + if (!class_exists('\Predis\Client')) { + return false; + } + + $host = defined('WP_REDIS_HOST') ? WP_REDIS_HOST : '127.0.0.1'; + $port = defined('WP_REDIS_PORT') ? (int) WP_REDIS_PORT : 6379; + $password = defined('WP_REDIS_PASSWORD') ? WP_REDIS_PASSWORD : null; + $database = defined('WP_REDIS_DATABASE') ? (int) WP_REDIS_DATABASE : 0; + + $params = [ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => $port, + 'database' => $database, + ]; + if (is_string($password) && $password !== '') { + $params['password'] = $password; + } + + $client = new \Predis\Client($params, ['exceptions' => false]); + $pong = $client->ping(); + if ($pong === null || $pong === false) { + return false; + } + + self::$connection = $client; + self::$detectedBackend = self::BACKEND_REDIS; + return true; + } + private static function tryMemcached(): bool { if (!extension_loaded('memcached')) { @@ -389,6 +524,35 @@ private static function tryMemcache(): bool // Logging // ------------------------------------------------------------------------- + /** + * Build backend-internal namespaced key from logical key+group. + */ + private static function buildStorageKey(string $key, string $group): string + { + $normalizedGroup = self::normaliseGroup($group); + return 'scg:' . substr(hash('sha256', $normalizedGroup), 0, 16) . ':' . $key; + } + + private static function normaliseGroup(string $group): string + { + $group = strtolower(trim($group)); + $group = preg_replace('/[^a-z0-9_\-:]/', '', $group) ?? ''; + return $group !== '' ? $group : self::DEFAULT_GROUP; + } + + private static function isPredisConnection(): bool + { + return class_exists('\Predis\Client') && self::$connection instanceof \Predis\Client; + } + + private static function detectWpObjectCacheMode(): string + { + if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) { + return 'persistent'; + } + return 'runtime'; + } + private static function logError(string $context, Exception $e): void { if (class_exists('\StarExceptionHandler')) { @@ -398,4 +562,10 @@ private static function logError(string $context, Exception $e): void error_log("[StarCache] {$context}: {$e->getMessage()}"); } } + + private static function logMessage(string $message): void + { + $exception = new \RuntimeException($message); + self::logError('StarCacheAdapter', $exception); + } } diff --git a/StarPageCache.php b/StarPageCache.php index 76ecb9b..20ff925 100644 --- a/StarPageCache.php +++ b/StarPageCache.php @@ -137,6 +137,12 @@ public static function capturePageOutput(string $html): string return $html; } + // Final eligibility gate immediately before persist so any late request + // state changes (method flags, auth state, bypass filters) are respected. + if (!StarResponseController::isEligible()) { + return $html; + } + $ttl = (int) apply_filters('starcache_page_ttl', self::TTL_PAGE); $payload = ['html' => $html, 'headers' => $headers, 'time' => time()]; diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index d34995f..0d8621d 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -94,6 +94,50 @@ public function get_error_message(string $code = ''): string { return ''; } } } +if (!class_exists('Redis')) { + class Redis + { + public function connect(string $host, int $port, float $timeout = 0): bool { return false; } + public function auth(string $password): bool { return false; } + public function select(int $database): bool { return true; } + public function get(string $key): string|false { return false; } + public function setEx(string $key, int $ttl, string $value): bool { return true; } + public function set(string $key, mixed $value, mixed ...$options): bool|string { return true; } + public function del(string $key): int { return 1; } + public function flushDB(): bool { return true; } + public function close(): bool { return true; } + } +} + +if (!class_exists('Memcached')) { + class Memcached + { + public const RES_SUCCESS = 0; + public const RES_NOTFOUND = 16; + public function addServer(string $host, int $port): bool { return true; } + public function set(string $key, mixed $value, int $expiration = 0): bool { return true; } + public function get(string $key): mixed { return false; } + public function getResultCode(): int { return self::RES_NOTFOUND; } + public function delete(string $key): bool { return true; } + public function flush(): bool { return true; } + public function add(string $key, mixed $value, int $expiration = 0): bool { return true; } + public function close(): bool { return true; } + } +} + +if (!class_exists('Memcache')) { + class Memcache + { + public function connect(string $host, int $port): bool { return false; } + public function set(string $key, mixed $value, int $flags = 0, int $expiration = 0): bool { return true; } + public function get(string $key): mixed { return false; } + public function delete(string $key): bool { return true; } + public function flush(): bool { return true; } + public function add(string $key, mixed $value, int $flags = 0, int $expiration = 0): bool { return true; } + public function close(): bool { return true; } + } +} + // --------------------------------------------------------------------------- // WordPress stub functions // --------------------------------------------------------------------------- @@ -107,6 +151,9 @@ function wp_cache_get(string $key, string $group = '', bool $force = false, mixe if (!function_exists('wp_cache_set')) { function wp_cache_set(string $key, mixed $data, string $group = '', int $expire = 0): bool { return true; } } +if (!function_exists('wp_cache_add')) { + function wp_cache_add(string $key, mixed $data, string $group = '', int $expire = 0): bool { return true; } +} if (!function_exists('wp_cache_delete')) { function wp_cache_delete(string $key, string $group = ''): bool { return true; } } @@ -146,6 +193,9 @@ function is_admin(): bool { return false; } if (!function_exists('is_ssl')) { function is_ssl(): bool { return false; } } +if (!function_exists('wp_using_ext_object_cache')) { + function wp_using_ext_object_cache(): bool { return false; } +} if (!function_exists('apply_filters')) { function apply_filters(string $hook, mixed $value, mixed ...$args): mixed { return $value; } } diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 4e51b65..312b7ea 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -13,6 +13,88 @@ use StarCache\StarResponseController; use StarCache\StarVersionStore; use StarCache\StarQueryCache; +use StarCache\StarCacheAdapter; + +class FakeRedisConnection +{ + /** @var array */ + private array $store = []; + + public function get(string $key): string|false + { + return $this->store[$key] ?? false; + } + + public function setEx(string $key, int $expiration, string $value): bool + { + $this->store[$key] = $value; + return true; + } + + public function set(string $key, mixed $value, mixed ...$options): bool|string + { + if (is_array($options[0] ?? null) && in_array('nx', $options[0], true) && array_key_exists($key, $this->store)) { + return false; + } + $this->store[$key] = (string) $value; + return true; + } + + public function del(string $key): int + { + if (!array_key_exists($key, $this->store)) { + return 0; + } + unset($this->store[$key]); + return 1; + } +} + +class FakeMemcachedConnection +{ + /** @var array */ + private array $store = []; + + private int $resultCode = \Memcached::RES_NOTFOUND; + + public function get(string $key): mixed + { + if (!array_key_exists($key, $this->store)) { + $this->resultCode = \Memcached::RES_NOTFOUND; + return false; + } + $this->resultCode = \Memcached::RES_SUCCESS; + return $this->store[$key]; + } + + public function getResultCode(): int + { + return $this->resultCode; + } + + public function set(string $key, mixed $value, int $expiration = 0): bool + { + $this->store[$key] = (string) $value; + $this->resultCode = \Memcached::RES_SUCCESS; + return true; + } + + public function add(string $key, mixed $value, int $expiration = 0): bool + { + if (array_key_exists($key, $this->store)) { + return false; + } + $this->store[$key] = (string) $value; + $this->resultCode = \Memcached::RES_SUCCESS; + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + return true; + } +} /** * StarCache v2.1.1 Test Suite @@ -42,6 +124,10 @@ protected function setUp(): void $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; // Reset the blog-ID stub to 1 so tests that modify it don't pollute later tests. $GLOBALS['_starcache_test_blog_id'] = 1; + $GLOBALS['_starcache_wp_cache_flush_calls'] = 0; + $GLOBALS['_starcache_scheduled_events'] = []; + $GLOBALS['_starcache_wpcache'] = []; + $this->resetAdapterToWpFallback(); } // ========================================================================= @@ -814,4 +900,195 @@ public function testQueryCacheVersionGroupQueriesDefaultsToOne(): void $uniqueGroup = 'test_qcache_' . uniqid(); $this->assertSame(1, StarVersionStore::get($uniqueGroup)); } + + // ========================================================================= + // StarAssetMinifier — asynchronous stored-file model + // ========================================================================= + + public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile(): void + { + $assetDir = WP_CONTENT_DIR . '/themes/starcache-test'; + $assetPath = $assetDir . '/style.css'; + @mkdir($assetDir, 0755, true); + file_put_contents($assetPath, '/* c */ body { color : red ; }'); + + $blogId = (int) $GLOBALS['_starcache_test_blog_id']; + $baseDir = WP_CONTENT_DIR . '/cache/starcache/assets/' . $blogId; + @mkdir($baseDir, 0755, true); + + $styles = new \WP_Styles(); + $styles->queue = ['theme-style']; + $styles->registered['theme-style'] = (object) [ + 'src' => WP_CONTENT_URL . '/themes/starcache-test/style.css', + 'ver' => null, + ]; + $GLOBALS['wp_styles'] = $styles; + + StarAssetMinifier::init(); + StarAssetMinifier::processStyles(); + $this->assertSame( + WP_CONTENT_URL . '/themes/starcache-test/style.css', + $styles->registered['theme-style']->src, + 'First request must keep original asset URL while build is pending.' + ); + + $scheduled = $GLOBALS['_starcache_scheduled_events']; + $this->assertNotEmpty($scheduled, 'First request should schedule an async asset build.'); + $event = end($scheduled); + $args = $event['args']; + StarAssetMinifier::buildAssetFromCron($args[0], $args[1], $args[2]); + + StarAssetMinifier::processStyles(); + $this->assertStringContainsString('.min.css', $styles->registered['theme-style']->src); + $this->assertStringContainsString('/cache/starcache/assets/' . $blogId . '/', $styles->registered['theme-style']->src); + } + + // ========================================================================= + // StarCacheAdapter — backend round-trip semantics + // ========================================================================= + + public function testBackendRoundTripRedisValues(): void + { + $this->assertBackendRoundTripValues( + StarCacheAdapter::BACKEND_REDIS, + new FakeRedisConnection(), + [false, ['a' => 1], 'hello', null] + ); + } + + public function testBackendRoundTripMemcachedValues(): void + { + $this->assertBackendRoundTripValues( + StarCacheAdapter::BACKEND_MEMCACHED, + new FakeMemcachedConnection(), + [false, ['a' => 1], 'hello', null] + ); + } + + // ========================================================================= + // Version invalidation — no backend flush + // ========================================================================= + + public function testVersionBumpInvalidatesWithoutBackendFlush(): void + { + $group = 'vtest_' . uniqid('', true); + $v1Key = StarCacheKey::build('invalidate-key', null, $group); + $this->assertTrue(StarCacheAdapter::set($v1Key, 'payload-v1', 3600, 'vtest')); + + $this->assertSame('payload-v1', StarCacheAdapter::get($v1Key, 'vtest')); + StarVersionStore::bump($group); + $v2Key = StarCacheKey::build('invalidate-key', null, $group); + $this->assertNotSame($v1Key, $v2Key); + $this->assertFalse(StarCacheAdapter::get($v2Key, 'vtest')); + $this->assertSame('payload-v1', StarCacheAdapter::get($v1Key, 'vtest')); + $this->assertSame(0, (int) ($GLOBALS['_starcache_wp_cache_flush_calls'] ?? 0)); + } + + // ========================================================================= + // Multisite isolation — page, object, query, asset + // ========================================================================= + + public function testMultisiteBlogIdIsolatesPageObjectQueryAndAssetCacheSpaces(): void + { + // Object cache space + $GLOBALS['_starcache_test_blog_id'] = 1; + $obj1 = StarCacheKey::build('obj-test'); + $GLOBALS['_starcache_test_blog_id'] = 2; + $obj2 = StarCacheKey::build('obj-test'); + $this->assertNotSame($obj1, $obj2); + + // Query cache space (existing StarQueryCache key model) + global $wpdb; + $wpdb->callCount = 0; + $sql = 'SELECT ID FROM wp_posts WHERE post_status = "publish" LIMIT 3'; + $GLOBALS['_starcache_test_blog_id'] = 1; + StarQueryCache::cachedWpdbQuery($sql); + StarQueryCache::cachedWpdbQuery($sql); + $this->assertSame(1, $wpdb->callCount); + $GLOBALS['_starcache_test_blog_id'] = 2; + StarQueryCache::cachedWpdbQuery($sql); + $this->assertSame(2, $wpdb->callCount); + + // Page cache key space + $GLOBALS['_starcache_test_blog_id'] = 1; + $page1 = $this->buildPageKeyForTest('http://localhost/sample'); + $GLOBALS['_starcache_test_blog_id'] = 2; + $page2 = $this->buildPageKeyForTest('http://localhost/sample'); + $this->assertNotSame($page1, $page2); + + // Asset cache directory space + $GLOBALS['_starcache_test_blog_id'] = 1; + StarAssetMinifier::init(); + $dir1 = $this->readAssetCacheDir(); + $GLOBALS['_starcache_test_blog_id'] = 2; + StarAssetMinifier::init(); + $dir2 = $this->readAssetCacheDir(); + $this->assertNotSame($dir1, $dir2); + } + + // ========================================================================= + // Test helpers + // ========================================================================= + + private function resetAdapterToWpFallback(): void + { + $ref = new \ReflectionClass(StarCacheAdapter::class); + foreach ( + [ + 'connection' => null, + 'detectedBackend' => StarCacheAdapter::BACKEND_WP, + 'initialised' => true, + ] as $name => $value + ) { + $prop = $ref->getProperty($name); + $prop->setAccessible(true); + $prop->setValue(null, $value); + } + } + + private function setAdapterBackendForTest(string $backend, object $connection): void + { + $ref = new \ReflectionClass(StarCacheAdapter::class); + + $connectionProperty = $ref->getProperty('connection'); + $connectionProperty->setAccessible(true); + $connectionProperty->setValue(null, $connection); + + $backendProperty = $ref->getProperty('detectedBackend'); + $backendProperty->setAccessible(true); + $backendProperty->setValue(null, $backend); + + $initialisedProperty = $ref->getProperty('initialised'); + $initialisedProperty->setAccessible(true); + $initialisedProperty->setValue(null, true); + } + + /** + * @param list $values + */ + private function assertBackendRoundTripValues(string $backend, object $connection, array $values): void + { + $this->setAdapterBackendForTest($backend, $connection); + foreach ($values as $index => $value) { + $key = 'roundtrip_' . $backend . '_' . $index; + $this->assertTrue(StarCacheAdapter::set($key, $value, 120, 'grp')); + $hit = StarCacheAdapter::getWithFound($key, 'grp'); + $this->assertTrue($hit['found'], 'Expected cache hit for ' . $backend . ' value index ' . $index); + $this->assertSame($value, $hit['value']); + } + } + + private function buildPageKeyForTest(string $url): string + { + $method = new \ReflectionMethod(StarPageCache::class, 'buildPageKeyFromUrl'); + $method->setAccessible(true); + return $method->invoke(null, $url); + } + + private function readAssetCacheDir(): string + { + $prop = new \ReflectionProperty(StarAssetMinifier::class, 'cacheDir'); + $prop->setAccessible(true); + return (string) $prop->getValue(); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b280300..3ffe51e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -25,6 +25,8 @@ // In-memory WP object cache shim // --------------------------------------------------------------------------- $GLOBALS['_starcache_wpcache'] = []; +$GLOBALS['_starcache_wp_cache_flush_calls'] = 0; +$GLOBALS['_starcache_scheduled_events'] = []; if (!function_exists('wp_cache_get')) { function wp_cache_get(string $key, string $group = '', bool $force = false, &$found = null) @@ -58,11 +60,24 @@ function wp_cache_delete(string $key, string $group = ''): bool if (!function_exists('wp_cache_flush')) { function wp_cache_flush(): bool { + $GLOBALS['_starcache_wp_cache_flush_calls']++; $GLOBALS['_starcache_wpcache'] = []; return true; } } +if (!function_exists('wp_cache_add')) { + function wp_cache_add(string $key, $data, string $group = '', int $expire = 0): bool + { + $cacheKey = $group . ':' . $key; + if (array_key_exists($cacheKey, $GLOBALS['_starcache_wpcache'])) { + return false; + } + $GLOBALS['_starcache_wpcache'][$cacheKey] = $data; + return true; + } +} + if (!function_exists('wp_cache_delete_group')) { function wp_cache_delete_group(string $group): bool { @@ -162,6 +177,13 @@ function wp_parse_url(string $url, int $component = -1) } } +if (!function_exists('wp_using_ext_object_cache')) { + function wp_using_ext_object_cache(): bool + { + return false; + } +} + if (!function_exists('wp_mkdir_p')) { function wp_mkdir_p(string $target): bool { @@ -276,6 +298,11 @@ function admin_url(string $path = ''): string if (!function_exists('wp_schedule_single_event')) { function wp_schedule_single_event(int $timestamp, string $hook, array $args = []): bool { + $GLOBALS['_starcache_scheduled_events'][] = [ + 'timestamp' => $timestamp, + 'hook' => $hook, + 'args' => $args, + ]; return true; } } @@ -283,10 +310,51 @@ function wp_schedule_single_event(int $timestamp, string $hook, array $args = [] if (!function_exists('wp_next_scheduled')) { function wp_next_scheduled(string $hook, array $args = []): int|false { + foreach ($GLOBALS['_starcache_scheduled_events'] ?? [] as $event) { + if (($event['hook'] ?? '') === $hook && ($event['args'] ?? []) === $args) { + return (int) ($event['timestamp'] ?? time()); + } + } return false; } } +if (!class_exists('WP_Dependencies')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Dependencies + { + /** @var array */ + public array $registered = []; + } +} + +if (!class_exists('WP_Styles')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Styles extends WP_Dependencies + { + /** @var string[] */ + public array $queue = []; + } +} + +if (!class_exists('WP_Scripts')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class WP_Scripts extends WP_Dependencies + { + /** @var string[] */ + public array $queue = []; + } +} + +if (!class_exists('Memcached')) { + // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace + class Memcached + { + public const RES_SUCCESS = 0; + public const RES_NOTFOUND = 16; + } +} + if (!class_exists('WP_Post')) { // phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace class WP_Post From 3b5636b6cafce11aacd46be63872ddf4f4882c27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 02:58:38 +0000 Subject: [PATCH 02/10] chore: address review follow-ups and finalize spec-alignment updates Agent-Logs-Url: https://github.com/MaximillianGroupInc/StarCache/sessions/c740e11c-fc9b-483b-b759-09f1619c08e2 Co-authored-by: MaximillianGroup <34328348+MaximillianGroup@users.noreply.github.com> --- StarAssetMinifier.php | 13 ++++++++++-- StarCache.php | 5 +++-- StarCacheAdapter.php | 46 ++++++++++++++++++++++++++++++++++++------- tests/UnitTests.php | 6 +++++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/StarAssetMinifier.php b/StarAssetMinifier.php index a9a467b..e6e3329 100644 --- a/StarAssetMinifier.php +++ b/StarAssetMinifier.php @@ -47,6 +47,9 @@ class StarAssetMinifier */ public const CRON_HOOK = 'starcache_build_asset'; + /** Run stale hashed-file cleanup in ~5% of requests. */ + private const CLEANUP_PROBABILITY_DIVISOR = 20; + /** @var string Filesystem path to the asset cache directory. */ private static string $cacheDir = ''; @@ -230,7 +233,12 @@ public static function buildAssetFromCron(string $localPath, string $destPath, s $minified = ($type === 'css') ? self::minifyCss($source) : self::normalizeJs($source); // Write atomically via unique temp file so partial writes are never visible. - $tmpPath = $destPath . '.tmp.' . uniqid('', true); + try { + $tmpPath = $destPath . '.tmp.' . bin2hex(random_bytes(8)); + } catch (\Exception $e) { + error_log('[StarCache] random_bytes() failed for asset temp name, falling back to uniqid(): ' . $e->getMessage()); + $tmpPath = $destPath . '.tmp.' . uniqid('', true); + } if (file_put_contents($tmpPath, $minified, LOCK_EX) === false) { return; } @@ -426,7 +434,8 @@ private static function isEnabled(): bool private static function cleanupStaleHashedAssets(string $safeHandle, string $currentFileName): void { // Keep cleanup lightweight in frontend hot paths. - if (random_int(1, 20) !== 1) { + // With divisor=20, this runs 1/20 requests (~5% sampling). + if (mt_rand(1, self::CLEANUP_PROBABILITY_DIVISOR) !== 1) { return; } diff --git a/StarCache.php b/StarCache.php index 6b444ac..98f9657 100644 --- a/StarCache.php +++ b/StarCache.php @@ -194,7 +194,7 @@ public function star_remember(string $reference, callable $callback, int $ttl = { $hardTtl = $ttl > 0 ? $ttl : self::CACHE_EXPIRATION_DYNAMIC; $softTtl = $this->resolveSoftTtl($hardTtl); - $swr = (bool) apply_filters('starcache_remember_swr_enabled', true); + $staleWhileRevalidate = (bool) apply_filters('starcache_remember_swr_enabled', true); $key = $this->buildKey($reference, $userId); $group = $this->star_getUserGroup($reference, $userId); @@ -217,7 +217,7 @@ public function star_remember(string $reference, callable $callback, int $ttl = } } - if ($swr) { + if ($staleWhileRevalidate) { return $entry['value']; } @@ -389,6 +389,7 @@ private function resolveSoftTtl(int $hardTtl): int { $default = max(1, (int) floor($hardTtl * self::DEFAULT_SOFT_TTL_RATIO)); $softTtl = (int) apply_filters('starcache_remember_soft_ttl', $default, $hardTtl); + // Keep soft TTL valid even for very short hard TTLs. return max(1, min($softTtl, $hardTtl)); } diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index bf7941b..7430ebe 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -42,6 +42,9 @@ class StarCacheAdapter /** @var bool */ private static bool $initialised = false; + /** @var array */ + private static array $groupHashCache = []; + /** * Initialise the adapter (idempotent – safe to call multiple times). */ @@ -300,7 +303,9 @@ public static function delete(string $key, string $group = ''): bool public static function flush(): bool { if (!defined('STARCACHE_ALLOW_DANGEROUS_FLUSH') || STARCACHE_ALLOW_DANGEROUS_FLUSH !== true) { - self::logMessage('StarCacheAdapter::flush blocked. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true to enable.'); + self::logMessage( + 'StarCacheAdapter::flush blocked. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true in wp-config.php to enable.' + ); return false; } @@ -341,15 +346,19 @@ public static function add(string $key, mixed $value, int $expiration = 30, stri switch (self::$detectedBackend) { case self::BACKEND_REDIS: if (self::isPredisConnection()) { - return self::$connection->set($storageKey, $serialised, 'EX', $expiration, 'NX') === 'OK'; + $options = ['NX']; + if ($expiration > 0) { + $options['EX'] = $expiration; + } + return self::$connection->set($storageKey, $serialised, $options) === 'OK'; } - $options = ['nx']; + $options = ['NX']; if ($expiration > 0) { - $options['ex'] = $expiration; + $options['EX'] = $expiration; } $result = self::$connection->set($storageKey, $serialised, $options); - return $result === true || $result === 'OK'; + return self::isSuccessfulSetResult($result); case self::BACKEND_MEMCACHED: return self::$connection->add($storageKey, $serialised, $expiration); @@ -530,13 +539,30 @@ private static function tryMemcache(): bool private static function buildStorageKey(string $key, string $group): string { $normalizedGroup = self::normaliseGroup($group); - return 'scg:' . substr(hash('sha256', $normalizedGroup), 0, 16) . ':' . $key; + if (!isset(self::$groupHashCache[$normalizedGroup])) { + self::$groupHashCache[$normalizedGroup] = substr(hash('sha256', $normalizedGroup), 0, 16); + } + return 'scg:' . self::$groupHashCache[$normalizedGroup] . ':' . $key; } private static function normaliseGroup(string $group): string { $group = strtolower(trim($group)); - $group = preg_replace('/[^a-z0-9_\-:]/', '', $group) ?? ''; + $sanitized = ''; + $length = strlen($group); + for ($i = 0; $i < $length; $i++) { + $char = $group[$i]; + if ( + ($char >= 'a' && $char <= 'z') + || ($char >= '0' && $char <= '9') + || $char === '_' + || $char === '-' + || $char === ':' + ) { + $sanitized .= $char; + } + } + $group = $sanitized; return $group !== '' ? $group : self::DEFAULT_GROUP; } @@ -553,6 +579,12 @@ private static function detectWpObjectCacheMode(): string return 'runtime'; } + private static function isSuccessfulSetResult(mixed $result): bool + { + // PhpRedis returns bool; Predis returns "OK"/null. + return $result === true || $result === 'OK'; + } + private static function logError(string $context, Exception $e): void { if (class_exists('\StarExceptionHandler')) { diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 312b7ea..dc0a459 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -33,7 +33,11 @@ public function setEx(string $key, int $expiration, string $value): bool public function set(string $key, mixed $value, mixed ...$options): bool|string { - if (is_array($options[0] ?? null) && in_array('nx', $options[0], true) && array_key_exists($key, $this->store)) { + if ( + is_array($options[0] ?? null) + && (in_array('NX', $options[0], true) || in_array('nx', $options[0], true)) + && array_key_exists($key, $this->store) + ) { return false; } $this->store[$key] = (string) $value; From 404535b922fe4480f02dfba763167625467cd612 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:12:17 +0000 Subject: [PATCH 03/10] Address review revisions for adapter hardening and docs --- README.md | 3 ++ StarAssetMinifier.php | 1 + StarCache.php | 5 ++-- StarCacheAdapter.php | 44 ++++++++++++++++++++++++---- tests/UnitTests.php | 68 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 113 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9738a32..709834a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ $data = $cache->star_getCachedData('user_profile', '42'); $cache->star_deleteCachedData('user_profile', '42'); // Cache-aside (get-or-set pattern) +// Uses lock-based soft-TTL stale protection (not full background SWR). $posts = $cache->star_remember('homepage_posts', function () { return get_posts(['numberposts' => 10]); }, 300); @@ -181,6 +182,8 @@ define('STARCACHE_ALLOW_DANGEROUS_FLUSH', true); ``` Without this constant, `StarCacheAdapter::flush()` returns `false` and logs a warning. +Treat `StarCacheAdapter::flush()` as local dev/test tooling only. In production, +invalidate via version bumping plus edge/cache-layer purge flows instead of backend flushes. --- diff --git a/StarAssetMinifier.php b/StarAssetMinifier.php index e6e3329..8a80160 100644 --- a/StarAssetMinifier.php +++ b/StarAssetMinifier.php @@ -435,6 +435,7 @@ private static function cleanupStaleHashedAssets(string $safeHandle, string $cur { // Keep cleanup lightweight in frontend hot paths. // With divisor=20, this runs 1/20 requests (~5% sampling). + // TODO: Move this cleanup path to a scheduled cron job. if (mt_rand(1, self::CLEANUP_PROBABILITY_DIVISOR) !== 1) { return; } diff --git a/StarCache.php b/StarCache.php index 98f9657..a68d75e 100644 --- a/StarCache.php +++ b/StarCache.php @@ -181,8 +181,9 @@ public function star_flushReloadCachedData(string $reference, ?string $userId = /** * Get or set a cached value using a callback (cache-aside pattern). * - * Returns the cached value if available; otherwise calls $callback, - * stores the result, and returns it. + * Uses lock-based soft-TTL stale protection. When stale data exists, the + * lock holder recomputes synchronously while other callers can reuse stale + * data (or briefly wait when stale reuse is disabled via filter). * * @param string $reference * @param callable $callback Must return the value to cache. diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 7430ebe..721aaae 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -298,7 +298,7 @@ public static function delete(string $key, string $group = ''): bool } /** - * Flush all cache entries (use with care in shared environments). + * Flush all cache entries (dangerous; intended for local dev/test only). */ public static function flush(): bool { @@ -467,9 +467,15 @@ private static function tryPredis(): bool $params['password'] = $password; } - $client = new \Predis\Client($params, ['exceptions' => false]); - $pong = $client->ping(); - if ($pong === null || $pong === false) { + try { + $client = new \Predis\Client($params, ['exceptions' => false]); + $pong = $client->ping(); + } catch (Exception $e) { + self::logError('StarCacheAdapter::tryPredis', $e); + return false; + } + + if (!self::isSuccessfulPredisPing($pong)) { return false; } @@ -539,7 +545,7 @@ private static function tryMemcache(): bool private static function buildStorageKey(string $key, string $group): string { $normalizedGroup = self::normaliseGroup($group); - if (!isset(self::$groupHashCache[$normalizedGroup])) { + if (!array_key_exists($normalizedGroup, self::$groupHashCache)) { self::$groupHashCache[$normalizedGroup] = substr(hash('sha256', $normalizedGroup), 0, 16); } return 'scg:' . self::$groupHashCache[$normalizedGroup] . ':' . $key; @@ -585,6 +591,34 @@ private static function isSuccessfulSetResult(mixed $result): bool return $result === true || $result === 'OK'; } + private static function isSuccessfulPredisPing(mixed $pong): bool + { + if ($pong === true) { + return true; + } + + if (is_string($pong)) { + return strtoupper(trim($pong, " \t\n\r\0\x0B+")) === 'PONG'; + } + + if (!is_object($pong)) { + return false; + } + + if (method_exists($pong, 'getPayload')) { + $payload = $pong->getPayload(); + if (is_string($payload)) { + return strtoupper(trim($payload, " \t\n\r\0\x0B+")) === 'PONG'; + } + } + + if (method_exists($pong, '__toString')) { + return strtoupper(trim((string) $pong, " \t\n\r\0\x0B+")) === 'PONG'; + } + + return false; + } + private static function logError(string $context, Exception $e): void { if (class_exists('\StarExceptionHandler')) { diff --git a/tests/UnitTests.php b/tests/UnitTests.php index dc0a459..f895783 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -100,6 +100,22 @@ public function delete(string $key): bool } } +class FakePredisPongResponse +{ + public function getPayload(): string + { + return 'PONG'; + } +} + +class FakePredisErrorResponse +{ + public function getPayload(): string + { + return 'NOAUTH Authentication required.'; + } +} + /** * StarCache v2.1.1 Test Suite * @@ -763,6 +779,35 @@ public function testRememberCachesFalseValue(): void $this->assertSame(1, $callCount, 'False values must be cached and reused.'); } + public function testRememberLockContentionServesStaleWithoutSecondCallbackRun(): void + { + $cache = new StarCache(); + $callCount = 0; + $callback = static function () use (&$callCount): array { + $callCount++; + return ['computed' => $callCount]; + }; + + $first = $cache->star_remember('remember_lock_contention', $callback, 2); + usleep(1100000); // Let soft TTL (floor(2 * 0.9) = 1s) become stale. + + $keyMethod = new \ReflectionMethod(StarCache::class, 'buildKey'); + $keyMethod->setAccessible(true); + $key = $keyMethod->invoke($cache, 'remember_lock_contention', null); + + $lockMethod = new \ReflectionMethod(StarCache::class, 'buildRememberLockKey'); + $lockMethod->setAccessible(true); + $lockKey = $lockMethod->invoke($cache, $key); + + $group = $cache->star_getUserGroup('remember_lock_contention', null); + $this->assertTrue(StarCacheAdapter::add($lockKey, 1, 30, $group)); + + $second = $cache->star_remember('remember_lock_contention', $callback, 2); + + $this->assertSame($first, $second); + $this->assertSame(1, $callCount, 'Callback should not run again while another lock holder is refreshing.'); + } + public function testGetCachedDataFoundFlagDistinguishesFalseHitFromMiss(): void { $cache = new StarCache(); @@ -969,6 +1014,27 @@ public function testBackendRoundTripMemcachedValues(): void ); } + public function testPredisPingHelperAcceptsValidPongResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulPredisPing'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, 'PONG')); + $this->assertTrue($method->invoke(null, '+PONG')); + $this->assertTrue($method->invoke(null, new FakePredisPongResponse())); + } + + public function testPredisPingHelperRejectsErrorLikeResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulPredisPing'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, null)); + $this->assertFalse($method->invoke(null, false)); + $this->assertFalse($method->invoke(null, 'NOAUTH Authentication required.')); + $this->assertFalse($method->invoke(null, new FakePredisErrorResponse())); + } + // ========================================================================= // Version invalidation — no backend flush // ========================================================================= @@ -992,7 +1058,7 @@ public function testVersionBumpInvalidatesWithoutBackendFlush(): void // Multisite isolation — page, object, query, asset // ========================================================================= - public function testMultisiteBlogIdIsolatesPageObjectQueryAndAssetCacheSpaces(): void + public function testMultisiteBlogIdIsolatesPageObjectLegacyQueryAndAssetCacheSpaces(): void { // Object cache space $GLOBALS['_starcache_test_blog_id'] = 1; From f29ca40cf55e059df73d9d154bbd77479a4d7ea2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:14:04 +0000 Subject: [PATCH 04/10] Refine review follow-ups for Predis ping and test clarity --- StarCacheAdapter.php | 7 ++++--- tests/UnitTests.php | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 721aaae..4cd10ef 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -32,6 +32,7 @@ class StarCacheAdapter public const BACKEND_WP = 'wp'; private const DEFAULT_GROUP = 'default'; + private const PONG_TRIM_CHARS = " \t\n\r\0\x0B+"; /** @var \Redis|\Predis\Client|\Memcached|\Memcache|null */ private static $connection = null; @@ -598,7 +599,7 @@ private static function isSuccessfulPredisPing(mixed $pong): bool } if (is_string($pong)) { - return strtoupper(trim($pong, " \t\n\r\0\x0B+")) === 'PONG'; + return strtoupper(trim($pong, self::PONG_TRIM_CHARS)) === 'PONG'; } if (!is_object($pong)) { @@ -608,12 +609,12 @@ private static function isSuccessfulPredisPing(mixed $pong): bool if (method_exists($pong, 'getPayload')) { $payload = $pong->getPayload(); if (is_string($payload)) { - return strtoupper(trim($payload, " \t\n\r\0\x0B+")) === 'PONG'; + return strtoupper(trim($payload, self::PONG_TRIM_CHARS)) === 'PONG'; } } if (method_exists($pong, '__toString')) { - return strtoupper(trim((string) $pong, " \t\n\r\0\x0B+")) === 'PONG'; + return strtoupper(trim((string) $pong, self::PONG_TRIM_CHARS)) === 'PONG'; } return false; diff --git a/tests/UnitTests.php b/tests/UnitTests.php index f895783..5fac2da 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -133,6 +133,8 @@ public function getPayload(): string */ class UnitTests extends TestCase { + private const SOFT_TTL_WAIT_MICROSECONDS = 1100000; + protected function setUp(): void { parent::setUp(); @@ -789,7 +791,7 @@ public function testRememberLockContentionServesStaleWithoutSecondCallbackRun(): }; $first = $cache->star_remember('remember_lock_contention', $callback, 2); - usleep(1100000); // Let soft TTL (floor(2 * 0.9) = 1s) become stale. + usleep(self::SOFT_TTL_WAIT_MICROSECONDS); // Let soft TTL (floor(2 * 0.9) = 1s) become stale. $keyMethod = new \ReflectionMethod(StarCache::class, 'buildKey'); $keyMethod->setAccessible(true); @@ -1058,7 +1060,7 @@ public function testVersionBumpInvalidatesWithoutBackendFlush(): void // Multisite isolation — page, object, query, asset // ========================================================================= - public function testMultisiteBlogIdIsolatesPageObjectLegacyQueryAndAssetCacheSpaces(): void + public function testMultisiteBlogIdIsolatesPageObjectDeprecatedQueryAndAssetCacheSpaces(): void { // Object cache space $GLOBALS['_starcache_test_blog_id'] = 1; From eaa4a0546239dbe05e94995a8ef3a4ec1bb70b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:19:12 +0000 Subject: [PATCH 05/10] fix: address PR review follow-up items --- README.md | 3 ++- StarCacheAdapter.php | 33 +++++++++++++++++++++++++++++++-- tests/UnitTests.php | 4 ++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 709834a..4f96005 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,8 @@ the WordPress admin bar for administrators. ### Dangerous flush guard -Global backend flush operations are blocked by default. To allow them explicitly: +Global backend flush operations are blocked by default and intended only for development/testing. +In production, prefer version bump invalidation and edge purge flows. To allow a backend flush explicitly: ```php define('STARCACHE_ALLOW_DANGEROUS_FLUSH', true); diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 4cd10ef..b7a0b8a 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -61,17 +61,29 @@ public static function init(): void if (self::tryRedis()) { return; } + } catch (Exception $e) { + self::logError('StarCacheAdapter init redis probe error', $e); + } + try { if (self::tryPredis()) { return; } + } catch (Exception $e) { + self::logError('StarCacheAdapter init predis probe error', $e); + } + try { if (self::tryMemcached()) { return; } + } catch (Exception $e) { + self::logError('StarCacheAdapter init memcached probe error', $e); + } + try { if (self::tryMemcache()) { return; } } catch (Exception $e) { - self::logError('StarCacheAdapter init error', $e); + self::logError('StarCacheAdapter init memcache probe error', $e); } // Fallback: WordPress built-in object cache (wp_cache_*) @@ -305,7 +317,7 @@ public static function flush(): bool { if (!defined('STARCACHE_ALLOW_DANGEROUS_FLUSH') || STARCACHE_ALLOW_DANGEROUS_FLUSH !== true) { self::logMessage( - 'StarCacheAdapter::flush blocked. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true in wp-config.php to enable.' + 'StarCacheAdapter::flush blocked. This is intended for development/testing only; use version bumps in production. Define STARCACHE_ALLOW_DANGEROUS_FLUSH=true in wp-config.php to enable.' ); return false; } @@ -479,6 +491,23 @@ private static function tryPredis(): bool if (!self::isSuccessfulPredisPing($pong)) { return false; } + if (is_string($pong) && strtoupper(trim($pong)) !== 'PONG') { + return false; + } + if (is_object($pong)) { + if (method_exists($pong, 'getPayload')) { + $payload = $pong->getPayload(); + if (!is_string($payload) || strtoupper(trim($payload)) !== 'PONG') { + return false; + } + } elseif (method_exists($pong, '__toString')) { + if (strtoupper(trim((string) $pong)) !== 'PONG') { + return false; + } + } else { + return false; + } + } self::$connection = $client; self::$detectedBackend = self::BACKEND_REDIS; diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 5fac2da..b495223 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -129,7 +129,7 @@ public function getPayload(): string * - StarCacheContext: registration, resolution, constraints, locking, hash * - StarResponseController: eligibility checks * - StarVersionStore: version get/bump/reset with renamed groups - * - StarQueryCache: key determinism, version invalidation + * - StarQueryCache (legacy utility): key determinism, version invalidation */ class UnitTests extends TestCase { @@ -853,7 +853,7 @@ public function testIsOpcacheEnabledReturnsBool(): void } // ========================================================================= - // StarQueryCache — key determinism + version invalidation + // StarQueryCache (legacy utility) — key determinism + version invalidation // ========================================================================= public function testCachedWpdbQueryKeyIsDeterministic(): void From 74a6122abadd1f25ee093df7250a9408c09baf7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:27:27 +0000 Subject: [PATCH 06/10] fix: harden Predis status handling and review follow-up cleanups --- StarCache.php | 2 +- StarCacheAdapter.php | 31 +++++++++++++++++++++------ StarCacheContext.php | 6 +++--- StarQueryCache.php | 2 +- tests/UnitTests.php | 50 ++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/StarCache.php b/StarCache.php index a68d75e..3acfb6e 100644 --- a/StarCache.php +++ b/StarCache.php @@ -316,7 +316,7 @@ public function star_closeConnections(bool $isStatic = false, ?string $cacheKey if (!$isStatic && $cacheKey) { global $wpdb; - if (!isset($wpdb)) { + if (!is_object($wpdb)) { return; } diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index b7a0b8a..8aff0b1 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -245,12 +245,12 @@ public static function set(string $key, mixed $value, int $expiration = 3600, st $serialised = serialize($value); if ($expiration > 0) { if (self::isPredisConnection()) { - return self::$connection->setex($storageKey, $expiration, $serialised) === 'OK'; + return self::isSuccessfulSetResult(self::$connection->setex($storageKey, $expiration, $serialised)); } return (bool) self::$connection->setEx($storageKey, $expiration, $serialised); } if (self::isPredisConnection()) { - return self::$connection->set($storageKey, $serialised) === 'OK'; + return self::isSuccessfulSetResult(self::$connection->set($storageKey, $serialised)); } return (bool) self::$connection->set($storageKey, $serialised); @@ -326,7 +326,7 @@ public static function flush(): bool switch (self::$detectedBackend) { case self::BACKEND_REDIS: if (self::isPredisConnection()) { - return self::$connection->flushdb() === 'OK'; + return self::isSuccessfulSetResult(self::$connection->flushdb()); } return (bool) self::$connection->flushDB(); @@ -363,7 +363,7 @@ public static function add(string $key, mixed $value, int $expiration = 30, stri if ($expiration > 0) { $options['EX'] = $expiration; } - return self::$connection->set($storageKey, $serialised, $options) === 'OK'; + return self::isSuccessfulSetResult(self::$connection->set($storageKey, $serialised, $options)); } $options = ['NX']; @@ -617,8 +617,27 @@ private static function detectWpObjectCacheMode(): string private static function isSuccessfulSetResult(mixed $result): bool { - // PhpRedis returns bool; Predis returns "OK"/null. - return $result === true || $result === 'OK'; + // PhpRedis returns bool; Predis may return "OK" or status response objects. + if ($result === true) { + return true; + } + + if (is_string($result)) { + return strtoupper(trim($result, self::PONG_TRIM_CHARS)) === 'OK'; + } + + if (is_object($result) && method_exists($result, 'getPayload')) { + $payload = $result->getPayload(); + if (is_string($payload)) { + return strtoupper(trim($payload, self::PONG_TRIM_CHARS)) === 'OK'; + } + } + + if (is_object($result) && method_exists($result, '__toString')) { + return strtoupper(trim((string) $result, self::PONG_TRIM_CHARS)) === 'OK'; + } + + return false; } private static function isSuccessfulPredisPing(mixed $pong): bool diff --git a/StarCacheContext.php b/StarCacheContext.php index aa12a8f..d00b01d 100644 --- a/StarCacheContext.php +++ b/StarCacheContext.php @@ -183,13 +183,13 @@ public static function resolve(): void self::$resolved = true; // Ensure built-in dimensions are registered. - if (!isset(self::$registered[self::DIM_AUTH])) { + if (!array_key_exists(self::DIM_AUTH, self::$registered)) { self::$registered[self::DIM_AUTH] = [self::AUTH_AUTHENTICATED, self::AUTH_ANONYMOUS]; } - if (!isset(self::$registered[self::DIM_DEVICE])) { + if (!array_key_exists(self::DIM_DEVICE, self::$registered)) { self::$registered[self::DIM_DEVICE] = [self::DEVICE_MOBILE, self::DEVICE_TABLET, self::DEVICE_DESKTOP]; } - if (!isset(self::$registered[self::DIM_EXPERIMENT])) { + if (!array_key_exists(self::DIM_EXPERIMENT, self::$registered)) { self::$registered[self::DIM_EXPERIMENT] = []; // any sanitized value } diff --git a/StarQueryCache.php b/StarQueryCache.php index d693e07..af143c2 100644 --- a/StarQueryCache.php +++ b/StarQueryCache.php @@ -67,7 +67,7 @@ public static function postsPreQuery(?array $posts, \WP_Query $query): ?array } // Restore found_posts and max_num_pages from cached meta - if (isset($cached['found_posts'])) { + if (array_key_exists('found_posts', $cached)) { $query->found_posts = (int) $cached['found_posts']; $query->max_num_pages = (int) ($cached['max_num_pages'] ?? 1); } diff --git a/tests/UnitTests.php b/tests/UnitTests.php index b495223..348164b 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -116,6 +116,22 @@ public function getPayload(): string } } +class FakePredisOkResponse +{ + public function getPayload(): string + { + return 'OK'; + } +} + +class FakePredisStringOkResponse +{ + public function __toString(): string + { + return 'OK'; + } +} + /** * StarCache v2.1.1 Test Suite * @@ -791,7 +807,7 @@ public function testRememberLockContentionServesStaleWithoutSecondCallbackRun(): }; $first = $cache->star_remember('remember_lock_contention', $callback, 2); - usleep(self::SOFT_TTL_WAIT_MICROSECONDS); // Let soft TTL (floor(2 * 0.9) = 1s) become stale. + usleep(self::SOFT_TTL_WAIT_MICROSECONDS); // Let soft TTL (floor(2 * 0.8) = 1s) become stale. $keyMethod = new \ReflectionMethod(StarCache::class, 'buildKey'); $keyMethod->setAccessible(true); @@ -960,12 +976,16 @@ public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile( { $assetDir = WP_CONTENT_DIR . '/themes/starcache-test'; $assetPath = $assetDir . '/style.css'; - @mkdir($assetDir, 0755, true); - file_put_contents($assetPath, '/* c */ body { color : red ; }'); + if (!is_dir($assetDir)) { + $this->assertTrue(mkdir($assetDir, 0755, true)); + } + $this->assertNotFalse(file_put_contents($assetPath, '/* c */ body { color : red ; }')); $blogId = (int) $GLOBALS['_starcache_test_blog_id']; $baseDir = WP_CONTENT_DIR . '/cache/starcache/assets/' . $blogId; - @mkdir($baseDir, 0755, true); + if (!is_dir($baseDir)) { + $this->assertTrue(mkdir($baseDir, 0755, true)); + } $styles = new \WP_Styles(); $styles->queue = ['theme-style']; @@ -1037,6 +1057,28 @@ public function testPredisPingHelperRejectsErrorLikeResponses(): void $this->assertFalse($method->invoke(null, new FakePredisErrorResponse())); } + public function testPredisSetResultHelperAcceptsStatusResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulSetResult'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, 'OK')); + $this->assertTrue($method->invoke(null, '+OK')); + $this->assertTrue($method->invoke(null, new FakePredisOkResponse())); + $this->assertTrue($method->invoke(null, new FakePredisStringOkResponse())); + } + + public function testPredisSetResultHelperRejectsErrorResponses(): void + { + $method = new \ReflectionMethod(StarCacheAdapter::class, 'isSuccessfulSetResult'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, null)); + $this->assertFalse($method->invoke(null, false)); + $this->assertFalse($method->invoke(null, 'NOAUTH Authentication required.')); + $this->assertFalse($method->invoke(null, new FakePredisErrorResponse())); + } + // ========================================================================= // Version invalidation — no backend flush // ========================================================================= From fb3d1c76d99f3f3347035d03e79f16c4708990ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:27:44 +0000 Subject: [PATCH 07/10] Remove redundant Predis ping checks --- StarCacheAdapter.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 8aff0b1..407cbe4 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -491,23 +491,6 @@ private static function tryPredis(): bool if (!self::isSuccessfulPredisPing($pong)) { return false; } - if (is_string($pong) && strtoupper(trim($pong)) !== 'PONG') { - return false; - } - if (is_object($pong)) { - if (method_exists($pong, 'getPayload')) { - $payload = $pong->getPayload(); - if (!is_string($payload) || strtoupper(trim($payload)) !== 'PONG') { - return false; - } - } elseif (method_exists($pong, '__toString')) { - if (strtoupper(trim((string) $pong)) !== 'PONG') { - return false; - } - } else { - return false; - } - } self::$connection = $client; self::$detectedBackend = self::BACKEND_REDIS; From 076b67e7e10f4f70ab44688ce2d9cbf6d6361aee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 01:23:16 +0000 Subject: [PATCH 08/10] test: add assertions for filesystem operations in asset minifier tests --- tests/UnitTests.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 348164b..d91ac07 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -620,12 +620,12 @@ public function testMinifyJsTrimsSurroundingWhitespace(): void public function testBuildAssetFromCronWritesMinifiedCssFile(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/style.css'; $destPath = $dir . '/style.min.css'; - file_put_contents($srcPath, "/* comment */ body { color : red ; } "); + $this->assertNotFalse(file_put_contents($srcPath, "/* comment */ body { color : red ; } ")); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'css'); @@ -643,12 +643,12 @@ public function testBuildAssetFromCronWritesMinifiedCssFile(): void public function testBuildAssetFromCronWritesNormalizedJsFile(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/app.js'; $destPath = $dir . '/app.min.js'; - file_put_contents($srcPath, " var x = 1; \n"); + $this->assertNotFalse(file_put_contents($srcPath, " var x = 1; \n")); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'js'); @@ -665,13 +665,13 @@ public function testBuildAssetFromCronWritesNormalizedJsFile(): void public function testBuildAssetFromCronSkipsWhenDestAlreadyExists(): void { $dir = sys_get_temp_dir() . '/starcache_test_' . uniqid('', true); - mkdir($dir, 0755, true); + $this->assertTrue(mkdir($dir, 0755, true)); $srcPath = $dir . '/style.css'; $destPath = $dir . '/style.min.css'; - file_put_contents($srcPath, 'body { color: blue }'); - file_put_contents($destPath, 'original_content'); + $this->assertNotFalse(file_put_contents($srcPath, 'body { color: blue }')); + $this->assertNotFalse(file_put_contents($destPath, 'original_content')); StarAssetMinifier::buildAssetFromCron($srcPath, $destPath, 'css'); From cabc9e677f9d9994d3f8678a14c18a2eab1bf8ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 01:26:34 +0000 Subject: [PATCH 09/10] tests: add descriptive failure messages to mkdir assertions --- tests/UnitTests.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/UnitTests.php b/tests/UnitTests.php index d91ac07..4ee9b9f 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -977,14 +977,14 @@ public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile( $assetDir = WP_CONTENT_DIR . '/themes/starcache-test'; $assetPath = $assetDir . '/style.css'; if (!is_dir($assetDir)) { - $this->assertTrue(mkdir($assetDir, 0755, true)); + $this->assertTrue(mkdir($assetDir, 0755, true), 'Failed to create asset directory: ' . $assetDir); } $this->assertNotFalse(file_put_contents($assetPath, '/* c */ body { color : red ; }')); $blogId = (int) $GLOBALS['_starcache_test_blog_id']; $baseDir = WP_CONTENT_DIR . '/cache/starcache/assets/' . $blogId; if (!is_dir($baseDir)) { - $this->assertTrue(mkdir($baseDir, 0755, true)); + $this->assertTrue(mkdir($baseDir, 0755, true), 'Failed to create cache base directory: ' . $baseDir); } $styles = new \WP_Styles(); From 66ed5b92abd51e89952c144c853cbfe7e6431a94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 01:55:36 +0000 Subject: [PATCH 10/10] Fix hard TTL enforcement, update $group docblocks, make asset test deterministic --- StarCache.php | 29 +++++++++++++++++------------ StarCacheAdapter.php | 8 ++++---- tests/UnitTests.php | 27 ++++++++++++++++++--------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/StarCache.php b/StarCache.php index 3acfb6e..0a16e25 100644 --- a/StarCache.php +++ b/StarCache.php @@ -218,14 +218,16 @@ public function star_remember(string $reference, callable $callback, int $ttl = } } - if ($staleWhileRevalidate) { + if ($staleWhileRevalidate && !$entry['isPastHardTtl']) { return $entry['value']; } $reloaded = $this->waitForRememberRefresh($key, $group); if ($reloaded['found']) { $latest = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); - return $latest['value']; + if (!$latest['isPastHardTtl']) { + return $latest['value']; + } } } @@ -250,7 +252,9 @@ public function star_remember(string $reference, callable $callback, int $ttl = $reloaded = $this->waitForRememberRefresh($key, $group); if ($reloaded['found']) { $entry = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl); - return $entry['value']; + if (!$entry['isPastHardTtl']) { + return $entry['value']; + } } $value = $callback(); @@ -353,7 +357,7 @@ private function buildRememberLockKey(string $key): string } /** - * @return array{value:mixed,isFresh:bool} + * @return array{value:mixed,isFresh:bool,isPastHardTtl:bool} */ private function normalizeRememberEntry(mixed $raw, int $softTtl, int $hardTtl): array { @@ -362,16 +366,17 @@ private function normalizeRememberEntry(mixed $raw, int $softTtl, int $hardTtl): && ($raw['marker'] ?? '') === self::REMEMBER_ENVELOPE_V1 && array_key_exists('value', $raw) ) { - $createdAt = (int) ($raw['created_at'] ?? 0); - $soft = max(1, (int) ($raw['soft_ttl'] ?? $softTtl)); - $hard = max($soft, (int) ($raw['hard_ttl'] ?? $hardTtl)); - $age = max(0, time() - $createdAt); - $fresh = $age < $soft; - - return ['value' => $raw['value'], 'isFresh' => $fresh && $age < $hard]; + $createdAt = (int) ($raw['created_at'] ?? 0); + $soft = max(1, (int) ($raw['soft_ttl'] ?? $softTtl)); + $hard = max($soft, (int) ($raw['hard_ttl'] ?? $hardTtl)); + $age = max(0, time() - $createdAt); + $fresh = $age < $soft; + $isPastHardTtl = $age >= $hard; + + return ['value' => $raw['value'], 'isFresh' => $fresh && !$isPastHardTtl, 'isPastHardTtl' => $isPastHardTtl]; } - return ['value' => $raw, 'isFresh' => true]; + return ['value' => $raw, 'isFresh' => true, 'isPastHardTtl' => false]; } private function storeRememberEntry(string $key, string $group, mixed $value, int $softTtl, int $hardTtl): bool diff --git a/StarCacheAdapter.php b/StarCacheAdapter.php index 407cbe4..8374bff 100644 --- a/StarCacheAdapter.php +++ b/StarCacheAdapter.php @@ -148,7 +148,7 @@ public static function isOpcacheEnabled(): bool * Retrieve a value from the active cache backend. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return mixed */ public static function get(string $key, string $group = ''): mixed @@ -161,7 +161,7 @@ public static function get(string $key, string $group = ''): mixed * Retrieve a value plus an explicit hit/miss indicator. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return array{found: bool, value: mixed} */ public static function getWithFound(string $key, string $group = ''): array @@ -233,7 +233,7 @@ public static function getWithFound(string $key, string $group = ''): array * @param string $key * @param mixed $value * @param int $expiration Seconds (0 = no expiry for WP/Redis). - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. * @return bool */ public static function set(string $key, mixed $value, int $expiration = 3600, string $group = ''): bool @@ -286,7 +286,7 @@ public static function set(string $key, mixed $value, int $expiration = 3600, st * Delete a cached value. * * @param string $key - * @param string $group Used only by the WP object cache. + * @param string $group Used for namespacing across all cache backends; maps to a WP cache group for the WP object cache backend. */ public static function delete(string $key, string $group = ''): bool { diff --git a/tests/UnitTests.php b/tests/UnitTests.php index 4ee9b9f..dfe0e81 100644 --- a/tests/UnitTests.php +++ b/tests/UnitTests.php @@ -974,8 +974,11 @@ public function testQueryCacheVersionGroupQueriesDefaultsToOne(): void public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile(): void { - $assetDir = WP_CONTENT_DIR . '/themes/starcache-test'; - $assetPath = $assetDir . '/style.css'; + $uniqueSuffix = uniqid('', true); + $themeName = 'starcache-test-' . $uniqueSuffix; + $assetHandle = 'theme-style-' . $uniqueSuffix; + $assetDir = WP_CONTENT_DIR . '/themes/' . $themeName; + $assetPath = $assetDir . '/style.css'; if (!is_dir($assetDir)) { $this->assertTrue(mkdir($assetDir, 0755, true), 'Failed to create asset directory: ' . $assetDir); } @@ -987,10 +990,16 @@ public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile( $this->assertTrue(mkdir($baseDir, 0755, true), 'Failed to create cache base directory: ' . $baseDir); } + // Remove any leftover cached file for this handle so the first request is always a miss. + $cachedPattern = $baseDir . '/' . $assetHandle . '.*'; + foreach (glob($cachedPattern) ?: [] as $staleFile) { + @unlink($staleFile); + } + $styles = new \WP_Styles(); - $styles->queue = ['theme-style']; - $styles->registered['theme-style'] = (object) [ - 'src' => WP_CONTENT_URL . '/themes/starcache-test/style.css', + $styles->queue = [$assetHandle]; + $styles->registered[$assetHandle] = (object) [ + 'src' => WP_CONTENT_URL . '/themes/' . $themeName . '/style.css', 'ver' => null, ]; $GLOBALS['wp_styles'] = $styles; @@ -998,8 +1007,8 @@ public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile( StarAssetMinifier::init(); StarAssetMinifier::processStyles(); $this->assertSame( - WP_CONTENT_URL . '/themes/starcache-test/style.css', - $styles->registered['theme-style']->src, + WP_CONTENT_URL . '/themes/' . $themeName . '/style.css', + $styles->registered[$assetHandle]->src, 'First request must keep original asset URL while build is pending.' ); @@ -1010,8 +1019,8 @@ public function testAssetFirstRequestServesOriginalThenServesStoredMinifiedFile( StarAssetMinifier::buildAssetFromCron($args[0], $args[1], $args[2]); StarAssetMinifier::processStyles(); - $this->assertStringContainsString('.min.css', $styles->registered['theme-style']->src); - $this->assertStringContainsString('/cache/starcache/assets/' . $blogId . '/', $styles->registered['theme-style']->src); + $this->assertStringContainsString('.min.css', $styles->registered[$assetHandle]->src); + $this->assertStringContainsString('/cache/starcache/assets/' . $blogId . '/', $styles->registered[$assetHandle]->src); } // =========================================================================