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('bpn_xml_sync')) { $io->warning('Sync is already running in another process'); $this->logger->info('XML sync skipped: another instance is running'); return Command::SUCCESS; } return $this->sync( (bool) $input->getOption('force'), (bool) $input->getOption('dry-run'), $io, $output, ); } private function sync(bool $force, bool $dryRun, SymfonyStyle $io, OutputInterface $output): int { $totalSyncedCount = 0; $totalDeletedCount = 0; $hasSyncedFiles = false; $failedDatasets = []; $travelSynced = false; $travelRemoteTimestamp = null; $snapshotResult = null; $orphanedDeleted = null; foreach ($this->syncManager->getSyncTargets() as $target) { $targetResult = $this->syncTarget($target, $force, $dryRun, $io, $output); if ($targetResult['failed']) { $failedDatasets[] = $targetResult['dataset']; } if (false === $targetResult['synced']) { continue; } $hasSyncedFiles = true; $totalSyncedCount += $targetResult['updated']; $totalDeletedCount += $targetResult['deleted']; if ($targetResult['travelSynced']) { $travelSynced = true; $travelRemoteTimestamp = $targetResult['remoteTimestamp']; } } if (false === $dryRun && $hasSyncedFiles) { $invalidatedTags = $this->syncManager->invalidateCaches(); $io->text(sprintf( 'Invalidated %d cache tags', $invalidatedTags, )); } if (false === $dryRun && $travelSynced) { $io->section('Refreshing snapshots'); $snapshotResult = $this->refreshSnapshots($io); if (null !== $snapshotResult) { $orphanedDeleted = $snapshotResult->orphanedDeleted; } } if ($dryRun) { return Command::SUCCESS; } if ($hasSyncedFiles) { $successMessage = sprintf( 'Sync complete: %d files updated, %d files deleted', $totalSyncedCount, $totalDeletedCount ); if (null !== $snapshotResult) { $successMessage .= sprintf( ', snapshots: %d processed, %d failed', $snapshotResult->processed, $snapshotResult->failed ); } if (null !== $orphanedDeleted) { $successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted); } if ([] !== $failedDatasets) { $successMessage .= sprintf(', failed datasets: %s', implode(', ', $failedDatasets)); } $logContext = [ 'files_updated' => $totalSyncedCount, 'files_deleted' => $totalDeletedCount, 'snapshots_processed' => $snapshotResult?->processed, 'snapshots_failed' => $snapshotResult?->failed, 'orphaned_deleted' => $orphanedDeleted, 'remote_timestamp' => $travelRemoteTimestamp, 'failed_datasets' => $failedDatasets, ]; if ([] !== $failedDatasets) { $io->warning($successMessage); $this->logger->warning('XML sync completed with dataset errors', $logContext); } else { $io->success($successMessage); $this->logger->info('XML sync completed', $logContext); } return Command::SUCCESS; } if ([] !== $failedDatasets) { $io->warning(sprintf( 'Sync completed with errors for: %s', implode(', ', $failedDatasets), )); $this->logger->warning('XML sync completed with dataset errors', [ 'failed_datasets' => $failedDatasets, ]); return Command::SUCCESS; } $io->success('No sync required'); return Command::SUCCESS; } /** * @return array{dataset: string, synced: bool, failed: bool, updated: int, deleted: int, travelSynced: bool, remoteTimestamp: ?string} */ private function syncTarget( BpnXmlSyncTarget $target, bool $force, bool $dryRun, SymfonyStyle $io, OutputInterface $output, ): array { $datasetName = $target->name; $remoteInfo = $this->syncManager->readRemoteInfo($target->source, $datasetName); if (null === $remoteInfo) { $io->warning(sprintf( '[%s] Remote info unavailable - will retry on next scheduled run', $datasetName )); return [ 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, 'deleted' => 0, 'travelSynced' => false, 'remoteTimestamp' => null, ]; } $io->text($this->formatInfoLine(sprintf('[%s] Remote', $datasetName), $remoteInfo, $output)); $localInfo = $this->syncManager->readLocalInfo($target->destination); if (null !== $localInfo) { $io->text($this->formatInfoLine(sprintf('[%s] Local', $datasetName), $localInfo, $output)); } else { $io->text(sprintf('[%s] Local: no data', $datasetName)); } $needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo); if (false === $needsSync) { $io->text(sprintf('[%s] Local data is up to date, no sync required', $datasetName)); $this->logger->info('XML sync skipped: local data is up to date', [ 'dataset' => $datasetName, ]); return [ 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, 'deleted' => 0, 'travelSynced' => false, 'remoteTimestamp' => null, ]; } if ($dryRun) { $io->note(sprintf('[%s] Sync required (dry-run mode, no files downloaded)', $datasetName)); return [ 'dataset' => $datasetName, 'synced' => false, 'failed' => false, 'updated' => 0, 'deleted' => 0, 'travelSynced' => false, 'remoteTimestamp' => null, ]; } $io->section(sprintf('Syncing %s files', $datasetName)); $progressStarted = false; try { $syncResult = $this->syncManager->syncFiles( $target->source, $target->destination, function (int $total) use ($io, &$progressStarted): void { $progressStarted = true; $io->progressStart($total); }, fn () => $io->progressAdvance(), ); } catch (FilesystemException $e) { $io->error(sprintf('[%s] Sync failed: %s', $datasetName, $e->getMessage())); $this->logger->error('XML sync failed during file transfer', [ 'dataset' => $datasetName, 'exception' => $e, ]); return [ 'dataset' => $datasetName, 'synced' => false, 'failed' => true, 'updated' => 0, 'deleted' => 0, 'travelSynced' => false, 'remoteTimestamp' => null, ]; } finally { if ($progressStarted) { $io->progressFinish(); } } if ('travel' === $datasetName) { $travelSynced = true; $remoteTimestamp = $remoteInfo->lastTransfer->format('c'); } else { $travelSynced = false; $remoteTimestamp = null; } $io->text(sprintf( '[%s] Synced %d files, deleted %d', $datasetName, $syncResult['updated'], $syncResult['deleted'] )); $this->logger->info('XML sync completed', [ 'dataset' => $datasetName, 'files_updated' => $syncResult['updated'], 'files_deleted' => $syncResult['deleted'], 'remote_timestamp' => $remoteInfo->lastTransfer->format('c'), ]); return [ 'dataset' => $datasetName, 'synced' => true, 'failed' => false, 'updated' => $syncResult['updated'], 'deleted' => $syncResult['deleted'], 'travelSynced' => $travelSynced, 'remoteTimestamp' => $remoteTimestamp, ]; } private function refreshSnapshots(SymfonyStyle $io): ?BpnXmlSnapshotRefreshResult { $plan = $this->snapshotRefreshManager->buildPlan(); if (null === $plan) { return null; } $progressStarted = false; try { $io->progressStart($plan->total); $progressStarted = true; return $this->snapshotRefreshManager->refresh( $plan, fn () => $io->progressAdvance(), ); } finally { if ($progressStarted) { $io->progressFinish(); } } } private function formatInfoLine(string $label, XmlExportInfo $info, OutputInterface $output): string { if ($output->isVerbose()) { return sprintf( '%s: %s (%d files)', $label, $info->lastTransfer->format('d.m.Y H:i:s'), $info->fileCount ); } return sprintf('%s: %d files', $label, $info->fileCount); } }