feat: improved contingents api performance with db backed snapshots

This commit is contained in:
Björn Fromme
2026-08-20 11:46:03 +02:00
parent d5f9f4ef10
commit 351fbab498
20 changed files with 1521 additions and 109 deletions
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Groups\Accommodation;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotManager;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn:sync-contingents',
description: 'Refreshes the local contingent snapshots from bpn-connect.',
)]
/**
* Console entrypoint for the scheduled contingent snapshot sync.
*
* Runs at two cadences: a short horizon frequently, the full horizon a couple of times a day.
*/
class BpnSyncContingentsCommand extends Command
{
use LockableTrait;
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSnapshotManager $snapshotManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
/**
* Configures horizon, filtering and refresh-control options.
*/
protected function configure(): void
{
$this
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync', '24')
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
;
}
/**
* Executes the sync flow and the retention cleanup.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (false === $this->lock('bpn_sync_contingents')) {
$this->logger->info('Contingent sync skipped: another run is still in progress');
return Command::SUCCESS;
}
$horizonMonths = max(1, (int) $input->getOption('horizon-months'));
$staleAfterMinutes = max(0, (int) $input->getOption('stale-after'));
$force = true === $input->getOption('force');
$hotelCode = $input->getOption('hotel');
$today = CarbonImmutable::now()->setTime(0, 0);
$dateFrom = $today->toDateTimeImmutable();
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
if ([] === $accommodations) {
$io->warning('No accommodation with a calendarCode matched.');
return Command::SUCCESS;
}
$processed = 0;
$skipped = 0;
$changed = 0;
$failedCodes = [];
foreach ($accommodations as $accommodation) {
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
++$skipped;
continue;
}
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
++$processed;
if (!$result->successful) {
$failedCodes[] = (string) $accommodation->getCalendarCode();
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
continue;
}
if ($result->changed) {
++$changed;
}
$io->writeln(sprintf(
'<info>%s</info>: %s (+%d ~%d -%d)',
(string) $accommodation->getCalendarCode(),
$result->changed ? 'changed' : 'unchanged',
$result->added,
$result->updated,
$result->removed,
), OutputInterface::VERBOSITY_VERBOSE);
}
$failed = count($failedCodes);
$summary = [
'processed' => $processed,
'skipped' => $skipped,
'changed' => $changed,
'failed' => $failed,
'failedHotelCodes' => $failedCodes,
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
];
$io->success(sprintf(
'Contingent sync complete: %d processed, %d skipped, %d changed, %d failed',
$processed,
$skipped,
$changed,
$failed,
));
$this->logger->info('Contingent snapshot sync finished', $summary);
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
// Only a *total* failure is worth failing the task over: that means upstream is down or
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
// A single hotel breaking must stay quiet — this task runs every 15 minutes, so failing
// on it would mail dozens of times a day until someone silenced the task. That hotel is
// not lost track of: its own calendar serves a 502, and contingent_sync_state records
// failure_count and last_error.
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
}
/**
* @return Accommodation[]
*/
private function resolveAccommodations(?string $hotelCode): array
{
if (null === $hotelCode) {
return $this->accommodationRepository->findAllWithCalendarCode();
}
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
return null !== $accommodation ? [$accommodation] : [];
}
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
{
if (0 === $staleAfterMinutes) {
return false;
}
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
if (null === $syncedAt) {
return false;
}
return $syncedAt > CarbonImmutable::now()->subMinutes($staleAfterMinutes);
}
}