feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -16,9 +16,9 @@ final readonly class CachePrefix implements CacheIdentifier
private const string PREFIX_MARKER = 'prefix:';
private function __construct(
private string $prefix
private string $value
) {
$this->validate($prefix);
$this->validate($value);
}
/**
@@ -82,7 +82,15 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function toString(): string
{
return $this->prefix;
return $this->value;
}
/**
* Magic method to allow automatic string casting
*/
public function __toString(): string
{
return $this->value;
}
/**
@@ -98,7 +106,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function equals(CacheIdentifier $other): bool
{
return $other instanceof self && $this->prefix === $other->prefix;
return $other instanceof self && $this->value === $other->value;
}
/**
@@ -106,7 +114,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function matchesKey(CacheKey $key): bool
{
return str_starts_with($key->toString(), $this->prefix);
return str_starts_with($key->toString(), $this->value);
}
/**
@@ -114,7 +122,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function getNormalizedString(): string
{
return self::PREFIX_MARKER . $this->prefix;
return self::PREFIX_MARKER . $this->value;
}
/**
@@ -122,7 +130,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function createKey(string $suffix): CacheKey
{
return CacheKey::fromString($this->prefix . $suffix);
return CacheKey::fromString($this->value . $suffix);
}
/**
@@ -130,8 +138,8 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function removeFromKey(string $key): string
{
if (str_starts_with($key, $this->prefix)) {
return substr($key, strlen($this->prefix));
if (str_starts_with($key, $this->value)) {
return substr($key, strlen($this->value));
}
return $key;
@@ -142,7 +150,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function hasTrailingSeparator(): bool
{
return str_ends_with($this->prefix, ':');
return str_ends_with($this->value, ':');
}
/**
@@ -154,7 +162,7 @@ final readonly class CachePrefix implements CacheIdentifier
return $this;
}
return new self($this->prefix . ':');
return new self($this->value . ':');
}
/**

View File

@@ -36,7 +36,9 @@ final readonly class FileCache implements CacheDriver, Scannable
private function getFilesForKey(CacheKey $key): array
{
$keyString = (string)$key;
$hash = md5($keyString);
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$hash = md5($safeKey);
$pattern = self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '*.cache.php';
@@ -46,7 +48,9 @@ final readonly class FileCache implements CacheDriver, Scannable
private function getLockFileName(CacheKey $key): string
{
$keyString = (string)$key;
$hash = md5($keyString);
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$hash = md5($safeKey);
return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '.lock';
}
@@ -98,7 +102,15 @@ final readonly class FileCache implements CacheDriver, Scannable
return CacheItem::miss($key);
}
$content = $this->fileSystem->get($bestFile);
try {
$content = $this->fileSystem->get($bestFile);
} catch (\App\Framework\Filesystem\Exceptions\FileNotFoundException $e) {
// File was deleted between finding and reading (race condition)
return CacheItem::miss($key);
} catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) {
// File permissions changed or file being written (race condition)
return CacheItem::miss($key);
}
if ($content === null || $content === '') {
$this->fileSystem->delete($bestFile);
@@ -193,7 +205,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function clear(): bool
{
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
$this->fileSystem->delete($file);
}
@@ -209,7 +221,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$matches = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {
@@ -231,7 +243,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$matches = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {
@@ -253,7 +265,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$keys = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {

View File

@@ -9,7 +9,7 @@ use App\Framework\Cache\Events\CacheDelete;
use App\Framework\Cache\Events\CacheHit;
use App\Framework\Cache\Events\CacheMiss;
use App\Framework\Cache\Events\CacheSet;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\EventBus\EventBus;
use App\Framework\Core\ValueObjects\Duration;
/**
@@ -22,7 +22,7 @@ final readonly class EventCacheDecorator implements Cache
{
public function __construct(
private Cache $innerCache,
private EventDispatcher $eventDispatcher
private EventBus $eventBus
) {
}
@@ -33,9 +33,9 @@ final readonly class EventCacheDecorator implements Cache
foreach ($result->getItems() as $item) {
if ($item->isHit) {
$valueSize = $this->calculateValueSize($item->value);
$this->eventDispatcher->dispatch(CacheHit::create($item->key, $item->value, $valueSize));
$this->eventBus->dispatch(CacheHit::create($item->key, $item->value, $valueSize));
} else {
$this->eventDispatcher->dispatch(CacheMiss::create($item->key));
$this->eventBus->dispatch(CacheMiss::create($item->key));
}
}
@@ -48,7 +48,7 @@ final readonly class EventCacheDecorator implements Cache
foreach ($items as $item) {
$valueSize = $this->calculateValueSize($item->value);
$this->eventDispatcher->dispatch(CacheSet::create($item->key, $item->value, $item->ttl, $result, $valueSize));
$this->eventBus->dispatch(CacheSet::create($item->key, $item->value, $item->ttl, $result, $valueSize));
}
return $result;
@@ -65,7 +65,7 @@ final readonly class EventCacheDecorator implements Cache
foreach ($identifiers as $identifier) {
if ($identifier instanceof CacheKey) {
$this->eventDispatcher->dispatch(CacheDelete::create($identifier, $result));
$this->eventBus->dispatch(CacheDelete::create($identifier, $result));
}
}
@@ -76,7 +76,7 @@ final readonly class EventCacheDecorator implements Cache
{
$result = $this->innerCache->clear();
$this->eventDispatcher->dispatch(CacheClear::create($result));
$this->eventBus->dispatch(CacheClear::create($result));
return $result;
}
@@ -89,19 +89,19 @@ final readonly class EventCacheDecorator implements Cache
if ($existing->isHit) {
$valueSize = $this->calculateValueSize($existing->value);
$this->eventDispatcher->dispatch(CacheHit::create($key, $existing->value, $valueSize));
$this->eventBus->dispatch(CacheHit::create($key, $existing->value, $valueSize));
return $existing;
}
// Cache miss - execute callback
$this->eventDispatcher->dispatch(CacheMiss::create($key));
$this->eventBus->dispatch(CacheMiss::create($key));
$result = $this->innerCache->remember($key, $callback, $ttl);
if (! $result->isHit) {
$valueSize = $this->calculateValueSize($result->value);
$this->eventDispatcher->dispatch(CacheSet::create($key, $result->value, $ttl, true, $valueSize));
$this->eventBus->dispatch(CacheSet::create($key, $result->value, $ttl, true, $valueSize));
}
return $result;

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when the entire cache is cleared
*/
@@ -11,7 +13,7 @@ final readonly class CacheClear
{
public function __construct(
public bool $success,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -20,6 +22,6 @@ final readonly class CacheClear
*/
public static function create(bool $success): self
{
return new self($success, microtime(true));
return new self($success, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a key is deleted from cache
@@ -14,7 +15,7 @@ final readonly class CacheDelete
public function __construct(
public CacheKey $key,
public bool $success,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -23,6 +24,6 @@ final readonly class CacheDelete
*/
public static function create(CacheKey $key, bool $success): self
{
return new self($key, $success, microtime(true));
return new self($key, $success, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a cache hit occurs
@@ -14,8 +15,8 @@ final readonly class CacheHit
public function __construct(
public CacheKey $key,
public mixed $value,
public int $valueSize = 0,
public float $timestamp = 0.0
public int $valueSize,
public Timestamp $timestamp
) {
}
@@ -24,6 +25,6 @@ final readonly class CacheHit
*/
public static function create(CacheKey $key, mixed $value, int $valueSize = 0): self
{
return new self($key, $value, $valueSize, microtime(true));
return new self($key, $value, $valueSize, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a cache miss occurs
@@ -13,7 +14,7 @@ final readonly class CacheMiss
{
public function __construct(
public CacheKey $key,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -22,6 +23,6 @@ final readonly class CacheMiss
*/
public static function create(CacheKey $key): self
{
return new self($key, microtime(true));
return new self($key, Timestamp::now());
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a value is set in cache
@@ -17,8 +18,8 @@ final readonly class CacheSet
public mixed $value,
public ?Duration $ttl,
public bool $success,
public int $valueSize = 0,
public float $timestamp = 0.0
public int $valueSize,
public Timestamp $timestamp
) {
}
@@ -32,6 +33,6 @@ final readonly class CacheSet
bool $success,
int $valueSize = 0
): self {
return new self($key, $value, $ttl, $success, $valueSize, microtime(true));
return new self($key, $value, $ttl, $success, $valueSize, Timestamp::now());
}
}

View File

@@ -157,14 +157,15 @@ final readonly class MultiLevelCache implements Cache, DriverAccessible
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$item = $this->get($key);
$result = $this->get($key);
$item = $result->getItem($key);
if ($item->isHit) {
return $item;
}
// Wert generieren, speichern und zurückgeben
$value = $callback();
$this->set($key, $value, $ttl);
$this->set(CacheItem::forSet($key, $value, $ttl));
// Erstelle neuen CacheItem als Treffer
return CacheItem::hit($key, $value);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache;
use App\Framework\Cache\Contracts\DriverAccessible;
use App\Framework\Core\ValueObjects\Duration;
use ReflectionException;
use ReflectionMethod;
@@ -26,15 +27,45 @@ final readonly class ServiceCacheDecorator implements DriverAccessible
if ($attrs) {
$attr = $attrs[0]->newInstance();
$key = $attr->key ?? $method->getName() . ':' . md5(serialize($args));
$ttl = $attr->ttl ?? 3600;
$keyString = $attr->key ?? $method->getName() . ':' . md5(serialize($args));
return $this->cache->remember($key, fn () => $method->invokeArgs($this->service, $args), $ttl);
// Replace placeholders in custom key templates
if ($attr->key !== null) {
$keyString = $this->replacePlaceholders($keyString, $method, $args);
}
$cacheKey = CacheKey::fromString($keyString);
$ttl = Duration::fromSeconds($attr->ttl ?? 3600);
$cacheItem = $this->cache->remember($cacheKey, fn () => $method->invokeArgs($this->service, $args), $ttl);
return $cacheItem->value;
}
return $method->invokeArgs($this->service, $args);
}
/**
* Replace placeholders in cache key template with actual argument values
*/
private function replacePlaceholders(string $template, ReflectionMethod $method, array $args): string
{
$parameters = $method->getParameters();
$result = $template;
foreach ($parameters as $index => $parameter) {
$paramName = $parameter->getName();
$paramValue = $args[$index] ?? null;
if ($paramValue !== null) {
// Convert value to string for cache key
$stringValue = is_scalar($paramValue) ? (string) $paramValue : md5(serialize($paramValue));
$result = str_replace('{' . $paramName . '}', $stringValue, $result);
}
}
return $result;
}
/**
* Get the underlying cache driver
*/

View File

@@ -28,6 +28,7 @@ final class SmartCache implements Cache, DriverAccessible
private const int LARGE_BATCH_THRESHOLD = 20; // Extra optimization for large batches
private readonly ?TagIndex $tagIndex;
private readonly ?CacheStrategyManager $strategyManager;
public function __construct(
@@ -475,8 +476,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
// Batch load all matching keys through innerCache (for proper deserialization)
return $this->innerCache->get(...$cacheKeys);
} catch (\Throwable $e) {
// On any error, return empty result
@@ -499,7 +500,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix (more efficient than pattern matching)
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
if (empty($matchingKeys)) {
return CacheResult::empty();
@@ -508,8 +509,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
// Batch load all matching keys through innerCache (for proper deserialization)
return $this->innerCache->get(...$cacheKeys);
} catch (\Throwable $e) {
// On any error, return empty result
@@ -586,7 +587,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
$results = [];
foreach ($matchingKeys as $key) {
@@ -651,8 +652,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
// Batch delete all matching keys through innerCache
$this->innerCache->forget(...$cacheKeys);
return count($matchingKeys);
@@ -676,7 +677,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
if (empty($matchingKeys)) {
return 0; // Nothing to delete
@@ -685,8 +686,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
// Batch delete all matching keys through innerCache
$this->innerCache->forget(...$cacheKeys);
return count($matchingKeys);
@@ -1056,11 +1057,12 @@ final class SmartCache implements Cache, DriverAccessible
*/
public function getStrategyStats(string $strategyName): ?array
{
if (!$this->strategyManager) {
if (! $this->strategyManager) {
return null;
}
$strategy = $this->strategyManager->getStrategy($strategyName);
return $strategy?->getStats();
}

View File

@@ -29,6 +29,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
private array $itemStats = [];
private readonly Duration $minTtl;
private readonly Duration $maxTtl;
public function __construct(
@@ -45,20 +46,20 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
// Record access pattern
if (!isset($this->accessPatterns[$keyString])) {
if (! isset($this->accessPatterns[$keyString])) {
$this->accessPatterns[$keyString] = new AccessPattern($this->learningWindow);
}
$this->accessPatterns[$keyString]->recordAccess();
// Record hit/miss statistics
if (!isset($this->itemStats[$keyString])) {
if (! isset($this->itemStats[$keyString])) {
$this->itemStats[$keyString] = new AdaptiveTtlStats();
}
$this->itemStats[$keyString]->recordHitMiss($isHit);
@@ -66,7 +67,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -75,7 +76,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -99,7 +100,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
'extension_factor' => $this->extensionFactor,
'reduction_factor' => $this->reductionFactor,
],
'key_patterns' => []
'key_patterns' => [],
];
// Include top accessed keys and their patterns
@@ -154,7 +155,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
$baseTtl = $originalTtl ?? Duration::fromHours(1);
$baseSeconds = $baseTtl->toSeconds();
if (!$pattern) {
if (! $pattern) {
// No access pattern yet, use original TTL
return $this->enforceTtlBounds($baseTtl);
}
@@ -213,7 +214,8 @@ final class AccessPattern
public function __construct(
private readonly int $windowSize
) {}
) {
}
public function recordAccess(): void
{
@@ -253,6 +255,7 @@ final class AccessPattern
}
$hours = $timeSpan->toSeconds() / 3600.0;
return count($this->accessTimes) / $hours;
}
}
@@ -263,6 +266,7 @@ final class AccessPattern
final class AdaptiveTtlStats
{
private int $hits = 0;
private int $misses = 0;
public function recordHitMiss(bool $isHit): void
@@ -277,6 +281,7 @@ final class AdaptiveTtlStats
public function getHitRate(): float
{
$total = $this->hits + $this->misses;
return $total > 0 ? ($this->hits / $total) : 0.0;
}
@@ -294,4 +299,4 @@ final class AdaptiveTtlStats
{
return $this->misses;
}
}
}

View File

@@ -66,4 +66,4 @@ interface CacheStrategy
* @return bool Whether strategy is active
*/
public function isEnabled(): bool;
}
}

View File

@@ -23,7 +23,8 @@ final class CacheStrategyManager
public function __construct(
private readonly bool $enabled = true
) {}
) {
}
/**
* Register a cache strategy
@@ -48,7 +49,7 @@ final class CacheStrategyManager
unset($this->strategies[$name]);
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
fn ($strategyName) => $strategyName !== $name
);
return $this;
@@ -59,7 +60,7 @@ final class CacheStrategyManager
*/
public function enableStrategy(string $name): self
{
if (isset($this->strategies[$name]) && !in_array($name, $this->enabledStrategies)) {
if (isset($this->strategies[$name]) && ! in_array($name, $this->enabledStrategies)) {
$this->enabledStrategies[] = $name;
}
@@ -73,7 +74,7 @@ final class CacheStrategyManager
{
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
fn ($strategyName) => $strategyName !== $name
);
return $this;
@@ -84,7 +85,7 @@ final class CacheStrategyManager
*/
public function notifyAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -103,7 +104,7 @@ final class CacheStrategyManager
*/
public function notifySet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -126,7 +127,7 @@ final class CacheStrategyManager
*/
public function notifyForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -153,7 +154,7 @@ final class CacheStrategyManager
'strategy_names' => array_keys($this->strategies),
'enabled_strategy_names' => $this->enabledStrategies,
],
'strategies' => []
'strategies' => [],
];
foreach ($this->strategies as $name => $strategy) {
@@ -161,7 +162,7 @@ final class CacheStrategyManager
$stats['strategies'][$name] = $strategy->getStats();
} catch (\Throwable $e) {
$stats['strategies'][$name] = [
'error' => 'Failed to get stats: ' . $e->getMessage()
'error' => 'Failed to get stats: ' . $e->getMessage(),
];
}
}
@@ -311,4 +312,4 @@ final class CacheStrategyManager
return $manager;
}
}
}

View File

@@ -33,22 +33,23 @@ final class HeatMapCacheStrategy implements CacheStrategy
private readonly int $hotThreshold = self::DEFAULT_HOT_THRESHOLD,
private readonly int $coldThreshold = self::DEFAULT_COLD_THRESHOLD,
private readonly int $analysisWindowHours = self::DEFAULT_ANALYSIS_WINDOW_HOURS
) {}
) {
}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
// Prevent memory overflow by limiting tracked keys
if (!isset($this->heatMap[$keyString]) && count($this->heatMap) >= $this->maxTrackedKeys) {
if (! isset($this->heatMap[$keyString]) && count($this->heatMap) >= $this->maxTrackedKeys) {
$this->evictOldestEntry();
}
if (!isset($this->heatMap[$keyString])) {
if (! isset($this->heatMap[$keyString])) {
$this->heatMap[$keyString] = new HeatMapEntry($key);
}
@@ -57,7 +58,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -77,7 +78,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -175,8 +176,8 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
// Sort by priority
usort($hotKeys, fn($a, $b) => $b['accesses_per_hour'] <=> $a['accesses_per_hour']);
usort($coldKeys, fn($a, $b) => $a['accesses_per_hour'] <=> $b['accesses_per_hour']);
usort($hotKeys, fn ($a, $b) => $b['accesses_per_hour'] <=> $a['accesses_per_hour']);
usort($coldKeys, fn ($a, $b) => $a['accesses_per_hour'] <=> $b['accesses_per_hour']);
return [
'total_tracked_keys' => count($this->heatMap),
@@ -231,7 +232,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
// Sort by impact score (highest first)
usort($bottlenecks, fn($a, $b) => $b['impact_score'] <=> $a['impact_score']);
usort($bottlenecks, fn ($a, $b) => $b['impact_score'] <=> $a['impact_score']);
return array_slice($bottlenecks, 0, 20); // Top 20 bottlenecks
}
@@ -254,6 +255,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
arsort($hotKeys);
return array_slice($hotKeys, 0, $limit, true);
}
@@ -295,6 +297,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
$hours = $this->analysisWindowHours;
return count($accesses) / $hours;
}
@@ -342,13 +345,17 @@ final class HeatMapEntry
private array $accesses = [];
private int $totalHits = 0;
private int $totalMisses = 0;
private float $totalRetrievalTime = 0.0;
private int $retrievalTimeCount = 0;
public function __construct(
private readonly CacheKey $key
) {}
) {
}
public function recordAccess(bool $isHit, ?Duration $retrievalTime = null): void
{
@@ -373,7 +380,7 @@ final class HeatMapEntry
$cutoff = Timestamp::now()->subtract(Duration::fromHours(48)); // Keep 48 hours
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -381,13 +388,14 @@ final class HeatMapEntry
{
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($since)
fn ($access) => $access['timestamp']->isAfter($since)
);
}
public function getHitRate(): float
{
$total = $this->totalHits + $this->totalMisses;
return $total > 0 ? ($this->totalHits / $total) : 0.0;
}
@@ -426,5 +434,6 @@ final readonly class WriteOperation
public int $valueSize,
public Duration $writeTime,
public Timestamp $timestamp
) {}
}
) {
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
@@ -39,11 +38,12 @@ final class PredictiveCacheStrategy implements CacheStrategy
private readonly int $predictionWindowHours = self::DEFAULT_PREDICTION_WINDOW_HOURS,
private readonly float $confidenceThreshold = self::DEFAULT_CONFIDENCE_THRESHOLD,
private readonly int $maxConcurrentWarming = self::MAX_CONCURRENT_WARMING
) {}
) {
}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -63,7 +63,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -130,14 +130,14 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function recordAccess(CacheKey $key, array $context = []): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
$timestamp = Timestamp::now();
if (!isset($this->patterns[$keyString])) {
if (! isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
@@ -149,13 +149,13 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function recordDependency(CacheKey $primaryKey, CacheKey $dependentKey): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$primaryString = $primaryKey->toString();
if (!isset($this->patterns[$primaryString])) {
if (! isset($this->patterns[$primaryString])) {
$this->patterns[$primaryString] = new PredictionPattern($primaryKey);
}
@@ -167,7 +167,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function generatePredictions(): array
{
if (!$this->enabled) {
if (! $this->enabled) {
return [];
}
@@ -180,7 +180,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
}
// Sort by confidence score
usort($predictions, fn($a, $b) => $b['confidence'] <=> $a['confidence']);
usort($predictions, fn ($a, $b) => $b['confidence'] <=> $a['confidence']);
return $predictions;
}
@@ -190,7 +190,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function performPredictiveWarming(): array
{
if (!$this->enabled) {
if (! $this->enabled) {
return [];
}
@@ -221,14 +221,14 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function registerWarmingCallback(CacheKey $key, callable $callback): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
$this->warmingCallbacks[$keyString] = $callback;
if (!isset($this->patterns[$keyString])) {
if (! isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
@@ -251,7 +251,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $timeBasedConfidence,
'reason' => 'time_based_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(30)),
'callback' => $pattern->getWarmingCallback()
'callback' => $pattern->getWarmingCallback(),
];
}
@@ -263,7 +263,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $frequencyConfidence,
'reason' => 'access_frequency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(15)),
'callback' => $pattern->getWarmingCallback()
'callback' => $pattern->getWarmingCallback(),
];
}
@@ -328,7 +328,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
// Calculate average time between accesses
$intervals = [];
for ($i = 1; $i < count($accesses); $i++) {
$interval = $accesses[$i]['timestamp']->diff($accesses[$i-1]['timestamp']);
$interval = $accesses[$i]['timestamp']->diff($accesses[$i - 1]['timestamp']);
$intervals[] = $interval->toSeconds();
}
@@ -372,7 +372,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $primaryConfidence * 0.8, // Slightly lower confidence for dependencies
'reason' => 'dependency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(5)),
'callback' => $this->warmingCallbacks[$dependentKey->toString()] ?? null
'callback' => $this->warmingCallbacks[$dependentKey->toString()] ?? null,
];
}
}
@@ -394,7 +394,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'key' => $keyString,
'status' => 'already_warming',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
}
@@ -404,12 +404,12 @@ final class PredictiveCacheStrategy implements CacheStrategy
try {
// If no callback provided, we can't warm the cache
if (!$callback) {
if (! $callback) {
return [
'key' => $keyString,
'status' => 'no_callback',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
}
@@ -428,7 +428,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'status' => 'warmed',
'reason' => $reason,
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
} catch (\Throwable $e) {
@@ -441,7 +441,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'reason' => $reason,
'error' => $e->getMessage(),
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
} finally {
@@ -466,20 +466,21 @@ final class PredictionPattern
public function __construct(
private readonly CacheKey $key
) {}
) {
}
public function recordAccess(Timestamp $timestamp, array $context = []): void
{
$this->accesses[] = [
'timestamp' => $timestamp,
'context' => $context
'context' => $context,
];
// Keep only recent accesses to prevent memory bloat
$cutoff = $timestamp->subtract(Duration::fromHours(168)); // 1 week
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -504,7 +505,7 @@ final class PredictionPattern
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -529,7 +530,8 @@ final readonly class WarmingJob
public mixed $callback,
public string $reason,
public Timestamp $startTime
) {}
) {
}
}
/**
@@ -542,5 +544,6 @@ final readonly class WarmingResult
public bool $successful,
public Duration $duration,
public string $reason
) {}
}
) {
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupMetrics;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Core service for managing cache warming operations
*
* Coordinates multiple warming strategies and tracks metrics.
*/
final readonly class CacheWarmingService
{
/** @var array<WarmupStrategy> */
private array $strategies;
public function __construct(
private Logger $logger,
array $strategies = []
) {
// Sort strategies by priority (highest first)
$this->strategies = $this->sortStrategiesByPriority($strategies);
}
/**
* Execute all registered warming strategies
*
* @param bool $force Force execution even if shouldRun() returns false
* @return WarmupMetrics Aggregated metrics from all strategies
*/
public function warmAll(bool $force = false): WarmupMetrics
{
$this->logger->info(
'Starting cache warmup',
LogContext::withData([
'strategies_count' => count($this->strategies),
'force' => $force
])
);
$results = [];
foreach ($this->strategies as $strategy) {
if (!$force && !$strategy->shouldRun()) {
$this->logger->debug(
'Skipping strategy (shouldRun returned false)',
LogContext::withData(['strategy' => $strategy->getName()])
);
continue;
}
$result = $this->executeStrategy($strategy);
$results[] = $result;
}
$metrics = WarmupMetrics::fromResults($results);
$this->logger->info(
'Cache warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics;
}
/**
* Execute a specific warming strategy by name
*
* @param string $strategyName Name of the strategy to execute
* @return WarmupResult Result of the strategy execution
* @throws \InvalidArgumentException If strategy not found
*/
public function warmStrategy(string $strategyName): WarmupResult
{
$strategy = $this->findStrategy($strategyName);
if ($strategy === null) {
throw new \InvalidArgumentException(
"Strategy '{$strategyName}' not found. Available: " .
implode(', ', $this->getStrategyNames())
);
}
return $this->executeStrategy($strategy);
}
/**
* Execute only strategies with a specific minimum priority
*
* @param int $minPriority Minimum priority level
* @return WarmupMetrics Aggregated metrics
*/
public function warmByPriority(int $minPriority): WarmupMetrics
{
$this->logger->info(
'Starting priority-based cache warmup',
LogContext::withData(['min_priority' => $minPriority])
);
$results = [];
foreach ($this->strategies as $strategy) {
if ($strategy->getPriority() >= $minPriority) {
$result = $this->executeStrategy($strategy);
$results[] = $result;
}
}
return WarmupMetrics::fromResults($results);
}
/**
* Get list of all registered strategies
*
* @return array<array{name: string, priority: int, estimated_duration: int}>
*/
public function getStrategies(): array
{
return array_map(function (WarmupStrategy $strategy) {
return [
'name' => $strategy->getName(),
'priority' => $strategy->getPriority(),
'estimated_duration' => $strategy->getEstimatedDuration(),
'should_run' => $strategy->shouldRun()
];
}, $this->strategies);
}
/**
* Get estimated total warmup duration in seconds
*/
public function getEstimatedTotalDuration(): int
{
$total = 0;
foreach ($this->strategies as $strategy) {
if ($strategy->shouldRun()) {
$total += $strategy->getEstimatedDuration();
}
}
return $total;
}
private function executeStrategy(WarmupStrategy $strategy): WarmupResult
{
$this->logger->info(
'Executing warmup strategy',
LogContext::withData([
'strategy' => $strategy->getName(),
'priority' => $strategy->getPriority(),
'estimated_duration' => $strategy->getEstimatedDuration()
])
);
try {
$result = $strategy->warmup();
$this->logger->info(
'Strategy completed',
LogContext::withData(array_merge(
['strategy' => $strategy->getName()],
$result->toArray()
))
);
return $result;
} catch (\Throwable $e) {
$this->logger->error(
'Strategy failed',
LogContext::withData([
'strategy' => $strategy->getName(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
])
);
// Return failed result
return new WarmupResult(
strategyName: $strategy->getName(),
itemsWarmed: 0,
itemsFailed: 1,
durationSeconds: 0.0,
memoryUsedBytes: 0,
errors: [['error' => $e->getMessage()]]
);
}
}
private function findStrategy(string $name): ?WarmupStrategy
{
foreach ($this->strategies as $strategy) {
if ($strategy->getName() === $name) {
return $strategy;
}
}
return null;
}
private function getStrategyNames(): array
{
return array_map(fn($s) => $s->getName(), $this->strategies);
}
/**
* @param array<WarmupStrategy> $strategies
* @return array<WarmupStrategy>
*/
private function sortStrategiesByPriority(array $strategies): array
{
usort($strategies, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
return $strategies;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Commands;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Cache\Warming\CacheWarmingService;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
/**
* Console command for cache warmup operations
*
* Usage:
* php console.php cache:warmup # Warm all strategies
* php console.php cache:warmup --strategy=critical_path # Warm specific strategy
* php console.php cache:warmup --priority=high # Warm by priority level
* php console.php cache:warmup --list # List available strategies
*/
#[ConsoleCommand(
name: 'cache:warmup',
description: 'Warm up cache using registered warming strategies'
)]
final readonly class CacheWarmupCommand
{
public function __construct(
private CacheWarmingService $warmingService
) {}
public function execute(ConsoleInput $input): int
{
// List strategies
if ($input->getOption('list', false)) {
return $this->listStrategies();
}
// Specific strategy
if ($strategyName = $input->getOption('strategy')) {
return $this->warmStrategy($strategyName);
}
// Priority-based warming
if ($priorityLevel = $input->getOption('priority')) {
return $this->warmByPriority($priorityLevel);
}
// Warm all
return $this->warmAll($input->getOption('force', false));
}
private function warmAll(bool $force): int
{
echo "🔥 Warming all cache strategies...\n\n";
if ($force) {
echo "⚠️ Force mode: Ignoring shouldRun() checks\n\n";
}
$estimatedDuration = $this->warmingService->getEstimatedTotalDuration();
echo "⏱️ Estimated duration: {$estimatedDuration} seconds\n\n";
$metrics = $this->warmingService->warmAll($force);
$this->displayMetrics($metrics);
return $metrics->totalItemsFailed > 0 ? ExitCode::ERROR : ExitCode::SUCCESS;
}
private function warmStrategy(string $strategyName): int
{
echo "🔥 Warming strategy: {$strategyName}\n\n";
try {
$result = $this->warmingService->warmStrategy($strategyName);
echo "Strategy: {$result->strategyName}\n";
echo "Items warmed: {$result->itemsWarmed}\n";
echo "Items failed: {$result->itemsFailed}\n";
echo "Duration: " . round($result->durationSeconds, 3) . "s\n";
echo "Memory used: " . round($result->getMemoryUsedMB(), 2) . " MB\n";
echo "Success rate: " . round($result->getSuccessRate() * 100, 2) . "%\n";
if (!empty($result->errors)) {
echo "\n⚠️ Errors:\n";
foreach ($result->errors as $error) {
echo " - " . ($error['item'] ?? 'unknown') . ": " . $error['error'] . "\n";
}
}
return $result->isSuccess() ? ExitCode::SUCCESS : ExitCode::ERROR;
} catch (\InvalidArgumentException $e) {
echo "❌ Error: {$e->getMessage()}\n\n";
echo "Use --list to see available strategies.\n";
return ExitCode::ERROR;
}
}
private function warmByPriority(string $priorityLevel): int
{
$priority = $this->parsePriority($priorityLevel);
if ($priority === null) {
echo "❌ Invalid priority level: {$priorityLevel}\n";
echo "Valid values: critical, high, medium, low, background\n";
return ExitCode::ERROR;
}
echo "🔥 Warming strategies with priority >= {$priorityLevel} ({$priority})\n\n";
$metrics = $this->warmingService->warmByPriority($priority);
$this->displayMetrics($metrics);
return $metrics->totalItemsFailed > 0 ? ExitCode::ERROR : ExitCode::SUCCESS;
}
private function listStrategies(): int
{
echo "📋 Available Cache Warming Strategies:\n\n";
$strategies = $this->warmingService->getStrategies();
if (empty($strategies)) {
echo "No strategies registered.\n";
return ExitCode::SUCCESS;
}
foreach ($strategies as $strategy) {
$shouldRun = $strategy['should_run'] ? '✅' : '⏸️';
echo "{$shouldRun} {$strategy['name']}\n";
echo " Priority: {$strategy['priority']}\n";
echo " Estimated duration: {$strategy['estimated_duration']}s\n";
echo " Should run: " . ($strategy['should_run'] ? 'yes' : 'no') . "\n\n";
}
$totalDuration = $this->warmingService->getEstimatedTotalDuration();
echo "⏱️ Total estimated duration: {$totalDuration} seconds\n";
return ExitCode::SUCCESS;
}
private function displayMetrics($metrics): void
{
echo "\n📊 Warmup Results:\n";
echo "==================\n";
echo "Strategies executed: {$metrics->totalStrategiesExecuted}\n";
echo "Items warmed: {$metrics->totalItemsWarmed}\n";
echo "Items failed: {$metrics->totalItemsFailed}\n";
echo "Duration: " . round($metrics->totalDurationSeconds, 3) . "s\n";
echo "Memory used: " . round($metrics->getTotalMemoryUsedMB(), 2) . " MB\n";
echo "Success rate: " . round($metrics->getOverallSuccessRate() * 100, 2) . "%\n";
echo "Items/second: " . round($metrics->getAverageItemsPerSecond(), 2) . "\n\n";
if ($metrics->totalStrategiesExecuted > 0) {
echo "📋 Strategy Details:\n";
foreach ($metrics->strategyResults as $result) {
$icon = $result['is_success'] ? '✅' : '❌';
echo "{$icon} {$result['strategy_name']}: {$result['items_warmed']} items, " .
"{$result['duration_seconds']}s\n";
}
}
}
private function parsePriority(string $level): ?int
{
return match (strtolower($level)) {
'critical' => WarmupPriority::CRITICAL->value,
'high' => WarmupPriority::HIGH->value,
'medium' => WarmupPriority::MEDIUM->value,
'low' => WarmupPriority::LOW->value,
'background' => WarmupPriority::BACKGROUND->value,
default => null
};
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Scheduled job for automatic cache warmup
*
* This job can be registered with the Scheduler to run at regular intervals:
*
* Example:
* $scheduler->schedule(
* 'cache-warmup-critical',
* CronSchedule::fromExpression('0 * * * *'), // Every hour
* fn() => $this->scheduledWarmupJob->warmCriticalPaths()
* );
*/
final readonly class ScheduledWarmupJob
{
public function __construct(
private CacheWarmingService $warmingService,
private Logger $logger
) {}
/**
* Warm only critical paths (fast, essential data)
*
* Recommended schedule: Every hour or on deployment
*/
public function warmCriticalPaths(): array
{
$this->logger->info(
'Starting scheduled warmup: critical paths',
LogContext::empty()
);
$metrics = $this->warmingService->warmByPriority(WarmupPriority::CRITICAL->value);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* Warm high-priority caches (frequent data, slower than critical)
*
* Recommended schedule: Every 6 hours or on deployment
*/
public function warmHighPriority(): array
{
$this->logger->info(
'Starting scheduled warmup: high priority',
LogContext::empty()
);
$metrics = $this->warmingService->warmByPriority(WarmupPriority::HIGH->value);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* Warm all caches including predictive (slow, comprehensive)
*
* Recommended schedule: Daily during off-peak hours (e.g., 3 AM)
*/
public function warmAllCaches(): array
{
$this->logger->info(
'Starting scheduled warmup: all caches',
LogContext::empty()
);
$metrics = $this->warmingService->warmAll(force: false);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* On-demand warmup triggered by specific events
*
* Example: After deployment, configuration changes, or cache clear
*/
public function warmOnDemand(string $reason = 'manual trigger'): array
{
$this->logger->info(
'Starting on-demand warmup',
LogContext::withData(['reason' => $reason])
);
// Force warmup even if shouldRun() returns false
$metrics = $this->warmingService->warmAll(force: true);
$this->logger->info(
'On-demand warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\Warming\WarmupStrategy;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
/**
* Base class for warmup strategies with common functionality
*/
abstract readonly class BaseWarmupStrategy implements WarmupStrategy
{
public function __construct(
protected Cache $cache
) {}
abstract protected function getItemsToWarm(): array;
public function warmup(): WarmupResult
{
$startTime = microtime(true);
$startMemory = memory_get_usage();
$itemsWarmed = 0;
$itemsFailed = 0;
$errors = [];
$items = $this->getItemsToWarm();
foreach ($items as $item) {
try {
$this->warmItem($item);
$itemsWarmed++;
} catch (\Throwable $e) {
$itemsFailed++;
$errors[] = [
'item' => $this->getItemIdentifier($item),
'error' => $e->getMessage()
];
}
}
$duration = microtime(true) - $startTime;
$memoryUsed = memory_get_usage() - $startMemory;
return new WarmupResult(
strategyName: $this->getName(),
itemsWarmed: $itemsWarmed,
itemsFailed: $itemsFailed,
durationSeconds: $duration,
memoryUsedBytes: max(0, $memoryUsed),
errors: $errors,
metadata: $this->getMetadata()
);
}
abstract protected function warmItem(mixed $item): void;
protected function getItemIdentifier(mixed $item): string
{
if (is_array($item) && isset($item['key'])) {
return (string) $item['key'];
}
return is_string($item) ? $item : 'unknown';
}
protected function getMetadata(): array
{
return [];
}
public function shouldRun(): bool
{
return true;
}
public function getEstimatedDuration(): int
{
return count($this->getItemsToWarm());
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Config\Environment;
/**
* Warms critical application paths that must be cached for optimal performance
*
* This strategy loads:
* - Compiled routes
* - Framework configuration
* - Environment variables
* - Common application settings
*/
final readonly class CriticalPathWarmingStrategy extends BaseWarmupStrategy
{
public function __construct(
Cache $cache,
private CompiledRoutes $compiledRoutes,
private Environment $environment
) {
parent::__construct($cache);
}
public function getName(): string
{
return 'critical_path';
}
public function getPriority(): int
{
return WarmupPriority::CRITICAL->value;
}
protected function getItemsToWarm(): array
{
return [
[
'key' => 'routes_static',
'loader' => fn() => $this->compiledRoutes->getStaticRoutes(),
'ttl' => Duration::fromDays(7)
],
[
'key' => 'routes_dynamic',
'loader' => fn() => $this->compiledRoutes->getDynamicRoutes(),
'ttl' => Duration::fromDays(7)
],
[
'key' => 'framework_config',
'loader' => fn() => $this->loadFrameworkConfig(),
'ttl' => Duration::fromHours(24)
],
[
'key' => 'env_variables',
'loader' => fn() => $this->loadEnvironmentVariables(),
'ttl' => Duration::fromHours(24)
]
];
}
protected function warmItem(mixed $item): void
{
if (!is_array($item) || !isset($item['key'], $item['loader'], $item['ttl'])) {
throw new \InvalidArgumentException('Invalid warmup item structure');
}
$key = CacheKey::fromString($item['key']);
$data = $item['loader']();
$ttl = $item['ttl'];
$cacheItem = CacheItem::forSetting(
key: $key,
value: $data,
ttl: $ttl
);
$this->cache->set($cacheItem);
}
private function loadFrameworkConfig(): array
{
return [
'cache_enabled' => $this->environment->getBool('CACHE_ENABLED', true),
'debug_mode' => $this->environment->getBool('APP_DEBUG', false),
'app_env' => $this->environment->get('APP_ENV', 'production'),
'session_lifetime' => $this->environment->getInt('SESSION_LIFETIME', 7200),
'cache_driver' => $this->environment->get('CACHE_DRIVER', 'file')
];
}
private function loadEnvironmentVariables(): array
{
// Only cache non-sensitive environment variables
return [
'app_name' => $this->environment->get('APP_NAME', 'Framework'),
'app_url' => $this->environment->get('APP_URL', 'http://localhost'),
'app_timezone' => $this->environment->get('APP_TIMEZONE', 'UTC'),
'cache_prefix' => $this->environment->get('CACHE_PREFIX', 'app_')
];
}
protected function getMetadata(): array
{
return [
'routes_count' => count($this->compiledRoutes->getStaticRoutes()) +
count($this->compiledRoutes->getDynamicRoutes()),
'config_keys' => 5,
'env_vars' => 4
];
}
public function getEstimatedDuration(): int
{
return 2; // Critical path should be very fast (2 seconds)
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Core\ValueObjects\Duration;
/**
* Predictive warming based on access patterns and historical data
*
* This strategy analyzes cache hit/miss patterns and proactively warms
* cache entries that are likely to be accessed soon based on:
* - Historical access patterns
* - Time of day patterns
* - Seasonal patterns
* - User behavior patterns
*/
final readonly class PredictiveWarmingStrategy extends BaseWarmupStrategy
{
private const ACCESS_PATTERN_CACHE_KEY = 'warmup_access_patterns';
private const MIN_ACCESS_COUNT = 5; // Minimum accesses to consider for warmup
private const PREDICTION_THRESHOLD = 0.7; // 70% probability threshold
public function __construct(
Cache $cache
) {
parent::__construct($cache);
}
public function getName(): string
{
return 'predictive';
}
public function getPriority(): int
{
return WarmupPriority::BACKGROUND->value;
}
protected function getItemsToWarm(): array
{
$accessPatterns = $this->loadAccessPatterns();
$predictions = $this->generatePredictions($accessPatterns);
$items = [];
foreach ($predictions as $prediction) {
if ($prediction['probability'] >= self::PREDICTION_THRESHOLD) {
$items[] = [
'key' => $prediction['cache_key'],
'loader' => $prediction['loader'],
'ttl' => $prediction['ttl'],
'probability' => $prediction['probability']
];
}
}
return $items;
}
protected function warmItem(mixed $item): void
{
if (!is_array($item) || !isset($item['key'], $item['loader'], $item['ttl'])) {
throw new \InvalidArgumentException('Invalid warmup item structure');
}
$key = CacheKey::fromString($item['key']);
// Check if item is already in cache (don't override fresh data)
if ($this->cache->has($key)) {
return;
}
$data = $item['loader']();
$ttl = $item['ttl'];
$cacheItem = CacheItem::forSetting(
key: $key,
value: $data,
ttl: $ttl
);
$this->cache->set($cacheItem);
}
private function loadAccessPatterns(): array
{
$key = CacheKey::fromString(self::ACCESS_PATTERN_CACHE_KEY);
$result = $this->cache->get($key);
if ($result->isHit()) {
return $result->value;
}
return [];
}
private function generatePredictions(array $accessPatterns): array
{
$currentHour = (int) date('H');
$currentDayOfWeek = (int) date('N'); // 1 (Monday) to 7 (Sunday)
$predictions = [];
foreach ($accessPatterns as $pattern) {
$probability = $this->calculateProbability(
pattern: $pattern,
currentHour: $currentHour,
currentDayOfWeek: $currentDayOfWeek
);
if ($probability >= self::PREDICTION_THRESHOLD) {
$predictions[] = [
'cache_key' => $pattern['cache_key'],
'loader' => $pattern['loader'] ?? fn() => null,
'ttl' => $pattern['ttl'] ?? Duration::fromHours(1),
'probability' => $probability,
'reason' => $this->getPredictionReason($pattern, $currentHour, $currentDayOfWeek)
];
}
}
// Sort by probability (highest first)
usort($predictions, fn($a, $b) => $b['probability'] <=> $a['probability']);
// Limit to top 50 predictions to avoid overwhelming the cache
return array_slice($predictions, 0, 50);
}
private function calculateProbability(array $pattern, int $currentHour, int $currentDayOfWeek): float
{
$accessCount = $pattern['access_count'] ?? 0;
if ($accessCount < self::MIN_ACCESS_COUNT) {
return 0.0;
}
$hourlyPattern = $pattern['hourly_distribution'] ?? [];
$dailyPattern = $pattern['daily_distribution'] ?? [];
// Base probability from access frequency
$baseProbability = min($accessCount / 100, 1.0);
// Hour-based adjustment
$hourlyWeight = $hourlyPattern[$currentHour] ?? 0.0;
// Day-based adjustment
$dailyWeight = $dailyPattern[$currentDayOfWeek] ?? 0.0;
// Combined probability
$probability = $baseProbability * 0.4 + $hourlyWeight * 0.4 + $dailyWeight * 0.2;
return min($probability, 1.0);
}
private function getPredictionReason(array $pattern, int $currentHour, int $currentDayOfWeek): string
{
$reasons = [];
if (($pattern['access_count'] ?? 0) >= 50) {
$reasons[] = 'high access frequency';
}
$hourlyPattern = $pattern['hourly_distribution'] ?? [];
if (($hourlyPattern[$currentHour] ?? 0.0) > 0.7) {
$reasons[] = "typically accessed at hour {$currentHour}";
}
$dailyPattern = $pattern['daily_distribution'] ?? [];
$dayName = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][$currentDayOfWeek - 1];
if (($dailyPattern[$currentDayOfWeek] ?? 0.0) > 0.7) {
$reasons[] = "typically accessed on {$dayName}";
}
return !empty($reasons) ? implode(', ', $reasons) : 'access pattern detected';
}
protected function getMetadata(): array
{
$accessPatterns = $this->loadAccessPatterns();
$predictions = $this->generatePredictions($accessPatterns);
return [
'total_patterns' => count($accessPatterns),
'predictions_generated' => count($predictions),
'top_probability' => !empty($predictions) ? round($predictions[0]['probability'], 2) : 0.0
];
}
public function shouldRun(): bool
{
// Only run predictive warming if we have enough historical data
$accessPatterns = $this->loadAccessPatterns();
return count($accessPatterns) >= 10;
}
public function getEstimatedDuration(): int
{
$predictions = $this->generatePredictions($this->loadAccessPatterns());
return max(count($predictions) * 2, 10); // 2 seconds per prediction, min 10 seconds
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Aggregated metrics for cache warmup operations
*/
final readonly class WarmupMetrics
{
public function __construct(
public int $totalStrategiesExecuted,
public int $totalItemsWarmed,
public int $totalItemsFailed,
public float $totalDurationSeconds,
public int $totalMemoryUsedBytes,
public array $strategyResults = [],
public \DateTimeImmutable $executedAt = new \DateTimeImmutable()
) {}
public static function fromResults(array $results): self
{
$totalItemsWarmed = 0;
$totalItemsFailed = 0;
$totalDuration = 0.0;
$totalMemory = 0;
foreach ($results as $result) {
if (!$result instanceof WarmupResult) {
continue;
}
$totalItemsWarmed += $result->itemsWarmed;
$totalItemsFailed += $result->itemsFailed;
$totalDuration += $result->durationSeconds;
$totalMemory += $result->memoryUsedBytes;
}
return new self(
totalStrategiesExecuted: count($results),
totalItemsWarmed: $totalItemsWarmed,
totalItemsFailed: $totalItemsFailed,
totalDurationSeconds: $totalDuration,
totalMemoryUsedBytes: $totalMemory,
strategyResults: array_map(fn($r) => $r->toArray(), $results)
);
}
public function getOverallSuccessRate(): float
{
$total = $this->totalItemsWarmed + $this->totalItemsFailed;
if ($total === 0) {
return 1.0;
}
return $this->totalItemsWarmed / $total;
}
public function getAverageItemsPerSecond(): float
{
if ($this->totalDurationSeconds === 0.0) {
return 0.0;
}
return $this->totalItemsWarmed / $this->totalDurationSeconds;
}
public function getTotalMemoryUsedMB(): float
{
return $this->totalMemoryUsedBytes / 1024 / 1024;
}
public function toArray(): array
{
return [
'executed_at' => $this->executedAt->format('Y-m-d H:i:s'),
'total_strategies_executed' => $this->totalStrategiesExecuted,
'total_items_warmed' => $this->totalItemsWarmed,
'total_items_failed' => $this->totalItemsFailed,
'total_duration_seconds' => round($this->totalDurationSeconds, 3),
'total_memory_used_mb' => round($this->getTotalMemoryUsedMB(), 2),
'overall_success_rate' => round($this->getOverallSuccessRate() * 100, 2),
'average_items_per_second' => round($this->getAverageItemsPerSecond(), 2),
'strategy_results' => $this->strategyResults
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Priority levels for cache warmup strategies
*/
enum WarmupPriority: int
{
case CRITICAL = 1000; // Routes, config - must warm first
case HIGH = 500; // Frequent database queries
case MEDIUM = 250; // Session data, templates
case LOW = 100; // Less frequently accessed data
case BACKGROUND = 0; // Nice-to-have, run when idle
public function getDescription(): string
{
return match ($this) {
self::CRITICAL => 'Critical application data (routes, config)',
self::HIGH => 'Frequently accessed data (popular queries)',
self::MEDIUM => 'Moderately accessed data (templates, session)',
self::LOW => 'Infrequently accessed data',
self::BACKGROUND => 'Background optimization (predictive)',
};
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Result of a cache warmup operation
*/
final readonly class WarmupResult
{
public function __construct(
public string $strategyName,
public int $itemsWarmed,
public int $itemsFailed,
public float $durationSeconds,
public int $memoryUsedBytes,
public array $errors = [],
public array $metadata = []
) {
if ($itemsWarmed < 0) {
throw new \InvalidArgumentException('Items warmed cannot be negative');
}
if ($itemsFailed < 0) {
throw new \InvalidArgumentException('Items failed cannot be negative');
}
if ($durationSeconds < 0.0) {
throw new \InvalidArgumentException('Duration cannot be negative');
}
if ($memoryUsedBytes < 0) {
throw new \InvalidArgumentException('Memory used cannot be negative');
}
}
public function isSuccess(): bool
{
return $this->itemsFailed === 0;
}
public function getSuccessRate(): float
{
$total = $this->itemsWarmed + $this->itemsFailed;
if ($total === 0) {
return 1.0;
}
return $this->itemsWarmed / $total;
}
public function getItemsPerSecond(): float
{
if ($this->durationSeconds === 0.0) {
return 0.0;
}
return $this->itemsWarmed / $this->durationSeconds;
}
public function getMemoryUsedMB(): float
{
return $this->memoryUsedBytes / 1024 / 1024;
}
public function toArray(): array
{
return [
'strategy_name' => $this->strategyName,
'items_warmed' => $this->itemsWarmed,
'items_failed' => $this->itemsFailed,
'duration_seconds' => round($this->durationSeconds, 3),
'memory_used_mb' => round($this->getMemoryUsedMB(), 2),
'success_rate' => round($this->getSuccessRate() * 100, 2),
'items_per_second' => round($this->getItemsPerSecond(), 2),
'is_success' => $this->isSuccess(),
'errors' => $this->errors,
'metadata' => $this->metadata
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
/**
* Interface for cache warming strategies
*
* Strategies determine which cache entries to warm up and in what order.
*/
interface WarmupStrategy
{
/**
* Get the strategy name for identification
*/
public function getName(): string;
/**
* Get the priority of this strategy (higher = executed first)
*/
public function getPriority(): int;
/**
* Execute the warmup strategy
*
* @return WarmupResult Result of the warmup operation
*/
public function warmup(): WarmupResult;
/**
* Check if this strategy should run based on current conditions
*/
public function shouldRun(): bool;
/**
* Get estimated time to complete warmup in seconds
*/
public function getEstimatedDuration(): int;
}