lthn.io/app/Core/Storage/CircuitBreaker.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

350 lines
9.1 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 Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\Cache;
/**
* Circuit breaker pattern implementation for Redis resilience.
*
* Prevents cascading failures by temporarily "opening" the circuit when
* Redis fails repeatedly, allowing the system to use the fallback without
* continually attempting Redis connections.
*
* ## States
*
* - **Closed**: Normal operation, requests go to Redis
* - **Open**: Redis failing, requests go directly to fallback
* - **Half-Open**: Testing if Redis has recovered
*
* ## Configuration
*
* - `failure_threshold`: Number of failures before opening (default: 5)
* - `recovery_timeout`: Seconds before attempting recovery (default: 30)
* - `success_threshold`: Successes needed to close circuit (default: 2)
*
* ## Usage
*
* ```php
* $breaker = new CircuitBreaker('redis');
*
* if ($breaker->isAvailable()) {
* try {
* $result = $redis->get($key);
* $breaker->recordSuccess();
* } catch (\Throwable $e) {
* $breaker->recordFailure();
* // Use fallback
* }
* } else {
* // Use fallback directly, skip Redis attempt
* }
* ```
*/
class CircuitBreaker
{
/**
* Circuit states.
*/
public const STATE_CLOSED = 'closed';
public const STATE_OPEN = 'open';
public const STATE_HALF_OPEN = 'half_open';
/**
* Cache key prefix.
*/
protected const CACHE_PREFIX = 'circuit_breaker:';
/**
* @var int Number of failures before opening circuit
*/
protected int $failureThreshold;
/**
* @var int Seconds to wait before attempting recovery
*/
protected int $recoveryTimeout;
/**
* @var int Successes needed to close circuit from half-open
*/
protected int $successThreshold;
/**
* @var CacheRepository|null Fallback cache for storing state
*/
protected ?CacheRepository $stateCache = null;
public function __construct(
protected string $serviceName,
?int $failureThreshold = null,
?int $recoveryTimeout = null,
?int $successThreshold = null,
) {
$this->failureThreshold = $failureThreshold
?? (int) config('core.storage.circuit_breaker.failure_threshold', 5);
$this->recoveryTimeout = $recoveryTimeout
?? (int) config('core.storage.circuit_breaker.recovery_timeout', 30);
$this->successThreshold = $successThreshold
?? (int) config('core.storage.circuit_breaker.success_threshold', 2);
}
/**
* Check if the service is available (circuit closed or half-open).
*/
public function isAvailable(): bool
{
$state = $this->getState();
if ($state === self::STATE_CLOSED) {
return true;
}
if ($state === self::STATE_OPEN) {
// Check if recovery timeout has passed
if ($this->shouldAttemptRecovery()) {
$this->transitionToHalfOpen();
return true;
}
return false;
}
// Half-open: allow one request through
return true;
}
/**
* Record a successful operation.
*/
public function recordSuccess(): void
{
$state = $this->getState();
if ($state === self::STATE_HALF_OPEN) {
$successes = $this->incrementSuccessCount();
if ($successes >= $this->successThreshold) {
$this->transitionToClosed();
}
} elseif ($state === self::STATE_CLOSED) {
// Reset failure count on success
$this->resetFailureCount();
}
}
/**
* Record a failed operation.
*/
public function recordFailure(): void
{
$state = $this->getState();
if ($state === self::STATE_HALF_OPEN) {
// Single failure in half-open reopens the circuit
$this->transitionToOpen();
} elseif ($state === self::STATE_CLOSED) {
$failures = $this->incrementFailureCount();
if ($failures >= $this->failureThreshold) {
$this->transitionToOpen();
}
}
}
/**
* Get the current circuit state.
*/
public function getState(): string
{
return $this->getStateValue('state') ?? self::STATE_CLOSED;
}
/**
* Force the circuit to a specific state.
*/
public function forceState(string $state): void
{
$this->setStateValue('state', $state);
$this->setStateValue('state_changed_at', time());
if ($state === self::STATE_CLOSED) {
$this->resetFailureCount();
$this->resetSuccessCount();
}
}
/**
* Get circuit statistics.
*
* @return array<string, mixed>
*/
public function getStats(): array
{
return [
'service' => $this->serviceName,
'state' => $this->getState(),
'failure_count' => (int) $this->getStateValue('failure_count'),
'success_count' => (int) $this->getStateValue('success_count'),
'last_failure_at' => $this->getStateValue('last_failure_at'),
'state_changed_at' => $this->getStateValue('state_changed_at'),
'thresholds' => [
'failure' => $this->failureThreshold,
'recovery_timeout' => $this->recoveryTimeout,
'success' => $this->successThreshold,
],
];
}
/**
* Reset the circuit breaker to closed state.
*/
public function reset(): void
{
$this->forceState(self::STATE_CLOSED);
}
/**
* Transition to open state.
*/
protected function transitionToOpen(): void
{
$this->setStateValue('state', self::STATE_OPEN);
$this->setStateValue('state_changed_at', time());
$this->setStateValue('last_failure_at', time());
$this->resetSuccessCount();
}
/**
* Transition to half-open state.
*/
protected function transitionToHalfOpen(): void
{
$this->setStateValue('state', self::STATE_HALF_OPEN);
$this->setStateValue('state_changed_at', time());
$this->resetSuccessCount();
}
/**
* Transition to closed state.
*/
protected function transitionToClosed(): void
{
$this->setStateValue('state', self::STATE_CLOSED);
$this->setStateValue('state_changed_at', time());
$this->resetFailureCount();
$this->resetSuccessCount();
}
/**
* Check if recovery should be attempted.
*/
protected function shouldAttemptRecovery(): bool
{
$changedAt = (int) $this->getStateValue('state_changed_at');
return (time() - $changedAt) >= $this->recoveryTimeout;
}
/**
* Increment and return failure count.
*/
protected function incrementFailureCount(): int
{
$count = ((int) $this->getStateValue('failure_count')) + 1;
$this->setStateValue('failure_count', $count);
return $count;
}
/**
* Reset failure count.
*/
protected function resetFailureCount(): void
{
$this->setStateValue('failure_count', 0);
}
/**
* Increment and return success count.
*/
protected function incrementSuccessCount(): int
{
$count = ((int) $this->getStateValue('success_count')) + 1;
$this->setStateValue('success_count', $count);
return $count;
}
/**
* Reset success count.
*/
protected function resetSuccessCount(): void
{
$this->setStateValue('success_count', 0);
}
/**
* Get a state value from storage.
*/
protected function getStateValue(string $key): mixed
{
return $this->getStateCache()->get($this->getCacheKey($key));
}
/**
* Set a state value in storage.
*/
protected function setStateValue(string $key, mixed $value): void
{
// Store for 24 hours (enough to survive any reasonable recovery timeout)
$this->getStateCache()->put(
$this->getCacheKey($key),
$value,
86400
);
}
/**
* Get cache key for a state value.
*/
protected function getCacheKey(string $key): string
{
return self::CACHE_PREFIX.$this->serviceName.':'.$key;
}
/**
* Get the cache repository for storing state.
*
* Uses database cache to avoid circular dependency with Redis.
*/
protected function getStateCache(): CacheRepository
{
if ($this->stateCache === null) {
// Use file or database cache to avoid Redis dependency
$driver = config('core.storage.circuit_breaker.state_driver', 'file');
try {
$this->stateCache = Cache::store($driver);
} catch (\Throwable) {
// Fallback to array cache if driver unavailable
$this->stateCache = Cache::store('array');
}
}
return $this->stateCache;
}
}