mirror of
https://github.com/nextcloud/server.git
synced 2026-10-28 15:15:54 +01:00
Set the location of migrated previews before inserting them, resolving it once per bucket and batch, instead of updating every preview after its insert. Orphaned previews are deleted from the object store by urn directly, with their filecache rows removed in bulk. Migrating 100 000 previews on an object store primary storage with PostgreSQL now takes 24s instead of 123s. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
411 lines
13 KiB
PHP
411 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||
* SPDX-FileContributor: Carl Schwan
|
||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||
*/
|
||
|
||
namespace OC\Preview\Storage;
|
||
|
||
use LogicException;
|
||
use OC;
|
||
use OC\Preview\Db\Preview;
|
||
use OC\Preview\Db\PreviewMapper;
|
||
use OCP\DB\Exception;
|
||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||
use OCP\Files\IMimeTypeDetector;
|
||
use OCP\Files\IMimeTypeLoader;
|
||
use OCP\Files\IRootFolder;
|
||
use OCP\Files\NotFoundException;
|
||
use OCP\Files\NotPermittedException;
|
||
use OCP\IAppConfig;
|
||
use OCP\IConfig;
|
||
use OCP\IDBConnection;
|
||
use Override;
|
||
use Psr\Log\LoggerInterface;
|
||
use RecursiveDirectoryIterator;
|
||
use RecursiveIteratorIterator;
|
||
|
||
class LocalPreviewStorage implements IPreviewStorage {
|
||
private const int SCAN_BATCH_SIZE = 1000;
|
||
|
||
public function __construct(
|
||
private readonly IConfig $config,
|
||
private readonly PreviewMapper $previewMapper,
|
||
private readonly IAppConfig $appConfig,
|
||
private readonly IDBConnection $connection,
|
||
private readonly IMimeTypeDetector $mimeTypeDetector,
|
||
private readonly LoggerInterface $logger,
|
||
private readonly IMimeTypeLoader $mimeTypeLoader,
|
||
private readonly IRootFolder $rootFolder,
|
||
) {
|
||
}
|
||
|
||
#[Override]
|
||
public function writePreview(Preview $preview, mixed $stream): int {
|
||
$previewPath = $this->constructPath($preview);
|
||
$this->createParentFiles($previewPath);
|
||
return file_put_contents($previewPath, $stream);
|
||
}
|
||
|
||
#[Override]
|
||
public function readPreview(Preview $preview): mixed {
|
||
$previewPath = $this->constructPath($preview);
|
||
$resource = @fopen($previewPath, 'r');
|
||
if ($resource === false) {
|
||
throw new NotFoundException('Unable to open preview stream at ' . $previewPath);
|
||
}
|
||
return $resource;
|
||
}
|
||
|
||
#[Override]
|
||
public function deletePreview(Preview $preview): void {
|
||
$previewPath = $this->constructPath($preview);
|
||
if (!@unlink($previewPath) && is_file($previewPath)) {
|
||
throw new NotPermittedException('Unable to delete preview at ' . $previewPath);
|
||
}
|
||
}
|
||
|
||
#[Override]
|
||
public function deleteUnreferencedPreview(Preview $preview): void {
|
||
// constructPath() keys on the file id and the preview specification, not
|
||
// on the preview id, so this file is shared with the preview that won the
|
||
// race. Deleting it would leave that one with a row but no file.
|
||
}
|
||
|
||
public function getRootFolder(): string {
|
||
return $this->config->getSystemValueString('datadirectory', OC::$SERVERROOT . '/data');
|
||
}
|
||
|
||
public function getPreviewRootFolder(): string {
|
||
return $this->getRootFolder() . '/' . $this->rootFolder->getAppDataDirectoryName() . '/preview/';
|
||
}
|
||
|
||
private function constructPath(Preview $preview): string {
|
||
return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName();
|
||
}
|
||
|
||
private function createParentFiles(string $path): void {
|
||
$dirname = dirname($path);
|
||
if (!is_dir($dirname)) {
|
||
mkdir($dirname, recursive: true);
|
||
}
|
||
if (!is_dir($dirname)) {
|
||
throw new NotPermittedException("Unable to create directory '$dirname'");
|
||
}
|
||
}
|
||
|
||
#[Override]
|
||
public function migratePreviews(array $previews): void {
|
||
foreach ($previews as $preview) {
|
||
$this->migratePreview($preview);
|
||
}
|
||
}
|
||
|
||
private function migratePreview(Preview $preview): void {
|
||
// legacy flat directory
|
||
$sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName();
|
||
if (!file_exists($sourcePath)) {
|
||
return;
|
||
}
|
||
|
||
$destinationPath = $this->constructPath($preview);
|
||
if (file_exists($destinationPath)) {
|
||
@unlink($sourcePath); // We already have a new preview, just delete the old one
|
||
return;
|
||
}
|
||
|
||
$this->createParentFiles($destinationPath);
|
||
$ok = rename($sourcePath, $destinationPath);
|
||
if (!$ok) {
|
||
throw new LogicException('Failed to move ' . $sourcePath . ' to ' . $destinationPath);
|
||
}
|
||
}
|
||
|
||
#[Override]
|
||
public function scan(): int {
|
||
$checkForFileCache = !$this->appConfig->getValueBool('core', 'previewMovedDone');
|
||
|
||
if (!file_exists($this->getPreviewRootFolder())) {
|
||
return 0;
|
||
}
|
||
|
||
$scanner = new RecursiveDirectoryIterator($this->getPreviewRootFolder());
|
||
$previewsFound = 0;
|
||
|
||
/**
|
||
* Use an associative array keyed by path for O(1) lookup instead of
|
||
* the O(n) in_array() the original code used.
|
||
*
|
||
* @var array<string, true> $skipPaths
|
||
*/
|
||
$skipPaths = [];
|
||
|
||
/**
|
||
* Pending previews grouped by fileId. A single original file can have
|
||
* many preview variants (different sizes/formats), so we group them to
|
||
* issue one filecache lookup per original file rather than one per
|
||
* preview variant.
|
||
*
|
||
* @var array<int, list<array{preview: Preview, filePath: string, realPath: string}>> $pendingByFileId
|
||
*/
|
||
$pendingByFileId = [];
|
||
|
||
/**
|
||
* path_hash => realPath for legacy filecache entries that need to be
|
||
* cleaned up. Only populated when $checkForFileCache is true.
|
||
*
|
||
* @var array<string, string> $pendingPathHashes
|
||
*/
|
||
$pendingPathHashes = [];
|
||
$pendingCount = 0;
|
||
|
||
foreach (new RecursiveIteratorIterator($scanner) as $file) {
|
||
if (!$file->isFile()) {
|
||
continue;
|
||
}
|
||
|
||
$filePath = $file->getPathname();
|
||
if (isset($skipPaths[$filePath])) {
|
||
continue;
|
||
}
|
||
|
||
$preview = Preview::fromPath($filePath, $this->mimeTypeDetector);
|
||
if ($preview === false) {
|
||
$this->logger->error('Unable to parse preview information for ' . $file->getRealPath());
|
||
continue;
|
||
}
|
||
|
||
$preview->setSize($file->getSize());
|
||
$preview->setMtime($file->getMtime());
|
||
$preview->setEncrypted(false);
|
||
|
||
$realPath = $file->getRealPath();
|
||
$pendingByFileId[$preview->getFileId()][] = [
|
||
'preview' => $preview,
|
||
'filePath' => $filePath,
|
||
'realPath' => $realPath,
|
||
];
|
||
$pendingCount++;
|
||
|
||
if ($checkForFileCache) {
|
||
$relativePath = str_replace($this->getRootFolder() . '/', '', $realPath);
|
||
$pendingPathHashes[md5($relativePath)] = $realPath;
|
||
}
|
||
|
||
if ($pendingCount >= self::SCAN_BATCH_SIZE) {
|
||
$this->connection->beginTransaction();
|
||
try {
|
||
$previewsFound += $this->processScanBatch($pendingByFileId, $pendingPathHashes, $checkForFileCache, $skipPaths);
|
||
$this->connection->commit();
|
||
} catch (\Exception $e) {
|
||
$this->connection->rollBack();
|
||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||
throw $e;
|
||
}
|
||
$pendingByFileId = [];
|
||
$pendingPathHashes = [];
|
||
$pendingCount = 0;
|
||
}
|
||
}
|
||
|
||
if ($pendingCount > 0) {
|
||
$this->connection->beginTransaction();
|
||
try {
|
||
$previewsFound += $this->processScanBatch($pendingByFileId, $pendingPathHashes, $checkForFileCache, $skipPaths);
|
||
$this->connection->commit();
|
||
} catch (\Exception $e) {
|
||
$this->connection->rollBack();
|
||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
return $previewsFound;
|
||
}
|
||
|
||
/**
|
||
* Process one batch of preview files collected during scan().
|
||
*
|
||
* @param array<int, list<array{preview: Preview, filePath: string, realPath: string}>> $pendingByFileId
|
||
* @param array<string, string> $pendingPathHashes path_hash => realPath
|
||
* @param array<string, true> $skipPaths Modified in place: newly-moved paths are added so the outer iterator skips them.
|
||
*/
|
||
private function processScanBatch(
|
||
array $pendingByFileId,
|
||
array $pendingPathHashes,
|
||
bool $checkForFileCache,
|
||
array &$skipPaths,
|
||
): int {
|
||
$filecacheByFileId = $this->fetchFilecacheByFileIds(array_keys($pendingByFileId));
|
||
$legacyByPathHash = [];
|
||
if ($checkForFileCache && $pendingPathHashes !== []) {
|
||
$legacyByPathHash = $this->fetchFilecacheByPathHashes(array_keys($pendingPathHashes));
|
||
}
|
||
|
||
$previewsFound = 0;
|
||
foreach ($pendingByFileId as $fileId => $items) {
|
||
if (!isset($filecacheByFileId[$fileId])) {
|
||
// Original file has been deleted – clean up all its previews.
|
||
foreach ($items as $item) {
|
||
$this->logger->warning('Original file ' . $fileId . ' was not found. Deleting preview at ' . $item['realPath']);
|
||
@unlink($item['realPath']);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
$filecacheRow = $filecacheByFileId[$fileId];
|
||
foreach ($items as $item) {
|
||
$preview = $item['preview'];
|
||
|
||
if ($checkForFileCache) {
|
||
$relativePath = str_replace($this->getRootFolder() . '/', '', $item['realPath']);
|
||
$pathHash = md5($relativePath);
|
||
if (isset($legacyByPathHash[$pathHash])) {
|
||
$legacyRow = $legacyByPathHash[$pathHash];
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$qb->delete('filecache')
|
||
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($legacyRow['fileid'])))
|
||
->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($legacyRow['storage'])))
|
||
->executeStatement();
|
||
$this->deleteParentsFromFileCache((int)$legacyRow['parent'], (int)$legacyRow['storage']);
|
||
}
|
||
}
|
||
|
||
$preview->setStorageId((int)$filecacheRow['storage']);
|
||
$preview->setEtag($filecacheRow['etag']);
|
||
$preview->setSourceMimetype($this->mimeTypeLoader->getMimetypeById((int)$filecacheRow['mimetype']));
|
||
$preview->generateId();
|
||
|
||
$this->connection->beginTransaction();
|
||
try {
|
||
$this->previewMapper->insert($preview);
|
||
$this->connection->commit();
|
||
} catch (Exception $e) {
|
||
$this->connection->rollBack();
|
||
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
// Move old flat preview to new nested directory format.
|
||
$dirName = str_replace($this->getPreviewRootFolder(), '', $item['filePath']);
|
||
if (preg_match('/([[:xdigit:]]\/){7}[0-9]+/', $dirName) !== 1) {
|
||
$previewPath = $this->constructPath($preview);
|
||
$this->createParentFiles($previewPath);
|
||
$ok = rename($item['realPath'], $previewPath);
|
||
if (!$ok) {
|
||
throw new LogicException('Failed to move ' . $item['realPath'] . ' to ' . $previewPath);
|
||
}
|
||
// Mark the destination so the outer iterator skips it if it encounters the path later.
|
||
$skipPaths[$previewPath] = true;
|
||
}
|
||
|
||
$previewsFound++;
|
||
}
|
||
}
|
||
|
||
return $previewsFound;
|
||
}
|
||
|
||
/**
|
||
* Bulk-fetch filecache rows for a set of fileIds.
|
||
*
|
||
* @param int[] $fileIds
|
||
*/
|
||
private function fetchFilecacheByFileIds(array $fileIds): array {
|
||
if (empty($fileIds)) {
|
||
return [];
|
||
}
|
||
|
||
$result = [];
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$qb->selectColumns('fileid', 'storage', 'etag', 'mimetype')
|
||
->from('filecache')
|
||
->where($qb->expr()->in('fileid', $qb->createParameter('fileIds')))
|
||
->runAcrossAllShards();
|
||
foreach (array_chunk($fileIds, IQueryBuilder::MAX_IN_PARAMETERS) as $chunk) {
|
||
$qb->setParameter('fileIds', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
|
||
$rows = $qb->executeQuery();
|
||
while ($row = $rows->fetchAssociative()) {
|
||
$result[(int)$row['fileid']] = $row;
|
||
}
|
||
$rows->closeCursor();
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Bulk-fetch filecache rows for a set of path_hashes (legacy migration).
|
||
*
|
||
* @param string[] $pathHashes
|
||
*/
|
||
private function fetchFilecacheByPathHashes(array $pathHashes): array {
|
||
if (empty($pathHashes)) {
|
||
return [];
|
||
}
|
||
|
||
$result = [];
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$qb->selectColumns('fileid', 'storage', 'etag', 'mimetype', 'parent', 'path_hash')
|
||
->from('filecache')
|
||
->where($qb->expr()->in('path_hash', $qb->createParameter('pathHashes')))
|
||
->runAcrossAllShards();
|
||
foreach (array_chunk($pathHashes, IQueryBuilder::MAX_IN_PARAMETERS) as $chunk) {
|
||
$qb->setParameter('pathHashes', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
|
||
$rows = $qb->executeQuery();
|
||
while ($row = $rows->fetchAssociative()) {
|
||
$result[$row['path_hash']] = $row;
|
||
}
|
||
$rows->closeCursor();
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Recursive method that deletes the folder and its parent folders if it's not
|
||
* empty.
|
||
*/
|
||
private function deleteParentsFromFileCache(int $folderId, int $storageId): void {
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$result = $qb->selectColumns('fileid', 'path', 'storage', 'parent')
|
||
->from('filecache')
|
||
->where($qb->expr()->eq('parent', $qb->createNamedParameter($folderId)))
|
||
->setMaxResults(1)
|
||
->runAcrossAllShards()
|
||
->executeQuery();
|
||
$row = $result->fetchAssociative();
|
||
$result->closeCursor();
|
||
|
||
if ($row !== false) {
|
||
// there are other files in the directory, don't delete yet
|
||
return;
|
||
}
|
||
|
||
// Get new parent
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$result = $qb->selectColumns('fileid', 'path', 'parent')
|
||
->from('filecache')
|
||
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($folderId)))
|
||
->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($storageId)))
|
||
->setMaxResults(1)
|
||
->executeQuery();
|
||
$row = $result->fetchAssociative();
|
||
$result->closeCursor();
|
||
if ($row !== false) {
|
||
$parentFolderId = (int)$row['parent'];
|
||
|
||
$qb = $this->connection->getTypedQueryBuilder();
|
||
$qb->delete('filecache')
|
||
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($folderId)))
|
||
->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($storageId)))
|
||
->executeStatement();
|
||
|
||
$this->deleteParentsFromFileCache($parentFolderId, $storageId);
|
||
}
|
||
}
|
||
}
|