feat: improved contingents api performance with db backed snapshots
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user