refactor(cache): implement graceful degradation for BrandCache

This commit is contained in:
Jérémy Munsch 2026-08-25 08:26:58 +02:00
parent 0766e9dc8f
commit daa8d1e0fa
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 151 additions and 44 deletions

View file

@ -20,22 +20,38 @@ final readonly class BrandCache
public function __construct(
private CacheInterface $cacheStore,
private \Psr\Log\LoggerInterface $logger,
) {
}
public function getById(BrandId $id): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->idKey($id)));
try {
return $this->fromCacheData($this->cacheStore->get($this->idKey($id)));
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::getById failed', ['exception' => $e->getMessage()]);
return null;
}
}
public function getByCode(BrandCode $code): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->codeKey($code)));
try {
return $this->fromCacheData($this->cacheStore->get($this->codeKey($code)));
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::getByCode failed', ['exception' => $e->getMessage()]);
return null;
}
}
public function getByArcadiaId(ArcadiaId $arcadiaId): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->arcadiaKey($arcadiaId)));
try {
return $this->fromCacheData($this->cacheStore->get($this->arcadiaKey($arcadiaId)));
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::getByArcadiaId failed', ['exception' => $e->getMessage()]);
return null;
}
}
/**
@ -43,65 +59,86 @@ final readonly class BrandCache
*/
public function getAll(): ?array
{
$data = $this->cacheStore->get(self::KEY_ALL);
try {
$data = $this->cacheStore->get(self::KEY_ALL);
if (! is_array($data)) {
return null;
}
$brands = [];
foreach ($data as $item) {
$brand = $this->fromCacheData($item);
if ($brand !== null) {
$brands[] = $brand;
if (! is_array($data)) {
return null;
}
}
if (count($brands) !== count($data) || empty($brands)) {
$brands = [];
foreach ($data as $item) {
$brand = $this->fromCacheData($item);
if ($brand !== null) {
$brands[] = $brand;
}
}
if (count($brands) !== count($data) || empty($brands)) {
return null;
}
return $brands;
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::getAll failed', ['exception' => $e->getMessage()]);
return null;
}
return $brands;
}
public function put(Brand $brand): void
{
$data = $this->toCacheData($brand);
try {
$data = $this->toCacheData($brand);
$this->cacheStore->set($this->idKey($brand->id()), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->codeKey($brand->code), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->arcadiaKey($brand->arcadiaId), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->idKey($brand->id()), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->codeKey($brand->code), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->arcadiaKey($brand->arcadiaId), $data, self::CACHE_TTL_SECONDS);
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::put failed', ['exception' => $e->getMessage()]);
}
}
public function putAll(Brand ...$brands): void
{
$cachedList = array_map(fn (Brand $brand): array => $this->toCacheData($brand), $brands);
$this->cacheStore->set(self::KEY_ALL, $cachedList, self::CACHE_TTL_SECONDS);
try {
$cachedList = array_map(fn (Brand $brand): array => $this->toCacheData($brand), $brands);
$this->cacheStore->set(self::KEY_ALL, $cachedList, self::CACHE_TTL_SECONDS);
foreach ($brands as $brand) {
$this->put($brand);
foreach ($brands as $brand) {
$this->put($brand);
}
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::putAll failed', ['exception' => $e->getMessage()]);
}
}
public function invalidate(Brand $brand): void
{
$this->cacheStore->delete(self::KEY_ALL);
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
try {
$this->cacheStore->delete(self::KEY_ALL);
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::invalidate failed', ['exception' => $e->getMessage()]);
}
}
public function reset(): void
{
$this->cacheStore->delete(self::KEY_ALL);
try {
$this->cacheStore->delete(self::KEY_ALL);
$all = $this->getAll();
if ($all !== null) {
foreach ($all as $brand) {
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
$all = $this->getAll();
if ($all !== null) {
foreach ($all as $brand) {
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
}
}
} catch (\Throwable $e) {
$this->logger->warning('BrandCache::reset failed', ['exception' => $e->getMessage()]);
}
}

View file

@ -45,8 +45,9 @@ class DomainServiceProvider extends ServiceProvider
$this->app->bind(BrandCache::class, static function (Application $app): BrandCache {
$storageConfig = $app->make(ArcadiaStorageConfig::class);
$cache = Cache::store($storageConfig->cacheStore);
$logger = $app->make(\Psr\Log\LoggerInterface::class);
return new BrandCache($cache);
return new BrandCache($cache, $logger);
});
$this->app->scoped(LaravelTransactionalDomainEventDispatcher::class);

View file

@ -36,12 +36,16 @@ describe('BrandCache Unit Tests', function (): void {
->with("brand:id:{$uuid}")
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$result = $brandCache->getById($brandId);
// THEN
assert($result !== null);
expect($result)->not->toBeNull()
->and($result->id()->value)->toBe($brandId->value)
->and($result->name->value)->toBe('Le Gaulois')
@ -68,12 +72,16 @@ describe('BrandCache Unit Tests', function (): void {
->with('brand:code:LGL01')
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$result = $brandCache->getByCode($brandCode);
// THEN
assert($result !== null);
expect($result)->not->toBeNull()
->and($result->code->value)->toBe('LGL01')
->and($result->name->value)->toBe('Le Gaulois');
@ -98,12 +106,16 @@ describe('BrandCache Unit Tests', function (): void {
->with('brand:arcadia:10')
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$result = $brandCache->getByArcadiaId($arcadiaId);
// THEN
assert($result !== null);
expect($result)->not->toBeNull()
->and($result->arcadiaId->value)->toBe('10')
->and($result->name->value)->toBe('Maître CoQ');
@ -120,7 +132,10 @@ describe('BrandCache Unit Tests', function (): void {
->with("brand:id:{$brandId->value}")
->andReturn('corrupted_string');
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$result = $brandCache->getById($brandId);
@ -158,7 +173,10 @@ describe('BrandCache Unit Tests', function (): void {
->once()
->with('brand:arcadia:20', $expectedData, 86400);
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$brandCache->put($brand);
@ -192,7 +210,10 @@ describe('BrandCache Unit Tests', function (): void {
->once()
->with('brand:arcadia:30');
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$brandCache->invalidate($brand);
@ -233,7 +254,10 @@ describe('BrandCache Unit Tests', function (): void {
->once()
->with('brand:arcadia:1');
$brandCache = new BrandCache($cacheMock);
/** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->zeroOrMoreTimes();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$brandCache->reset();
@ -241,4 +265,49 @@ describe('BrandCache Unit Tests', function (): void {
// THEN
expect(true)->toBeTrue();
});
it('returns null and logs warning when getById throws exception', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
/** @var CacheInterface&\Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')->andThrow(new \RuntimeException('Redis error'));
/** @var \Psr\Log\LoggerInterface&\Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->once();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$result = $brandCache->getById($brandId);
// THEN
expect($result)->toBeNull();
});
it('logs warning and does not throw when put throws exception', function (): void {
// GIVEN
$brand = Brand::hydrate(
id: new BrandId(Uuid::uuid7()),
name: new BrandName('Ghost'),
code: new BrandCode('GHO01'),
arcadiaId: new ArcadiaId('999'),
);
/** @var CacheInterface&\Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('set')->andThrow(new \RuntimeException('Redis error'));
/** @var \Psr\Log\LoggerInterface&\Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class);
$loggerMock->shouldReceive('warning')->once();
$brandCache = new BrandCache($cacheMock, $loggerMock);
// WHEN
$brandCache->put($brand);
// THEN
// No exception is thrown
expect(true)->toBeTrue();
});
});