7 Commits

Author SHA1 Message Date
d1279c90cc Merge pull request 'fix(deploy): corriger la race condition sur le cache prod au déploiement' (#43) from fix/deploy-cache-race-condition into main
All checks were successful
Deploy / deploy (push) Successful in 1m6s
Reviewed-on: #43
2026-03-27 14:29:06 +01:00
a0729d2e6e Merge branch 'main' into fix/deploy-cache-race-condition 2026-03-27 14:28:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f47d1a245f fix(deploy): corriger la race condition sur le cache prod au déploiement
L'entrypoint faisait rm -rf var/cache/prod puis lançait FrankenPHP.
FrankenPHP compilait partiellement le container DI pendant que le script
Deployer lançait aussi cache:clear → fichiers manquants → crash.

- entrypoint.sh : ajouter cache:warmup après rm -rf, avant exec FrankenPHP
  (l'entrypoint est séquentiel, FrankenPHP ne démarre qu'une fois le cache prêt)
- deploy.php : supprimer le docker exec cache:clear devenu inutile et dangereux
2026-03-27 14:28:30 +01:00
78cc83d465 Merge pull request 'feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement' (#42) from feat/monitoring-run-command into main
Some checks failed
Deploy / deploy (push) Failing after 1m7s
Reviewed-on: #42
2026-03-27 14:23:30 +01:00
7204ea7754 Merge branch 'main' into feat/monitoring-run-command 2026-03-27 14:23:21 +01:00
5edd28309f Merge pull request 'fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres' (#41) from fix/monitoring-scheduler-since-frozen into main
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Reviewed-on: #41
2026-03-27 12:08:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
3f08e1c899 fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres
- MonitoringSchedule : supprimer la date passée au message (était évaluée une
  seule fois au démarrage du container, rendant la requête caduque après le
  premier cycle)
- CheckMonitoredMangasHandler : calculer `since` dynamiquement à l'exécution
  (`new \DateTimeImmutable('-2 hours')`) plutôt que de dépendre du message
- AutoScrapingListener : corriger le TypeError silencieux — créer un ScrapingJob
  avant d'appeler ScrapeChapterHandler (paramètre jobId manquant)

Ajoute les tests unitaires CheckMonitoredMangasHandlerTest et AutoScrapingListenerTest.
2026-03-27 12:08:06 +01:00
8 changed files with 233 additions and 33 deletions

View File

@@ -116,14 +116,13 @@ task('webpack_encore:build', function () {
sh -c '$installCmd'");
});
// Restart Docker containers (entrypoint gère les migrations automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
desc('Restart Docker containers');
task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr');
run('docker exec mangarr php bin/console cache:clear --env=prod');
});
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin

View File

@@ -53,11 +53,12 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi
fi
# Vider le cache prod stale avant le démarrage des workers FrankenPHP.
# Sans ça, les workers chargent l'ancien cache du volume Docker et crashent
# en boucle si les classes du cache ne correspondent plus à la version déployée.
# Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP.
# Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle
# → fichiers partiellement écrits → crash au démarrage des workers.
if [ "$APP_ENV" = "prod" ]; then
rm -rf var/cache/prod
php bin/console cache:warmup --env=prod
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var

View File

@@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
{
$criteria = new MonitoringCriteria(
enabled: true,
lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour')
lastCheckBefore: new \DateTimeImmutable('-2 hours')
);
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyCheckMonitoredMangasHandler
{
public function __construct(
private CheckMonitoredMangasHandler $handler,
) {
}
public function __invoke(CheckMonitoredMangas $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface
{
return (new Schedule())->add(
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
RecurringMessage::every('2 hours', new CheckMonitoredMangas(
new \DateTimeImmutable('-2 hours')
))
RecurringMessage::every('2 hours', new CheckMonitoredMangas())
)->stateful($this->cache);
}
}

View File

@@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener
@@ -15,6 +18,7 @@ class AutoScrapingListener
private readonly ScrapeChapterHandler $scrapeChapterHandler,
private readonly ChapterRepositoryInterface $chapterRepository,
private readonly MangaRepositoryInterface $mangaRepository,
private readonly JobRepositoryInterface $jobRepository,
) {
}
@@ -25,7 +29,12 @@ class AutoScrapingListener
$manga = $this->mangaRepository->getById($chapter->mangaId);
if ($manga->isMonitored()) {
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
$jobId = Uuid::uuid4()->toString();
$job = new ScrapingJob($jobId);
$job->context['chapterId'] = $event->chapterId->getValue();
$this->jobRepository->save($job);
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue(), $jobId));
}
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CheckMonitoredMangasHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryMessageBus $commandBus;
private CheckMonitoredMangasHandler $handler;
protected function setUp(): void
{
$this->mangaRepository = new InMemoryMangaRepository();
$this->commandBus = new InMemoryMessageBus();
$this->commandBus->clear();
$this->handler = new CheckMonitoredMangasHandler($this->mangaRepository, $this->commandBus);
}
private function createManga(string $id): Manga
{
return new Manga(
new MangaId($id),
new MangaTitle('Manga ' . $id),
new MangaSlug('manga-' . $id),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('ext-' . $id)
);
}
public function testDispatchesRefreshForMonitoredMangaWithOldCheck(): void
{
$manga = $this->createManga('manga-1');
$manga->enableMonitoring();
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertTrue($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
$dispatched = array_filter(
$this->commandBus->getDispatchedMessages(),
fn ($m) => $m instanceof RefreshMangaChapters
);
$this->assertCount(1, $dispatched);
$this->assertSame('manga-1', array_values($dispatched)[0]->mangaId->getValue());
}
public function testDoesNotDispatchForNonMonitoredManga(): void
{
$manga = $this->createManga('manga-2');
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
}
public function testDoesNotDispatchForMangaWithRecentCheck(): void
{
$manga = $this->createManga('manga-3');
$manga->enableMonitoring();
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
}
public function testDispatchesOnlyMangasWithOldCheck(): void
{
$mangaOld = $this->createManga('manga-old');
$mangaOld->enableMonitoring();
$mangaOld->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
$this->mangaRepository->save($mangaOld);
$mangaRecent = $this->createManga('manga-recent');
$mangaRecent->enableMonitoring();
$mangaRecent->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
$this->mangaRepository->save($mangaRecent);
$mangaDisabled = $this->createManga('manga-disabled');
$this->mangaRepository->save($mangaDisabled);
$this->handler->handle(new CheckMonitoredMangas());
$dispatched = array_filter(
$this->commandBus->getDispatchedMessages(),
fn ($m) => $m instanceof RefreshMangaChapters
);
$this->assertCount(1, $dispatched);
$this->assertSame('manga-old', array_values($dispatched)[0]->mangaId->getValue());
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Tests\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\Manga;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Infrastructure\EventListener\AutoScrapingListener;
use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use PHPUnit\Framework\TestCase;
class AutoScrapingListenerTest extends TestCase
{
private InMemoryChapterRepository $chapterRepository;
private InMemoryMangaRepository $mangaRepository;
private InMemoryJobRepository $jobRepository;
private InMemoryEventBus $eventBus;
private AutoScrapingListener $listener;
protected function setUp(): void
{
$this->chapterRepository = new InMemoryChapterRepository();
$this->mangaRepository = new InMemoryMangaRepository();
$this->mangaRepository->clear();
$this->jobRepository = new InMemoryJobRepository();
$this->eventBus = new InMemoryEventBus();
$handler = new ScrapeChapterHandler(
new InMemoryScraperFactory(),
new InMemoryImageDownloader(),
new InMemoryImageStorage(),
$this->jobRepository,
$this->chapterRepository,
$this->mangaRepository,
new InMemorySourceRepository(),
$this->eventBus,
);
$this->listener = new AutoScrapingListener(
$handler,
$this->chapterRepository,
$this->mangaRepository,
$this->jobRepository,
);
}
public function testCreatesJobAndScrapesWhenMangaIsMonitored(): void
{
$chapterId = 'chapter-uuid-1';
$mangaId = 'manga-monitored';
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1177.0, null));
$this->mangaRepository->save(new Manga(
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', true
));
$this->listener->onChapterReadyForScraping(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
$jobs = $this->jobRepository->findByType('scraping_job');
$this->assertCount(1, $jobs);
$job = array_values($jobs)[0];
$this->assertSame($chapterId, $job->context['chapterId']);
$this->assertInstanceOf(ScrapingJob::class, $job);
$hasChapterScraped = count(array_filter(
$this->eventBus->getDispatchedMessages(),
fn ($m) => $m instanceof ChapterScraped
)) > 0;
$this->assertTrue($hasChapterScraped);
}
public function testDoesNothingWhenMangaIsNotMonitored(): void
{
$chapterId = 'chapter-uuid-2';
$mangaId = 'manga-not-monitored';
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1176.0, null));
$this->mangaRepository->save(new Manga(
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', false
));
$this->listener->onChapterReadyForScraping(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
$this->assertEmpty($this->jobRepository->findByType('scraping_job'));
$this->assertEmpty($this->eventBus->getDispatchedMessages());
}
}