From 034888b33dcf1b50de934ded9e110d74fbdf0015 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:16:18 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat(ServerLoadChecker):=20expo?= =?UTF-8?q?se=20getLoadAverage=20et=20getThreshold=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/Takeout/ServerLoadChecker.php | 19 +++++++++++++++++-- .../Service/Takeout/ServerLoadCheckerTest.php | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Service/Takeout/ServerLoadChecker.php b/src/Service/Takeout/ServerLoadChecker.php index 8f7ae387..17da8981 100644 --- a/src/Service/Takeout/ServerLoadChecker.php +++ b/src/Service/Takeout/ServerLoadChecker.php @@ -28,10 +28,25 @@ public function __construct( private $loadAverageProvider = 'sys_getloadavg', ) {} - public function isServerCalmEnough(): bool + /** + * @return array{0: float, 1: float, 2: float}|null null si la mesure est indisponible + */ + public function getLoadAverage(): ?array { $loadAverage = ($this->loadAverageProvider)(); - if ($loadAverage === false) { + + return $loadAverage === false ? null : $loadAverage; + } + + public function getThreshold(): float + { + return $this->threshold; + } + + public function isServerCalmEnough(): bool + { + $loadAverage = $this->getLoadAverage(); + if ($loadAverage === null) { return false; } diff --git a/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php b/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php index e0d61acd..d8149c2f 100644 --- a/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php +++ b/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php @@ -72,4 +72,21 @@ public function testDefaultThresholdStillRejectsLoadNearKillLevel(): void $this->assertFalse($checker->isServerCalmEnough()); } + + // #528 : la page admin affiche le load average 1/5/15 et le seuil — elle + // passe par le checker plutôt que de relire /proc/loadavg elle-même. + public function testExposesLoadAverageAndThreshold(): void + { + $checker = new ServerLoadChecker(threshold: 4.5, loadAverageProvider: fn () => [1.5, 1.2, 1.0]); + + $this->assertSame([1.5, 1.2, 1.0], $checker->getLoadAverage()); + $this->assertSame(4.5, $checker->getThreshold()); + } + + public function testLoadAverageIsNullWhenUnavailable(): void + { + $checker = new ServerLoadChecker(loadAverageProvider: fn () => false); + + $this->assertNull($checker->getLoadAverage()); + } } From d0fc984a5d4ca400bd1c6015b96ecacd399814ba Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:17:30 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20feat(TakeoutDispatchLog):=20his?= =?UTF-8?q?torique=20des=20cycles=20de=20dispatch=20Takeout=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/services.yaml | 4 + migrations/Version20261002091651.php | 29 +++++ src/Entity/TakeoutDispatchLog.php | 119 ++++++++++++++++++ .../TakeoutDispatchLogRepository.php | 70 +++++++++++ .../TakeoutDispatchLogRepositoryTest.php | 118 +++++++++++++++++ 5 files changed, 340 insertions(+) create mode 100644 migrations/Version20261002091651.php create mode 100644 src/Entity/TakeoutDispatchLog.php create mode 100644 src/Repository/TakeoutDispatchLogRepository.php create mode 100644 tests/Repository/TakeoutDispatchLogRepositoryTest.php diff --git a/config/services.yaml b/config/services.yaml index e4b84921..0939f896 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -255,6 +255,10 @@ when@test: App\Service\Monitoring\InstanceMonitoringReporter: autowire: true public: true + # Rendre TakeoutDispatchLogRepository public en test (accès direct, #528) + App\Repository\TakeoutDispatchLogRepository: + autowire: true + public: true # ServerLoadChecker (#524) : sys_getloadavg() réel de la machine de # CI/dev rendait les tests fonctionnels Takeout flaky (échec aléatoire # selon la charge locale au moment du run, constaté le 2026-09-29). diff --git a/migrations/Version20261002091651.php b/migrations/Version20261002091651.php new file mode 100644 index 00000000..54a78a7d --- /dev/null +++ b/migrations/Version20261002091651.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE takeout_dispatch_log (id BINARY(16) NOT NULL, outcome VARCHAR(16) NOT NULL, load1 DOUBLE PRECISION DEFAULT NULL, load5 DOUBLE PRECISION DEFAULT NULL, load15 DOUBLE PRECISION DEFAULT NULL, threshold DOUBLE PRECISION NOT NULL, scheduled_count INT NOT NULL, dispatched_count INT NOT NULL, created_at DATETIME NOT NULL, INDEX idx_takeout_dispatch_log_created_at (created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE takeout_dispatch_log'); + } +} diff --git a/src/Entity/TakeoutDispatchLog.php b/src/Entity/TakeoutDispatchLog.php new file mode 100644 index 00000000..1e93b59a --- /dev/null +++ b/src/Entity/TakeoutDispatchLog.php @@ -0,0 +1,119 @@ +id = Uuid::v7(); + $this->outcome = $outcome; + $this->load1 = $loadAverage[0] ?? null; + $this->load5 = $loadAverage[1] ?? null; + $this->load15 = $loadAverage[2] ?? null; + $this->threshold = $threshold; + $this->scheduledCount = $scheduledCount; + $this->dispatchedCount = $dispatchedCount; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getOutcome(): string + { + return $this->outcome; + } + + /** + * @return array{0: float, 1: float, 2: float}|null + */ + public function getLoadAverage(): ?array + { + if ($this->load1 === null || $this->load5 === null || $this->load15 === null) { + return null; + } + + return [$this->load1, $this->load5, $this->load15]; + } + + public function getThreshold(): float + { + return $this->threshold; + } + + public function getScheduledCount(): int + { + return $this->scheduledCount; + } + + public function getDispatchedCount(): int + { + return $this->dispatchedCount; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Repository/TakeoutDispatchLogRepository.php b/src/Repository/TakeoutDispatchLogRepository.php new file mode 100644 index 00000000..f6e5b93a --- /dev/null +++ b/src/Repository/TakeoutDispatchLogRepository.php @@ -0,0 +1,70 @@ + + */ +class TakeoutDispatchLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TakeoutDispatchLog::class); + } + + public function save(TakeoutDispatchLog $log): void + { + $em = $this->getEntityManager(); + $em->persist($log); + $em->flush(); + } + + /** + * @return list + */ + public function findRecent(int $limit = 50): array + { + return $this->findBy([], ['createdAt' => 'DESC'], $limit); + } + + /** + * Un seul DELETE borné par date (index created_at) : pas de chargement + * d'entités, adapté au LVE. + */ + public function purgeOlderThan(\DateTimeImmutable $threshold): int + { + return (int) $this->createQueryBuilder('l') + ->delete() + ->where('l.createdAt < :threshold') + ->setParameter('threshold', $threshold) + ->getQuery() + ->execute(); + } + + /** + * @return array nombre de cycles par issue depuis $since (0 si absente) + */ + public function countByOutcomeSince(\DateTimeImmutable $since): array + { + $rows = $this->createQueryBuilder('l') + ->select('l.outcome AS outcome, COUNT(l.id) AS total') + ->where('l.createdAt >= :since') + ->setParameter('since', $since) + ->groupBy('l.outcome') + ->getQuery() + ->getArrayResult(); + + $counts = array_fill_keys(TakeoutDispatchLog::OUTCOMES, 0); + foreach ($rows as $row) { + $counts[$row['outcome']] = (int) $row['total']; + } + + return $counts; + } +} diff --git a/tests/Repository/TakeoutDispatchLogRepositoryTest.php b/tests/Repository/TakeoutDispatchLogRepositoryTest.php new file mode 100644 index 00000000..fe1ee91b --- /dev/null +++ b/tests/Repository/TakeoutDispatchLogRepositoryTest.php @@ -0,0 +1,118 @@ +em = static::getContainer()->get(EntityManagerInterface::class); + $this->repository = static::getContainer()->get(TakeoutDispatchLogRepository::class); + + $this->em->getConnection()->executeStatement('DELETE FROM takeout_dispatch_log'); + $this->em->clear(); + } + + private function insertLog(string $outcome, \DateTimeImmutable $createdAt, ?array $load = [1.0, 1.0, 1.0]): void + { + $log = new TakeoutDispatchLog($outcome, $load, 8.0, 2, $outcome === TakeoutDispatchLog::OUTCOME_DISPATCHED ? 2 : 0); + $this->em->persist($log); + $this->em->flush(); + + // createdAt est fixé dans le constructeur : on force la valeur voulue en base. + $this->em->getConnection()->executeStatement( + 'UPDATE takeout_dispatch_log SET created_at = ? WHERE id = ?', + [$createdAt->format('Y-m-d H:i:s'), $log->getId()->toBinary()], + ); + $this->em->clear(); + } + + public function testSavePersistsLogWithLoadAverage(): void + { + $log = new TakeoutDispatchLog(TakeoutDispatchLog::OUTCOME_DEFERRED, [9.5, 8.0, 7.5], 8.0, 3, 0); + + $this->repository->save($log); + $this->em->clear(); + + $found = $this->repository->find($log->getId()); + $this->assertNotNull($found); + $this->assertSame(TakeoutDispatchLog::OUTCOME_DEFERRED, $found->getOutcome()); + $this->assertSame([9.5, 8.0, 7.5], $found->getLoadAverage()); + $this->assertSame(8.0, $found->getThreshold()); + $this->assertSame(3, $found->getScheduledCount()); + $this->assertSame(0, $found->getDispatchedCount()); + } + + public function testLoadAverageIsNullWhenMeasureUnavailable(): void + { + $log = new TakeoutDispatchLog(TakeoutDispatchLog::OUTCOME_DEFERRED, null, 8.0, 0, 0); + + $this->repository->save($log); + $this->em->clear(); + + $this->assertNull($this->repository->find($log->getId())->getLoadAverage()); + } + + public function testFindRecentReturnsNewestFirstAndRespectsLimit(): void + { + $now = new \DateTimeImmutable(); + $this->insertLog(TakeoutDispatchLog::OUTCOME_IDLE, $now->modify('-45 minutes')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-30 minutes')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DISPATCHED, $now->modify('-15 minutes')); + + $recent = $this->repository->findRecent(2); + + $this->assertCount(2, $recent); + $this->assertSame(TakeoutDispatchLog::OUTCOME_DISPATCHED, $recent[0]->getOutcome()); + $this->assertSame(TakeoutDispatchLog::OUTCOME_DEFERRED, $recent[1]->getOutcome()); + } + + public function testFindRecentIsEmptyWhenNoLog(): void + { + $this->assertSame([], $this->repository->findRecent()); + } + + public function testPurgeOlderThanDeletesOnlyOldRowsAndReturnsCount(): void + { + $now = new \DateTimeImmutable(); + $this->insertLog(TakeoutDispatchLog::OUTCOME_IDLE, $now->modify('-31 days')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_IDLE, $now->modify('-40 days')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_IDLE, $now->modify('-2 days')); + + $deleted = $this->repository->purgeOlderThan($now->modify('-30 days')); + + $this->assertSame(2, $deleted); + $this->assertCount(1, $this->repository->findRecent()); + } + + public function testCountByOutcomeSinceIgnoresOlderRows(): void + { + $now = new \DateTimeImmutable(); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DISPATCHED, $now->modify('-1 hour')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-2 hours')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-3 hours')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-3 days')); + + $counts = $this->repository->countByOutcomeSince($now->modify('-24 hours')); + + $this->assertSame(1, $counts[TakeoutDispatchLog::OUTCOME_DISPATCHED]); + $this->assertSame(2, $counts[TakeoutDispatchLog::OUTCOME_DEFERRED]); + $this->assertSame(0, $counts[TakeoutDispatchLog::OUTCOME_IDLE]); + } +} From 3c575e404401b179cf5b1a32ffc452a369005b1b Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:18:31 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ServerLoadChe?= =?UTF-8?q?cker):=20isCalm()=20d=C3=A9cide=20sur=20une=20mesure=20d=C3=A9j?= =?UTF-8?q?=C3=A0=20prise=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/Takeout/ServerLoadChecker.php | 13 ++++++++++++- .../Unit/Service/Takeout/ServerLoadCheckerTest.php | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Service/Takeout/ServerLoadChecker.php b/src/Service/Takeout/ServerLoadChecker.php index 17da8981..a68c6cfb 100644 --- a/src/Service/Takeout/ServerLoadChecker.php +++ b/src/Service/Takeout/ServerLoadChecker.php @@ -45,7 +45,18 @@ public function getThreshold(): float public function isServerCalmEnough(): bool { - $loadAverage = $this->getLoadAverage(); + return $this->isCalm($this->getLoadAverage()); + } + + /** + * Décision sur une mesure déjà prise (#528) : permet à l'appelant de + * mesurer une seule fois et de journaliser la mesure et la décision + * sans risque d'incohérence entre deux lectures successives. + * + * @param array{0: float, 1: float, 2: float}|null $loadAverage + */ + public function isCalm(?array $loadAverage): bool + { if ($loadAverage === null) { return false; } diff --git a/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php b/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php index d8149c2f..84651463 100644 --- a/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php +++ b/tests/Unit/Service/Takeout/ServerLoadCheckerTest.php @@ -89,4 +89,15 @@ public function testLoadAverageIsNullWhenUnavailable(): void $this->assertNull($checker->getLoadAverage()); } + + // #528 : la commande mesure une fois et décide sur cette mesure, pour que + // l'issue loguée soit toujours cohérente avec la charge loguée. + public function testIsCalmDecidesOnGivenMeasureWithoutReadingAgain(): void + { + $checker = new ServerLoadChecker(threshold: 3.0, loadAverageProvider: fn () => throw new \LogicException('ne doit pas relire')); + + $this->assertTrue($checker->isCalm([2.9, 9.0, 9.0])); + $this->assertFalse($checker->isCalm([3.0, 0.0, 0.0])); + $this->assertFalse($checker->isCalm(null)); + } } From 77c9e24ae1042ebea8444499f4f5f443d72925ac Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:18:31 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=A8=20feat(TakeoutImportNightlyDispat?= =?UTF-8?q?chCommand):=20journalise=20chaque=20cycle=20et=20purge=20=C3=A0?= =?UTF-8?q?=2030=20jours=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TakeoutImportNightlyDispatchCommand.php | 49 +++++- src/Repository/TakeoutImportRepository.php | 7 + ...akeoutImportNightlyDispatchCommandTest.php | 157 +++++++++++++++++- 3 files changed, 210 insertions(+), 3 deletions(-) diff --git a/src/Command/TakeoutImportNightlyDispatchCommand.php b/src/Command/TakeoutImportNightlyDispatchCommand.php index ab0f69a7..b6211023 100644 --- a/src/Command/TakeoutImportNightlyDispatchCommand.php +++ b/src/Command/TakeoutImportNightlyDispatchCommand.php @@ -4,10 +4,13 @@ namespace App\Command; +use App\Entity\TakeoutDispatchLog; use App\Message\TakeoutImportExtractMessage; +use App\Repository\TakeoutDispatchLogRepository; use App\Repository\TakeoutImportRepository; use App\Service\Takeout\ServerLoadChecker; use App\Service\Takeout\TakeoutImportTmpDirLocator; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -29,6 +32,11 @@ * TakeoutImportStartController ne dispatche plus immédiatement : il marque * l'import "scheduled" et cette commande prend le relais ici, un ZIP à la * fois par import (même pattern que #520). + * + * #528 : chaque cycle (calme, chargé, ou rien en attente) est tracé dans + * TakeoutDispatchLog puis les lignes de plus de 30 jours sont purgées. Pure + * observation : un échec d'écriture est journalisé mais ne bloque jamais le + * dispatch ni ne fait échouer la commande. */ #[AsCommand(name: 'app:takeout:nightly-dispatch', description: 'Dispatche les imports Google Takeout dès que le serveur est calme')] final class TakeoutImportNightlyDispatchCommand extends Command @@ -38,6 +46,8 @@ public function __construct( private readonly TakeoutImportTmpDirLocator $tmpDirLocator, private readonly MessageBusInterface $bus, private readonly ServerLoadChecker $loadChecker, + private readonly TakeoutDispatchLogRepository $dispatchLogRepository, + private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -46,8 +56,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - if (!$this->loadChecker->isServerCalmEnough()) { + // Une seule mesure par cycle : l'issue loguée reste cohérente avec la charge loguée. + $loadAverage = $this->loadChecker->getLoadAverage(); + + if (!$this->loadChecker->isCalm($loadAverage)) { $io->writeln('Serveur trop chargé, aucun import dispatché — retenté au prochain cycle.'); + $this->recordCycle(TakeoutDispatchLog::OUTCOME_DEFERRED, $loadAverage, $this->repository->countScheduled(), 0); + $this->purgeOldLogs(); return Command::SUCCESS; } @@ -61,8 +76,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->bus->dispatch(new TakeoutImportExtractMessage((string) $import->getId(), $firstZipPath, $zipPaths)); } - $io->writeln(sprintf('%d import(s) Takeout dispatché(s).', count($scheduledImports))); + $count = count($scheduledImports); + $io->writeln(sprintf('%d import(s) Takeout dispatché(s).', $count)); + $this->recordCycle($count > 0 ? TakeoutDispatchLog::OUTCOME_DISPATCHED : TakeoutDispatchLog::OUTCOME_IDLE, $loadAverage, $count, $count); + $this->purgeOldLogs(); return Command::SUCCESS; } + + /** + * @param array{0: float, 1: float, 2: float}|null $loadAverage + */ + private function recordCycle(string $outcome, ?array $loadAverage, int $scheduledCount, int $dispatchedCount): void + { + try { + $this->dispatchLogRepository->save(new TakeoutDispatchLog( + $outcome, + $loadAverage, + $this->loadChecker->getThreshold(), + $scheduledCount, + $dispatchedCount, + )); + } catch (\Throwable $e) { + $this->logger->error('TakeoutImportNightlyDispatchCommand : échec de l\'écriture de l\'historique de dispatch', ['exception' => $e]); + } + } + + private function purgeOldLogs(): void + { + try { + $this->dispatchLogRepository->purgeOlderThan(new \DateTimeImmutable('-' . TakeoutDispatchLog::PURGE_AFTER_DAYS . ' days')); + } catch (\Throwable $e) { + $this->logger->error('TakeoutImportNightlyDispatchCommand : échec de la purge de l\'historique de dispatch', ['exception' => $e]); + } + } } diff --git a/src/Repository/TakeoutImportRepository.php b/src/Repository/TakeoutImportRepository.php index 96823958..f596ab63 100644 --- a/src/Repository/TakeoutImportRepository.php +++ b/src/Repository/TakeoutImportRepository.php @@ -83,6 +83,13 @@ public function findAllScheduled(): array return $this->findBy(['status' => TakeoutImport::STATUS_SCHEDULED], ['createdAt' => 'ASC']); } + // #528 : simple COUNT pour l'historique de dispatch — évite de charger + // les entités quand le cycle est reporté (serveur chargé). + public function countScheduled(): int + { + return $this->count(['status' => TakeoutImport::STATUS_SCHEDULED]); + } + // #493 : candidats à la purge automatique — un import pending jamais // repris au-delà du seuil devient orphelin indéfiniment (ligne base + // dossier var/takeout-tmp//, potentiellement plusieurs Go). diff --git a/tests/Command/TakeoutImportNightlyDispatchCommandTest.php b/tests/Command/TakeoutImportNightlyDispatchCommandTest.php index e97143b6..c06f2742 100644 --- a/tests/Command/TakeoutImportNightlyDispatchCommandTest.php +++ b/tests/Command/TakeoutImportNightlyDispatchCommandTest.php @@ -5,13 +5,17 @@ namespace App\Tests\Command; use App\Command\TakeoutImportNightlyDispatchCommand; +use App\Entity\TakeoutDispatchLog; use App\Entity\TakeoutImport; use App\Entity\User; use App\Message\TakeoutImportExtractMessage; +use App\Repository\TakeoutDispatchLogRepository; use App\Repository\TakeoutImportRepository; use App\Service\Takeout\ServerLoadChecker; use App\Service\Takeout\TakeoutImportTmpDirLocator; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Messenger\Envelope; @@ -177,11 +181,162 @@ private function commandTester( TakeoutImportTmpDirLocator $tmpDirLocator, MessageBusInterface $bus, ?ServerLoadChecker $loadChecker = null, + ?TakeoutDispatchLogRepository $logRepository = null, + ?LoggerInterface $logger = null, ): CommandTester { - $command = new TakeoutImportNightlyDispatchCommand($repository, $tmpDirLocator, $bus, $loadChecker ?? $this->calmChecker()); + $command = new TakeoutImportNightlyDispatchCommand( + $repository, + $tmpDirLocator, + $bus, + $loadChecker ?? $this->calmChecker(), + $logRepository ?? $this->createStub(TakeoutDispatchLogRepository::class), + $logger ?? new NullLogger(), + ); $application = new Application(); $application->addCommand($command); return new CommandTester($application->find('app:takeout:nightly-dispatch')); } + + private function busStub(): MessageBusInterface + { + $bus = $this->createStub(MessageBusInterface::class); + $bus->method('dispatch')->willReturn(new Envelope(new \stdClass())); + + return $bus; + } + + // #528 : une ligne d'historique par cycle, y compris "idle" (rien en + // attente) — c'est aussi l'échantillonnage de charge de #547. + public function testLogsIdleCycleWhenNothingScheduled(): void + { + $repository = $this->createStub(TakeoutImportRepository::class); + $repository->method('findAllScheduled')->willReturn([]); + + $logs = []; + $logRepository = $this->createMock(TakeoutDispatchLogRepository::class); + $logRepository->expects($this->once())->method('save')->willReturnCallback(function (TakeoutDispatchLog $log) use (&$logs) { + $logs[] = $log; + }); + + $tester = $this->commandTester($repository, new TakeoutImportTmpDirLocator($this->tmpDir), $this->busStub(), $this->calmChecker(), $logRepository); + $tester->execute([]); + + $this->assertSame(TakeoutDispatchLog::OUTCOME_IDLE, $logs[0]->getOutcome()); + $this->assertSame([1.0, 1.0, 1.0], $logs[0]->getLoadAverage()); + $this->assertSame(3.0, $logs[0]->getThreshold()); + $this->assertSame(0, $logs[0]->getScheduledCount()); + $this->assertSame(0, $logs[0]->getDispatchedCount()); + } + + public function testLogsDispatchedCycleWithCounts(): void + { + $imports = [$this->makeImport(), $this->makeImport()]; + $tmpDirLocator = new TakeoutImportTmpDirLocator($this->tmpDir); + foreach ($imports as $import) { + $import->markScheduled(); + $this->makeZipsFor($tmpDirLocator, $import, ['a.zip']); + } + $repository = $this->createStub(TakeoutImportRepository::class); + $repository->method('findAllScheduled')->willReturn($imports); + + $logs = []; + $logRepository = $this->createMock(TakeoutDispatchLogRepository::class); + $logRepository->expects($this->once())->method('save')->willReturnCallback(function (TakeoutDispatchLog $log) use (&$logs) { + $logs[] = $log; + }); + + $tester = $this->commandTester($repository, $tmpDirLocator, $this->busStub(), $this->calmChecker(), $logRepository); + $tester->execute([]); + + $this->assertSame(TakeoutDispatchLog::OUTCOME_DISPATCHED, $logs[0]->getOutcome()); + $this->assertSame(2, $logs[0]->getScheduledCount()); + $this->assertSame(2, $logs[0]->getDispatchedCount()); + } + + public function testLogsDeferredCycleWithScheduledCountWithoutLoadingImports(): void + { + $repository = $this->createMock(TakeoutImportRepository::class); + $repository->expects($this->never())->method('findAllScheduled'); + $repository->method('countScheduled')->willReturn(3); + + $logs = []; + $logRepository = $this->createMock(TakeoutDispatchLogRepository::class); + $logRepository->expects($this->once())->method('save')->willReturnCallback(function (TakeoutDispatchLog $log) use (&$logs) { + $logs[] = $log; + }); + + $tester = $this->commandTester($repository, new TakeoutImportTmpDirLocator($this->tmpDir), $this->busStub(), $this->loadedChecker(), $logRepository); + $tester->execute([]); + + $this->assertSame(TakeoutDispatchLog::OUTCOME_DEFERRED, $logs[0]->getOutcome()); + $this->assertSame([9.86, 10.45, 10.48], $logs[0]->getLoadAverage()); + $this->assertSame(3, $logs[0]->getScheduledCount()); + $this->assertSame(0, $logs[0]->getDispatchedCount()); + } + + public function testLogsDeferredWithNullLoadWhenMeasureUnavailable(): void + { + $repository = $this->createStub(TakeoutImportRepository::class); + $checker = new ServerLoadChecker(threshold: 3.0, loadAverageProvider: fn () => false); + + $logs = []; + $logRepository = $this->createStub(TakeoutDispatchLogRepository::class); + $logRepository->method('save')->willReturnCallback(function (TakeoutDispatchLog $log) use (&$logs) { + $logs[] = $log; + }); + + $tester = $this->commandTester($repository, new TakeoutImportTmpDirLocator($this->tmpDir), $this->busStub(), $checker, $logRepository); + $tester->execute([]); + + $this->assertSame(TakeoutDispatchLog::OUTCOME_DEFERRED, $logs[0]->getOutcome()); + $this->assertNull($logs[0]->getLoadAverage()); + } + + public function testPurgesLogsOlderThanRetentionAtEachCycle(): void + { + $repository = $this->createStub(TakeoutImportRepository::class); + $repository->method('findAllScheduled')->willReturn([]); + + $logRepository = $this->createMock(TakeoutDispatchLogRepository::class); + $logRepository->expects($this->once()) + ->method('purgeOlderThan') + ->with($this->callback(function (\DateTimeImmutable $threshold): bool { + $expected = new \DateTimeImmutable('-' . TakeoutDispatchLog::PURGE_AFTER_DAYS . ' days'); + + return abs($threshold->getTimestamp() - $expected->getTimestamp()) < 5; + })); + + $tester = $this->commandTester($repository, new TakeoutImportTmpDirLocator($this->tmpDir), $this->busStub(), $this->calmChecker(), $logRepository); + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + } + + // Rollback : l'historique est de l'observation, jamais une condition du + // dispatch — une table indisponible ne doit pas bloquer les imports. + public function testStillDispatchesWhenLogWriteFails(): void + { + $import = $this->makeImport(); + $import->markScheduled(); + $tmpDirLocator = new TakeoutImportTmpDirLocator($this->tmpDir); + $this->makeZipsFor($tmpDirLocator, $import, ['a.zip']); + $repository = $this->createStub(TakeoutImportRepository::class); + $repository->method('findAllScheduled')->willReturn([$import]); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects($this->once())->method('dispatch')->willReturn(new Envelope(new \stdClass())); + + $logRepository = $this->createStub(TakeoutDispatchLogRepository::class); + $logRepository->method('save')->willThrowException(new \RuntimeException('table absente')); + $logRepository->method('purgeOlderThan')->willThrowException(new \RuntimeException('table absente')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $tester = $this->commandTester($repository, $tmpDirLocator, $bus, $this->calmChecker(), $logRepository, $logger); + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + } } From 9c46fc0bab53ad74f302b0182328a59de0ebdeae Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:26:52 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20feat(TakeoutImport):=20date=20d?= =?UTF-8?q?e=20passage=20en=20scheduled=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20261002092640.php | 29 +++++++++++++++++++++++++ src/Entity/TakeoutImport.php | 14 ++++++++++++ tests/Unit/Entity/TakeoutImportTest.php | 21 ++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 migrations/Version20261002092640.php diff --git a/migrations/Version20261002092640.php b/migrations/Version20261002092640.php new file mode 100644 index 00000000..eadd9873 --- /dev/null +++ b/migrations/Version20261002092640.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE takeout_imports ADD scheduled_at DATETIME DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE takeout_imports DROP scheduled_at'); + } +} diff --git a/src/Entity/TakeoutImport.php b/src/Entity/TakeoutImport.php index 56601562..6597de30 100644 --- a/src/Entity/TakeoutImport.php +++ b/src/Entity/TakeoutImport.php @@ -95,6 +95,14 @@ class TakeoutImport #[ORM\Column] private \DateTimeImmutable $createdAt; + /** + * Posée par markScheduled() (#528) : permet d'afficher depuis quand un + * import attend un créneau calme. Null pour les imports passés en + * scheduled avant cette colonne, et pour tout autre statut. + */ + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $scheduledAt = null; + #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $completedAt = null; @@ -174,6 +182,11 @@ public function getCreatedAt(): \DateTimeImmutable return $this->createdAt; } + public function getScheduledAt(): ?\DateTimeImmutable + { + return $this->scheduledAt; + } + public function getCompletedAt(): ?\DateTimeImmutable { return $this->completedAt; @@ -187,6 +200,7 @@ public function getErrorMessage(): ?string public function markScheduled(): void { $this->status = self::STATUS_SCHEDULED; + $this->scheduledAt = new \DateTimeImmutable(); } public function markExtracting(?int $totalZipCount = null): void diff --git a/tests/Unit/Entity/TakeoutImportTest.php b/tests/Unit/Entity/TakeoutImportTest.php index 24429d7a..13278228 100644 --- a/tests/Unit/Entity/TakeoutImportTest.php +++ b/tests/Unit/Entity/TakeoutImportTest.php @@ -186,4 +186,25 @@ public function testMarkFailedSetsStatusErrorMessageAndPartialCounters(): void $this->assertSame(0, $import->getUnrecognizedFilesCount()); $this->assertNotNull($import->getCompletedAt()); } + + // #528 : la page admin affiche "en attente depuis…" — createdAt ne + // convient pas (un import peut rester pending des jours avant d'être + // scheduled), d'où une date dédiée posée au passage en scheduled. + public function testScheduledAtIsNullUntilMarkedScheduled(): void + { + $import = new TakeoutImport(new User('owner@example.com', 'Owner')); + + $this->assertNull($import->getScheduledAt()); + } + + public function testMarkScheduledRecordsScheduledAt(): void + { + $import = new TakeoutImport(new User('owner@example.com', 'Owner')); + $before = new \DateTimeImmutable(); + + $import->markScheduled(); + + $this->assertNotNull($import->getScheduledAt()); + $this->assertGreaterThanOrEqual($before->getTimestamp(), $import->getScheduledAt()->getTimestamp()); + } } From b3c28ea828087546d575214a1b785649b88f9b15 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:29:27 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=A8=20feat(AdminTakeoutLoad):=20page?= =?UTF-8?q?=20admin=20charge=20serveur=20et=20imports=20Takeout=20diff?= =?UTF-8?q?=C3=A9r=C3=A9s=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/AdminTakeoutLoadWebController.php | 57 +++++ templates/admin/layout.html.twig | 5 + templates/admin/takeout_load.html.twig | 101 +++++++++ .../Web/AdminTakeoutLoadWebControllerTest.php | 198 ++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 src/Controller/Web/Admin/AdminTakeoutLoadWebController.php create mode 100644 templates/admin/takeout_load.html.twig create mode 100644 tests/Web/AdminTakeoutLoadWebControllerTest.php diff --git a/src/Controller/Web/Admin/AdminTakeoutLoadWebController.php b/src/Controller/Web/Admin/AdminTakeoutLoadWebController.php new file mode 100644 index 00000000..9e36962c --- /dev/null +++ b/src/Controller/Web/Admin/AdminTakeoutLoadWebController.php @@ -0,0 +1,57 @@ +loadChecker->getLoadAverage(); + + $counts = $this->dispatchLogRepository->countByOutcomeSince(new \DateTimeImmutable('-24 hours')); + $cycles = array_sum($counts); + // Part de cycles où le serveur était assez calme pour dispatcher + // (dispatched + idle) : l'indicateur qui aurait révélé le 0 % de #543. + $calmRate = $cycles > 0 + ? (int) round(($counts[TakeoutDispatchLog::OUTCOME_DISPATCHED] + $counts[TakeoutDispatchLog::OUTCOME_IDLE]) / $cycles * 100) + : null; + + return $this->render('admin/takeout_load.html.twig', [ + 'loadAverage' => $loadAverage, + 'threshold' => $this->loadChecker->getThreshold(), + 'isCalm' => $this->loadChecker->isCalm($loadAverage), + 'scheduledImports' => $this->importRepository->findAllScheduled(), + 'recentLogs' => $this->dispatchLogRepository->findRecent(self::HISTORY_LIMIT), + 'cycles24h' => $cycles, + 'calmRate24h' => $calmRate, + ]); + } +} diff --git a/templates/admin/layout.html.twig b/templates/admin/layout.html.twig index fde6ea38..bf746163 100644 --- a/templates/admin/layout.html.twig +++ b/templates/admin/layout.html.twig @@ -45,6 +45,11 @@ Santé cron + + Charge Takeout + + Monitoring multi-instances diff --git a/templates/admin/takeout_load.html.twig b/templates/admin/takeout_load.html.twig new file mode 100644 index 00000000..8cf1d360 --- /dev/null +++ b/templates/admin/takeout_load.html.twig @@ -0,0 +1,101 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Charge Takeout — Administration{% endblock %} + +{% block content %} +

Charge serveur et imports Takeout différés

+ +

Charge actuelle

+ + + {% if loadAverage is null %} + + + + + {% else %} + + + + + {% endif %} + + + + + + + + + +
Load averageMesure indisponible — le dispatch est reporté par prudence.
Load average 1 / 5 / 15 min{{ loadAverage[0]|number_format(2, ',', ' ') }} / {{ loadAverage[1]|number_format(2, ',', ' ') }} / {{ loadAverage[2]|number_format(2, ',', ' ') }}
Seuil de dispatch (1 min){{ threshold|number_format(2, ',', ' ') }}
État{{ isCalm ? 'Serveur calme : les imports en attente démarrent au prochain cycle.' : 'Serveur trop chargé : dispatch reporté.' }}
+ +

Imports en attente d'un créneau calme

+{% if scheduledImports is empty %} +

Aucun import en attente.

+{% else %} + + + + + + + + + + {% for import in scheduledImports %} + + + + + + {% endfor %} + +
PropriétaireEn attente depuisZIP
{{ import.owner.email }} + {% if import.scheduledAt %} + {{ import.scheduledAt|date('d/m/Y H:i', 'Europe/Paris') }} + {% else %} + {{ import.createdAt|date('d/m/Y H:i', 'Europe/Paris') }} (date de création, planification non datée) + {% endif %} + {{ import.totalZipCount ?? '—' }}
+{% endif %} + +

Historique des dispatches

+{% if recentLogs is empty %} +

Aucun cycle enregistré pour l'instant.

+{% else %} +

+ Sur les dernières 24 h : {{ cycles24h }} cycle(s), + {% if calmRate24h is not null %}{{ calmRate24h }} % avec un serveur assez calme pour dispatcher.{% else %}aucune donnée.{% endif %} +

+ + + + + + + + + + + + + {% for log in recentLogs %} + + + + + + + + + {% endfor %} + +
DateRésultatLoad 1 minSeuilEn attenteDispatchés
{{ log.createdAt|date('d/m/Y H:i', 'Europe/Paris') }} + {% if log.outcome == 'dispatched' %}Démarré (calme) + {% elseif log.outcome == 'deferred' %}Reporté (chargé) + {% else %}Rien en attente{% endif %} + {{ log.loadAverage is null ? '—' : log.loadAverage[0]|number_format(2, ',', ' ') }}{{ log.threshold|number_format(2, ',', ' ') }}{{ log.scheduledCount }}{{ log.dispatchedCount }}
+

Les {{ recentLogs|length }} derniers cycles sont affichés ; l'historique est conservé 30 jours.

+{% endif %} +{% endblock %} diff --git a/tests/Web/AdminTakeoutLoadWebControllerTest.php b/tests/Web/AdminTakeoutLoadWebControllerTest.php new file mode 100644 index 00000000..c935fac9 --- /dev/null +++ b/tests/Web/AdminTakeoutLoadWebControllerTest.php @@ -0,0 +1,198 @@ +client = static::createClient(); + $container = static::getContainer(); + $this->em = $container->get(EntityManagerInterface::class); + + $conn = $this->em->getConnection(); + $conn->executeStatement('SET FOREIGN_KEY_CHECKS=0'); + $conn->executeStatement('DELETE FROM takeout_dispatch_log'); + $conn->executeStatement('DELETE FROM takeout_imports'); + $conn->executeStatement('DELETE FROM users'); + $conn->executeStatement('SET FOREIGN_KEY_CHECKS=1'); + $this->em->clear(); + + // Sans disableReboot(), le kernel redémarre entre le set() et la + // requête et le service substitué serait écrasé (cf. AdminMailStatus). + $this->client->disableReboot(); + + // Mesure déterministe : le sys_getloadavg() réel de la machine de test + // rendrait les assertions aléatoires. + $container->set(ServerLoadChecker::class, new ServerLoadChecker(threshold: 8.0, loadAverageProvider: fn () => [1.5, 1.25, 1.0])); + } + + private function loginAsAdmin(): void + { + $this->createWebUser($_ENV['BROADCAST_ADMIN_EMAIL'], 'secret123', 'Admin'); + $this->loginAs($_ENV['BROADCAST_ADMIN_EMAIL']); + } + + private function insertLog(string $outcome, \DateTimeImmutable $createdAt): void + { + $log = new TakeoutDispatchLog($outcome, [2.0, 2.0, 2.0], 8.0, 1, $outcome === TakeoutDispatchLog::OUTCOME_DISPATCHED ? 1 : 0); + $this->em->persist($log); + $this->em->flush(); + $this->em->getConnection()->executeStatement( + 'UPDATE takeout_dispatch_log SET created_at = ? WHERE id = ?', + [$createdAt->format('Y-m-d H:i:s'), $log->getId()->toBinary()], + ); + $this->em->clear(); + } + + public function testRejectsAnonymousUser(): void + { + $this->client->request('GET', '/admin/takeout-load'); + + $this->assertResponseRedirects('/login'); + } + + public function testRejectsNonAdminEmail(): void + { + $this->createWebUser('pas-admin@example.com', 'secret123', 'Pas Admin'); + $this->loginAs('pas-admin@example.com'); + + $this->client->request('GET', '/admin/takeout-load'); + + $this->assertResponseStatusCodeSame(403); + } + + public function testRendersEmptyStateWhenNothingScheduledAndNoHistory(): void + { + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('h1'); + $this->assertStringContainsString('Aucun import en attente', $crawler->html()); + $this->assertStringContainsString('Aucun cycle enregistré', $crawler->html()); + } + + public function testDisplaysCurrentLoadAverageAndThreshold(): void + { + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $text = $crawler->filter('.hc-takeout-load-current')->text(); + $this->assertStringContainsString('1,50', $text); + $this->assertStringContainsString('1,25', $text); + $this->assertStringContainsString('1,00', $text); + $this->assertStringContainsString('8,00', $text); + $this->assertStringContainsString('calme', $text); + } + + public function testDisplaysScheduledImportWithOwnerAndWaitingSince(): void + { + $owner = $this->createWebUser('proprio@example.com', 'secret123', 'Proprio'); + $import = new TakeoutImport($owner); + $import->markScheduled(); + $this->em->persist($import); + $this->em->flush(); + $this->em->getConnection()->executeStatement( + "UPDATE takeout_imports SET scheduled_at = '2026-10-01 08:30:00' WHERE id = ?", + [$import->getId()->toBinary()], + ); + $this->em->clear(); + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $text = $crawler->filter('.hc-takeout-load-scheduled')->text(); + $this->assertStringContainsString('proprio@example.com', $text); + // 08:30 UTC → 10:30 à Paris (heure d'été) + $this->assertStringContainsString('01/10/2026 10:30', $text); + } + + public function testFallsBackToCreatedAtWhenScheduledAtMissing(): void + { + $owner = $this->createWebUser('proprio@example.com', 'secret123', 'Proprio'); + $import = new TakeoutImport($owner); + $import->markScheduled(); + $this->em->persist($import); + $this->em->flush(); + // Import passé en scheduled avant la colonne scheduled_at (#528). + $this->em->getConnection()->executeStatement( + "UPDATE takeout_imports SET scheduled_at = NULL, created_at = '2026-09-20 12:00:00' WHERE id = ?", + [$import->getId()->toBinary()], + ); + $this->em->clear(); + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $text = $crawler->filter('.hc-takeout-load-scheduled')->text(); + $this->assertStringContainsString('20/09/2026 14:00', $text); + $this->assertStringContainsString('création', $text); + } + + public function testIgnoresImportsThatAreNotScheduled(): void + { + $owner = $this->createWebUser('proprio@example.com', 'secret123', 'Proprio'); + $pending = new TakeoutImport($owner); + $this->em->persist($pending); + $this->em->flush(); + $this->em->clear(); + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $this->assertStringContainsString('Aucun import en attente', $crawler->html()); + } + + public function testDisplaysDispatchHistoryAndCalmRateOverLast24Hours(): void + { + $now = new \DateTimeImmutable(); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DISPATCHED, $now->modify('-1 hour')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_IDLE, $now->modify('-2 hours')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-3 hours')); + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-4 hours')); + // Hors fenêtre 24 h : visible dans l'historique, absent du taux. + $this->insertLog(TakeoutDispatchLog::OUTCOME_DEFERRED, $now->modify('-3 days')); + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/takeout-load'); + + $this->assertCount(5, $crawler->filter('.hc-takeout-load-history tbody tr')); + $summary = $crawler->filter('.hc-takeout-load-summary')->text(); + // 2 cycles calmes (dispatched + idle) sur 4 → 50 % + $this->assertStringContainsString('50', $summary); + $this->assertStringContainsString('4', $summary); + } + + public function testAdminLayoutHasNavLinkToTakeoutLoad(): void + { + $this->loginAsAdmin(); + + $this->client->request('GET', '/admin/takeout-load'); + + $this->assertSelectorExists('a[href="/admin/takeout-load"]'); + } +} From e9926c291bb0d437b1514dbfca4f0d27a9303062 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 11:30:31 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=93=9D=20docs(avancement):=20suivi=20?= =?UTF-8?q?#528=20charge=20serveur=20et=20imports=20Takeout=20diff=C3=A9r?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/deploiement.md | 2 ++ .github/avancement.md | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.claude/deploiement.md b/.claude/deploiement.md index 95415350..7d56712a 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -392,6 +392,8 @@ par pas de 5 min à partir de `0 3` / `30 3` / `0 4` (inchangé) : | damien | `25 3` | `55 3` | `25 4` | | baptiste | `30 3` | `0 4` | `30 4` | +> **#528 — historique** : chaque exécution de `takeout-nightly-dispatch` écrit une ligne dans la table `takeout_dispatch_log` (charge mesurée, seuil, issue) et purge les lignes de plus de 30 jours dans la même commande — aucun cron supplémentaire. Consultable dans l'admin : `/admin/takeout-load`. + `takeout-nightly-dispatch` tourne toutes les 15 min (`-59/15 * * * *`), 24h/24 — étalé par **décalage de minute de départ** entre les 7 instances (pas par heure, cf. piège LVE ci-dessous) : diff --git a/.github/avancement.md b/.github/avancement.md index 7e0b526f..8238df3b 100644 --- a/.github/avancement.md +++ b/.github/avancement.md @@ -6,7 +6,7 @@ --- -## 🚧 Déploiement : composer en CLI, vérification de vendor/, rollback (2026-10-02, #570, branche `fix/570-deploy-composer-rollback`) +## ✅ Déploiement : composer en CLI, vérification de vendor/, rollback (2026-10-02, #570, PR #602 mergée) - Cause de l'incident #569 (6 instances en 500) : composer lancé en PHP CGI par le cron affichait son aide et sortait en 0 ; code déjà mis à jour par `git checkout`, `vendor/` ancien, aucun rollback. - Le correctif `61ba614` était lui-même cassé : `"$COMPOSER_BIN"` (deux mots) entre guillemets → exit 127 « commande introuvable ». 7 tests bash échouaient déjà sur `main` sans que personne le voie (les tests bash ne tournent pas en CI). @@ -14,11 +14,24 @@ - Vérification de `vendor/` (`composer install --dry-run` → « Nothing to install ») validée avec le vrai composer : présent sur ronan, absent sur yannick (3 paquets manquants listés). - Rollback si l'échec précède les migrations (jamais après : état de la base incertain), cible = `HEAD` réellement en place ; rapport `→ code restauré ()` / `→ ROLLBACK ÉCHOUÉ`. - Tests bash : 28/28 (dont 11 nouveaux, 7 réparés). Suite PHP non concernée. -- Reste : revue, `gh pr create` (label + `Closes #570` + assignee + board), CI verte, merge. Piste proposée : exécuter `tests/bash/run.sh` en CI. +- PR #602 mergée, CI verte. Reste hors périmètre : exécuter `tests/bash/run.sh` en CI. Les 6 instances en 500 (#569) restent à aligner par le déploiement exceptionnel. --- ## ✅ Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, PR #562 mergée) +## 🚧 Admin : charge serveur et imports Takeout différés (2026-10-02, #528, branche `feat/528-admin-charge-takeout`) + +- `ServerLoadChecker` expose `getLoadAverage()`, `getThreshold()` et `isCalm(?array)` (décision sur une mesure déjà prise : la commande mesure une fois, l'issue loguée reste cohérente avec la charge loguée). +- Nouvelle table `takeout_dispatch_log` (entité `TakeoutDispatchLog`) : `app:takeout:nightly-dispatch` y écrit une ligne par cycle de 15 min (`dispatched` / `deferred` / `idle`, load 1/5/15, seuil, nb en attente / dispatchés), puis purge au-delà de 30 jours (un seul `DELETE` indexé, pas de nouveau cron). Les cycles `idle` sont tracés exprès : ils servent d'échantillonnage de charge pour #547. Écriture et purge n'interrompent jamais le dispatch (erreur journalisée seulement). +- `TakeoutImport::scheduledAt` (migration) posée par `markScheduled()` pour afficher « en attente depuis » ; repli sur `createdAt` signalé dans l'UI pour les imports déjà scheduled avant la colonne. +- Page `/admin/takeout-load` (whitelist AdminVoter, lien « Charge Takeout » dans le layout admin) : load courant vs seuil, imports `scheduled` (propriétaire, depuis quand), historique des 50 derniers cycles et part de cycles calmes sur 24 h. Dates affichées en `Europe/Paris`. +- Migrations : deux, additives (CREATE TABLE + ADD COLUMN nullable). `make:migration` avait généré de la dérive de la base de dev locale (DROP `broadcast_messages`, DROP `users.last_broadcast_seen_at`, CHANGE sur `takeout_imports`) : retirée à la main, à garder en tête pour les prochaines migrations. +- Suite complète : 1410/1410 verts. +- Reste : revue, `gh pr create` (label + `Closes #528` + assignee + board), CI verte, merge ; l'échantillonnage ne démarre qu'au déploiement. + +--- + +## 🚧 Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, branche `chore/538-composer-symfony-8-1`) - Contraintes `symfony/*` et `extra.symfony.require` passées de `8.0.*` à `8.1.*` (un `composer update` seul n'aurait rien monté). Symfony en 8.1.8, Doctrine ORM 3.7.3, DoctrineBundle 3.3.2, phpunit 13.4.0, twig 3.30, monolog 3.12.1. - Hors périmètre, volontairement figés : api-platform 4.3.x, ux-live-component 2.36, ux-twig-component 3.2 + stimulus-bundle 3.2 (`--with-all-dependencies` les montait en 3.5.1, contourné par `--with`), tailwind-bundle 0.12. From c449fb66a7f72e298fa792561e52443e86a64253 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 12:05:28 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20docs(avancement):=20cl=C3=B4?= =?UTF-8?q?t=20l'entr=C3=A9e=20#570=20et=20corrige=20un=20doublon=20#538?= =?UTF-8?q?=20apr=C3=A8s=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/avancement.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/avancement.md b/.github/avancement.md index 8238df3b..212e99ac 100644 --- a/.github/avancement.md +++ b/.github/avancement.md @@ -18,7 +18,6 @@ --- -## ✅ Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, PR #562 mergée) ## 🚧 Admin : charge serveur et imports Takeout différés (2026-10-02, #528, branche `feat/528-admin-charge-takeout`) - `ServerLoadChecker` expose `getLoadAverage()`, `getThreshold()` et `isCalm(?array)` (décision sur une mesure déjà prise : la commande mesure une fois, l'issue loguée reste cohérente avec la charge loguée). @@ -31,7 +30,7 @@ --- -## 🚧 Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, branche `chore/538-composer-symfony-8-1`) +## ✅ Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, PR #562 mergée) - Contraintes `symfony/*` et `extra.symfony.require` passées de `8.0.*` à `8.1.*` (un `composer update` seul n'aurait rien monté). Symfony en 8.1.8, Doctrine ORM 3.7.3, DoctrineBundle 3.3.2, phpunit 13.4.0, twig 3.30, monolog 3.12.1. - Hors périmètre, volontairement figés : api-platform 4.3.x, ux-live-component 2.36, ux-twig-component 3.2 + stimulus-bundle 3.2 (`--with-all-dependencies` les montait en 3.5.1, contourné par `--with`), tailwind-bundle 0.12.