Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/deploiement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<offset>-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) :
Expand Down
16 changes: 14 additions & 2 deletions .github/avancement.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,27 @@

---

## 🚧 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).
- `bin/lib/deploy-common.sh` : définition unique de composer (PHP CLI explicite, `memory_limit`) et de la vérification de `vendor/`, sourcée par `deploy-nightly.sh`, `deploy-all.sh` et `deploy.sh`. Plus de `composer` nu dans `bin/`.
- 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é (<sha>)` / `→ 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.

---

## 🚧 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.

---

Expand Down
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions migrations/Version20261002091651.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20261002091651 extends AbstractMigration
{
public function getDescription(): string
{
return 'Table takeout_dispatch_log : historique des cycles de dispatch Takeout (#528)';
}

public function up(Schema $schema): void
{
$this->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');
}
}
29 changes: 29 additions & 0 deletions migrations/Version20261002092640.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20261002092640 extends AbstractMigration
{
public function getDescription(): string
{
return 'takeout_imports.scheduled_at : date de passage en scheduled (#528)';
}

public function up(Schema $schema): void
{
$this->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');
}
}
49 changes: 47 additions & 2 deletions src/Command/TakeoutImportNightlyDispatchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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();
}
Expand All @@ -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;
}
Expand All @@ -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]);
}
}
}
57 changes: 57 additions & 0 deletions src/Controller/Web/Admin/AdminTakeoutLoadWebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Controller\Web\Admin;

use App\Entity\TakeoutDispatchLog;
use App\Repository\TakeoutDispatchLogRepository;
use App\Repository\TakeoutImportRepository;
use App\Security\AdminVoter;
use App\Service\Takeout\ServerLoadChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
* Charge serveur et imports Takeout différés dans l'espace admin (#528) :
* visibilité sur le mécanisme de dispatch (#522/#524), sans le modifier.
* La mesure passe par ServerLoadChecker — pas de seconde lecture de
* /proc/loadavg.
*/
#[IsGranted(AdminVoter::ADMIN)]
final class AdminTakeoutLoadWebController extends AbstractController
{
private const HISTORY_LIMIT = 50;

public function __construct(
private readonly ServerLoadChecker $loadChecker,
private readonly TakeoutImportRepository $importRepository,
private readonly TakeoutDispatchLogRepository $dispatchLogRepository,
) {}

#[Route('/admin/takeout-load', name: 'app_admin_takeout_load', methods: ['GET'])]
public function __invoke(): Response
{
$loadAverage = $this->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,
]);
}
}
119 changes: 119 additions & 0 deletions src/Entity/TakeoutDispatchLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\TakeoutDispatchLogRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

/**
* Une ligne = un cycle de la commande app:takeout:nightly-dispatch (#528),
* toutes les 15 min par instance. Alimente la page admin (efficacité du
* dispatch dans le temps) et l'échantillonnage de charge de #547 : les
* cycles "idle" (rien en attente) sont volontairement tracés aussi, sinon
* aucune mesure de charge hors imports. Purgée au-delà de PURGE_AFTER_DAYS.
*/
#[ORM\Entity(repositoryClass: TakeoutDispatchLogRepository::class)]
#[ORM\Table(name: 'takeout_dispatch_log')]
#[ORM\Index(columns: ['created_at'], name: 'idx_takeout_dispatch_log_created_at')]
class TakeoutDispatchLog
{
/** Serveur calme, imports en attente dispatchés. */
public const OUTCOME_DISPATCHED = 'dispatched';
/** Serveur trop chargé (ou mesure indisponible), dispatch reporté. */
public const OUTCOME_DEFERRED = 'deferred';
/** Aucun import en attente (serveur calme) : simple échantillon de charge. */
public const OUTCOME_IDLE = 'idle';

public const OUTCOMES = [self::OUTCOME_DISPATCHED, self::OUTCOME_DEFERRED, self::OUTCOME_IDLE];

public const PURGE_AFTER_DAYS = 30;

#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;

#[ORM\Column(length: 16)]
private string $outcome;

#[ORM\Column(nullable: true)]
private ?float $load1;

#[ORM\Column(nullable: true)]
private ?float $load5;

#[ORM\Column(nullable: true)]
private ?float $load15;

#[ORM\Column]
private float $threshold;

#[ORM\Column]
private int $scheduledCount;

#[ORM\Column]
private int $dispatchedCount;

#[ORM\Column]
private \DateTimeImmutable $createdAt;

/**
* @param array{0: float, 1: float, 2: float}|null $loadAverage null si la mesure était indisponible
*/
public function __construct(string $outcome, ?array $loadAverage, float $threshold, int $scheduledCount, int $dispatchedCount)
{
$this->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;
}
}
Loading
Loading