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 ###
###> cache ###
REDIS_CACHE_DSN=redis://redis:6379/2
###< cache ###
###> symfony/mailer ###
MAILER_DSN=smtp://mailpit:1025
###< symfony/mailer ###

View File

@@ -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

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"
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

View File

@@ -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

View File

@@ -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 */

View File

@@ -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();
}
}

View File

@@ -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();
}
/**

View File

@@ -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,
);
}