diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..889961a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +composer.lock +vendor +.idea +.phpunit.result.cache +coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab4bcb7 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# pirate-circuit-breaker + +[![Latest Version on Packagist][ico-version]][link-packagist] +[![Software License][ico-license]](LICENSE.md) +[![Build Status][ico-travis]][link-travis] +[![Coverage Status][ico-scrutinizer]][link-scrutinizer] +[![Quality Score][ico-code-quality]][link-code-quality] +[![Total Downloads][ico-downloads]][link-downloads] + +This is an Implementation of the 2-state (Open and Closed) CircuitBreaker pattern that we use at HolidayPirates. +Unlike the [3-state CircuitBreaker proposed by Fowler](https://martinfowler.com/bliki/CircuitBreaker.html), this implementation has only two states, "Open" and "Closed". + +## Install + +Via Composer + +```bash +$ composer require holidaypirates/pirate-circuit-breaker +``` + +## Requirements +- PHP 7.3 +- An implementation of the `\Psr\SimpleCache\CacheInterface` to store the services failures and circuit state OR your own storage implementation of `\HolidayPirates\CircuitBreaker\Storage\StorageInterface` +- For development only : Docker and Docker-Compose +## Usage + +```php +registerService($service); + +// Usage: +$dummyApiClient = new DummyApiClient(); // This will be any service you want to protect with the CB + +if (false == $circuitBreaker->isServiceAvailable(DummyService::class)) { + throw new \Exception('Service unavailable'); +} + +try { + $response = $dummyApiClient->sendRequest(); + $circuitBreaker->reportSuccess(DummyService::class); +} catch (Exception $exception) { + $circuitBreaker->reportFailure(DummyService::class); + + throw new \Exception('Service unavailable',0, $exception); +} + +``` +> Please note that `HolidayPirates\CircuitBreaker\Service\DummyService` is just an implementation of `\HolidayPirates\CircuitBreaker\Service\ServiceInterface`. +> You must create your own implementations of `\HolidayPirates\CircuitBreaker\Service\ServiceInterface` for each service that you want the CircuitBreaker to operate in. + +For more examples of usage please see `\HolidayPirates\Tests\Integration\CircuitBreaker\CircuitBreakerTest` +## Testing + +```bash +$ docker-compose run php vendor/bin/phpunit +``` + +## Credits + +- [Ricardo Fiorani][link-author] +- [All Contributors][link-contributors] + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + +[ico-version]: https://img.shields.io/packagist/v/holidaypirates/pirate-circuit-breaker.svg?style=flat-square +[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square +[ico-travis]: https://img.shields.io/travis/holidaypirates/pirate-circuit-breaker/master.svg?style=flat-square +[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/holidaypirates/pirate-circuit-breaker.svg?style=flat-square +[ico-code-quality]: https://img.shields.io/scrutinizer/g/holidaypirates/pirate-circuit-breaker.svg?style=flat-square +[ico-downloads]: https://img.shields.io/packagist/dt/holidaypirates/pirate-circuit-breaker.svg?style=flat-square + +[link-packagist]: https://packagist.org/packages/holidaypirates/pirate-circuit-breaker +[link-travis]: https://travis-ci.org/holidaypirates/pirate-circuit-breaker +[link-scrutinizer]: https://scrutinizer-ci.com/g/holidaypirates/pirate-circuit-breaker/code-structure +[link-code-quality]: https://scrutinizer-ci.com/g/holidaypirates/pirate-circuit-breaker +[link-downloads]: https://packagist.org/packages/holidaypirates/pirate-circuit-breaker +[link-author]: https://github.com/ricardofiorani +[link-contributors]: ../../contributors diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ab2b714 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "holidaypirates/pirate-circuit-breaker", + "description": "Implementation of the 2-state CircuitBreaker pattern that we use at HolidayPirates", + "homepage": "https://www.holidaypirates.group", + "type": "library", + "keywords": [ + "circuit-breaker", + "service", + "protection" + ], + "license": "MIT", + "prefer-stable": true, + "authors": [ + { + "name": "Ricardo Fiorani", + "email": "r.fiorani@holidaypirates.com" + } + ], + "autoload": { + "psr-4": { + "HolidayPirates\\CircuitBreaker\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HolidayPirates\\Tests\\Integration\\CircuitBreaker\\": "tests/Integration", + "HolidayPirates\\Tests\\Unit\\CircuitBreaker\\": "tests/Unit" + } + }, + "require": { + "php": ">=7.3", + "psr/simple-cache": "^1.0" + }, + "require-dev": { + "roave/security-advisories": "dev-master", + "phpunit/phpunit": "^8.2", + "cache/array-adapter": "^1.0", + "cache/redis-adapter": "^1.0" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..54fbad6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3' +services: + php: + build: docker/php + volumes: + - .:/code + working_dir: "/code" + links: + - redis + + redis: + image: redis:5.0.4-alpine diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..1d340e6 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,5 @@ +FROM php:7.3-fpm +RUN pecl install xdebug-2.7.1 && pecl install redis-4.0.1 +RUN docker-php-ext-enable xdebug redis + +WORKDIR "/code" diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bc9abfb --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + ./tests/Integration + + + ./tests/Unit + + + + + ./src + + + diff --git a/src/Awareness/CircuitBreakerAwareInterface.php b/src/Awareness/CircuitBreakerAwareInterface.php new file mode 100644 index 0000000..f5a2092 --- /dev/null +++ b/src/Awareness/CircuitBreakerAwareInterface.php @@ -0,0 +1,10 @@ +circuitBreaker = $circuitBreaker; + } + + public function getCircuitBreaker(): CircuitBreakerInterface + { + return $this->circuitBreaker; + } + + /** + * @throws UnavailableServiceException + */ + public function throwExceptionIfServiceUnavailable(string $serviceName): void + { + if (false === $this->getCircuitBreaker()->isServiceAvailable($serviceName)) { + throw new UnavailableServiceException("Service {$serviceName} is not available right now."); + } + } + + public function reportServiceSuccess(string $serviceName): void + { + $this->getCircuitBreaker()->reportSuccess($serviceName); + } + + public function reportServiceFailure(string $serviceName): void + { + $this->getCircuitBreaker()->reportFailure($serviceName); + } +} diff --git a/src/CircuitBreaker.php b/src/CircuitBreaker.php new file mode 100644 index 0000000..d7b5692 --- /dev/null +++ b/src/CircuitBreaker.php @@ -0,0 +1,100 @@ +storage = $storage; + } + + public function registerService(ServiceInterface $service): void + { + $this->services[$service->getIdentifier()] = $service; + } + + public function isServiceAvailable(string $serviceName): bool + { + $service = $this->getService($serviceName); + + if ($this->isCircuitOpen($service)) { + return false; + } + + /** + * If we wanted a full 3-state CircuitBreaker implementation as described by Fowler, it is in here + * that we can add the logic for the "Half-open circuit", which would allow a smaller number of requests + * to go through before we really close the circuit. + */ + + return true; + } + + public function reportFailure(string $serviceName): void + { + $service = $this->getService($serviceName); + $amountOfFailures = $this->storage->getAmountOfFailures($service); + + if ($amountOfFailures >= $service->getMaxFailures()) { + $this->setOpenCircuit($service); + + return; + } + + $this->storage->incrementAmountOfFailures($service); + } + + public function reportSuccess(string $serviceName): void + { + $service = $this->getService($serviceName); + + $this->storage->incrementAmountOfSuccess($service); + } + + public function areAllServicesAvailable(): bool + { + foreach ($this->getRegisteredServiceNames() as $serviceName) { + if (!$this->isServiceAvailable($serviceName)) { + return false; + } + } + + return true; + } + + public function getRegisteredServiceNames(): array + { + return array_keys($this->services); + } + + private function getService(string $serviceName): ServiceInterface + { + if (false === isset($this->services[$serviceName])) { + throw new \LogicException( + sprintf( + 'Service not found. Did you forgot to call registerService(%s) ?', + $serviceName + ) + ); + } + + return $this->services[$serviceName]; + } + + private function setOpenCircuit(ServiceInterface $service): void + { + $this->storage->setOpenCircuit($service); + } + + private function isCircuitOpen(ServiceInterface $service): bool + { + return $this->storage->isCircuitOpen($service); + } +} diff --git a/src/CircuitBreakerInterface.php b/src/CircuitBreakerInterface.php new file mode 100644 index 0000000..ce02040 --- /dev/null +++ b/src/CircuitBreakerInterface.php @@ -0,0 +1,37 @@ +maxFailures = $maxFailures; + $this->retryTimeout = $retryTimeout; + } + + public function getIdentifier(): string + { + return static::class; + } + + public function getMaxFailures(): int + { + return $this->maxFailures; + } + + public function getRetryTimeout(): int + { + return $this->retryTimeout; + } +} diff --git a/src/Service/DummyService.php b/src/Service/DummyService.php new file mode 100644 index 0000000..4fe18e0 --- /dev/null +++ b/src/Service/DummyService.php @@ -0,0 +1,8 @@ +cache = $cache; + } + + public function getAmountOfFailures(ServiceInterface $service): int + { + $cacheKey = $this->getFailureCacheKey($service); + + return $this->cacheGet($cacheKey, 0); + } + + public function incrementAmountOfFailures(ServiceInterface $service): void + { + $cacheKey = $this->getFailureCacheKey($service); + $amountOfFailures = $this->getAmountOfFailures($service); + $this->cacheSet($cacheKey, ++$amountOfFailures); + } + + + /** + * fakeTODO rename this method to investInAppleInThe90s + * @throws StorageAdapterException + */ + public function incrementAmountOfSuccess(ServiceInterface $service): void + { + $cacheKey = $this->getFailureCacheKey($service); + $amount = $this->getAmountOfFailures($service); + $amount = max(--$amount, 0); //This is to ensure that any negative number will turn into 0 + $this->cacheSet($cacheKey, $amount); + } + + public function setOpenCircuit(ServiceInterface $service): void + { + $cacheKey = $this->getOpenCircuitCacheKey($service); + + $this->cacheSet( + $cacheKey, + true, + $service->getRetryTimeOut() + ); + } + + public function isCircuitOpen(ServiceInterface $service): bool + { + $cacheKey = $this->getOpenCircuitCacheKey($service); + + return (bool)$this->cacheGet($cacheKey, false); + } + + private function getOpenCircuitCacheKey(ServiceInterface $service): string + { + return sprintf( + '%s_%s_%s', + self::CACHE_PREFIX, + $this->normalizeServiceCacheKey($service->getIdentifier()), + self::CIRCUIT_OPEN_SUFFIX + ); + } + + private function getFailureCacheKey(ServiceInterface $service): string + { + return sprintf( + '%s_%s_%s', + self::CACHE_PREFIX, + $this->normalizeServiceCacheKey($service->getIdentifier()), + self::FAILURE_SUFFIX + ); + } + + private function normalizeServiceCacheKey(string $key): string + { + return str_replace('\\', '_', mb_strtolower($key)); + } + + /** + * @throws StorageAdapterException + */ + private function cacheSet(string $key, $value, $ttl = null): void + { + try { + $this->cache->set($key, $value, $ttl); + } catch (CacheException $e) { + $message = "There was some problem with the driver while trying to set the key : {$key}"; + + throw new StorageAdapterException($message, 0, $e); + } + } + + /** + * @throws StorageAdapterException + */ + private function cacheGet(string $key, $default = null) + { + try { + return $this->cache->get($key, $default); + } catch (CacheException $e) { + $message = "There was some problem with the driver while trying to get the key : {$key}"; + + throw new StorageAdapterException($message, 0, $e); + } + } +} diff --git a/src/Storage/StorageInterface.php b/src/Storage/StorageInterface.php new file mode 100644 index 0000000..f89a812 --- /dev/null +++ b/src/Storage/StorageInterface.php @@ -0,0 +1,34 @@ +buildCircuitBreaker(); + $dummyService = new DummyService(2, 60); + $circuitBreaker->registerService($dummyService); + + TestCase::assertTrue($circuitBreaker->isServiceAvailable(DummyService::class)); + + $circuitBreaker->reportFailure(DummyService::class); + $circuitBreaker->reportFailure(DummyService::class); + + TestCase::assertTrue($circuitBreaker->isServiceAvailable(DummyService::class)); + + $circuitBreaker->reportFailure(DummyService::class); + + TestCase::assertFalse($circuitBreaker->isServiceAvailable(DummyService::class)); + } + + public function testServiceAvailabilityInaComplexScenario(): void + { + $circuitBreaker = $this->buildCircuitBreaker(); + $dummyService = new DummyService(2, 2); + $circuitBreaker->registerService($dummyService); + + $circuitBreaker->reportFailure(DummyService::class); + $circuitBreaker->reportFailure(DummyService::class); + TestCase::assertTrue($circuitBreaker->isServiceAvailable(DummyService::class)); + + $circuitBreaker->reportSuccess(DummyService::class); + $circuitBreaker->reportSuccess(DummyService::class); + TestCase::assertTrue($circuitBreaker->isServiceAvailable(DummyService::class)); + + $circuitBreaker->reportFailure(DummyService::class); + $circuitBreaker->reportFailure(DummyService::class); + TestCase::assertTrue($circuitBreaker->isServiceAvailable(DummyService::class)); + + } + + public function testThrowsExceptionWhenServiceIsNotRegistered(): void + { + $this->expectException(LogicException::class); + $circuitBreaker = $this->buildCircuitBreaker(); + $circuitBreaker->isServiceAvailable(DummyService::class); + } + + public function testGetRegisteredServices(): void + { + $circuitBreaker = $this->buildCircuitBreaker(); + + TestCase::assertEquals([], $circuitBreaker->getRegisteredServiceNames()); + + $circuitBreaker->registerService(new DummyService(0, 0)); + + TestCase::assertEquals([DummyService::class], $circuitBreaker->getRegisteredServiceNames()); + } + + public function testServicesAvailable() + { + $circuitBreaker = $this->buildCircuitBreaker(); + $circuitBreaker->registerService(new DummyService(1,60)); + + TestCase::assertTrue($circuitBreaker->areAllServicesAvailable()); + + $circuitBreaker->reportFailure(DummyService::class); + $circuitBreaker->reportFailure(DummyService::class); + + TestCase::assertFalse($circuitBreaker->areAllServicesAvailable()); + } + + private function getStorage(): StorageInterface + { + $client = new Redis(); + $client->connect('redis'); + $pool = new RedisCachePool($client); + $pool->clear(); + + return new SimpleCacheAdapter($pool); + } + + private function buildCircuitBreaker(): CircuitBreakerInterface + { + $storage = $this->getStorage(); + + return new CircuitBreaker($storage); + } +} diff --git a/tests/Integration/Storage/Adapter/SimpleCacheAdapterTest.php b/tests/Integration/Storage/Adapter/SimpleCacheAdapterTest.php new file mode 100644 index 0000000..4b4b63d --- /dev/null +++ b/tests/Integration/Storage/Adapter/SimpleCacheAdapterTest.php @@ -0,0 +1,98 @@ +getAmountOfFailures($service)); + + $adapter->incrementAmountOfFailures($service); + + TestCase::assertEquals(1, $adapter->getAmountOfFailures($service)); + } + + public function testSuccessHandlers(): void + { + $cache = new ArrayCachePool(); + $adapter = new SimpleCacheAdapter($cache); + $service = new DummyService(1, 1); + + $adapter->incrementAmountOfFailures($service); + $adapter->incrementAmountOfFailures($service); + $adapter->incrementAmountOfFailures($service); + + TestCase::assertEquals(3, $adapter->getAmountOfFailures($service)); + + $adapter->incrementAmountOfSuccess($service); + $adapter->incrementAmountOfSuccess($service); + $adapter->incrementAmountOfSuccess($service); + + /** + * Each success operation reported should decrement the amount of failures to control the threshold + */ + TestCase::assertEquals(0, $adapter->getAmountOfFailures($service)); + } + + public function testCircuitHandling(): void + { + $cache = new ArrayCachePool(); + $adapter = new SimpleCacheAdapter($cache); + $service = new DummyService(1, 1); + + TestCase::assertFalse($adapter->isCircuitOpen($service)); + + $adapter->setOpenCircuit($service); + + TestCase::assertTrue($adapter->isCircuitOpen($service)); + } + + public function testExceptionThrowingGet(): void + { + $cache = $this->prophesize(CacheInterface::class); + $cache->get(Argument::any(), Argument::any())->willThrow($this->getMockCacheException()); + + $adapter = new SimpleCacheAdapter($cache->reveal()); + $service = new DummyService(1, 2); + + $this->expectException(StorageAdapterException::class); + + $adapter->incrementAmountOfFailures($service); + } + + public function testExceptionThrowingSet(): void + { + $cache = $this->prophesize(CacheInterface::class); + $cache->set(Argument::any(), Argument::any(), Argument::any())->willThrow($this->getMockCacheException()); + + $adapter = new SimpleCacheAdapter($cache->reveal()); + $service = new DummyService(1, 2); + + $this->expectException(StorageAdapterException::class); + + $adapter->setOpenCircuit($service); + } + + public function getMockCacheException(): CacheException + { + return new class extends Exception implements CacheException + { + }; + } +} diff --git a/tests/Unit/Awareness/CircuitBreakerAwareTraitTest.php b/tests/Unit/Awareness/CircuitBreakerAwareTraitTest.php new file mode 100644 index 0000000..bc6b869 --- /dev/null +++ b/tests/Unit/Awareness/CircuitBreakerAwareTraitTest.php @@ -0,0 +1,70 @@ +prophesize(CircuitBreakerInterface::class)->reveal(); + $traitedClass->setCircuitBreaker($circuitBreakerMock); + + TestCase::assertSame($circuitBreakerMock, $traitedClass->getCircuitBreaker()); + + } + + public function testExceptionThrowing(): void + { + $traitedClass = new class + { + use CircuitBreakerAwareTrait; + }; + + $fakeServiceName = 'fake-service'; + + $circuitBreakerMock = $this->prophesize(CircuitBreakerInterface::class); + $circuitBreakerMock->isServiceAvailable($fakeServiceName)->willReturn(false); + + $traitedClass->setCircuitBreaker($circuitBreakerMock->reveal()); + + $this->expectException(UnavailableServiceException::class); + + $traitedClass->throwExceptionIfServiceUnavailable($fakeServiceName); + } + + public function testReporting(): void + { + $traitedClass = new class + { + use CircuitBreakerAwareTrait; + }; + + $fakeServiceName = 'fake-service'; + + $circuitBreakerMock = $this->prophesize(CircuitBreakerInterface::class); + $circuitBreakerMock->reportSuccess($fakeServiceName)->will(function ($args) use ($fakeServiceName) { + TestCase::assertEquals($fakeServiceName, $args[0]); + }); + $circuitBreakerMock->reportFailure($fakeServiceName)->will(function ($args) use ($fakeServiceName) { + TestCase::assertEquals($fakeServiceName, $args[0]); + }); + + $circuitBreakerMock->isServiceAvailable($fakeServiceName)->willReturn(true); + + $traitedClass->setCircuitBreaker($circuitBreakerMock->reveal()); + + $traitedClass->reportServiceSuccess($fakeServiceName); + $traitedClass->reportServiceFailure($fakeServiceName); + $traitedClass->throwExceptionIfServiceUnavailable($fakeServiceName); + } +}