lthn.io/app/Core/Storage/CacheWarmer.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

598 lines
16 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Storage;
use Closure;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Cache warming mechanism for pre-populating cache.
*
* Provides a strategy for warming cache with frequently accessed data
* during off-peak times or after deployments to prevent cache stampedes.
*
* ## What is Cache Warming?
*
* Cache warming is the process of pre-populating the cache with data
* before it's needed, rather than waiting for the first request.
* This prevents the "cold cache" problem where the first users after
* a deployment or cache flush experience slow response times.
*
* ## Usage
*
* ```php
* $warmer = new CacheWarmer();
*
* // Register items to warm
* $warmer->register('user_settings', fn() => UserSettings::all()->keyBy('key'));
* $warmer->register('site_config', fn() => Config::pluck('value', 'key'), ttl: 3600);
*
* // Warm all registered items
* $results = $warmer->warmAll();
*
* // Warm specific item
* $warmer->warm('user_settings');
*
* // Schedule warming (typically in a scheduled command)
* $warmer->warmStale(); // Only warms items that are missing or expired
* ```
*
* ## Batch Warming
*
* For large datasets, use batch warming to prevent memory issues:
*
* ```php
* $warmer->registerBatch('products', function(int $offset, int $limit) {
* return Product::skip($offset)->take($limit)->get();
* }, batchSize: 100, totalItems: 10000);
* ```
*
* ## Configuration
*
* - `core.storage.cache_warming.enabled`: Enable/disable warming (default: true)
* - `core.storage.cache_warming.default_ttl`: Default TTL in seconds (default: 3600)
* - `core.storage.cache_warming.log_enabled`: Log warming operations (default: true)
* - `core.storage.cache_warming.concurrency`: Max concurrent warming operations (default: 5)
*/
class CacheWarmer
{
/**
* Registered warming items.
*
* @var array<string, array{callback: Closure, ttl: int, tags: array, priority: int}>
*/
protected array $items = [];
/**
* Registered batch warming items.
*
* @var array<string, array{callback: Closure, batchSize: int, totalItems: int, ttl: int, tags: array}>
*/
protected array $batchItems = [];
/**
* Warming results from the last operation.
*
* @var array<string, array{status: string, duration: float, error?: string}>
*/
protected array $lastResults = [];
/**
* Whether cache warming is enabled.
*/
protected bool $enabled;
/**
* Default TTL for cached items.
*/
protected int $defaultTtl;
/**
* Whether to log warming operations.
*/
protected bool $logEnabled;
/**
* Maximum concurrent warming operations.
*/
protected int $concurrency;
/**
* Cache store to use.
*/
protected ?CacheRepository $store = null;
public function __construct()
{
$this->enabled = (bool) config('core.storage.cache_warming.enabled', true);
$this->defaultTtl = (int) config('core.storage.cache_warming.default_ttl', 3600);
$this->logEnabled = (bool) config('core.storage.cache_warming.log_enabled', true);
$this->concurrency = (int) config('core.storage.cache_warming.concurrency', 5);
}
/**
* Register an item for cache warming.
*
* @param string $key Cache key
* @param Closure $callback Callback that returns the data to cache
* @param int|null $ttl Time-to-live in seconds (null uses default)
* @param array<string> $tags Cache tags (if supported by driver)
* @param int $priority Warming priority (lower = higher priority)
*/
public function register(
string $key,
Closure $callback,
?int $ttl = null,
array $tags = [],
int $priority = 50
): static {
$this->items[$key] = [
'callback' => $callback,
'ttl' => $ttl ?? $this->defaultTtl,
'tags' => $tags,
'priority' => $priority,
];
return $this;
}
/**
* Register a batch item for cache warming.
*
* Use for large datasets that should be warmed in chunks.
*
* @param string $keyPrefix Cache key prefix (actual keys will be {prefix}:{offset})
* @param Closure $callback Callback(int $offset, int $limit) that returns batch data
* @param int $batchSize Number of items per batch
* @param int $totalItems Total number of items (for progress tracking)
* @param int|null $ttl Time-to-live in seconds
* @param array<string> $tags Cache tags
*/
public function registerBatch(
string $keyPrefix,
Closure $callback,
int $batchSize = 100,
int $totalItems = 0,
?int $ttl = null,
array $tags = []
): static {
$this->batchItems[$keyPrefix] = [
'callback' => $callback,
'batchSize' => $batchSize,
'totalItems' => $totalItems,
'ttl' => $ttl ?? $this->defaultTtl,
'tags' => $tags,
];
return $this;
}
/**
* Unregister an item from cache warming.
*/
public function unregister(string $key): static
{
unset($this->items[$key], $this->batchItems[$key]);
return $this;
}
/**
* Warm all registered items.
*
* @return array<string, array{status: string, duration: float, error?: string}>
*/
public function warmAll(): array
{
if (! $this->enabled) {
return ['_disabled' => ['status' => 'skipped', 'duration' => 0.0]];
}
$this->lastResults = [];
$startTime = microtime(true);
$this->log('info', 'Starting cache warming', [
'items' => count($this->items),
'batch_items' => count($this->batchItems),
]);
// Sort by priority
$sortedItems = $this->items;
uasort($sortedItems, fn ($a, $b) => $a['priority'] <=> $b['priority']);
// Warm regular items
foreach ($sortedItems as $key => $item) {
$this->warmItem($key, $item);
}
// Warm batch items
foreach ($this->batchItems as $keyPrefix => $item) {
$this->warmBatchItem($keyPrefix, $item);
}
$totalDuration = microtime(true) - $startTime;
$this->log('info', 'Cache warming completed', [
'total_duration' => round($totalDuration, 3),
'items_warmed' => count($this->lastResults),
'successes' => count(array_filter($this->lastResults, fn ($r) => $r['status'] === 'success')),
'failures' => count(array_filter($this->lastResults, fn ($r) => $r['status'] === 'failed')),
]);
return $this->lastResults;
}
/**
* Warm a specific item by key.
*/
public function warm(string $key): bool
{
if (! $this->enabled) {
return false;
}
if (isset($this->items[$key])) {
$result = $this->warmItem($key, $this->items[$key]);
return $result['status'] === 'success';
}
if (isset($this->batchItems[$key])) {
$this->warmBatchItem($key, $this->batchItems[$key]);
return true;
}
return false;
}
/**
* Warm only stale (missing or expired) items.
*
* More efficient than warmAll() as it skips items that are still cached.
*
* @return array<string, array{status: string, duration: float, error?: string}>
*/
public function warmStale(): array
{
if (! $this->enabled) {
return ['_disabled' => ['status' => 'skipped', 'duration' => 0.0]];
}
$this->lastResults = [];
$cache = $this->getStore();
foreach ($this->items as $key => $item) {
if (! $cache->has($key)) {
$this->warmItem($key, $item);
} else {
$this->lastResults[$key] = ['status' => 'exists', 'duration' => 0.0];
}
}
return $this->lastResults;
}
/**
* Check if an item is warm (exists in cache).
*/
public function isWarm(string $key): bool
{
return $this->getStore()->has($key);
}
/**
* Get the warming status of all registered items.
*
* @return array<string, array{registered: bool, cached: bool, ttl: int}>
*/
public function getStatus(): array
{
$status = [];
$cache = $this->getStore();
foreach ($this->items as $key => $item) {
$status[$key] = [
'registered' => true,
'cached' => $cache->has($key),
'ttl' => $item['ttl'],
'priority' => $item['priority'],
'type' => 'single',
];
}
foreach ($this->batchItems as $keyPrefix => $item) {
$status[$keyPrefix] = [
'registered' => true,
'cached' => null, // Batch items have multiple keys
'ttl' => $item['ttl'],
'batch_size' => $item['batchSize'],
'total_items' => $item['totalItems'],
'type' => 'batch',
];
}
return $status;
}
/**
* Get the last warming results.
*
* @return array<string, array{status: string, duration: float, error?: string}>
*/
public function getLastResults(): array
{
return $this->lastResults;
}
/**
* Invalidate (remove) a warmed item from cache.
*/
public function invalidate(string $key): bool
{
return $this->getStore()->forget($key);
}
/**
* Invalidate all registered items from cache.
*/
public function invalidateAll(): int
{
$count = 0;
$cache = $this->getStore();
foreach (array_keys($this->items) as $key) {
if ($cache->forget($key)) {
$count++;
}
}
return $count;
}
/**
* Set the cache store to use.
*/
public function useStore(string $store): static
{
$this->store = Cache::store($store);
return $this;
}
/**
* Enable or disable cache warming.
*/
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
/**
* Set the default TTL.
*/
public function setDefaultTtl(int $seconds): static
{
$this->defaultTtl = $seconds;
return $this;
}
/**
* Get all registered item keys.
*
* @return array<string>
*/
public function getRegisteredKeys(): array
{
return array_merge(
array_keys($this->items),
array_keys($this->batchItems)
);
}
/**
* Check if an item is registered.
*/
public function isRegistered(string $key): bool
{
return isset($this->items[$key]) || isset($this->batchItems[$key]);
}
/**
* Get warming statistics summary.
*
* @return array{total_registered: int, total_cached: int, cache_rate: float}
*/
public function getStats(): array
{
$total = count($this->items);
$cached = 0;
$cache = $this->getStore();
foreach (array_keys($this->items) as $key) {
if ($cache->has($key)) {
$cached++;
}
}
return [
'total_registered' => $total,
'total_cached' => $cached,
'cache_rate' => $total > 0 ? round($cached / $total * 100, 2) : 0.0,
'batch_items' => count($this->batchItems),
];
}
/**
* Warm a single item.
*
* @param array{callback: Closure, ttl: int, tags: array, priority: int} $item
* @return array{status: string, duration: float, error?: string}
*/
protected function warmItem(string $key, array $item): array
{
$startTime = microtime(true);
try {
$data = ($item['callback'])();
$cache = $this->getStore();
if (! empty($item['tags']) && method_exists($cache, 'tags')) {
$cache->tags($item['tags'])->put($key, $data, $item['ttl']);
} else {
$cache->put($key, $data, $item['ttl']);
}
$duration = microtime(true) - $startTime;
$this->lastResults[$key] = [
'status' => 'success',
'duration' => round($duration, 4),
];
$this->log('debug', "Warmed cache key: {$key}", [
'duration' => round($duration, 4),
'ttl' => $item['ttl'],
]);
return $this->lastResults[$key];
} catch (\Throwable $e) {
$duration = microtime(true) - $startTime;
$this->lastResults[$key] = [
'status' => 'failed',
'duration' => round($duration, 4),
'error' => $e->getMessage(),
];
$this->log('error', "Failed to warm cache key: {$key}", [
'error' => $e->getMessage(),
'duration' => round($duration, 4),
]);
return $this->lastResults[$key];
}
}
/**
* Warm a batch item.
*
* @param array{callback: Closure, batchSize: int, totalItems: int, ttl: int, tags: array} $item
*/
protected function warmBatchItem(string $keyPrefix, array $item): void
{
$offset = 0;
$batchNumber = 0;
while (true) {
$batchKey = "{$keyPrefix}:{$offset}";
$startTime = microtime(true);
try {
$data = ($item['callback'])($offset, $item['batchSize']);
// Empty result means we've processed all items
if (empty($data)) {
break;
}
// Handle both arrays and collections
$count = is_countable($data) ? count($data) : 0;
if ($count === 0) {
break;
}
$cache = $this->getStore();
if (! empty($item['tags']) && method_exists($cache, 'tags')) {
$cache->tags($item['tags'])->put($batchKey, $data, $item['ttl']);
} else {
$cache->put($batchKey, $data, $item['ttl']);
}
$duration = microtime(true) - $startTime;
$this->lastResults[$batchKey] = [
'status' => 'success',
'duration' => round($duration, 4),
'items' => $count,
];
$offset += $item['batchSize'];
$batchNumber++;
// Safety check to prevent infinite loops
if ($item['totalItems'] > 0 && $offset >= $item['totalItems']) {
break;
}
// Also break if we got fewer items than batch size (end of data)
if ($count < $item['batchSize']) {
break;
}
} catch (\Throwable $e) {
$duration = microtime(true) - $startTime;
$this->lastResults[$batchKey] = [
'status' => 'failed',
'duration' => round($duration, 4),
'error' => $e->getMessage(),
];
$this->log('error', "Failed to warm batch key: {$batchKey}", [
'error' => $e->getMessage(),
]);
// Continue with next batch even if one fails
$offset += $item['batchSize'];
$batchNumber++;
if ($item['totalItems'] > 0 && $offset >= $item['totalItems']) {
break;
}
}
}
$this->log('debug', "Warmed batch: {$keyPrefix}", [
'batches' => $batchNumber,
'total_offset' => $offset,
]);
}
/**
* Get the cache store.
*/
protected function getStore(): CacheRepository
{
return $this->store ?? Cache::store();
}
/**
* Log a message if logging is enabled.
*
* @param array<string, mixed> $context
*/
protected function log(string $level, string $message, array $context = []): void
{
if (! $this->logEnabled) {
return;
}
Log::log($level, "[CacheWarmer] {$message}", $context);
}
}