Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -154,7 +155,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
Expand All @@ -163,6 +164,28 @@ 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 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);
```

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.

---

## Filters and hooks
Expand Down Expand Up @@ -201,4 +224,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).

48 changes: 44 additions & 4 deletions StarAssetMinifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';

Expand Down Expand Up @@ -229,12 +232,21 @@ 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.
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;
}

if (!@rename($tmpPath, $destPath)) {
@unlink($tmpPath);
return;
}
rename($tmpPath, $destPath);
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -314,6 +326,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;
Expand Down Expand Up @@ -413,4 +427,30 @@ 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.
// 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;
}

$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);
}
}
}
151 changes: 142 additions & 9 deletions StarCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ------------------------------------------------------------------
Expand Down Expand Up @@ -169,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.
Expand All @@ -180,15 +193,72 @@ 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);
$staleWhileRevalidate = (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 ($staleWhileRevalidate && !$entry['isPastHardTtl']) {
return $entry['value'];
}

$reloaded = $this->waitForRememberRefresh($key, $group);
if ($reloaded['found']) {
$latest = $this->normalizeRememberEntry($reloaded['value'], $softTtl, $hardTtl);
if (!$latest['isPastHardTtl']) {
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);
if (!$entry['isPastHardTtl']) {
return $entry['value'];
}
}
Comment thread
MaximillianGroup marked this conversation as resolved.

$value = $callback();
$this->storeRememberEntry($key, $group, $value, $softTtl, $hardTtl);
return $value;
}

Expand Down Expand Up @@ -250,7 +320,7 @@ public function star_closeConnections(bool $isStatic = false, ?string $cacheKey

if (!$isStatic && $cacheKey) {
global $wpdb;
if (!isset($wpdb)) {
if (!is_object($wpdb)) {
return;
}

Expand Down Expand Up @@ -281,6 +351,69 @@ 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,isPastHardTtl: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;
$isPastHardTtl = $age >= $hard;

return ['value' => $raw['value'], 'isFresh' => $fresh && !$isPastHardTtl, 'isPastHardTtl' => $isPastHardTtl];
}

return ['value' => $raw, 'isFresh' => true, 'isPastHardTtl' => false];
}

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);
// Keep soft TTL valid even for very short hard TTLs.
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().
*
Expand Down
Loading