addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes') ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50') ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $dryRun = $input->getOption('dry-run'); $batchSize = (int) $input->getOption('batch-size'); $limit = (int) $input->getOption('limit'); $io->title('Document Storage Migration: Base64 → Filesystem'); // Verify storage directory is writable $storageDir = $this->storageService->getStorageDir(); if (!$dryRun) { if (!is_dir($storageDir)) { mkdir($storageDir, 0o775, true); } if (!is_writable($storageDir)) { $io->error("Storage directory is not writable: {$storageDir}"); return Command::FAILURE; } $io->text("Storage directory: {$storageDir}"); } // Step 1: fetch only IDs of Base64 documents (no heavy path column loaded) $conn = $this->em->getConnection(); $ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'"); $total = count($ids); $migrated = 0; $skipped = 0; $errors = 0; $totalBytes = 0; $io->text(sprintf('Found %d documents with Base64 data to migrate', $total)); if (0 === $total) { $io->success('Nothing to migrate — all documents are already file-based.'); return Command::SUCCESS; } // Step 2: process one document at a time to avoid memory exhaustion foreach ($ids as $index => $docId) { if ($limit > 0 && $migrated >= $limit) { $io->text("Reached limit of {$limit} documents."); break; } // Fetch single row with raw SQL to keep memory flat $row = $conn->fetchAssociative( 'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?', [$docId] ); if (!$row) { ++$skipped; continue; } $path = $row['path']; if (!$this->storageService->isBase64DataUri($path)) { ++$skipped; continue; } $docName = $row['name'] ?: $row['filename']; $filename = $row['filename'] ?: $row['name']; $mimeType = $row['mimetype'] ?? 'application/octet-stream'; // Extract binary content from data URI $parts = explode(',', $path, 2); $base64 = $parts[1] ?? ''; $content = base64_decode($base64, true); // Free the raw row immediately unset($row, $path, $base64, $parts); if (false === $content || '' === $content) { $io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId)); ++$errors; continue; } $fileSize = strlen($content); $extension = $this->storageService->extensionFromFilename( $filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType)) ); if ($dryRun) { $io->text(sprintf( ' [DRY-RUN] Would migrate: %s (%s)', $docName, $this->formatBytes($fileSize) )); ++$migrated; $totalBytes += $fileSize; unset($content); continue; } try { $relativePath = $this->storageService->store($content, $docId, $extension); unset($content); // Update DB directly — avoid loading entity with huge path $conn->executeStatement( 'UPDATE documents SET path = ?, size = ? WHERE id = ?', [$relativePath, $fileSize, $docId] ); ++$migrated; $totalBytes += $fileSize; $io->text(sprintf( ' [OK] %s → %s (%s)', $docName, $relativePath, $this->formatBytes($fileSize) )); } catch (Throwable $e) { unset($content); $io->error(sprintf( ' [FAIL] %s: %s', $docName, $e->getMessage() )); ++$errors; continue; } if (0 === $migrated % $batchSize) { $io->text(sprintf(' ... %d migrated so far', $migrated)); } } $io->newLine(); $io->table( ['Metric', 'Count'], [ ['Total documents', (string) $total], ['Migrated', (string) $migrated], ['Skipped (already file-based)', (string) $skipped], ['Errors', (string) $errors], ['Total bytes written', $this->formatBytes($totalBytes)], ] ); if ($dryRun) { $io->info('Dry run completed. No changes were made.'); } elseif ($errors > 0) { $io->warning(sprintf('Migration completed with %d errors.', $errors)); } else { $io->success('Migration completed successfully.'); } return $errors > 0 ? Command::FAILURE : Command::SUCCESS; } private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; ++$i; } return round($bytes, 2).' '.$units[$i]; } }