From c5886e16964a9ff6bed870ec03ad26c8275c737d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bajsarowicz?= Date: Sat, 5 Sep 2026 22:28:10 +0200 Subject: [PATCH] perf(rules): cache rules in front of the flag lookup Every request that reaches the Shield plugin loads the sansec_shield_rules flag, which is a point SELECT on the flag table plus Flag model hydration. Put Magento's cache in front of it as a read accelerator: the flag stays the source of truth, a cache miss or a failing cache backend falls back to it, and both saveFlag() and deleteFlag() keep the cache in step. A 300 second lifetime bounds staleness to one cron cycle on cache backends that are not shared between nodes. --- Model/Rules.php | 55 +++++++++++++- Test/Model/RulesTest.php | 157 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 Test/Model/RulesTest.php diff --git a/Model/Rules.php b/Model/Rules.php index ab15cb8..e1cdc7f 100644 --- a/Model/Rules.php +++ b/Model/Rules.php @@ -2,6 +2,7 @@ namespace Sansec\Shield\Model; +use Magento\Framework\App\CacheInterface; use Magento\Framework\Flag; use Magento\Framework\Flag\FlagResource; use Magento\Framework\FlagFactory; @@ -15,6 +16,9 @@ class Rules { private const PROTOCOL_VERSION = '1'; private const FLAG_CODE = 'sansec_shield_rules'; + private const CACHE_KEY = 'sansec_shield_rules'; + private const CACHE_TAG = 'SANSEC_SHIELD'; + private const CACHE_LIFETIME = 300; /** @var Config */ private $config; @@ -37,6 +41,9 @@ class Rules /** @var DateTime */ private $dateTime; + /** @var CacheInterface */ + private $cache; + public function __construct( Config $config, FlagFactory $flagFactory, @@ -44,7 +51,8 @@ public function __construct( SerializerInterface $serializer, CurlFactory $curlFactory, ModuleDirReader $moduleDirReader, - DateTime $dateTime + DateTime $dateTime, + CacheInterface $cache ) { $this->config = $config; $this->flagFactory = $flagFactory; @@ -53,9 +61,52 @@ public function __construct( $this->curlFactory = $curlFactory; $this->moduleDirReader = $moduleDirReader; $this->dateTime = $dateTime; + $this->cache = $cache; } public function loadRules(): array + { + $rules = $this->loadRulesFromCache(); + if ($rules !== null) { + return $rules; + } + + $rules = $this->loadRulesFromFlag(); + if ($rules !== []) { + try { + $this->saveRulesToCache($rules); + } catch (\Throwable $exception) { + return $rules; + } + } + return $rules; + } + + private function loadRulesFromCache(): ?array + { + try { + $rulesData = $this->cache->load(self::CACHE_KEY); + if (!is_string($rulesData) || $rulesData === '') { + return null; + } + $rules = $this->serializer->unserialize($rulesData); + } catch (\Throwable $exception) { + return null; + } + return is_array($rules) && $rules !== [] ? $rules : null; + } + + private function saveRulesToCache(array $rules): void + { + $this->cache->save( + $this->serializer->serialize($rules), + self::CACHE_KEY, + [self::CACHE_TAG], + self::CACHE_LIFETIME + ); + } + + private function loadRulesFromFlag(): array { try { $rulesData = $this->loadFlag()->getFlagData(); @@ -166,6 +217,7 @@ private function saveFlag(array $rules): void $flag->setFlagData($rules); $flag->setData('last_update', $this->dateTime->gmtDate()); $this->flagResource->save($flag); + $this->saveRulesToCache($rules); } private function deleteFlag(): void @@ -174,5 +226,6 @@ private function deleteFlag(): void if ($flag->getId()) { $this->flagResource->delete($flag); } + $this->cache->remove(self::CACHE_KEY); } } diff --git a/Test/Model/RulesTest.php b/Test/Model/RulesTest.php new file mode 100644 index 0000000..4560ffe --- /dev/null +++ b/Test/Model/RulesTest.php @@ -0,0 +1,157 @@ + 1, 'conditions' => []]]; + + /** @var FlagFactory|MockObject */ + private $flagFactory; + + /** @var FlagResource|MockObject */ + private $flagResource; + + /** @var CacheInterface|MockObject */ + private $cache; + + /** @var Flag|MockObject */ + private $flag; + + /** @var Rules */ + private $rulesModel; + + public function setUp(): void + { + parent::setUp(); + + $this->flag = $this->createMock(Flag::class); + $this->flagFactory = $this->createMock(FlagFactory::class); + $this->flagFactory->method('create')->willReturn($this->flag); + $this->flagResource = $this->createMock(FlagResource::class); + $this->cache = $this->createMock(CacheInterface::class); + + $this->rulesModel = new Rules( + $this->createMock(Config::class), + $this->flagFactory, + $this->flagResource, + new Serializer(), + $this->createCurlFactoryMock(), + $this->createMock(ModuleDirReader::class), + $this->createMock(DateTime::class), + $this->cache + ); + } + + public function testCacheHitDoesNotTouchFlag() + { + $this->cache->method('load')->with(self::CACHE_KEY)->willReturn(json_encode($this->rules)); + $this->cache->expects($this->never())->method('save'); + $this->flagResource->expects($this->never())->method('load'); + + $this->assertEquals($this->rules, $this->rulesModel->loadRules()); + } + + public function testCacheMissLoadsFlagAndPopulatesCache() + { + $this->cache->method('load')->willReturn(false); + $this->flagResource->expects($this->once())->method('load'); + $this->flag->method('getFlagData')->willReturn($this->rules); + $this->cache->expects($this->once()) + ->method('save') + ->with(json_encode($this->rules), self::CACHE_KEY, ['SANSEC_SHIELD'], self::CACHE_LIFETIME); + + $this->assertEquals($this->rules, $this->rulesModel->loadRules()); + } + + public function testCorruptCacheEntryFallsBackToFlag() + { + $this->cache->method('load')->willReturn('{invalid json'); + $this->flag->method('getFlagData')->willReturn($this->rules); + $this->cache->expects($this->once())->method('save'); + + $this->assertEquals($this->rules, $this->rulesModel->loadRules()); + } + + public function testFailingCacheWriteStillReturnsFlagRules() + { + $this->cache->method('load')->willReturn(false); + $this->flag->method('getFlagData')->willReturn($this->rules); + $this->cache->method('save')->willThrowException(new \RuntimeException('backend down')); + + $this->assertEquals($this->rules, $this->rulesModel->loadRules()); + } + + public function testMissingFlagIsNotCached() + { + $this->cache->method('load')->willReturn(false); + $this->flag->method('getFlagData')->willReturn(null); + $this->cache->expects($this->never())->method('save'); + + $this->assertEquals([], $this->rulesModel->loadRules()); + } + + public function testSaveFlagWritesCache() + { + $this->flagResource->expects($this->once())->method('save')->with($this->flag); + $this->cache->expects($this->once()) + ->method('save') + ->with(json_encode($this->rules), self::CACHE_KEY, ['SANSEC_SHIELD'], self::CACHE_LIFETIME); + + $this->invoke('saveFlag', [$this->rules]); + } + + public function testDeleteFlagRemovesCache() + { + $this->flag->method('getId')->willReturn(1); + $this->flagResource->expects($this->once())->method('delete')->with($this->flag); + $this->cache->expects($this->once())->method('remove')->with(self::CACHE_KEY); + + $this->invoke('deleteFlag', []); + } + + public function testLegacyFlagFormatDeletesFlagAndCache() + { + $this->cache->method('load')->willReturn(false); + $this->flag->method('getFlagData')->willReturn(json_encode($this->rules)); + $this->flag->method('getId')->willReturn(1); + $this->flagResource->expects($this->once())->method('delete'); + $this->cache->expects($this->once())->method('remove')->with(self::CACHE_KEY); + $this->cache->expects($this->never())->method('save'); + + $this->assertEquals([], $this->rulesModel->loadRules()); + } + + private function createCurlFactoryMock(): CurlFactory + { + return $this->getMockBuilder(CurlFactory::class) + ->disableOriginalConstructor() + ->disableAutoload() + ->setMethods(['create']) + ->getMock(); + } + + private function invoke(string $method, array $arguments) + { + $reflection = new \ReflectionMethod(Rules::class, $method); + $reflection->setAccessible(true); + return $reflection->invokeArgs($this->rulesModel, $arguments); + } +}