Skip to content

Commit 906eb34

Browse files
committed
Add occ preview:migrate to migrate previews from the old flat structure to a subfolder structure
* `php occ preview:repair` - a preview migration tool that moves existing previews into the new location introduced with #19214 * moves `appdata_INSTANCEID/previews/FILEID` to `appdata_INSTANCEID/previews/0/5/8/4/c/e/5/FILEID` * migration tool can be stopped during migration via `CTRL+C` - it then finishes the current folder (with the previews of one file) and stops gracefully * if a PHP memory limit is set in the `php.ini` then it will stop automatically once it has less than 25 MiB memory left (this is to avoid hard crashes in the middle of a migration) * the tool can be used during operation - possible drawbacks: * there is the chance of a race condition that a new preview is generated in the moment the folder is already migrated away - so the old folder with the newly cached preview is deleted and one cached preview needs to be re-generated * there is the chance of a race condition during access of a preview while it is migrated to the other folder - then no preview can be shown and results in a 404 (as of now this is an accepted risk) Signed-off-by: Morris Jobke <hey@morrisjobke.de>
1 parent 3cbefa9 commit 906eb34

5 files changed

Lines changed: 287 additions & 3 deletions

File tree

core/Command/Preview/Repair.php

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* @copyright Copyright (c) 2020, Morris Jobke <hey@morrisjobke.de>
6+
*
7+
* @author Morris Jobke <hey@morrisjobke.de>
8+
*
9+
* @license GNU AGPL version 3 or any later version
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Affero General Public License as
13+
* published by the Free Software Foundation, either version 3 of the
14+
* License, or (at your option) any later version.
15+
*
16+
* This program is distributed in the hope that it will be useful,
17+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
* GNU Affero General Public License for more details.
20+
*
21+
* You should have received a copy of the GNU Affero General Public License
22+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
23+
*
24+
*/
25+
26+
namespace OC\Core\Command\Preview;
27+
28+
use bantu\IniGetWrapper\IniGetWrapper;
29+
use OC\Preview\Storage\Root;
30+
use OCP\Files\Folder;
31+
use OCP\Files\IRootFolder;
32+
use OCP\Files\NotFoundException;
33+
use OCP\IConfig;
34+
use OCP\ILogger;
35+
use Symfony\Component\Console\Command\Command;
36+
use Symfony\Component\Console\Helper\ProgressBar;
37+
use Symfony\Component\Console\Input\InputInterface;
38+
use Symfony\Component\Console\Input\InputOption;
39+
use Symfony\Component\Console\Output\OutputInterface;
40+
use Symfony\Component\Console\Question\ConfirmationQuestion;
41+
42+
class Repair extends Command {
43+
/** @var IConfig */
44+
protected $config;
45+
/** @var IRootFolder */
46+
private $rootFolder;
47+
/** @var ILogger */
48+
private $logger;
49+
50+
/** @var bool */
51+
private $stopSignalReceived = false;
52+
/** @var int */
53+
private $memoryLimit;
54+
/** @var int */
55+
private $memoryTreshold;
56+
57+
public function __construct(IConfig $config, IRootFolder $rootFolder, ILogger $logger, IniGetWrapper $phpIni) {
58+
$this->config = $config;
59+
$this->rootFolder = $rootFolder;
60+
$this->logger = $logger;
61+
62+
$this->memoryLimit = $phpIni->getBytes('memory_limit');
63+
$this->memoryTreshold = $this->memoryLimit - 25 * 1024 * 1024;
64+
65+
parent::__construct();
66+
}
67+
68+
protected function configure() {
69+
$this
70+
->setName('preview:repair')
71+
->setDescription('distributes the existing previews into subfolders')
72+
->addOption('batch', 'b', InputOption::VALUE_NONE, 'Batch mode - will not ask to start the migration and start it right away.')
73+
->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.');
74+
}
75+
76+
protected function execute(InputInterface $input, OutputInterface $output): int {
77+
if ($this->memoryLimit !== -1) {
78+
$limitInMiB = round($this->memoryLimit / 1024 /1024, 1);
79+
$thresholdInMiB = round($this->memoryTreshold / 1024 /1024, 1);
80+
$output->writeln("Memory limit is $limitInMiB MiB");
81+
$output->writeln("Memory threshold is $thresholdInMiB MiB");
82+
$output->writeln("");
83+
$memoryCheckEnabled = true;
84+
} else {
85+
$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.");
86+
$output->writeln("");
87+
$memoryCheckEnabled = false;
88+
}
89+
90+
$dryMode = $input->getOption('dry');
91+
92+
if ($dryMode) {
93+
$output->writeln("INFO: The migration is run in dry mode and will not modify anything.");
94+
$output->writeln("");
95+
}
96+
97+
$verbose = $output->isVerbose();
98+
99+
$instanceId = $this->config->getSystemValueString('instanceid');
100+
101+
$output->writeln("This will migrate all previews from the old preview location to the new one.");
102+
$output->writeln('');
103+
104+
$output->writeln('Fetching previews that need to be migrated …');
105+
/** @var \OCP\Files\Folder $currentPreviewFolder */
106+
$currentPreviewFolder = $this->rootFolder->get("appdata_$instanceId/preview");
107+
108+
$directoryListing = $currentPreviewFolder->getDirectoryListing();
109+
110+
$total = count($directoryListing);
111+
/**
112+
* by default there could be 0-9 a-f and the old-multibucket folder which are all fine
113+
*/
114+
if ($total < 18) {
115+
foreach ($directoryListing as $index => $dir) {
116+
if ($dir->getName() === 'old-multibucket') {
117+
unset($directoryListing[$index]);
118+
}
119+
// a-f can't be a file ID -> removing from migration
120+
if (preg_match('!^[a-f]$!', $dir->getName())) {
121+
unset($directoryListing[$index]);
122+
}
123+
if (preg_match('!^[0-9]$!', $dir->getName())) {
124+
// ignore folders that only has folders in them
125+
if ($dir instanceof Folder) {
126+
$hasFile = false;
127+
foreach ($dir->getDirectoryListing() as $entry) {
128+
if (!$entry instanceof Folder) {
129+
$hasFile = true;
130+
break;
131+
}
132+
}
133+
if (!$hasFile) {
134+
unset($directoryListing[$index]);
135+
}
136+
}
137+
}
138+
}
139+
$total = count($directoryListing);
140+
}
141+
142+
if ($total === 0) {
143+
$output->writeln("All previews are already migrated.");
144+
return 0;
145+
}
146+
147+
$output->writeln("A total of $total preview files need to be migrated.");
148+
$output->writeln("");
149+
$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 fill finish the current batch and then stop the migration. This migration can then just be started and it will continue.");
150+
151+
if ($input->getOption('batch')) {
152+
$output->writeln('Batch mode active: migration is started right away.');
153+
} else {
154+
$helper = $this->getHelper('question');
155+
$question = new ConfirmationQuestion('<info>Should the migration be started? (y/[n]) </info>', false);
156+
157+
if (!$helper->ask($input, $output, $question)) {
158+
return 0;
159+
}
160+
}
161+
162+
// register the SIGINT listener late in here to be able to exit in the early process of this command
163+
pcntl_signal(SIGINT, [$this, 'sigIntHandler']);
164+
165+
$output->writeln("");
166+
$output->writeln("");
167+
$section1 = $output->section();
168+
$section2 = $output->section();
169+
$progressBar = new ProgressBar($section2, $total);
170+
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% Used Memory: %memory:6s%");
171+
$time = (new \DateTime())->format('H:i:s');
172+
$progressBar->setMessage("$time Starting …");
173+
$progressBar->maxSecondsBetweenRedraws(0.2);
174+
$progressBar->start();
175+
176+
foreach ($directoryListing as $oldPreviewFolder) {
177+
pcntl_signal_dispatch();
178+
$name = $oldPreviewFolder->getName();
179+
$time = (new \DateTime())->format('H:i:s');
180+
$section1->writeln("$time Migrating previews of file with fileId $name");
181+
$progressBar->display();
182+
183+
if ($this->stopSignalReceived) {
184+
$section1->writeln("$time Stopping migration …");
185+
return 0;
186+
}
187+
if (!$oldPreviewFolder instanceof Folder) {
188+
$section1->writeln(" Skipping non-folder $name");
189+
$progressBar->advance();
190+
continue;
191+
}
192+
if ($name === 'old-multibucket') {
193+
$section1->writeln(" Skipping fallback mount point $name");
194+
$progressBar->advance();
195+
continue;
196+
}
197+
if (in_array($name, ['a', 'b', 'c', 'd', 'e', 'f'])) {
198+
$section1->writeln(" Skipping hex-digit folder $name");
199+
$progressBar->advance();
200+
continue;
201+
}
202+
if (!preg_match('!^\d+$!', $name)) {
203+
$section1->writeln(" Skipping non-numeric folder $name");
204+
$progressBar->advance();
205+
continue;
206+
}
207+
208+
$newFoldername = Root::getInternalFolder($name);
209+
210+
$memoryUsage = memory_get_usage();
211+
if ($memoryCheckEnabled && $memoryUsage > $this->memoryTreshold) {
212+
$section1->writeln("");
213+
$section1->writeln("");
214+
$section1->writeln("");
215+
$section1->writeln(" Stopped process 25 MB before reaching the memory limit to avoid a hard crash.");
216+
$time = (new \DateTime())->format('H:i:s');
217+
$section1->writeln("$time Reached memory limit and stopped to avoid hard crash.");
218+
return 1;
219+
}
220+
221+
$previews = $oldPreviewFolder->getDirectoryListing();
222+
if ($previews !== []) {
223+
try {
224+
$this->rootFolder->get("appdata_$instanceId/preview/$newFoldername");
225+
} catch (NotFoundException $e) {
226+
if ($verbose) {
227+
$section1->writeln(" Create folder preview/$newFoldername");
228+
}
229+
if (!$dryMode) {
230+
$this->rootFolder->newFolder("appdata_$instanceId/preview/$newFoldername");
231+
}
232+
}
233+
234+
foreach ($previews as $preview) {
235+
pcntl_signal_dispatch();
236+
$previewName = $preview->getName();
237+
238+
if ($preview instanceof Folder) {
239+
$section1->writeln(" Skipping folder $name/$previewName");
240+
$progressBar->advance();
241+
continue;
242+
}
243+
if ($verbose) {
244+
$section1->writeln(" Move preview/$name/$previewName to preview/$newFoldername");
245+
}
246+
if (!$dryMode) {
247+
try {
248+
$preview->move("appdata_$instanceId/preview/$newFoldername/$previewName");
249+
} catch (\Exception $e) {
250+
$this->logger->logException($e, ['app' => 'core', 'message' => "Failed to move preview from preview/$name/$previewName to preview/$newFoldername"]);
251+
}
252+
}
253+
}
254+
}
255+
if ($oldPreviewFolder->getDirectoryListing() === []) {
256+
if ($verbose) {
257+
$section1->writeln(" Delete empty folder preview/$name");
258+
}
259+
if (!$dryMode) {
260+
try {
261+
$oldPreviewFolder->delete();
262+
} catch (\Exception $e) {
263+
$this->logger->logException($e, ['app' => 'core', 'message' => "Failed to delete empty folder preview/$name"]);
264+
}
265+
}
266+
}
267+
$section1->writeln(" Finished migrating previews of file with fileId $name");
268+
$progressBar->advance();
269+
}
270+
271+
$progressBar->finish();
272+
$output->writeln("");
273+
return 0;
274+
}
275+
276+
protected function sigIntHandler() {
277+
echo "\nSignal received - will finish the step and then stop the migration.\n\n\n";
278+
$this->stopSignalReceived = true;
279+
}
280+
}

core/register_command.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@
143143
new \OC\Repair(\OC\Repair::getRepairSteps(), \OC::$server->getEventDispatcher()), \OC::$server->getConfig(),
144144
\OC::$server->getEventDispatcher(), \OC::$server->getAppManager()));
145145

146+
$application->add(\OC::$server->query(\OC\Core\Command\Preview\Repair::class));
147+
146148
$application->add(new OC\Core\Command\User\Add(\OC::$server->getUserManager(), \OC::$server->getGroupManager()));
147149
$application->add(new OC\Core\Command\User\Delete(\OC::$server->getUserManager()));
148150
$application->add(new OC\Core\Command\User\Disable(\OC::$server->getUserManager()));

lib/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@
619619
'OC\\Core\\Command\\Maintenance\\Repair' => $baseDir . '/core/Command/Maintenance/Repair.php',
620620
'OC\\Core\\Command\\Maintenance\\UpdateHtaccess' => $baseDir . '/core/Command/Maintenance/UpdateHtaccess.php',
621621
'OC\\Core\\Command\\Maintenance\\UpdateTheme' => $baseDir . '/core/Command/Maintenance/UpdateTheme.php',
622+
'OC\\Core\\Command\\Preview\\Repair' => $baseDir . '/core/Command/Preview/Repair.php',
622623
'OC\\Core\\Command\\Security\\ImportCertificate' => $baseDir . '/core/Command/Security/ImportCertificate.php',
623624
'OC\\Core\\Command\\Security\\ListCertificates' => $baseDir . '/core/Command/Security/ListCertificates.php',
624625
'OC\\Core\\Command\\Security\\RemoveCertificate' => $baseDir . '/core/Command/Security/RemoveCertificate.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
649649
'OC\\Core\\Command\\Maintenance\\Repair' => __DIR__ . '/../../..' . '/core/Command/Maintenance/Repair.php',
650650
'OC\\Core\\Command\\Maintenance\\UpdateHtaccess' => __DIR__ . '/../../..' . '/core/Command/Maintenance/UpdateHtaccess.php',
651651
'OC\\Core\\Command\\Maintenance\\UpdateTheme' => __DIR__ . '/../../..' . '/core/Command/Maintenance/UpdateTheme.php',
652+
'OC\\Core\\Command\\Preview\\Repair' => __DIR__ . '/../../..' . '/core/Command/Preview/Repair.php',
652653
'OC\\Core\\Command\\Security\\ImportCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/ImportCertificate.php',
653654
'OC\\Core\\Command\\Security\\ListCertificates' => __DIR__ . '/../../..' . '/core/Command/Security/ListCertificates.php',
654655
'OC\\Core\\Command\\Security\\RemoveCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/RemoveCertificate.php',

lib/private/Preview/Storage/Root.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function __construct(IRootFolder $rootFolder, SystemConfig $systemConfig)
4141

4242

4343
public function getFolder(string $name): ISimpleFolder {
44-
$internalFolder = $this->getInternalFolder($name);
44+
$internalFolder = self::getInternalFolder($name);
4545

4646
try {
4747
return parent::getFolder($internalFolder);
@@ -69,7 +69,7 @@ public function getFolder(string $name): ISimpleFolder {
6969
}
7070

7171
public function newFolder(string $name): ISimpleFolder {
72-
$internalFolder = $this->getInternalFolder($name);
72+
$internalFolder = self::getInternalFolder($name);
7373
return parent::newFolder($internalFolder);
7474
}
7575

@@ -81,7 +81,7 @@ public function getDirectoryListing(): array {
8181
return [];
8282
}
8383

84-
private function getInternalFolder(string $name): string {
84+
public static function getInternalFolder(string $name): string {
8585
return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
8686
}
8787
}

0 commit comments

Comments
 (0)