From 6d30c2b01586c5e68c6ef168b132e55e88ece203 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 7 May 2026 13:31:20 +0530 Subject: [PATCH] Add circuit breaker around cache adapter --- app/cli.php | 9 +- app/init/resources.php | 16 ++- app/realtime.php | 9 +- composer.json | 3 +- composer.lock | 64 ++++++++- src/Appwrite/Cache/Adapter/CircuitBreaker.php | 104 +++++++++++++++ .../unit/Cache/Adapter/CircuitBreakerTest.php | 124 ++++++++++++++++++ 7 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 src/Appwrite/Cache/Adapter/CircuitBreaker.php create mode 100644 tests/unit/Cache/Adapter/CircuitBreakerTest.php diff --git a/app/cli.php b/app/cli.php index ada155c4dc..4203f3ae4e 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/init.php'; +use Appwrite\Cache\Adapter\CircuitBreaker as CircuitBreakerCache; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; @@ -18,6 +19,7 @@ use Swoole\Timer; use Utopia\Cache\Adapter\Pool as CachePool; use Utopia\Cache\Adapter\Sharding; use Utopia\Cache\Cache; +use Utopia\CircuitBreaker\CircuitBreaker; use Utopia\CLI\Adapters\Generic; use Utopia\CLI\CLI; use Utopia\Config\Config; @@ -74,7 +76,12 @@ $container->set('cache', function ($pools) { $adapters[] = new CachePool($pools->get($value)); } - return new Cache(new Sharding($adapters)); + return new Cache(new CircuitBreakerCache(new Sharding($adapters), new CircuitBreaker( + threshold: 3, + timeout: 30, + successThreshold: 2, + metricPrefix: 'appwrite', + ))); }, ['pools']); $container->set('pools', function (Registry $register) { diff --git a/app/init/resources.php b/app/init/resources.php index 96457294de..a6a7fa653a 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,5 +1,6 @@ set('getLogsDB', function (Group $pools, Cache $cache, Authorization $container->set('telemetry', fn () => new NoTelemetry()); -$container->set('cache', function (Group $pools, Telemetry $telemetry) { +$container->set('breakerForCache', fn (Telemetry $telemetry): CircuitBreaker => new CircuitBreaker( + threshold: 3, + timeout: 30, + successThreshold: 2, + telemetry: $telemetry, + metricPrefix: 'appwrite', +)); + +$container->set('cache', function (Group $pools, CircuitBreaker $breaker, Telemetry $telemetry) { $list = Config::getParam('pools-cache', []); $adapters = []; @@ -192,11 +202,11 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { $adapters[] = new CachePool($pools->get($value)); } - $cache = new Cache(new Sharding($adapters)); + $cache = new Cache(new CircuitBreakerCache(new Sharding($adapters), $breaker)); $cache->setTelemetry($telemetry); return $cache; -}, ['pools', 'telemetry']); +}, ['pools', 'breakerForCache', 'telemetry']); $container->set('redis', function () { $host = System::getEnv('_APP_REDIS_HOST', 'localhost'); diff --git a/app/realtime.php b/app/realtime.php index 826d751b14..e3fc219fde 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1,5 +1,6 @@ get($value)); } - return $ctx['cache'] = new Cache(new Sharding($adapters)); + return $ctx['cache'] = new Cache(new CircuitBreakerCache(new Sharding($adapters), new CircuitBreaker( + threshold: 3, + timeout: 30, + successThreshold: 2, + metricPrefix: 'appwrite', + ))); } } diff --git a/composer.json b/composer.json index 9a84be6111..4e3f7c1857 100644 --- a/composer.json +++ b/composer.json @@ -95,7 +95,8 @@ "spomky-labs/otphp": "11.*", "webonyx/graphql-php": "15.32.*", "league/csv": "9.14.*", - "enshrined/svg-sanitize": "0.22.*" + "enshrined/svg-sanitize": "0.22.*", + "utopia-php/circuit-breaker": "0.1.*" }, "require-dev": { "ext-fileinfo": "*", diff --git a/composer.lock b/composer.lock index bbf0d59a96..63d251e6d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec2ad489c60f0102f0dfab223b6d1fe4", + "content-hash": "d3da7dc352f90132a1cb749153755373", "packages": [ { "name": "adhocore/jwt", @@ -3656,6 +3656,68 @@ }, "time": "2026-03-12T03:39:09+00:00" }, + { + "name": "utopia-php/circuit-breaker", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/circuit-breaker.git", + "reference": "a1647f71b95aa55b137d9667b316e3dffba60d2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/a1647f71b95aa55b137d9667b316e3dffba60d2d", + "reference": "a1647f71b95aa55b137d9667b316e3dffba60d2d", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0", + "utopia-php/telemetry": "0.2.*" + }, + "suggest": { + "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", + "ext-protobuf": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", + "ext-redis": "Required when using Utopia\\CircuitBreaker\\Adapter\\Redis with the phpredis extension.", + "ext-swoole": "Required when using Utopia\\CircuitBreaker\\Adapter\\SwooleTable.", + "utopia-php/telemetry": "Required when passing telemetry adapters or running the local telemetry demo." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\CircuitBreaker\\": "src/CircuitBreaker" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "Light & simple Circuit Breaker for PHP to prevent cascading failures in distributed systems.", + "keywords": [ + "circuit-breaker", + "fault-tolerance", + "framework", + "php", + "resilience", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/circuit-breaker/issues", + "source": "https://github.com/utopia-php/circuit-breaker/tree/0.1.0" + }, + "time": "2026-04-22T06:36:27+00:00" + }, { "name": "utopia-php/cli", "version": "0.23.3", diff --git a/src/Appwrite/Cache/Adapter/CircuitBreaker.php b/src/Appwrite/Cache/Adapter/CircuitBreaker.php new file mode 100644 index 0000000000..a5f2af8e2f --- /dev/null +++ b/src/Appwrite/Cache/Adapter/CircuitBreaker.php @@ -0,0 +1,104 @@ +breaker->call( + open: fn (): false => false, + close: fn (): mixed => $this->adapter->load($key, $ttl, $hash), + ); + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + return $this->breaker->call( + open: fn (): false => false, + close: fn (): bool|string|array => $this->adapter->save($key, $data, $hash), + ); + } + + public function list(string $key): array + { + return $this->breaker->call( + open: fn (): array => [], + close: fn (): array => $this->adapter->list($key), + ); + } + + public function purge(string $key, string $hash = ''): bool + { + return $this->breaker->call( + open: fn (): bool => false, + close: fn (): bool => $this->adapter->purge($key, $hash), + ); + } + + public function flush(): bool + { + return $this->breaker->call( + open: fn (): bool => false, + close: fn (): bool => $this->adapter->flush(), + ); + } + + public function ping(): bool + { + return $this->breaker->call( + open: fn (): bool => false, + close: fn (): bool => $this->adapter->ping(), + ); + } + + public function getSize(): int + { + return $this->breaker->call( + open: fn (): int => 0, + close: fn (): int => $this->adapter->getSize(), + ); + } + + public function getName(?string $key = null): string + { + try { + return $this->adapter->getName($key); + } catch (\Throwable) { + return 'circuit-breaker'; + } + } + + public function setMaxRetries(int $maxRetries): self + { + $this->adapter->setMaxRetries($maxRetries); + + return $this; + } + + public function setRetryDelay(int $retryDelay): self + { + $this->adapter->setRetryDelay($retryDelay); + + return $this; + } + + public function getMaxRetries(): int + { + return $this->adapter->getMaxRetries(); + } + + public function getRetryDelay(): int + { + return $this->adapter->getRetryDelay(); + } +} diff --git a/tests/unit/Cache/Adapter/CircuitBreakerTest.php b/tests/unit/Cache/Adapter/CircuitBreakerTest.php new file mode 100644 index 0000000000..d096d4f669 --- /dev/null +++ b/tests/unit/Cache/Adapter/CircuitBreakerTest.php @@ -0,0 +1,124 @@ +assertSame('value', $cache->save('key', 'value')); + $this->assertSame('value', $cache->load('key', 60)); + $this->assertSame(1, $cache->getSize()); + $this->assertTrue($cache->ping()); + $this->assertTrue($cache->purge('key')); + $this->assertFalse($cache->load('key', 60)); + } + + public function testReturnsFallbacksWhenCacheOperationsFail(): void + { + $cache = new CircuitBreaker(new FailingAdapter(), new UtopiaCircuitBreaker(threshold: 1)); + + $this->assertFalse($cache->load('key', 60)); + $this->assertFalse($cache->save('key', 'value')); + $this->assertSame([], $cache->list('key')); + $this->assertFalse($cache->purge('key')); + $this->assertFalse($cache->flush()); + $this->assertFalse($cache->ping()); + $this->assertSame(0, $cache->getSize()); + } + + public function testBreakerShortCircuitsAfterFailure(): void + { + $adapter = new CountingFailingAdapter(); + $cache = new CircuitBreaker($adapter, new UtopiaCircuitBreaker(threshold: 1)); + + $this->assertFalse($cache->load('key', 60)); + $this->assertFalse($cache->load('key', 60)); + $this->assertSame(1, $adapter->loads); + } +} + +class FailingAdapter implements Adapter +{ + public function load(string $key, int $ttl, string $hash = ''): mixed + { + throw new RuntimeException('Cache failed.'); + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + throw new RuntimeException('Cache failed.'); + } + + public function list(string $key): array + { + throw new RuntimeException('Cache failed.'); + } + + public function purge(string $key, string $hash = ''): bool + { + throw new RuntimeException('Cache failed.'); + } + + public function flush(): bool + { + throw new RuntimeException('Cache failed.'); + } + + public function ping(): bool + { + throw new RuntimeException('Cache failed.'); + } + + public function getSize(): int + { + throw new RuntimeException('Cache failed.'); + } + + public function getName(?string $key = null): string + { + return 'failing'; + } + + public function setMaxRetries(int $maxRetries): self + { + return $this; + } + + public function setRetryDelay(int $retryDelay): self + { + return $this; + } + + public function getMaxRetries(): int + { + return 0; + } + + public function getRetryDelay(): int + { + return 0; + } +} + +class CountingFailingAdapter extends FailingAdapter +{ + public int $loads = 0; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->loads++; + + return parent::load($key, $ttl, $hash); + } +}