Repair.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Core\Command\Preview;
  8. use bantu\IniGetWrapper\IniGetWrapper;
  9. use OC\Preview\Storage\Root;
  10. use OCP\Files\Folder;
  11. use OCP\Files\IRootFolder;
  12. use OCP\Files\NotFoundException;
  13. use OCP\IConfig;
  14. use OCP\Lock\ILockingProvider;
  15. use OCP\Lock\LockedException;
  16. use Psr\Log\LoggerInterface;
  17. use Symfony\Component\Console\Command\Command;
  18. use Symfony\Component\Console\Helper\ProgressBar;
  19. use Symfony\Component\Console\Helper\QuestionHelper;
  20. use Symfony\Component\Console\Input\InputInterface;
  21. use Symfony\Component\Console\Input\InputOption;
  22. use Symfony\Component\Console\Output\OutputInterface;
  23. use Symfony\Component\Console\Question\ConfirmationQuestion;
  24. use function pcntl_signal;
  25. class Repair extends Command {
  26. private bool $stopSignalReceived = false;
  27. private int $memoryLimit;
  28. private int $memoryTreshold;
  29. public function __construct(
  30. protected IConfig $config,
  31. private IRootFolder $rootFolder,
  32. private LoggerInterface $logger,
  33. IniGetWrapper $phpIni,
  34. private ILockingProvider $lockingProvider,
  35. ) {
  36. $this->memoryLimit = (int)$phpIni->getBytes('memory_limit');
  37. $this->memoryTreshold = $this->memoryLimit - 25 * 1024 * 1024;
  38. parent::__construct();
  39. }
  40. protected function configure() {
  41. $this
  42. ->setName('preview:repair')
  43. ->setDescription('distributes the existing previews into subfolders')
  44. ->addOption('batch', 'b', InputOption::VALUE_NONE, 'Batch mode - will not ask to start the migration and start it right away.')
  45. ->addOption('dry', 'd', InputOption::VALUE_NONE, 'Dry mode - will not create, move or delete any files - in combination with the verbose mode one could check the operations.')
  46. ->addOption('delete', null, InputOption::VALUE_NONE, 'Delete instead of migrating them. Usefull if too many entries to migrate.');
  47. }
  48. protected function execute(InputInterface $input, OutputInterface $output): int {
  49. if ($this->memoryLimit !== -1) {
  50. $limitInMiB = round($this->memoryLimit / 1024 / 1024, 1);
  51. $thresholdInMiB = round($this->memoryTreshold / 1024 / 1024, 1);
  52. $output->writeln("Memory limit is $limitInMiB MiB");
  53. $output->writeln("Memory threshold is $thresholdInMiB MiB");
  54. $output->writeln('');
  55. $memoryCheckEnabled = true;
  56. } else {
  57. $output->writeln('No memory limit in place - disabled memory check. Set a PHP memory limit to automatically stop the execution of this migration script once memory consumption is close to this limit.');
  58. $output->writeln('');
  59. $memoryCheckEnabled = false;
  60. }
  61. $dryMode = $input->getOption('dry');
  62. $deleteMode = $input->getOption('delete');
  63. if ($dryMode) {
  64. $output->writeln('INFO: The migration is run in dry mode and will not modify anything.');
  65. $output->writeln('');
  66. } elseif ($deleteMode) {
  67. $output->writeln('WARN: The migration will _DELETE_ old previews.');
  68. $output->writeln('');
  69. }
  70. $instanceId = $this->config->getSystemValueString('instanceid');
  71. $output->writeln('This will migrate all previews from the old preview location to the new one.');
  72. $output->writeln('');
  73. $output->writeln('Fetching previews that need to be migrated …');
  74. /** @var \OCP\Files\Folder $currentPreviewFolder */
  75. $currentPreviewFolder = $this->rootFolder->get("appdata_$instanceId/preview");
  76. $directoryListing = $currentPreviewFolder->getDirectoryListing();
  77. $total = count($directoryListing);
  78. /**
  79. * by default there could be 0-9 a-f and the old-multibucket folder which are all fine
  80. */
  81. if ($total < 18) {
  82. $directoryListing = array_filter($directoryListing, function ($dir) {
  83. if ($dir->getName() === 'old-multibucket') {
  84. return false;
  85. }
  86. // a-f can't be a file ID -> removing from migration
  87. if (preg_match('!^[a-f]$!', $dir->getName())) {
  88. return false;
  89. }
  90. if (preg_match('!^[0-9]$!', $dir->getName())) {
  91. // ignore folders that only has folders in them
  92. if ($dir instanceof Folder) {
  93. foreach ($dir->getDirectoryListing() as $entry) {
  94. if (!$entry instanceof Folder) {
  95. return true;
  96. }
  97. }
  98. return false;
  99. }
  100. }
  101. return true;
  102. });
  103. $total = count($directoryListing);
  104. }
  105. if ($total === 0) {
  106. $output->writeln('All previews are already migrated.');
  107. return 0;
  108. }
  109. $output->writeln("A total of $total preview files need to be migrated.");
  110. $output->writeln('');
  111. $output->writeln('The migration will always migrate all previews of a single file in a batch. After each batch the process can be canceled by pressing CTRL-C. This will finish the current batch and then stop the migration. This migration can then just be started and it will continue.');
  112. if ($input->getOption('batch')) {
  113. $output->writeln('Batch mode active: migration is started right away.');
  114. } else {
  115. /** @var QuestionHelper $helper */
  116. $helper = $this->getHelper('question');
  117. $question = new ConfirmationQuestion('<info>Should the migration be started? (y/[n]) </info>', false);
  118. if (!$helper->ask($input, $output, $question)) {
  119. return 0;
  120. }
  121. }
  122. // register the SIGINT listener late in here to be able to exit in the early process of this command
  123. pcntl_signal(SIGINT, [$this, 'sigIntHandler']);
  124. $output->writeln('');
  125. $output->writeln('');
  126. $section1 = $output->section();
  127. $section2 = $output->section();
  128. $progressBar = new ProgressBar($section2, $total);
  129. $progressBar->setFormat('%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% Used Memory: %memory:6s%');
  130. $time = (new \DateTime())->format('H:i:s');
  131. $progressBar->setMessage("$time Starting …");
  132. $progressBar->maxSecondsBetweenRedraws(0.2);
  133. $progressBar->start();
  134. foreach ($directoryListing as $oldPreviewFolder) {
  135. pcntl_signal_dispatch();
  136. $name = $oldPreviewFolder->getName();
  137. $time = (new \DateTime())->format('H:i:s');
  138. $section1->writeln("$time Migrating previews of file with fileId $name …");
  139. $progressBar->display();
  140. if ($this->stopSignalReceived) {
  141. $section1->writeln("$time Stopping migration …");
  142. return 0;
  143. }
  144. if (!$oldPreviewFolder instanceof Folder) {
  145. $section1->writeln(" Skipping non-folder $name …");
  146. $progressBar->advance();
  147. continue;
  148. }
  149. if ($name === 'old-multibucket') {
  150. $section1->writeln(" Skipping fallback mount point $name …");
  151. $progressBar->advance();
  152. continue;
  153. }
  154. if (in_array($name, ['a', 'b', 'c', 'd', 'e', 'f'])) {
  155. $section1->writeln(" Skipping hex-digit folder $name …");
  156. $progressBar->advance();
  157. continue;
  158. }
  159. if (!preg_match('!^\d+$!', $name)) {
  160. $section1->writeln(" Skipping non-numeric folder $name …");
  161. $progressBar->advance();
  162. continue;
  163. }
  164. $newFoldername = Root::getInternalFolder($name);
  165. $memoryUsage = memory_get_usage();
  166. if ($memoryCheckEnabled && $memoryUsage > $this->memoryTreshold) {
  167. $section1->writeln('');
  168. $section1->writeln('');
  169. $section1->writeln('');
  170. $section1->writeln(' Stopped process 25 MB before reaching the memory limit to avoid a hard crash.');
  171. $time = (new \DateTime())->format('H:i:s');
  172. $section1->writeln("$time Reached memory limit and stopped to avoid hard crash.");
  173. return 1;
  174. }
  175. $lockName = 'occ preview:repair lock ' . $oldPreviewFolder->getId();
  176. try {
  177. $section1->writeln(" Locking \"$lockName\" …", OutputInterface::VERBOSITY_VERBOSE);
  178. $this->lockingProvider->acquireLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
  179. } catch (LockedException $e) {
  180. $section1->writeln(' Skipping because it is locked - another process seems to work on this …');
  181. continue;
  182. }
  183. $previews = $oldPreviewFolder->getDirectoryListing();
  184. if ($previews !== []) {
  185. try {
  186. $this->rootFolder->get("appdata_$instanceId/preview/$newFoldername");
  187. } catch (NotFoundException $e) {
  188. $section1->writeln(" Create folder preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
  189. if (!$dryMode) {
  190. $this->rootFolder->newFolder("appdata_$instanceId/preview/$newFoldername");
  191. }
  192. }
  193. foreach ($previews as $preview) {
  194. pcntl_signal_dispatch();
  195. $previewName = $preview->getName();
  196. if ($preview instanceof Folder) {
  197. $section1->writeln(" Skipping folder $name/$previewName …");
  198. $progressBar->advance();
  199. continue;
  200. }
  201. // Execute process
  202. if (!$dryMode) {
  203. // Delete preview instead of moving
  204. if ($deleteMode) {
  205. try {
  206. $section1->writeln(" Delete preview/$name/$previewName", OutputInterface::VERBOSITY_VERBOSE);
  207. $preview->delete();
  208. } catch (\Exception $e) {
  209. $this->logger->error("Failed to delete preview at preview/$name/$previewName", [
  210. 'app' => 'core',
  211. 'exception' => $e,
  212. ]);
  213. }
  214. } else {
  215. try {
  216. $section1->writeln(" Move preview/$name/$previewName to preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
  217. $preview->move("appdata_$instanceId/preview/$newFoldername/$previewName");
  218. } catch (\Exception $e) {
  219. $this->logger->error("Failed to move preview from preview/$name/$previewName to preview/$newFoldername", [
  220. 'app' => 'core',
  221. 'exception' => $e,
  222. ]);
  223. }
  224. }
  225. }
  226. }
  227. }
  228. if ($oldPreviewFolder->getDirectoryListing() === []) {
  229. $section1->writeln(" Delete empty folder preview/$name", OutputInterface::VERBOSITY_VERBOSE);
  230. if (!$dryMode) {
  231. try {
  232. $oldPreviewFolder->delete();
  233. } catch (\Exception $e) {
  234. $this->logger->error("Failed to delete empty folder preview/$name", [
  235. 'app' => 'core',
  236. 'exception' => $e,
  237. ]);
  238. }
  239. }
  240. }
  241. $this->lockingProvider->releaseLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
  242. $section1->writeln(' Unlocked', OutputInterface::VERBOSITY_VERBOSE);
  243. $section1->writeln(" Finished migrating previews of file with fileId $name …");
  244. $progressBar->advance();
  245. }
  246. $progressBar->finish();
  247. $output->writeln('');
  248. return 0;
  249. }
  250. protected function sigIntHandler() {
  251. echo "\nSignal received - will finish the step and then stop the migration.\n\n\n";
  252. $this->stopSignalReceived = true;
  253. }
  254. }