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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:24:35 +01:00
parent a544496104
commit e4edc76f58
8 changed files with 91 additions and 40 deletions

4
.env
View File

@@ -36,6 +36,10 @@ MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
SESSION_HANDLER_DSN=redis://redis:6379/1 SESSION_HANDLER_DSN=redis://redis:6379/1
###< session ### ###< session ###
###> cache ###
REDIS_CACHE_DSN=redis://redis:6379/2
###< cache ###
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=smtp://mailpit:1025 MAILER_DSN=smtp://mailpit:1025
###< symfony/mailer ### ###< symfony/mailer ###

View File

@@ -13,5 +13,6 @@ STRIPE_WEBHOOK_SECRET_CONNECT=whsec_test_connect
OUTSIDE_URL=https://test.example.com OUTSIDE_URL=https://test.example.com
MESSENGER_TRANSPORT_DSN=redis://:e_ticket@redis:6379/messages MESSENGER_TRANSPORT_DSN=redis://:e_ticket@redis:6379/messages
SESSION_HANDLER_DSN=redis://:e_ticket@redis:6379/1 SESSION_HANDLER_DSN=redis://:e_ticket@redis:6379/1
REDIS_CACHE_DSN=redis://:e_ticket@redis:6379/2
SMIME_PASSPHRASE=test SMIME_PASSPHRASE=test
ADMIN_EMAIL=contact@test.com ADMIN_EMAIL=contact@test.com

View File

@@ -3,6 +3,7 @@ APP_SECRET={{ app_secret }}
DATABASE_URL="postgresql://e-ticket:{{ db_password }}@pgbouncer:6432/e-ticket?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://e-ticket:{{ db_password }}@pgbouncer:6432/e-ticket?serverVersion=16&charset=utf8"
MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages
SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1 SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1
REDIS_CACHE_DSN=redis://:{{ redis_password }}@redis:6379/2
MAILER_DSN={{ mailer_dsn }} MAILER_DSN={{ mailer_dsn }}
DEFAULT_URI=https://ticket.e-cosplay.fr DEFAULT_URI=https://ticket.e-cosplay.fr
VITE_LOAD=1 VITE_LOAD=1

View File

@@ -1,6 +1,19 @@
framework: framework:
cache: cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_CACHE_DSN)%'
pools: pools:
siret.cache: siret.cache:
adapter: cache.app adapter: cache.app
default_lifetime: 86400 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

View File

@@ -14,6 +14,7 @@ use App\Service\MailerService;
use App\Service\MeilisearchService; use App\Service\MeilisearchService;
use App\Service\SiretService; use App\Service\SiretService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -23,6 +24,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/admin')] #[Route('/admin')]
#[IsGranted('ROLE_ROOT')] #[IsGranted('ROLE_ROOT')]
@@ -31,51 +34,58 @@ class AdminController extends AbstractController
private const DQL_STATUS_PAID = 'o.status = :paid'; private const DQL_STATUS_PAID = 'o.status = :paid';
#[Route('', name: 'app_admin_dashboard')] #[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(); $stats = $cache->get('admin_dashboard_stats', function (ItemInterface $item) use ($em) {
$organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()); $item->expiresAfter(600);
$totalCA = (int) ($em->createQueryBuilder() $allUsers = $em->getRepository(User::class)->findAll();
->select('SUM(o.totalHT)') $organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved());
->from(BilletBuyer::class, 'o')
->where(self::DQL_STATUS_PAID)
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult() ?? 0);
$nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]); $totalCA = (int) ($em->createQueryBuilder()
$nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]); ->select('SUM(o.totalHT)')
->from(BilletBuyer::class, 'o')
->where(self::DQL_STATUS_PAID)
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult() ?? 0);
$commissionEticket = 0; $nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
$commissionStripe = 0; $nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]);
$paidOrders = $em->createQueryBuilder()
->select('o', 'e') $commissionEticket = 0;
->from(BilletBuyer::class, 'o') $commissionStripe = 0;
->join('o.event', 'e') $paidOrders = $em->createQueryBuilder()
->join('e.account', 'a') ->select('o', 'e')
->where(self::DQL_STATUS_PAID) ->from(BilletBuyer::class, 'o')
->setParameter('paid', BilletBuyer::STATUS_PAID) ->join('o.event', 'e')
->getQuery() ->join('e.account', 'a')
->getResult(); ->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'); $stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed'); $stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
$commissionStripe += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
}
return $this->render('admin/dashboard.html.twig', [ foreach ($paidOrders as $order) {
'nbOrgas' => \count($organizers), $rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3;
'nbOrders' => $nbOrders, $ht = $order->getTotalHT() / 100;
'nbBillets' => $nbBillets, $commissionEticket += $ht * ($rate / 100);
'totalCA' => $totalCA / 100, $commissionStripe += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
'commissionEticket' => $commissionEticket, }
'commissionStripe' => $commissionStripe,
]); 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 */ /** @codeCoverageIgnore Requires live Meilisearch */

View File

@@ -29,5 +29,7 @@ class MeilisearchMessageHandler
'updateSettings' => $this->meilisearch->request('PATCH', "/indexes/{$message->index}/settings", $message->payload['settings']), 'updateSettings' => $this->meilisearch->request('PATCH', "/indexes/{$message->index}/settings", $message->payload['settings']),
default => throw new \InvalidArgumentException("Unknown action: {$message->action}"), default => throw new \InvalidArgumentException("Unknown action: {$message->action}"),
}; };
$this->meilisearch->invalidateSearchCache();
} }
} }

View File

@@ -5,6 +5,8 @@ namespace App\Service;
use App\Message\MeilisearchMessage; use App\Message\MeilisearchMessage;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class MeilisearchService class MeilisearchService
@@ -14,6 +16,7 @@ class MeilisearchService
private MessageBusInterface $bus, private MessageBusInterface $bus,
#[Autowire(env: 'MEILISEARCH_URL')] private string $url, #[Autowire(env: 'MEILISEARCH_URL')] private string $url,
#[Autowire(env: 'MEILISEARCH_API_KEY')] private string $apiKey, #[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 public function search(string $index, string $query, array $options = []): array
{ {
return $this->request('POST', "/indexes/{$index}/search", array_merge([ $cacheKey = 'ms_search_'.md5($index.$query.serialize($options));
'q' => $query,
], $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();
} }
/** /**

View File

@@ -7,6 +7,8 @@ use App\Service\MeilisearchService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
@@ -14,17 +16,21 @@ class MeilisearchServiceTest extends TestCase
{ {
private HttpClientInterface $httpClient; private HttpClientInterface $httpClient;
private MessageBusInterface $bus; private MessageBusInterface $bus;
private CacheInterface $cache;
private MeilisearchService $service; private MeilisearchService $service;
protected function setUp(): void protected function setUp(): void
{ {
$this->httpClient = $this->createMock(HttpClientInterface::class); $this->httpClient = $this->createMock(HttpClientInterface::class);
$this->bus = $this->createMock(MessageBusInterface::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->service = new MeilisearchService(
$this->httpClient, $this->httpClient,
$this->bus, $this->bus,
'http://meilisearch:7700', 'http://meilisearch:7700',
'test-key', 'test-key',
$this->cache,
); );
} }