diff --git a/README.md b/README.md index 4d543fd..ababf35 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,28 @@ # Laravel SmartCache [](https://packagist.org/packages/iazaran/smart-cache) +[](https://packagist.org/packages/iazaran/smart-cache) +[](https://github.com/iazaran/smart-cache) [](https://packagist.org/packages/iazaran/smart-cache) [](https://packagist.org/packages/iazaran/smart-cache) -[](https://github.com/iazaran/smart-cache/actions) +[](https://github.com/iazaran/smart-cache/actions) -A drop-in replacement for Laravel's `Cache` facade that automatically compresses, chunks, and optimizes cached data. Implements `Illuminate\Contracts\Cache\Repository` and PSR-16 `SimpleCache` — your existing code works unchanged. +A drop-in replacement for Laravel's `Cache` facade that automatically compresses, chunks, and optimizes cached data — with write deduplication, self-healing recovery, and cost-aware eviction built in. Implements `Illuminate\Contracts\Cache\Repository` and PSR-16 `SimpleCache`; your existing code works unchanged. **PHP 8.1+ · Laravel 8–12 · All cache drivers** +## Why SmartCache? + +| Concern | Without SmartCache | With SmartCache | +|---|---|---| +| Large payloads (100 KB+) | Stored as-is, slow reads | Auto-compressed & chunked | +| Redundant writes | Every `put()` hits the store | Skipped when content is unchanged (write deduplication) | +| Corrupted entries | Exception propagates to users | Auto-evicted and regenerated (self-healing) | +| Eviction strategy | LRU / random | Cost-aware scoring — keeps high-value keys | +| Cache stampede | Thundering herd on expiry | XFetch, jitter, and rate limiting | +| Conditional caching | Manual `if` checks around `put()` | `rememberIf()` — one-liner | +| Monitoring | DIY logging | Built-in dashboard, metrics, and health checks | + ## Installation ```bash @@ -177,6 +191,49 @@ SmartCache::cacheValue('analytics'); SmartCache::suggestEvictions(5); // lowest-value entries to remove first ``` +### Write Deduplication (Cache DNA) + +SmartCache hashes every value before writing. When the stored content is identical, the write is skipped entirely — eliminating redundant I/O for frequently refreshed but rarely changing data (configuration, feature flags, rate-limit counters). + +```php +// Frequent cron refreshes? Only the first write hits the store. +SmartCache::put('app_config', Config::all(), 3600); +// Second call with the same data → no I/O, returns true immediately +SmartCache::put('app_config', Config::all(), 3600); +``` + +Enabled by default. Disable per-environment: + +```php +'deduplication' => ['enabled' => false], +``` + +### Conditional Caching (`rememberIf`) + +Cache values only when a condition is met. The callback always executes, but the result is stored only if the condition returns `true` — useful for filtering out empty or invalid API responses. + +```php +$data = SmartCache::rememberIf('external_api', 3600, + fn() => Http::get('https://api.example.com/data')->json(), + fn($value) => !empty($value) && isset($value['status']) +); +``` + +### Self-Healing Cache + +Corrupted or unrestorable cache entries are automatically evicted instead of propagating an exception. Combined with `remember()` or `rememberIf()`, the entry is transparently regenerated on the next read — zero downtime, zero manual intervention. + +```php +// If 'report' is corrupted, SmartCache evicts it and the callback runs again +$report = SmartCache::remember('report', 3600, fn() => Analytics::generate()); +``` + +Enabled by default. Disable per-environment: + +```php +'self_healing' => ['enabled' => false], +``` + ### Model Auto-Invalidation ```php @@ -284,6 +341,8 @@ return [ 'rate_limiter' => ['enabled' => true, 'default_limit' => 100, 'window' => 60], 'encryption' => ['enabled' => false, 'keys' => []], 'jitter' => ['enabled' => false, 'percentage' => 0.1], + 'deduplication' => ['enabled' => true], // Write deduplication (Cache DNA) + 'self_healing' => ['enabled' => true], // Auto-evict corrupted entries 'dashboard' => ['enabled' => false, 'prefix' => 'smart-cache', 'middleware' => ['web']], ]; ``` @@ -307,7 +366,7 @@ $users = SmartCache::get('users'); ## Testing ```bash -composer test # 415 tests, 1 732 assertions +composer test # 425 tests, 1 780+ assertions composer test-coverage # with code coverage ``` diff --git a/config/smart-cache.php b/config/smart-cache.php index b532b3b..2ea4585 100644 --- a/config/smart-cache.php +++ b/config/smart-cache.php @@ -126,6 +126,36 @@ 'metadata_ttl' => 86400, // How long to keep cost metadata (seconds) ], + /* + |-------------------------------------------------------------------------- + | Write Deduplication (Cache DNA) + |-------------------------------------------------------------------------- + | + | When enabled, SmartCache hashes every value before writing and skips + | the write when the stored content is identical. This eliminates + | redundant I/O for frequently refreshed but rarely changing data + | (e.g., configuration, feature flags, rate-limit counters). + | + */ + 'deduplication' => [ + 'enabled' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Self-Healing Cache + |-------------------------------------------------------------------------- + | + | When enabled, corrupted or unrestorable cache entries are automatically + | evicted instead of propagating an exception. Combined with a + | `remember()` or `rememberIf()` callback, the entry is transparently + | regenerated on the next read. + | + */ + 'self_healing' => [ + 'enabled' => true, + ], + /* |-------------------------------------------------------------------------- | Performance Monitoring diff --git a/docs/index.html b/docs/index.html index 60a9ed2..42e2f8e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,11 +3,45 @@
- - + + -Use exactly like Laravel's Cache facade with automatic optimizations.
+Skips redundant writes when content is unchanged — saves I/O for frequently refreshed data.
+Corrupted entries are auto-evicted and transparently regenerated on the next read.
+Cache only when a condition is met — filter out empty or invalid responses with rememberIf().
Automatically encrypt sensitive cached data with pattern matching.
@@ -1402,7 +1448,7 @@SmartCache hashes every value before writing. When the stored content is identical to what is already cached, the write is skipped entirely — eliminating redundant I/O for frequently refreshed but rarely changing data such as configuration, feature flags, and rate-limit counters.
+ +// Frequent cron refreshes? Only the first write hits the store.
+SmartCache::put('app_config', Config::all(), 3600);
+
+// Second call with the same data — no I/O, returns true immediately
+SmartCache::put('app_config', Config::all(), 3600);
+
+// When data actually changes, the write proceeds normally
+SmartCache::put('app_config', $updatedConfig, 3600); // written
+
+ _sc_dna:{key}. On subsequent writes, SmartCache compares the hash — if identical, the write is skipped. Hashes share the same TTL as the data and are cleaned up on forget().
+ // Enable/disable in config/smart-cache.php
+'deduplication' => [
+ 'enabled' => true, // enabled by default
+],
+
+ rememberIf)Cache values only when a condition is met. The callback always executes, but the result is stored only if the condition returns true. This is useful for filtering out empty, error, or invalid responses from external APIs.
// Only cache non-empty API responses
+$data = SmartCache::rememberIf('external_api', 3600,
+ fn() => Http::get('https://api.example.com/data')->json(),
+ fn($value) => !empty($value) && isset($value['status'])
+);
+
+// Only cache results above a minimum size
+$report = SmartCache::rememberIf('analytics_report', 7200,
+ fn() => AnalyticsService::generate(),
+ fn($result) => count($result) > 0
+);
+
+// Combined with cost-aware caching — generation cost is still tracked
+$users = SmartCache::rememberIf('active_users', 1800,
+ fn() => User::where('active', true)->get(),
+ fn($users) => $users->isNotEmpty()
+);
+
+ remember() with an if check, rememberIf() integrates with cost tracking and deduplication out of the box.
+ Corrupted or unrestorable cache entries are automatically evicted instead of propagating an exception to the application. Combined with remember() or rememberIf(), the entry is transparently regenerated on the next read — zero downtime, zero manual intervention.
// If 'report' is corrupted, SmartCache evicts it silently
+// and the callback regenerates it on the next call
+$report = SmartCache::remember('report', 3600, fn() => Analytics::generate());
+
+// Self-healing also cleans up associated DNA hashes
+// and logs the eviction for observability:
+// "SmartCache self-healing: evicted corrupted key [report]"
+
+ // Enable/disable in config/smart-cache.php
+'self_healing' => [
+ 'enabled' => true, // enabled by default
+],
+
+ // config/smart-cache.php
return [
@@ -1660,6 +1772,14 @@ Configuration Reference
'metadata_ttl' => 86400, // Metadata persistence (1 day)
],
+ 'deduplication' => [
+ 'enabled' => true, // Write deduplication (Cache DNA)
+ ],
+
+ 'self_healing' => [
+ 'enabled' => true, // Auto-evict corrupted entries
+ ],
+
'dashboard' => [
'enabled' => false,
'prefix' => 'smart-cache',
@@ -1745,6 +1865,34 @@ 🔧 Basic Cache Operations
+
+ SmartCache::rememberIf()
+ SmartCache::rememberIf(string $key, mixed $ttl, Closure $callback, callable $condition): mixed
+ Get an item from the cache, or execute the callback and store the result only if the condition is met. The callback always runs on a cache miss, but the value is persisted only when $condition($value) returns true.
+
+ $key (string) - The cache key
+
+
+ $ttl (DateTimeInterface|DateInterval|int|null) - Time to live
+
+
+ $callback (Closure) - Function to generate fresh data
+
+
+ $condition (callable) - Receives the callback result; return true to cache
+
+
+ Returns: mixed - Cached or fresh data (always returns the value, even when not cached)
+
+
+ Example:
+ $data = SmartCache::rememberIf('api_data', 3600,
+ fn() => Http::get('https://api.example.com/data')->json(),
+ fn($value) => !empty($value) && isset($value['status'])
+);
+
+
+
SmartCache::has()
SmartCache::has(string $key): bool
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index f0bccbf..d3a4cf9 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -2,19 +2,19 @@
https://iazaran.github.io/smart-cache/
- 2025-06-04
+ 2026-02-20
weekly
1.0
https://github.com/iazaran/smart-cache
- 2025-06-04
+ 2026-02-20
weekly
0.9
https://packagist.org/packages/iazaran/smart-cache
- 2025-06-04
+ 2026-02-20
monthly
0.8
diff --git a/src/Contracts/SmartCache.php b/src/Contracts/SmartCache.php
index 8e9b8be..96143ce 100644
--- a/src/Contracts/SmartCache.php
+++ b/src/Contracts/SmartCache.php
@@ -419,4 +419,19 @@ public function throttle(string $key, int $maxAttempts, int $decaySeconds, calla
* @return mixed
*/
public function rememberWithStampedeProtection(string $key, int $ttl, \Closure $callback, float $beta = 1.0): mixed;
+
+ /**
+ * Cache a value only when a condition is satisfied.
+ *
+ * The callback is always executed, but the result is stored in cache
+ * only if $condition($value) returns true. This keeps invalid or
+ * empty data out of cache while still returning the value to the caller.
+ *
+ * @param string $key
+ * @param \DateTimeInterface|\DateInterval|int|null $ttl
+ * @param \Closure $callback Value generator
+ * @param callable $condition Receives generated value; return true to cache
+ * @return mixed
+ */
+ public function rememberIf(string $key, mixed $ttl, \Closure $callback, callable $condition): mixed;
}
\ No newline at end of file
diff --git a/src/Facades/SmartCache.php b/src/Facades/SmartCache.php
index f6f961c..1a7ea6f 100644
--- a/src/Facades/SmartCache.php
+++ b/src/Facades/SmartCache.php
@@ -36,6 +36,7 @@
* @method static array analyzePerformance()
* @method static int cleanupExpiredManagedKeys()
* @method static bool hasFeature(string $feature)
+ * @method static mixed rememberIf(string $key, mixed $ttl, \Closure $callback, callable $condition)
*
* @see \SmartCache\SmartCache
*/
diff --git a/src/Providers/SmartCacheServiceProvider.php b/src/Providers/SmartCacheServiceProvider.php
index 3304fe9..4409081 100644
--- a/src/Providers/SmartCacheServiceProvider.php
+++ b/src/Providers/SmartCacheServiceProvider.php
@@ -49,7 +49,7 @@ public function register(): void
$compressionMode = $config->get('smart-cache.strategies.compression.mode', 'fixed');
if ($compressionMode === 'adaptive') {
- $strategies[] = new AdaptiveCompressionStrategy(
+ $adaptiveStrategy = new AdaptiveCompressionStrategy(
$config->get('smart-cache.thresholds.compression', 51200),
$config->get('smart-cache.strategies.compression.level', 6),
$config->get('smart-cache.strategies.compression.adaptive.sample_size', 1024),
@@ -57,6 +57,8 @@ public function register(): void
$config->get('smart-cache.strategies.compression.adaptive.low_compression_threshold', 0.7),
$config->get('smart-cache.strategies.compression.adaptive.frequency_threshold', 100)
);
+ $adaptiveStrategy->setCacheRepository($cacheManager->store());
+ $strategies[] = $adaptiveStrategy;
} else {
$strategies[] = new CompressionStrategy(
$config->get('smart-cache.thresholds.compression', 51200),
@@ -117,6 +119,18 @@ public function boot(): void
// Register dashboard routes if enabled
$this->registerDashboardRoutes();
+ // Register terminating callback to persist cost-aware metadata reliably
+ $this->app->terminating(function () {
+ try {
+ $smartCache = $this->app->make(SmartCacheContract::class);
+ if (\method_exists($smartCache, 'persistCostMetadata')) {
+ $smartCache->persistCostMetadata();
+ }
+ } catch (\Throwable $e) {
+ // Silently fail — don't break the response
+ }
+ });
+
// Register command metadata for HTTP context
$this->app->singleton('smart-cache.commands', fn () => [
'smart-cache:clear' => [
diff --git a/src/SmartCache.php b/src/SmartCache.php
index 4d9d392..5703fce 100644
--- a/src/SmartCache.php
+++ b/src/SmartCache.php
@@ -45,20 +45,34 @@ protected static function sentinel(): object
*
* Laravel's Repository treats null returns from the store as cache misses,
* so we wrap null values in a marker array before storing.
+ * Uses a unique two-key marker to avoid collision with user data.
*/
protected static function wrapNullValue(mixed $value): mixed
{
- return $value === null ? ['_sc_null' => true] : $value;
+ return $value === null ? ['__smartcache_null__' => true, '__sc_v' => 2] : $value;
}
/**
* Unwrap a null marker back to an actual null value.
+ * Supports both legacy (_sc_null) and current (__smartcache_null__) markers.
*/
protected static function unwrapNullValue(mixed $value): mixed
{
- if (\is_array($value) && \array_key_exists('_sc_null', $value) && $value['_sc_null'] === true && \count($value) === 1) {
+ if (!\is_array($value)) {
+ return $value;
+ }
+
+ // Current marker format (v2)
+ if (\array_key_exists('__smartcache_null__', $value) && $value['__smartcache_null__'] === true
+ && \array_key_exists('__sc_v', $value) && \count($value) === 2) {
return null;
}
+
+ // Legacy marker format (v1) — backwards compatibility
+ if (\array_key_exists('_sc_null', $value) && $value['_sc_null'] === true && \count($value) === 1) {
+ return null;
+ }
+
return $value;
}
@@ -187,6 +201,16 @@ protected static function unwrapNullValue(mixed $value): mixed
*/
protected ?CostAwareCacheManager $costAwareManager = null;
+ /**
+ * @var bool Whether write deduplication (Cache DNA) is enabled
+ */
+ protected bool $deduplicationEnabled = false;
+
+ /**
+ * @var bool Whether self-healing cache is enabled
+ */
+ protected bool $selfHealingEnabled = false;
+
/**
* SmartCache constructor.
*
@@ -218,6 +242,12 @@ public function __construct(
// Initialize performance monitoring flag (no cache read needed)
$this->enablePerformanceMonitoring = $config->get('smart-cache.monitoring.enabled', true);
+ // Initialize write deduplication (Cache DNA)
+ $this->deduplicationEnabled = (bool) $config->get('smart-cache.deduplication.enabled', true);
+
+ // Initialize self-healing cache
+ $this->selfHealingEnabled = (bool) $config->get('smart-cache.self_healing.enabled', true);
+
// Managed keys, dependencies, and performance metrics are lazy-loaded on first access
}
@@ -296,6 +326,20 @@ public function put($key, $value, $ttl = null): bool
$storable = static::wrapNullValue($value);
$optimizedValue = $this->maybeOptimizeValue($storable, $key, $ttl);
+ // Cache DNA — skip write when content is identical
+ $newHash = null;
+ if ($this->deduplicationEnabled) {
+ $newHash = $this->contentHash($optimizedValue);
+ $storedHash = $this->cache->get("_sc_dna:{$key}");
+ if ($storedHash === $newHash) {
+ // Content unchanged — record a deduplicated write metric and return early
+ if ($this->enablePerformanceMonitoring) {
+ $this->recordPerformanceMetric('cache_write_dedup', $key, $startTime);
+ }
+ return true;
+ }
+ }
+
// Track all keys for pattern matching and invalidation
$this->trackKey($key);
@@ -306,6 +350,12 @@ public function put($key, $value, $ttl = null): bool
$result = $this->cache->put($key, $optimizedValue, $ttl);
+ // Persist content hash for future deduplication
+ if ($this->deduplicationEnabled && $result) {
+ $hashTtl = \is_int($ttl) && $ttl > 0 ? $ttl : 86400;
+ $this->cache->put("_sc_dna:{$key}", $newHash, $hashTtl);
+ }
+
if ($this->enablePerformanceMonitoring) {
$this->recordPerformanceMetric('cache_write', $key, $startTime, [
'original_size' => $this->calculateDataSize($value),
@@ -372,6 +422,11 @@ public function forget($key): bool
// Clean up SWR/stampede metadata (consistent format)
$this->cache->forget("_sc_meta:{$key}");
+ // Clean up Cache DNA hash
+ if ($this->deduplicationEnabled) {
+ $this->cache->forget("_sc_dna:{$key}");
+ }
+
// Remove from tracked keys
$this->untrackKey($key);
@@ -770,6 +825,72 @@ public function getCostAwareManager(): ?CostAwareCacheManager
return $this->costAwareManager;
}
+ /**
+ * Persist cost-aware metadata to cache.
+ * Called by terminating callback to ensure data is saved reliably.
+ *
+ * @return void
+ */
+ public function persistCostMetadata(): void
+ {
+ if ($this->costAwareManager !== null) {
+ $this->costAwareManager->persist();
+ }
+ }
+
+ /**
+ * Compute a fast content hash for write deduplication (Cache DNA).
+ *
+ * @param mixed $value
+ * @return string
+ */
+ protected function contentHash(mixed $value): string
+ {
+ return \md5(\serialize($value));
+ }
+
+ /**
+ * Cache a value only when the condition is met.
+ *
+ * If the condition callable returns false the value is still returned
+ * but is NOT stored in cache, keeping stale or invalid data out of the
+ * store. Useful for business-rule filtering (e.g. don't cache empty
+ * API responses or error payloads).
+ *
+ * @param string $key
+ * @param \DateTimeInterface|\DateInterval|int|null $ttl
+ * @param \Closure $callback Value generator
+ * @param callable $condition Receives the generated value; return true to cache
+ * @return mixed The generated value (cached or not)
+ */
+ public function rememberIf(string $key, mixed $ttl, \Closure $callback, callable $condition): mixed
+ {
+ $sentinel = static::sentinel();
+ $value = $this->get($key, $sentinel);
+
+ if ($value !== $sentinel) {
+ if ($this->costAwareManager !== null) {
+ $this->costAwareManager->recordAccess($this->applyNamespace($key));
+ }
+ return $value;
+ }
+
+ $startTime = $this->costAwareManager !== null ? \microtime(true) : null;
+ $value = $callback();
+
+ if ($condition($value)) {
+ $this->put($key, $value, $ttl);
+
+ if ($this->costAwareManager !== null && $startTime !== null) {
+ $costMs = (\microtime(true) - $startTime) * 1000;
+ $size = $this->calculateDataSize($value);
+ $this->costAwareManager->recordCost($this->applyNamespace($key), $costMs, $size);
+ }
+ }
+
+ return $value;
+ }
+
/**
* Apply optimization strategies if applicable.
*
@@ -842,12 +963,25 @@ protected function maybeRestoreValue(mixed $value, string $key): mixed
if ($this->config->get('smart-cache.fallback.log_errors', true)) {
Log::warning("SmartCache restoration failed for {$key}: " . $e->getMessage());
}
-
+
+ // Self-Healing: evict the corrupted entry so the next remember()
+ // call transparently regenerates the data instead of serving garbage.
+ if ($this->selfHealingEnabled) {
+ try {
+ $this->cache->forget($key);
+ $this->cache->forget("_sc_dna:{$key}");
+ Log::info("SmartCache self-healing: evicted corrupted key [{$key}]");
+ } catch (\Throwable $evictError) {
+ // Best-effort eviction
+ }
+ return null;
+ }
+
if ($this->config->get('smart-cache.fallback.enabled', true)) {
// Return the original value as fallback
return $value;
}
-
+
throw $e;
}
}
diff --git a/src/Strategies/AdaptiveCompressionStrategy.php b/src/Strategies/AdaptiveCompressionStrategy.php
index 1be8f22..dac5a4e 100644
--- a/src/Strategies/AdaptiveCompressionStrategy.php
+++ b/src/Strategies/AdaptiveCompressionStrategy.php
@@ -3,7 +3,7 @@
namespace SmartCache\Strategies;
use SmartCache\Contracts\OptimizationStrategy;
-use Illuminate\Support\Facades\Cache;
+use Illuminate\Contracts\Cache\Repository;
/**
* Adaptive Compression Strategy
@@ -45,6 +45,28 @@ class AdaptiveCompressionStrategy implements OptimizationStrategy
*/
protected int $frequencyThreshold;
+ /**
+ * In-memory access frequency counters for the current request.
+ *
+ * @var array
+ */
+ protected array $accessFrequency = [];
+
+ /**
+ * Whether frequency data has been loaded from cache.
+ */
+ protected bool $frequencyLoaded = false;
+
+ /**
+ * Whether frequency data has been modified and needs persisting.
+ */
+ protected bool $frequencyDirty = false;
+
+ /**
+ * Optional cache repository for persisting frequency data.
+ */
+ protected ?Repository $cache = null;
+
/**
* AdaptiveCompressionStrategy constructor.
*
@@ -71,6 +93,18 @@ public function __construct(
$this->frequencyThreshold = $frequencyThreshold;
}
+ /**
+ * Set the cache repository for persisting frequency data.
+ *
+ * @param Repository $cache
+ * @return static
+ */
+ public function setCacheRepository(Repository $cache): static
+ {
+ $this->cache = $cache;
+ return $this;
+ }
+
/**
* {@inheritdoc}
*/
@@ -209,14 +243,10 @@ protected function getAccessFrequency(?string $key): int
if (!$key) {
return 0;
}
-
- $frequencyKey = "_sc_access_freq_{$key}";
-
- try {
- return (int) Cache::get($frequencyKey, 0);
- } catch (\Exception $e) {
- return 0;
- }
+
+ $this->ensureFrequencyLoaded();
+
+ return $this->accessFrequency[$key] ?? 0;
}
/**
@@ -227,20 +257,68 @@ protected function getAccessFrequency(?string $key): int
*/
public function trackAccess(string $key): void
{
- $frequencyKey = "_sc_access_freq_{$key}";
-
+ $this->ensureFrequencyLoaded();
+
+ $this->accessFrequency[$key] = ($this->accessFrequency[$key] ?? 0) + 1;
+ $this->frequencyDirty = true;
+
+ // Trim if tracking too many keys (keep top 500 by frequency)
+ if (\count($this->accessFrequency) > 500) {
+ \arsort($this->accessFrequency);
+ $this->accessFrequency = \array_slice($this->accessFrequency, 0, 400, true);
+ }
+ }
+
+ /**
+ * Load persisted frequency data from cache.
+ */
+ protected function ensureFrequencyLoaded(): void
+ {
+ if ($this->frequencyLoaded) {
+ return;
+ }
+
+ $this->frequencyLoaded = true;
+
+ if ($this->cache === null) {
+ return;
+ }
+
try {
- Cache::increment($frequencyKey);
-
- // Set TTL if not already set (24 hours)
- if (!Cache::has($frequencyKey . '_ttl')) {
- Cache::put($frequencyKey . '_ttl', true, 86400);
+ $persisted = $this->cache->get('_sc_adaptive_freq', []);
+ if (\is_array($persisted)) {
+ $this->accessFrequency = $persisted;
}
} catch (\Exception $e) {
- // Silently fail if cache doesn't support increment
+ // Silently fail — in-memory tracking still works
+ }
+ }
+
+ /**
+ * Persist frequency data to cache.
+ */
+ public function persistFrequency(): void
+ {
+ if (!$this->frequencyDirty || $this->cache === null) {
+ return;
+ }
+
+ try {
+ $this->cache->put('_sc_adaptive_freq', $this->accessFrequency, 86400);
+ $this->frequencyDirty = false;
+ } catch (\Exception $e) {
+ // Silently fail
}
}
+ /**
+ * Persist frequency data on shutdown.
+ */
+ public function __destruct()
+ {
+ $this->persistFrequency();
+ }
+
/**
* Get compression statistics for monitoring.
*
diff --git a/src/Strategies/SmartSerializationStrategy.php b/src/Strategies/SmartSerializationStrategy.php
index 6489f37..270c943 100644
--- a/src/Strategies/SmartSerializationStrategy.php
+++ b/src/Strategies/SmartSerializationStrategy.php
@@ -139,36 +139,25 @@ protected function selectSerializationMethod(mixed $value): string
/**
* Check if a value can be safely serialized with JSON.
*
+ * Uses a single json_encode call which natively detects all unsupported types
+ * (resources, closures, non-stdClass objects with private state, etc.).
+ *
* @param mixed $value
* @return bool
*/
protected function isJsonSafe(mixed $value): bool
{
- // JSON can't handle objects (except stdClass), resources, or closures
- if (is_object($value) && !($value instanceof \stdClass)) {
- return false;
- }
-
- if (is_resource($value) || $value instanceof \Closure) {
+ // Quick rejection for types that json_encode cannot handle
+ if (\is_resource($value) || $value instanceof \Closure) {
return false;
}
-
- // For arrays, check recursively
- if (is_array($value)) {
- foreach ($value as $item) {
- if (!$this->isJsonSafe($item)) {
- return false;
- }
- }
- }
-
- // Try encoding to verify
- $encoded = json_encode($value);
- if ($encoded === false || json_last_error() !== JSON_ERROR_NONE) {
+
+ if (\is_object($value) && !($value instanceof \stdClass)) {
return false;
}
-
- return true;
+
+ // Single encode attempt — catches nested issues too
+ return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) !== false;
}
/**
diff --git a/src/Traits/CacheInvalidation.php b/src/Traits/CacheInvalidation.php
index 8b93e11..f349010 100644
--- a/src/Traits/CacheInvalidation.php
+++ b/src/Traits/CacheInvalidation.php
@@ -94,19 +94,18 @@ public function getCacheInvalidationConfig(): array
public function getCacheKeysToInvalidate(): array
{
$keys = $this->cacheInvalidation['keys'];
-
- // Add dynamic keys based on model attributes
- $dynamicKeys = [];
+
+ // Resolve dynamic keys based on model attributes
+ $resolvedKeys = [];
foreach ($keys as $key) {
// Replace placeholders like {id}, {slug}, etc.
- $dynamicKey = preg_replace_callback('/\{(\w+)\}/', function ($matches) {
+ $resolvedKeys[] = preg_replace_callback('/\{(\w+)\}/', function ($matches) {
$attribute = $matches[1];
return $this->getAttribute($attribute) ?? $matches[0];
}, $key);
- $dynamicKeys[] = $dynamicKey;
}
-
- return array_merge($keys, $dynamicKeys);
+
+ return array_unique($resolvedKeys);
}
/**
@@ -118,19 +117,18 @@ public function getCacheKeysToInvalidate(): array
public function getCacheTagsToFlush(): array
{
$tags = $this->cacheInvalidation['tags'];
-
- // Add dynamic tags based on model attributes
- $dynamicTags = [];
+
+ // Resolve dynamic tags based on model attributes
+ $resolvedTags = [];
foreach ($tags as $tag) {
// Replace placeholders like {id}, {category_id}, etc.
- $dynamicTag = preg_replace_callback('/\{(\w+)\}/', function ($matches) {
+ $resolvedTags[] = preg_replace_callback('/\{(\w+)\}/', function ($matches) {
$attribute = $matches[1];
return $this->getAttribute($attribute) ?? $matches[0];
}, $tag);
- $dynamicTags[] = $dynamicTag;
}
-
- return array_merge($tags, $dynamicTags);
+
+ return array_unique($resolvedTags);
}
/**
diff --git a/tests/TestCase.php b/tests/TestCase.php
index a8f1591..248df7d 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -97,6 +97,12 @@ protected function defineEnvironment($app): void
'max_tracked_keys' => 100,
'metadata_ttl' => 3600,
],
+ 'deduplication' => [
+ 'enabled' => false,
+ ],
+ 'self_healing' => [
+ 'enabled' => false,
+ ],
]);
}
diff --git a/tests/Unit/SmartCacheTest.php b/tests/Unit/SmartCacheTest.php
index 437a030..d522765 100644
--- a/tests/Unit/SmartCacheTest.php
+++ b/tests/Unit/SmartCacheTest.php
@@ -1000,6 +1000,258 @@ public function test_cost_aware_caching_disabled_returns_empty()
$this->assertEmpty($smartCache->suggestEvictions());
}
+ // ---------------------------------------------------------------
+ // Cache DNA (Write Deduplication) tests
+ // ---------------------------------------------------------------
+
+ public function test_cache_dna_skips_redundant_write()
+ {
+ $this->app['config']->set('smart-cache.deduplication.enabled', true);
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ );
+
+ $key = 'dna-test-key';
+ $value = 'hello world';
+
+ // First write — should go through
+ $this->assertTrue($smartCache->put($key, $value, 3600));
+
+ // Write same value again — should be skipped (dedup)
+ $this->assertTrue($smartCache->put($key, $value, 3600));
+
+ // Value should still be retrievable
+ $this->assertEquals($value, $smartCache->get($key));
+ }
+
+ public function test_cache_dna_writes_when_value_changes()
+ {
+ $this->app['config']->set('smart-cache.deduplication.enabled', true);
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ );
+
+ $key = 'dna-change-key';
+
+ $smartCache->put($key, 'original', 3600);
+ $this->assertEquals('original', $smartCache->get($key));
+
+ // Different value — should write
+ $smartCache->put($key, 'changed', 3600);
+ $this->assertEquals('changed', $smartCache->get($key));
+ }
+
+ public function test_cache_dna_hash_cleaned_up_on_forget()
+ {
+ $this->app['config']->set('smart-cache.deduplication.enabled', true);
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ );
+
+ $key = 'dna-forget-key';
+ $smartCache->put($key, 'data', 3600);
+
+ // Verify the DNA hash exists in the underlying store
+ $nsKey = $key; // no namespace by default
+ $this->assertNotNull($this->getCacheStore()->get("_sc_dna:{$nsKey}"));
+
+ $smartCache->forget($key);
+
+ // DNA hash should be cleaned up
+ $this->assertNull($this->getCacheStore()->get("_sc_dna:{$nsKey}"));
+ }
+
+ public function test_cache_dna_disabled_always_writes()
+ {
+ // Deduplication disabled by default in test config
+ $key = 'dna-disabled-key';
+
+ $this->smartCache->put($key, 'value', 3600);
+
+ // No DNA hash should be stored
+ $this->assertNull($this->getCacheStore()->get("_sc_dna:{$key}"));
+ }
+
+ // ---------------------------------------------------------------
+ // rememberIf (Conditional Caching) tests
+ // ---------------------------------------------------------------
+
+ public function test_remember_if_caches_when_condition_true()
+ {
+ $key = 'remember-if-true';
+ $callCount = 0;
+
+ $value = $this->smartCache->rememberIf(
+ $key,
+ 3600,
+ function () use (&$callCount) {
+ $callCount++;
+ return ['status' => 'ok', 'data' => [1, 2, 3]];
+ },
+ fn ($v) => !empty($v['data'])
+ );
+
+ $this->assertEquals(['status' => 'ok', 'data' => [1, 2, 3]], $value);
+ $this->assertEquals(1, $callCount);
+
+ // Should be cached — callback should NOT be called again
+ $value2 = $this->smartCache->rememberIf(
+ $key,
+ 3600,
+ function () use (&$callCount) {
+ $callCount++;
+ return ['status' => 'different'];
+ },
+ fn ($v) => true
+ );
+
+ $this->assertEquals(['status' => 'ok', 'data' => [1, 2, 3]], $value2);
+ $this->assertEquals(1, $callCount);
+ }
+
+ public function test_remember_if_does_not_cache_when_condition_false()
+ {
+ $key = 'remember-if-false';
+ $callCount = 0;
+
+ $value = $this->smartCache->rememberIf(
+ $key,
+ 3600,
+ function () use (&$callCount) {
+ $callCount++;
+ return [];
+ },
+ fn ($v) => !empty($v) // empty array → false
+ );
+
+ $this->assertEquals([], $value);
+ $this->assertEquals(1, $callCount);
+
+ // Not cached — callback runs again
+ $value2 = $this->smartCache->rememberIf(
+ $key,
+ 3600,
+ function () use (&$callCount) {
+ $callCount++;
+ return ['filled'];
+ },
+ fn ($v) => !empty($v)
+ );
+
+ $this->assertEquals(['filled'], $value2);
+ $this->assertEquals(2, $callCount);
+ }
+
+ public function test_remember_if_returns_value_even_when_not_cached()
+ {
+ $key = 'remember-if-return';
+
+ $value = $this->smartCache->rememberIf(
+ $key,
+ 3600,
+ fn () => 'uncacheable',
+ fn () => false // never cache
+ );
+
+ $this->assertEquals('uncacheable', $value);
+ $this->assertNull($this->smartCache->get($key));
+ }
+
+ // ---------------------------------------------------------------
+ // Self-Healing Cache tests
+ // ---------------------------------------------------------------
+
+ public function test_self_healing_evicts_corrupted_entry()
+ {
+ $this->app['config']->set('smart-cache.self_healing.enabled', true);
+
+ // Create a failing restoration strategy
+ $failingStrategy = Mockery::mock(OptimizationStrategy::class);
+ $failingStrategy->shouldReceive('getIdentifier')->andReturn('failing');
+ $failingStrategy->shouldReceive('restore')->andThrow(new \Exception('Corrupted data'));
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ [$failingStrategy]
+ );
+
+ // Put a value directly in the store to simulate corruption
+ $key = 'corrupted-key';
+ $this->getCacheStore()->put($key, 'corrupted-payload', 3600);
+
+ // Self-healing should evict and return null
+ $result = $smartCache->get($key);
+ $this->assertNull($result);
+
+ // Entry should be evicted
+ $this->assertNull($this->getCacheStore()->get($key));
+ }
+
+ public function test_self_healing_disabled_returns_fallback()
+ {
+ // self_healing is not enabled in default test config
+
+ $failingStrategy = Mockery::mock(OptimizationStrategy::class);
+ $failingStrategy->shouldReceive('getIdentifier')->andReturn('failing');
+ $failingStrategy->shouldReceive('restore')->andThrow(new \Exception('Corrupted data'));
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ [$failingStrategy]
+ );
+
+ $key = 'corrupted-key-no-heal';
+ $this->getCacheStore()->put($key, 'bad-payload', 3600);
+
+ // Without self-healing, fallback returns original value
+ $result = $smartCache->get($key);
+ $this->assertEquals('bad-payload', $result);
+
+ // Entry should still exist
+ $this->assertNotNull($this->getCacheStore()->get($key));
+ }
+
+ public function test_self_healing_also_cleans_dna_hash()
+ {
+ $this->app['config']->set('smart-cache.self_healing.enabled', true);
+ $this->app['config']->set('smart-cache.deduplication.enabled', true);
+
+ $failingStrategy = Mockery::mock(OptimizationStrategy::class);
+ $failingStrategy->shouldReceive('getIdentifier')->andReturn('failing');
+ $failingStrategy->shouldReceive('restore')->andThrow(new \Exception('Corrupted'));
+
+ $smartCache = new SmartCache(
+ $this->getCacheStore(),
+ $this->getCacheManager(),
+ $this->app['config'],
+ [$failingStrategy]
+ );
+
+ $key = 'dna-heal-key';
+ $this->getCacheStore()->put($key, 'bad', 3600);
+ $this->getCacheStore()->put("_sc_dna:{$key}", 'stale-hash', 3600);
+
+ $result = $smartCache->get($key);
+ $this->assertNull($result);
+
+ // Both entry and DNA hash should be evicted
+ $this->assertNull($this->getCacheStore()->get($key));
+ $this->assertNull($this->getCacheStore()->get("_sc_dna:{$key}"));
+ }
+
protected function tearDown(): void
{
Mockery::close();
diff --git a/tests/Unit/Traits/ModelIntegrationTest.php b/tests/Unit/Traits/ModelIntegrationTest.php
index 7840cbc..5a73f61 100644
--- a/tests/Unit/Traits/ModelIntegrationTest.php
+++ b/tests/Unit/Traits/ModelIntegrationTest.php
@@ -211,28 +211,30 @@ public function test_get_cache_keys_to_invalidate_with_placeholders()
{
$user = new TestUser();
$keys = $user->getCacheKeysToInvalidate();
-
- $this->assertContains('user_{id}_profile', $keys);
- $this->assertContains('user_{id}_stats', $keys);
- $this->assertContains('users_list_*', $keys);
-
- // Should also contain resolved placeholders
+
+ // Resolved placeholders should be present (raw templates are NOT returned)
$this->assertContains('user_123_profile', $keys);
$this->assertContains('user_123_stats', $keys);
+ // Keys without placeholders are returned as-is
+ $this->assertContains('users_list_*', $keys);
+ // Raw templates should NOT appear — they would cause double-invalidation
+ $this->assertNotContains('user_{id}_profile', $keys);
+ $this->assertNotContains('user_{id}_stats', $keys);
}
public function test_get_cache_tags_to_flush_with_placeholders()
{
$user = new TestUser();
$tags = $user->getCacheTagsToFlush();
-
+
+ // Tags without placeholders are returned as-is
$this->assertContains('users', $tags);
- $this->assertContains('user_{id}', $tags);
- $this->assertContains('team_{team_id}', $tags);
-
- // Should also contain resolved placeholders
+ // Resolved placeholders should be present
$this->assertContains('user_123', $tags);
$this->assertContains('team_5', $tags);
+ // Raw templates should NOT appear — they would cause double-flush
+ $this->assertNotContains('user_{id}', $tags);
+ $this->assertNotContains('team_{team_id}', $tags);
}
public function test_perform_cache_invalidation_basic()