diff --git a/.env b/.env index 2f75c48..6be2d99 100644 --- a/.env +++ b/.env @@ -71,3 +71,9 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac ###< league/oauth2-server-bundle ### + +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/composer.json b/composer.json index f1f603d..a95e1cf 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "symfony/html-sanitizer": "6.4.*", "symfony/http-client": "6.4.*", "symfony/intl": "6.4.*", + "symfony/lock": "6.4.*", "symfony/mailer": "6.4.*", "symfony/mime": "6.4.*", "symfony/monolog-bundle": "^3.0", diff --git a/composer.lock b/composer.lock index 0141f7d..cfc7e0a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cdf22f45562d34493b3d37f46bea70bc", + "content-hash": "c898aa38c821e66646b38af7f5335200", "packages": [ { "name": "brick/math", @@ -6465,6 +6465,89 @@ ], "time": "2025-11-24T13:57:00+00:00" }, + { + "name": "symfony/lock", + "version": "v6.4.26", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97", + "reference": "c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/dbal": "<2.13", + "symfony/cache": "<6.2" + }, + "require-dev": { + "doctrine/dbal": "^2.13|^3|^4", + "predis/predis": "^1.1|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v6.4.26" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T09:57:09+00:00" + }, { "name": "symfony/mailer", "version": "v6.4.27", diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index c660ada..c82e644 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -4,13 +4,13 @@ flysystem: adapter: 'local' options: directory: '%env(resolve:XML_EXPORT_PATH)%' -# xml_export.storage: -# adapter: 'sftp' -# options: -# host: '%env(SFTP_XML_EXPORT_HOST)%' -# port: '%env(int:SFTP_XML_EXPORT_PORT)%' -# username: '%env(SFTP_XML_EXPORT_USER)%' -# password: '%env(SFTP_XML_EXPORT_PASSWORD)%' + xml_source.storage: + adapter: 'sftp' + options: + host: '%env(SFTP_XML_EXPORT_HOST)%' + port: '%env(int:SFTP_XML_EXPORT_PORT)%' + username: '%env(SFTP_XML_EXPORT_USER)%' + password: '%env(SFTP_XML_EXPORT_PASSWORD)%' xml_dump.storage: adapter: 'local' options: diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index dc1f295..19ef793 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -12,3 +12,11 @@ zenstruck_schedule: - task: app:cleanup:xml-dumps frequency: '0 1 * * *' description: "Removes outdated XML dumps of requests/responses to BPN API for debugging" + + - task: app:bpn:xml-sync + frequency: '*/15 8-19 * * *' + description: 'Sync BusPro XML data' + + - task: app:bpn:xml-sync + frequency: '0 20-23,0-7 * * *' + description: 'Sync BusPro XML data' diff --git a/config/services.yaml b/config/services.yaml index fb11212..1fc099b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -34,6 +34,7 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: $xmlExport: '@xml_export.storage' + $xmlSource: '@xml_source.storage' $xmlDump: '@xml_dump.storage' $jsonExport: '@json_export.storage' $travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%' diff --git a/src/BusProNet/Model/XmlExportInfo.php b/src/BusProNet/Model/XmlExportInfo.php new file mode 100644 index 0000000..b8975ea --- /dev/null +++ b/src/BusProNet/Model/XmlExportInfo.php @@ -0,0 +1,48 @@ +lastTransfer > $other->lastTransfer; + } +} diff --git a/src/Command/BpnReplayCommand.php b/src/Command/BpnReplayCommand.php index 8a06739..e14063c 100644 --- a/src/Command/BpnReplayCommand.php +++ b/src/Command/BpnReplayCommand.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( - name: 'bpn:replay', + name: 'app:bpn:replay', description: 'Replay a BPN API request from an XML file', )] class BpnReplayCommand extends Command diff --git a/src/Command/BpnXmlSyncCommand.php b/src/Command/BpnXmlSyncCommand.php new file mode 100644 index 0000000..5e5d61b --- /dev/null +++ b/src/Command/BpnXmlSyncCommand.php @@ -0,0 +1,182 @@ +addOption('force', 'f', InputOption::VALUE_NONE, 'Force sync even if local data is up to date') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Check for updates without downloading'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (false === $this->lock()) { + $io->warning('Sync is already running in another process'); + $this->logger->info('XML sync skipped: another instance is running'); + + return Command::SUCCESS; + } + + $force = $input->getOption('force'); + $dryRun = $input->getOption('dry-run'); + + try { + $remoteInfo = $this->readRemoteInfo(); + } catch (FilesystemException|\InvalidArgumentException $e) { + $io->error(sprintf('Failed to read remote info: %s', $e->getMessage())); + $this->logger->error('XML sync failed: could not read remote info', ['exception' => $e]); + + return Command::FAILURE; + } + + $io->text(sprintf( + 'Remote: %s (%d files)', + $remoteInfo->lastTransfer->format('d.m.Y H:i:s'), + $remoteInfo->fileCount + )); + + $localInfo = $this->readLocalInfo(); + if (null !== $localInfo) { + $io->text(sprintf( + 'Local: %s (%d files)', + $localInfo->lastTransfer->format('d.m.Y H:i:s'), + $localInfo->fileCount + )); + } else { + $io->text('Local: no data'); + } + + $needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo); + + if (false === $needsSync) { + $io->success('Local data is up to date, no sync required'); + $this->logger->info('XML sync skipped: local data is up to date'); + + return Command::SUCCESS; + } + + if ($dryRun) { + $io->note('Sync required (dry-run mode, no files downloaded)'); + + return Command::SUCCESS; + } + + $io->section('Syncing files'); + + try { + $syncedCount = $this->syncFiles($io); + } catch (FilesystemException $e) { + $io->error(sprintf('Sync failed: %s', $e->getMessage())); + $this->logger->error('XML sync failed during file transfer', ['exception' => $e]); + + return Command::FAILURE; + } + + $this->invalidateCaches(); + $io->text(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE))); + + $io->success(sprintf('Synced %d files', $syncedCount)); + $this->logger->info('XML sync completed', [ + 'files_synced' => $syncedCount, + 'remote_timestamp' => $remoteInfo->lastTransfer->format('c'), + ]); + + return Command::SUCCESS; + } + + private function readRemoteInfo(): XmlExportInfo + { + $content = $this->xmlSource->read(XmlExportInfo::getFilename()); + + return XmlExportInfo::fromString($content); + } + + private function readLocalInfo(): ?XmlExportInfo + { + try { + if (false === $this->xmlExport->fileExists(XmlExportInfo::getFilename())) { + return null; + } + + $content = $this->xmlExport->read(XmlExportInfo::getFilename()); + + return XmlExportInfo::fromString($content); + } catch (FilesystemException|\InvalidArgumentException) { + return null; + } + } + + /** + * @throws FilesystemException + */ + private function syncFiles(SymfonyStyle $io): int + { + $files = $this->xmlSource + ->listContents('.') + ->filter(fn (StorageAttributes $attributes) => $attributes->isFile()) + ->map(fn (StorageAttributes $attributes) => $attributes->path()) + ->toArray(); + + $io->progressStart(count($files)); + + foreach ($files as $path) { + $content = $this->xmlSource->read($path); + $this->xmlExport->write($path, $content); + $io->progressAdvance(); + } + + $io->progressFinish(); + + return count($files); + } + + private function invalidateCaches(): void + { + foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) { + $this->cache->delete($key); + } + } +} diff --git a/symfony.lock b/symfony.lock index 0674ae6..f57dc98 100644 --- a/symfony.lock +++ b/symfony.lock @@ -166,6 +166,18 @@ "src/Kernel.php" ] }, + "symfony/lock": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/mailer": { "version": "6.4", "recipe": {