From e4edc76f589491fdee63a8fc99e1277c4315bc33 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 26 Mar 2026 10:24:35 +0100 Subject: [PATCH] Add Redis cache for Meilisearch search results and admin dashboard stats - Configure Redis DB 2 as Symfony cache adapter - Cache Meilisearch search results for 5 minutes (invalidated on writes) - Cache admin dashboard stats for 10 minutes - Add invalidateSearchCache() called after each Meilisearch write - Update tests to support cache mock injection Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 4 + .env.test | 1 + ansible/env.local.j2 | 1 + config/packages/cache.yaml | 13 +++ src/Controller/AdminController.php | 84 +++++++++++-------- .../MeilisearchMessageHandler.php | 2 + src/Service/MeilisearchService.php | 20 ++++- tests/Service/MeilisearchServiceTest.php | 6 ++ 8 files changed, 91 insertions(+), 40 deletions(-) diff --git a/.env b/.env index f4e1fc8..47f11fa 100644 --- a/.env +++ b/.env @@ -36,6 +36,10 @@ MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages SESSION_HANDLER_DSN=redis://redis:6379/1 ###< session ### +###> cache ### +REDIS_CACHE_DSN=redis://redis:6379/2 +###< cache ### + ###> symfony/mailer ### MAILER_DSN=smtp://mailpit:1025 ###< symfony/mailer ### diff --git a/.env.test b/.env.test index fdb8e46..83c5f1d 100644 --- a/.env.test +++ b/.env.test @@ -13,5 +13,6 @@ STRIPE_WEBHOOK_SECRET_CONNECT=whsec_test_connect OUTSIDE_URL=https://test.example.com MESSENGER_TRANSPORT_DSN=redis://:e_ticket@redis:6379/messages SESSION_HANDLER_DSN=redis://:e_ticket@redis:6379/1 +REDIS_CACHE_DSN=redis://:e_ticket@redis:6379/2 SMIME_PASSPHRASE=test ADMIN_EMAIL=contact@test.com diff --git a/ansible/env.local.j2 b/ansible/env.local.j2 index 838cd68..971b8d1 100644 --- a/ansible/env.local.j2 +++ b/ansible/env.local.j2 @@ -3,6 +3,7 @@ APP_SECRET={{ app_secret }} DATABASE_URL="postgresql://e-ticket:{{ db_password }}@pgbouncer:6432/e-ticket?serverVersion=16&charset=utf8" MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1 +REDIS_CACHE_DSN=redis://:{{ redis_password }}@redis:6379/2 MAILER_DSN={{ mailer_dsn }} DEFAULT_URI=https://ticket.e-cosplay.fr VITE_LOAD=1 diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 687ad32..c23aecc 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -1,6 +1,19 @@ framework: cache: + app: cache.adapter.redis + default_redis_provider: '%env(REDIS_CACHE_DSN)%' pools: siret.cache: adapter: cache.app default_lifetime: 86400 + meilisearch.cache: + adapter: cache.app + default_lifetime: 300 + stats.cache: + adapter: cache.app + default_lifetime: 600 + +when@test: + framework: + cache: + app: cache.adapter.array diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index c75061f..615712f 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -14,6 +14,7 @@ use App\Service\MailerService; use App\Service\MeilisearchService; use App\Service\SiretService; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -23,6 +24,8 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; #[Route('/admin')] #[IsGranted('ROLE_ROOT')] @@ -31,51 +34,58 @@ class AdminController extends AbstractController private const DQL_STATUS_PAID = 'o.status = :paid'; #[Route('', name: 'app_admin_dashboard')] - public function dashboard(EntityManagerInterface $em): Response + public function dashboard(EntityManagerInterface $em, #[Autowire(service: 'stats.cache')] CacheInterface $cache): Response { - $allUsers = $em->getRepository(User::class)->findAll(); - $organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()); + $stats = $cache->get('admin_dashboard_stats', function (ItemInterface $item) use ($em) { + $item->expiresAfter(600); - $totalCA = (int) ($em->createQueryBuilder() - ->select('SUM(o.totalHT)') - ->from(BilletBuyer::class, 'o') - ->where(self::DQL_STATUS_PAID) - ->setParameter('paid', BilletBuyer::STATUS_PAID) - ->getQuery() - ->getSingleScalarResult() ?? 0); + $allUsers = $em->getRepository(User::class)->findAll(); + $organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()); - $nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]); - $nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]); + $totalCA = (int) ($em->createQueryBuilder() + ->select('SUM(o.totalHT)') + ->from(BilletBuyer::class, 'o') + ->where(self::DQL_STATUS_PAID) + ->setParameter('paid', BilletBuyer::STATUS_PAID) + ->getQuery() + ->getSingleScalarResult() ?? 0); - $commissionEticket = 0; - $commissionStripe = 0; - $paidOrders = $em->createQueryBuilder() - ->select('o', 'e') - ->from(BilletBuyer::class, 'o') - ->join('o.event', 'e') - ->join('e.account', 'a') - ->where(self::DQL_STATUS_PAID) - ->setParameter('paid', BilletBuyer::STATUS_PAID) - ->getQuery() - ->getResult(); + $nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]); + $nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]); + + $commissionEticket = 0; + $commissionStripe = 0; + $paidOrders = $em->createQueryBuilder() + ->select('o', 'e') + ->from(BilletBuyer::class, 'o') + ->join('o.event', 'e') + ->join('e.account', 'a') + ->where(self::DQL_STATUS_PAID) + ->setParameter('paid', BilletBuyer::STATUS_PAID) + ->getQuery() + ->getResult(); - foreach ($paidOrders as $order) { - $rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3; - $ht = $order->getTotalHT() / 100; - $commissionEticket += $ht * ($rate / 100); $stripeFeeRate = (float) $this->getParameter('stripe_fee_rate'); $stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed'); - $commissionStripe += $ht * $stripeFeeRate + $stripeFeeFixed / 100; - } - return $this->render('admin/dashboard.html.twig', [ - 'nbOrgas' => \count($organizers), - 'nbOrders' => $nbOrders, - 'nbBillets' => $nbBillets, - 'totalCA' => $totalCA / 100, - 'commissionEticket' => $commissionEticket, - 'commissionStripe' => $commissionStripe, - ]); + foreach ($paidOrders as $order) { + $rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3; + $ht = $order->getTotalHT() / 100; + $commissionEticket += $ht * ($rate / 100); + $commissionStripe += $ht * $stripeFeeRate + $stripeFeeFixed / 100; + } + + return [ + 'nbOrgas' => \count($organizers), + 'nbOrders' => $nbOrders, + 'nbBillets' => $nbBillets, + 'totalCA' => $totalCA / 100, + 'commissionEticket' => $commissionEticket, + 'commissionStripe' => $commissionStripe, + ]; + }); + + return $this->render('admin/dashboard.html.twig', $stats); } /** @codeCoverageIgnore Requires live Meilisearch */ diff --git a/src/MessageHandler/MeilisearchMessageHandler.php b/src/MessageHandler/MeilisearchMessageHandler.php index c7aee37..489da2d 100644 --- a/src/MessageHandler/MeilisearchMessageHandler.php +++ b/src/MessageHandler/MeilisearchMessageHandler.php @@ -29,5 +29,7 @@ class MeilisearchMessageHandler 'updateSettings' => $this->meilisearch->request('PATCH', "/indexes/{$message->index}/settings", $message->payload['settings']), default => throw new \InvalidArgumentException("Unknown action: {$message->action}"), }; + + $this->meilisearch->invalidateSearchCache(); } } diff --git a/src/Service/MeilisearchService.php b/src/Service/MeilisearchService.php index 4d38613..9fe94f8 100644 --- a/src/Service/MeilisearchService.php +++ b/src/Service/MeilisearchService.php @@ -5,6 +5,8 @@ namespace App\Service; use App\Message\MeilisearchMessage; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class MeilisearchService @@ -14,6 +16,7 @@ class MeilisearchService private MessageBusInterface $bus, #[Autowire(env: 'MEILISEARCH_URL')] private string $url, #[Autowire(env: 'MEILISEARCH_API_KEY')] private string $apiKey, + #[Autowire(service: 'meilisearch.cache')] private CacheInterface $cache, ) { } @@ -91,9 +94,20 @@ class MeilisearchService */ public function search(string $index, string $query, array $options = []): array { - return $this->request('POST', "/indexes/{$index}/search", array_merge([ - 'q' => $query, - ], $options)); + $cacheKey = 'ms_search_'.md5($index.$query.serialize($options)); + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($index, $query, $options) { + $item->expiresAfter(300); + + return $this->request('POST', "/indexes/{$index}/search", array_merge([ + 'q' => $query, + ], $options)); + }); + } + + public function invalidateSearchCache(): void + { + $this->cache->clear(); } /** diff --git a/tests/Service/MeilisearchServiceTest.php b/tests/Service/MeilisearchServiceTest.php index 17f605f..4e7f305 100644 --- a/tests/Service/MeilisearchServiceTest.php +++ b/tests/Service/MeilisearchServiceTest.php @@ -7,6 +7,8 @@ use App\Service\MeilisearchService; use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -14,17 +16,21 @@ class MeilisearchServiceTest extends TestCase { private HttpClientInterface $httpClient; private MessageBusInterface $bus; + private CacheInterface $cache; private MeilisearchService $service; protected function setUp(): void { $this->httpClient = $this->createMock(HttpClientInterface::class); $this->bus = $this->createMock(MessageBusInterface::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->cache->method('get')->willReturnCallback(fn (string $key, callable $callback) => $callback($this->createMock(ItemInterface::class))); $this->service = new MeilisearchService( $this->httpClient, $this->bus, 'http://meilisearch:7700', 'test-key', + $this->cache, ); }