feat: refresh existing snapshots from XML after sync

This commit is contained in:
Björn Fromme
2026-03-24 11:13:11 +01:00
parent 909e8a1d8e
commit 3b6ee90a7e
9 changed files with 289 additions and 22 deletions
+3
View File
@@ -137,6 +137,9 @@ class PickupLoader extends AbstractLoader
{
foreach ($pickups as $pickup) {
$pickupData = $this->loadById($pickup->id);
if (null === $pickupData) {
continue;
}
$pickup->code = $pickupData->code;
$pickup->postalCode = $pickupData->postalCode;
+1 -1
View File
@@ -102,7 +102,7 @@ class TravelLoader extends AbstractLoader
return $mapping;
});
} catch (InvalidArgumentException $e) {
return [];
throw new \RuntimeException('Cache key error in generateFilesMap', 0, $e);
}
}
@@ -9,6 +9,7 @@ use App\Service\TravelSnapshotService;
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;
@@ -26,6 +27,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
class BpnRefreshTravelSnapshotCommand extends Command
{
use LockableTrait;
public function __construct(
private readonly TravelSnapshotService $snapshotService,
private readonly LoggerInterface $logger,
@@ -52,19 +55,28 @@ class BpnRefreshTravelSnapshotCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (false === $this->lock('bpn_xml_sync')) {
$this->logger->info('Snapshot refresh skipped: xml-sync is running');
return Command::SUCCESS;
}
$limit = (int) $input->getOption('limit');
$force = true === $input->getOption('force');
$refreshAfterMinutes = (int) $input->getOption('refresh-after');
$xmlDateIds = [];
$xmlFileMap = null;
try {
$xmlDateIds = array_keys($this->travelLoader->generateFilesMap());
$xmlFileMap = $this->travelLoader->generateFilesMap();
} catch (\Throwable $e) {
$this->logger->warning('Failed to load XML files map for snapshot refresh filtering; refreshing all snapshots', [
$this->logger->warning('Failed to load XML files map for snapshot refresh filtering; skipping all candidates', [
'error' => $e->getMessage(),
]);
}
// Phase 2 — enrich non-XML-backed snapshots via extended-availability API
$xmlDateIds = null !== $xmlFileMap ? array_keys($xmlFileMap) : null;
$result = $this->snapshotService->refreshExtendedSnapshots($limit, $force, $refreshAfterMinutes, $xmlDateIds);
$io->success(sprintf(
+36 -7
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Command;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\TravelDataService;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
@@ -38,6 +40,8 @@ class BpnXmlSyncCommand extends Command
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly TravelLoader $travelLoader,
private readonly TravelDataService $travelDataService,
) {
parent::__construct();
}
@@ -53,7 +57,7 @@ class BpnXmlSyncCommand extends Command
{
$io = new SymfonyStyle($input, $output);
if (false === $this->lock()) {
if (false === $this->lock('bpn_xml_sync')) {
$io->warning('Sync is already running in another process');
$this->logger->info('XML sync skipped: another instance is running');
@@ -115,13 +119,38 @@ class BpnXmlSyncCommand extends Command
}
$this->invalidateCaches();
$io->text(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE)));
$io->info(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE)));
$io->success(sprintf(
'Sync complete: %d files updated, %d files deleted',
$syncResult['updated'],
$syncResult['deleted']
));
$io->section('Refreshing snapshots');
$xmlFileMap = null;
$snapshotResult = null;
try {
$xmlFileMap = $this->travelLoader->generateFilesMap();
} catch (\Throwable $e) {
$this->logger->warning('Failed to generate XML file map after sync; snapshot refresh skipped', [
'error' => $e->getMessage(),
]);
}
if (null !== $xmlFileMap) {
$total = array_sum(array_map(fn ($entry) => count($entry['hotels']), $xmlFileMap));
$io->progressStart($total);
$snapshotResult = $this->travelDataService->syncSnapshotsFromXml(
$xmlFileMap,
function () use ($io): void { $io->progressAdvance(); },
);
$io->progressFinish();
$this->logger->info('Travel snapshot XML sync triggered by xml-sync command', $snapshotResult);
}
$successMessage = sprintf('Sync complete: %d files updated, %d files deleted', $syncResult['updated'], $syncResult['deleted']);
if (null !== $snapshotResult) {
$successMessage .= sprintf(', snapshots: %d processed, %d failed', $snapshotResult['processed'], $snapshotResult['failed']);
}
$io->success($successMessage);
$this->logger->info('XML sync completed', [
'files_updated' => $syncResult['updated'],
'files_deleted' => $syncResult['deleted'],
+50
View File
@@ -179,6 +179,56 @@ class TravelDataService
return $travel;
}
/**
* Syncs existing snapshots from fresh XML for all entries in the given file map.
*
* Iterates every date/hotel combination, calls getTravelDataFromXml() which
* loads, enriches, and upserts the snapshot (hash-guarded; no write if unchanged).
*
* @param array<int|string, array{hotels: array<int|string, mixed>}> $xmlFileMap
*
* @return array{processed:int,failed:int}
*/
public function syncSnapshotsFromXml(array $xmlFileMap, ?callable $onProgress = null): array
{
$processed = 0;
$failed = 0;
foreach ($xmlFileMap as $dateId => $entry) {
foreach (array_keys($entry['hotels']) as $hotelId) {
try {
$travel = $this->getTravelDataFromXml((int) $dateId, $hotelId);
} catch (\Throwable $e) {
$this->logger->warning('Failed to sync snapshot from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'error' => $e->getMessage(),
]);
++$failed;
if (null !== $onProgress) {
($onProgress)();
}
continue;
}
if (null === $travel) {
++$failed;
if (null !== $onProgress) {
($onProgress)();
}
continue;
}
++$processed;
if (null !== $onProgress) {
($onProgress)();
}
}
}
return ['processed' => $processed, 'failed' => $failed];
}
/**
* Retrieve travel data specifically from remote API.
*
+8 -6
View File
@@ -176,9 +176,9 @@ class TravelSnapshotService
*
* @return array{processed:int,updated:int,failed:int}
*/
public function refreshExtendedSnapshots(int $limit = 500, bool $force = false, int $refreshAfterMinutes = 360, array $xmlAvailableDateIds = []): array
public function refreshExtendedSnapshots(int $limit = 500, bool $force = false, int $refreshAfterMinutes = 360, ?array $xmlAvailableDateIds = null): array
{
$dateToThreshold = new \DateTimeImmutable(sprintf('-%d days', $this->retentionBufferDays));
$dateToThreshold = new \DateTimeImmutable('today');
$refreshBefore = new \DateTimeImmutable(sprintf('-%d minutes', $refreshAfterMinutes));
$candidates = true === $force
@@ -192,10 +192,12 @@ class TravelSnapshotService
$extendedResponseByDateId = [];
foreach ($candidates as $snapshot) {
// Skip travels whose XML is still live; their snapshot is kept authoritative
// by the XML load path (getTravelDataFromXml → upsertFromTravel). An empty
// $xmlAvailableDateIds means the file map could not be loaded, so fall back
// to refreshing everything rather than skipping all candidates.
// null → file map unavailable; skip all candidates (conservative fallback)
// [] → no XML files exist; fall through and refresh everything
// [...] → skip travels whose XML is still live
if (null === $xmlAvailableDateIds) {
continue;
}
if ([] !== $xmlAvailableDateIds && in_array($snapshot->getDateId(), $xmlAvailableDateIds, true)) {
continue;
}