Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
perf(preview): Migrate previews to the new optimized table
Signed-off-by: Carl Schwan <[email protected]>
  • Loading branch information
Carl Schwan authored and CarlSchwan committed Oct 6, 2025
commit 13c35c0f17d41a0c6e567c345021ee1c0ae9c4e6
238 changes: 238 additions & 0 deletions core/BackgroundJobs/MovePreviewJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Core\BackgroundJobs;

use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\Exception;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IPreview;

class MovePreviewJob extends TimedJob {
private IAppData $appData;

public function __construct(
ITimeFactory $time,
private IAppConfig $appConfig,
private PreviewMapper $previewMapper,
private StorageFactory $storageFactory,
private IDBConnection $connection,
IAppDataFactory $appDataFactory,
) {
parent::__construct($time);

$this->appData = $appDataFactory->get('preview');
$this->setTimeSensitivity(self::TIME_INSENSITIVE);
$this->setInterval(24 * 60 * 60);
}

protected function run(mixed $argument): void {
try {
$this->doRun($argument);
} catch (\Throwable $exception) {
echo $exception->getMessage();
throw $exception;
}
}

private function doRun($argument): void {
if ($this->appConfig->getValueBool('core', 'previewMovedDone')) {
//return;
}

$emptyHierarchicalPreviewFolders = false;

$startTime = time();
while (true) {
$previewFolders = [];

// Check new hierarchical preview folders first
if (!$emptyHierarchicalPreviewFolders) {
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%/%/%/%/%/%/%/%')))
->setMaxResults(100);

$result = $qb->executeQuery();
while ($row = $result->fetch()) {
$pathSplit = explode('/', $row['path']);
assert(count($pathSplit) >= 2);
$fileId = $pathSplit[count($pathSplit) - 2];
$previewFolders[$fileId][] = $row['path'];
}

if (!empty($previewFolders)) {
$this->processPreviews($previewFolders, false);
continue;
}
}

// And then the flat preview folder (legacy)
$emptyHierarchicalPreviewFolders = true;
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%.jpg')))
->setMaxResults(100);

$result = $qb->executeQuery();
while ($row = $result->fetch()) {
$pathSplit = explode('/', $row['path']);
assert(count($pathSplit) >= 2);
$fileId = $pathSplit[count($pathSplit) - 2];
array_pop($pathSplit);
$path = implode('/', $pathSplit);
if (!isset($previewFolders[$fileId])) {
$previewFolders[$fileId] = [];
}
if (!in_array($path, $previewFolders[$fileId])) {
$previewFolders[$fileId][] = $path;
}
}

if (empty($previewFolders)) {
break;
} else {
$this->processPreviews($previewFolders, true);
}

// Stop if execution time is more than one hour.
if (time() - $startTime > 3600) {
return;
}
}

// Delete any left over preview directory
$this->appData->getFolder('.')->delete();
$this->appConfig->setValueBool('core', 'previewMovedDone', true);
}

/**
* @param array<string, string[]> $previewFolders
*/
private function processPreviews(array $previewFolders, bool $simplePaths): void {
foreach ($previewFolders as $fileId => $previewFolder) {
$internalPath = $this->getInternalFolder((string)$fileId, $simplePaths);
$folder = $this->appData->getFolder($internalPath);

/**
* @var list<array{file: ISimpleFile, width: int, height: int, crop: bool, max: bool, extension: string, mtime: int, size: int}> $previewFiles
*/
$previewFiles = [];

foreach ($folder->getDirectoryListing() as $previewFile) {
[0 => $baseName, 1 => $extension] = explode('.', $previewFile->getName());
$nameSplit = explode('-', $baseName);

// TODO VERSION/PREFIX extraction

$width = $nameSplit[0];
$height = $nameSplit[1];

if (isset($nameSplit[2])) {
$crop = $nameSplit[2] === 'crop';
$max = $nameSplit[2] === 'max';
}

$previewFiles[] = [
'file' => $previewFile,
'width' => $width,
'height' => $height,
'crop' => $crop,
'max' => $max,
'extension' => $extension,
'size' => $previewFile->getSize(),
'mtime' => $previewFile->getMTime(),
];
}

$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('fileid', $qb->createNamedParameter($fileId)));

$result = $qb->executeQuery();
$result = $result->fetchAll();

if (count($result) > 0) {
foreach ($previewFiles as $previewFile) {
$preview = new Preview();
$preview->setFileId((int)$fileId);
$preview->setStorageId($result[0]['storage']);
$preview->setEtag($result[0]['etag']);
$preview->setMtime($previewFile['mtime']);
$preview->setWidth($previewFile['width']);
$preview->setHeight($previewFile['height']);
$preview->setCrop($previewFile['crop']);
$preview->setIsMax($previewFile['max']);
$preview->setMimetype(match ($previewFile['extension']) {
'png' => IPreview::MIMETYPE_PNG,
'webp' => IPreview::MIMETYPE_WEBP,
'gif' => IPreview::MIMETYPE_GIF,
default => IPreview::MIMETYPE_JPEG,
});
$preview->setSize($previewFile['size']);
try {
$preview = $this->previewMapper->insert($preview);
} catch (Exception $e) {
// We already have this preview in the preview table, skip
continue;
}

try {
$this->storageFactory->migratePreview($preview, $previewFile['file']);
$previewFile['file']->delete();
} catch (Exception $e) {
$this->previewMapper->delete($preview);
throw $e;
}

}
}

$this->deleteFolder($internalPath, $folder);
}
}

public static function getInternalFolder(string $name, bool $simplePaths): string {
if ($simplePaths) {
return '/' . $name;
}
return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
}

private function deleteFolder(string $path, ISimpleFolder $folder): void {
$folder->delete();

$current = $path;

while (true) {
$current = dirname($current);
if ($current === '/' || $current === '.' || $current === '') {
break;
}


$folder = $this->appData->getFolder($current);
if (count($folder->getDirectoryListing()) !== 0) {
break;
}
$folder->delete();
}
}
}
3 changes: 2 additions & 1 deletion core/Migrations/Version33000Date20250819110529.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
$table = $schema->createTable('previews');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('file_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('storage_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('width', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('height', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('mimetype', Types::INTEGER, ['notnull' => true]);
Expand All @@ -38,7 +39,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
$table->addColumn('etag', Types::STRING, ['notnull' => true, 'length' => 40]);
$table->addColumn('mtime', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('size', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('version', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$table->addColumn('version', Types::BIGINT, ['notnull' => true, 'default' => -1]); // can not be null otherwise unique index doesn't work
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype', 'crop', 'version'], 'previews_file_uniq_idx');
}
Expand Down
4 changes: 3 additions & 1 deletion lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,7 @@
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => $baseDir . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => $baseDir . '/core/BackgroundJobs/GenerateMetadataJob.php',
'OC\\Core\\BackgroundJobs\\LookupServerSendCheckBackgroundJob' => $baseDir . '/core/BackgroundJobs/LookupServerSendCheckBackgroundJob.php',
'OC\\Core\\BackgroundJobs\\MovePreviewJob' => $baseDir . '/core/BackgroundJobs/MovePreviewJob.php',
'OC\\Core\\Command\\App\\Disable' => $baseDir . '/core/Command/App/Disable.php',
'OC\\Core\\Command\\App\\Enable' => $baseDir . '/core/Command/App/Enable.php',
'OC\\Core\\Command\\App\\GetPath' => $baseDir . '/core/Command/App/GetPath.php',
Expand Down Expand Up @@ -1528,7 +1529,7 @@
'OC\\Core\\Migrations\\Version32000Date20250620081925' => $baseDir . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Migrations\\Version32000Date20250806110519' => $baseDir . '/core/Migrations/Version32000Date20250806110519.php',
'OC\\Core\\Migrations\\Version33000Date20250819110523' => $baseDir . '/core/Migrations/Version33000Date20250819110523.php',
'OC\\Core\\Migrations\\Version33000Date20250819110529' => $baseDir . '/core/Migrations/Version33000Date20250819110529.php',
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',
Expand Down Expand Up @@ -1958,6 +1959,7 @@
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => $baseDir . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
'OC\\Repair\\AddCleanupUpdaterBackupsJob' => $baseDir . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php',
'OC\\Repair\\AddMetadataGenerationJob' => $baseDir . '/lib/private/Repair/AddMetadataGenerationJob.php',
'OC\\Repair\\AddMovePreviewJob' => $baseDir . '/lib/private/Repair/AddMovePreviewJob.php',
'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => $baseDir . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php',
'OC\\Repair\\CleanTags' => $baseDir . '/lib/private/Repair/CleanTags.php',
'OC\\Repair\\CleanUpAbandonedApps' => $baseDir . '/lib/private/Repair/CleanUpAbandonedApps.php',
Expand Down
4 changes: 3 additions & 1 deletion lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/GenerateMetadataJob.php',
'OC\\Core\\BackgroundJobs\\LookupServerSendCheckBackgroundJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/LookupServerSendCheckBackgroundJob.php',
'OC\\Core\\BackgroundJobs\\MovePreviewJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/MovePreviewJob.php',
'OC\\Core\\Command\\App\\Disable' => __DIR__ . '/../../..' . '/core/Command/App/Disable.php',
'OC\\Core\\Command\\App\\Enable' => __DIR__ . '/../../..' . '/core/Command/App/Enable.php',
'OC\\Core\\Command\\App\\GetPath' => __DIR__ . '/../../..' . '/core/Command/App/GetPath.php',
Expand Down Expand Up @@ -1569,7 +1570,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Migrations\\Version32000Date20250620081925' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Migrations\\Version32000Date20250806110519' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250806110519.php',
'OC\\Core\\Migrations\\Version33000Date20250819110523' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20250819110523.php',
'OC\\Core\\Migrations\\Version33000Date20250819110529' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20250819110529.php',
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',
Expand Down Expand Up @@ -1999,6 +2000,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
'OC\\Repair\\AddCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php',
'OC\\Repair\\AddMetadataGenerationJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddMetadataGenerationJob.php',
'OC\\Repair\\AddMovePreviewJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddMovePreviewJob.php',
'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php',
'OC\\Repair\\CleanTags' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanTags.php',
'OC\\Repair\\CleanUpAbandonedApps' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanUpAbandonedApps.php',
Expand Down
1 change: 1 addition & 0 deletions lib/private/BackgroundJob/JobList.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ private function buildJob(array $row): ?IJob {
/** @var IJob $job */
$job = \OCP\Server::get($row['class']);
} catch (QueryException $e) {
echo $e->getMessage();
if (class_exists($row['class'])) {
$class = $row['class'];
$job = new $class();
Expand Down
4 changes: 2 additions & 2 deletions lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use OCP\IUser;

/**
* @psalm-type ObjectStoreConfig array{class: class-string<IObjectStore>, arguments: array{multibucket: bool, objectPrefix: ?string, ...}}
* @psalm-type ObjectStoreConfig array{class: class-string<IObjectStore>, arguments: array{multibucket: bool, objectPrefix?: string, ...}}
*/
class PrimaryObjectStoreConfig {
public function __construct(
Expand Down Expand Up @@ -155,7 +155,7 @@ public function getObjectStoreConfigs(): ?array {
}

/**
* @param array|string $config
* @param array{multibucket?: bool, objectPrefix?: string, ...}|string $config
* @return string|ObjectStoreConfig
*/
private function validateObjectStoreConfig(array|string $config): array|string {
Expand Down
4 changes: 2 additions & 2 deletions lib/private/Preview/Db/Preview.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function __construct() {
}

public function getName(): string {
$path = ($this->getVersion() ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight();
$path = ($this->getVersion() > -1 ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight();
if ($this->getCrop()) {
$path .= '-crop';
}
Expand All @@ -102,7 +102,7 @@ public function getMimetypeValue(): string {

public function getExtension(): string {
return match ($this->mimetype) {
IPreview::MIMETYPE_JPEG => 'jpeg',
IPreview::MIMETYPE_JPEG => 'jpg',
IPreview::MIMETYPE_PNG => 'png',
IPreview::MIMETYPE_WEBP => 'webp',
IPreview::MIMETYPE_GIF => 'gif',
Expand Down
Loading