feat: improved contingents api performance with db backed snapshots
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Model\ContingentSyncResult;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Keeps the local contingent snapshot in sync with bpn-connect.
|
||||
*
|
||||
* This is the only place that still talks to the upstream contingent API: the read path
|
||||
* serves the stored snapshot, so upstream latency and upstream outages stay out of it.
|
||||
*/
|
||||
class ContingentSnapshotManager
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContingentsClient $contingentsClient,
|
||||
private readonly ContingentDayRepository $dayRepository,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes [dateFrom, dateTo] for a single accommodation.
|
||||
*
|
||||
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
|
||||
* the requested window is left untouched, which is what lets the near-term and full-horizon
|
||||
* schedules run at different cadences without fighting each other.
|
||||
*/
|
||||
public function sync(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ContingentSyncResult {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
|
||||
}
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
|
||||
try {
|
||||
$calendar = $this->contingentsClient->getContingentCalendar(
|
||||
$hotelCode,
|
||||
$dateFrom->format('Y-m-d'),
|
||||
$dateTo->format('Y-m-d'),
|
||||
);
|
||||
} catch (BpnConnectException $e) {
|
||||
return $this->recordFailure($state, $hotelCode, $e->getMessage());
|
||||
}
|
||||
|
||||
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
|
||||
|
||||
// An empty upstream response would otherwise wipe the window and render the whole
|
||||
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
|
||||
if ([] === $calendar->data && [] !== $existing) {
|
||||
return $this->recordFailure($state, $hotelCode, 'Upstream returned no contingent days for a range that is already populated.');
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
$seen = [];
|
||||
|
||||
foreach ($calendar->data as $entry) {
|
||||
try {
|
||||
$date = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Skipping contingent day with an unparsable date', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'date' => $entry->date,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($date < $dateFrom || $date > $dateTo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $date->format('Y-m-d');
|
||||
$seen[$key] = true;
|
||||
$day = $existing[$key] ?? null;
|
||||
|
||||
if (null === $day) {
|
||||
$day = (new ContingentDay())
|
||||
->setAccommodation($accommodation)
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
++$added;
|
||||
} elseif ($day->getStatus() === $entry->status) {
|
||||
continue;
|
||||
} else {
|
||||
++$updated;
|
||||
}
|
||||
|
||||
$day->setStatus($entry->status);
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
|
||||
foreach ($existing as $key => $day) {
|
||||
if (!isset($seen[$key])) {
|
||||
$this->entityManager->remove($day);
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
||||
$hash = $this->fingerprint($accommodation);
|
||||
$changed = $hash !== $state->getContentHash();
|
||||
|
||||
if ($changed) {
|
||||
$state->setContentHash($hash)->setChangedAt($now);
|
||||
}
|
||||
|
||||
$state->recordSuccess($now, $dateTo);
|
||||
$this->entityManager->persist($state);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return ContingentSyncResult::synced($changed, $added, $updated, $removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 over the accommodation's complete stored snapshot, contingent status only.
|
||||
*/
|
||||
public function fingerprint(Accommodation $accommodation): string
|
||||
{
|
||||
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
|
||||
}
|
||||
|
||||
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
||||
{
|
||||
$this->logger->error('Contingent snapshot sync failed', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
$state->recordFailure($error);
|
||||
$this->entityManager->persist($state);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return ContingentSyncResult::failed($error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Reads contingent availability out of the local snapshot, and says when it cannot be trusted.
|
||||
*
|
||||
* Callers get null rather than a silently empty result, because "no data" and "everything is
|
||||
* blocked" are indistinguishable once the statuses are flattened — and the two current callers
|
||||
* want opposite fallbacks: the API fails loud with a 502, the booking calendar falls back to
|
||||
* price coverage alone.
|
||||
*/
|
||||
class ContingentSnapshotReader
|
||||
{
|
||||
/**
|
||||
* How old the snapshot may get before we stop presenting it as current availability.
|
||||
*
|
||||
* The near-term sync runs every 15 minutes during the day and hourly overnight, so this only
|
||||
* trips when the scheduler is genuinely broken, never on the normal cadence.
|
||||
*/
|
||||
private const string MAX_SNAPSHOT_AGE = '-6 hours';
|
||||
|
||||
public function __construct(
|
||||
private readonly ContingentDayRepository $dayRepository,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel.
|
||||
*
|
||||
* Days inside the range that the snapshot does not cover are simply absent from the map;
|
||||
* what that means is the caller's decision.
|
||||
*
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
public function statusesFor(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ?array {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isUsable($accommodation, $hotelCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
|
||||
foreach ($this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo) as $date => $day) {
|
||||
$statuses[$date] = $day->getStatus();
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
|
||||
{
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
|
||||
$this->logger->error('Contingent snapshot is stale', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user