diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php index 7d8012ea15774..e23c2ec33ba72 100644 --- a/apps/files/lib/Command/DeleteOrphanedFiles.php +++ b/apps/files/lib/Command/DeleteOrphanedFiles.php @@ -15,7 +15,8 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Delete all file entries that have no matching entries in the storage table. + * Delete all file entries that have no matching entries in the storage table, + * and the rows keyed by file id that have no matching file entry. */ class DeleteOrphanedFiles extends Command { public const CHUNK_SIZE = 200; @@ -31,29 +32,28 @@ protected function configure(): void { $this ->setName('files:cleanup') ->setDescription('Clean up orphaned filecache and mount entries') - ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage).') + ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage), and filecache_extended and file metadata entries without a filecache entry.') ->addOption('skip-filecache-extended', null, InputOption::VALUE_NONE, 'don\'t remove orphaned entries from filecache_extended'); } #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - $fileIdsByStorage = []; - $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages()); - $deleteExtended = !$input->getOption('skip-filecache-extended'); - if ($deleteExtended) { - $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages); - } - $deletedEntries = $this->cleanupOrphanedFileCache($deletedStorages); $output->writeln("$deletedEntries orphaned file cache entries deleted"); - if ($deleteExtended) { - $deletedFileCacheExtended = $this->cleanupOrphanedFileCacheExtended($fileIdsByStorage); + if (!$input->getOption('skip-filecache-extended')) { + $deletedFileCacheExtended = $this->cleanupEntriesWithoutFileCache('filecache_extended', 'fileid'); $output->writeln("$deletedFileCacheExtended orphaned file cache extended entries deleted"); } + $deletedMetadata = $this->cleanupEntriesWithoutFileCache('files_metadata', 'file_id'); + $output->writeln("$deletedMetadata orphaned file metadata entries deleted"); + + $deletedMetadataIndex = $this->cleanupEntriesWithoutFileCache('files_metadata_index', 'file_id'); + $output->writeln("$deletedMetadataIndex orphaned file metadata index entries deleted"); + $deletedMounts = $this->cleanupOrphanedMounts(); $output->writeln("$deletedMounts orphaned mount entries deleted"); @@ -77,28 +77,6 @@ private function getExistingStorages(): array { return $query->executeQuery()->fetchFirstColumn(); } - /** - * @param int[] $storageIds - * @return array - */ - private function getFileIdsForStorages(array $storageIds): array { - $query = $this->connection->getQueryBuilder(); - $query->select('storage', 'fileid') - ->from('filecache') - ->where($query->expr()->in('storage', $query->createParameter('storage_ids'))); - - $result = []; - $storageIdChunks = array_chunk($storageIds, self::CHUNK_SIZE); - foreach ($storageIdChunks as $storageIdChunk) { - $query->setParameter('storage_ids', $storageIdChunk, IQueryBuilder::PARAM_INT_ARRAY); - $chunk = $query->executeQuery()->fetchAllAssociative(); - foreach ($chunk as $row) { - $result[$row['storage']][] = $row['fileid']; - } - } - return $result; - } - private function cleanupOrphanedFileCache(array $deletedStorages): int { $deletedEntries = 0; @@ -115,27 +93,38 @@ private function cleanupOrphanedFileCache(array $deletedStorages): int { return $deletedEntries; } - /** - * @param array $fileIdsByStorage - * @return int - */ - private function cleanupOrphanedFileCacheExtended(array $fileIdsByStorage): int { + private function cleanupEntriesWithoutFileCache(string $table, string $fileIdColumn): int { $deletedEntries = 0; + $lastFileId = 0; + + while (true) { + $query = $this->connection->getQueryBuilder(); + $query->select($fileIdColumn) + ->from($table) + ->where($query->expr()->gt($fileIdColumn, $query->createNamedParameter($lastFileId, IQueryBuilder::PARAM_INT))) + ->orderBy($fileIdColumn) + ->setMaxResults(1000) + ->runAcrossAllShards(); + $fileIds = array_unique(array_map(intval(...), $query->executeQuery()->fetchFirstColumn())); + if ($fileIds === []) { + return $deletedEntries; + } - $deleteQuery = $this->connection->getQueryBuilder(); - $deleteQuery->delete('filecache_extended') - ->where($deleteQuery->expr()->in('fileid', $deleteQuery->createParameter('file_ids'))); - - foreach ($fileIdsByStorage as $storageId => $fileIds) { - $deleteQuery->hintShardKey('storage', $storageId, true); - $fileChunks = array_chunk($fileIds, self::CHUNK_SIZE); - foreach ($fileChunks as $fileChunk) { - $deleteQuery->setParameter('file_ids', $fileChunk, IQueryBuilder::PARAM_INT_ARRAY); - $deletedEntries += $deleteQuery->executeStatement(); + $query = $this->connection->getQueryBuilder(); + $query->select('fileid') + ->from('filecache') + ->where($query->expr()->in('fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))); + $missingFileIds = array_diff($fileIds, $query->executeQuery()->fetchFirstColumn()); + + if ($missingFileIds !== []) { + $query = $this->connection->getQueryBuilder(); + $query->delete($table) + ->where($query->expr()->in($fileIdColumn, $query->createNamedParameter($missingFileIds, IQueryBuilder::PARAM_INT_ARRAY))); + $deletedEntries += $query->executeStatement(); } - } - return $deletedEntries; + $lastFileId = max($fileIds); + } } private function cleanupOrphanedMounts(): int { diff --git a/apps/files/tests/Command/DeleteOrphanedFilesTest.php b/apps/files/tests/Command/DeleteOrphanedFilesTest.php index 13c7a36785ae6..5127e29228888 100644 --- a/apps/files/tests/Command/DeleteOrphanedFilesTest.php +++ b/apps/files/tests/Command/DeleteOrphanedFilesTest.php @@ -8,10 +8,14 @@ */ namespace OCA\Files\Tests\Command; +use OC\Files\Storage\Temporary; use OC\Files\View; use OCA\Files\Command\DeleteOrphanedFiles; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\IRootFolder; use OCP\Files\StorageNotAvailableException; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IDBConnection; use OCP\IUserManager; use OCP\Server; @@ -73,6 +77,31 @@ protected function getMountsCount(int $storageId): int { return (int)$query->executeQuery()->fetchOne(); } + /** + * @param list $fileIds + */ + protected function countRows(string $table, string $column, array $fileIds): int { + // selecting rows instead of COUNT(*), which a sharded query answers once per shard + $query = $this->connection->getQueryBuilder(); + $query->select($column) + ->from($table) + ->where($query->expr()->in($column, $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))); + return count($query->executeQuery()->fetchFirstColumn()); + } + + /** + * @param list $calls + */ + protected function expectOutput(OutputInterface&\PHPUnit\Framework\MockObject\MockObject $output, array $calls): void { + $output + ->expects($this->exactly(count($calls))) + ->method('writeln') + ->willReturnCallback(function (string $message) use (&$calls): void { + $expected = array_shift($calls); + $this->assertSame($expected, $message); + }); + } + /** * Test clearing orphaned files */ @@ -103,24 +132,28 @@ public function testClearFiles(): void { $this->assertCount(1, $this->getFile($fileInfo->getId()), 'Asserts that file is still available'); $this->assertEquals(1, $this->getMountsCount($numericStorageId), 'Asserts that mount is still available'); + $qb = $this->connection->getQueryBuilder(); + $storageFileIds = array_map('intval', $qb->select('fileid') + ->from('filecache') + ->where($qb->expr()->eq('storage', $qb->createNamedParameter($numericStorageId, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetchFirstColumn()); + $extendedEntries = $this->countRows('filecache_extended', 'fileid', $storageFileIds); + $metadataEntries = $this->countRows('files_metadata', 'file_id', $storageFileIds); + $metadataIndexEntries = $this->countRows('files_metadata_index', 'file_id', $storageFileIds); $deletedRows = $this->connection->executeUpdate('DELETE FROM `*PREFIX*storages` WHERE `id` = ?', [$storageId]); $this->assertNotNull($deletedRows, 'Asserts that storage got deleted'); $this->assertSame(1, $deletedRows, 'Asserts that storage got deleted'); // parent folder, `files`, ´test` and `welcome.txt` => 4 elements - $calls = [ + $this->expectOutput($output, [ '3 orphaned file cache entries deleted', - '0 orphaned file cache extended entries deleted', + "$extendedEntries orphaned file cache extended entries deleted", + "$metadataEntries orphaned file metadata entries deleted", + "$metadataIndexEntries orphaned file metadata index entries deleted", '1 orphaned mount entries deleted', - ]; - $output - ->expects($this->exactly(3)) - ->method('writeln') - ->willReturnCallback(function (string $message) use (&$calls): void { - $expected = array_shift($calls); - $this->assertSame($expected, $message); - }); + ]); $this->command->execute($input, $output); @@ -135,4 +168,50 @@ public function testClearFiles(): void { } catch (StorageNotAvailableException $e) { } } + + public function testClearEntriesWithoutFileCacheEntry(): void { + // remove orphans left behind by other tests so that the counts below only cover this test + $input = $this->createMock(InputInterface::class); + $this->command->execute($input, $this->createMock(OutputInterface::class)); + + $storage = new Temporary([]); + $cache = $storage->getCache(); + $cache->put('', ['size' => 0, 'mtime' => 0, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]); + $data = ['size' => 1, 'mtime' => 1, 'mimetype' => 'text/plain', 'upload_time' => 25]; + $orphanId = $cache->put('orphan.txt', $data); + $keptId = $cache->put('kept.txt', $data); + + $metadataManager = Server::get(IFilesMetadataManager::class); + foreach ([$orphanId, $keptId] as $fileId) { + $metadata = $metadataManager->getMetadata($fileId, true); + $metadata->setString('test-key', 'value', true); + $metadataManager->saveMetadata($metadata); + } + + $qb = $this->connection->getQueryBuilder(); + $qb->delete('filecache') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($orphanId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + + $output = $this->createMock(OutputInterface::class); + $this->expectOutput($output, [ + '0 orphaned file cache entries deleted', + '1 orphaned file cache extended entries deleted', + '1 orphaned file metadata entries deleted', + '1 orphaned file metadata index entries deleted', + '0 orphaned mount entries deleted', + ]); + + $this->command->execute($input, $output); + + $this->assertSame(0, $this->countRows('filecache_extended', 'fileid', [$orphanId])); + $this->assertSame(0, $this->countRows('files_metadata', 'file_id', [$orphanId])); + $this->assertSame(0, $this->countRows('files_metadata_index', 'file_id', [$orphanId])); + + $this->assertSame(1, $this->countRows('filecache_extended', 'fileid', [$keptId])); + $this->assertSame(1, $this->countRows('files_metadata', 'file_id', [$keptId])); + $this->assertSame(1, $this->countRows('files_metadata_index', 'file_id', [$keptId])); + + $cache->clear(); + } } diff --git a/lib/private/Files/Cache/Cache.php b/lib/private/Files/Cache/Cache.php index 7c8023c90c1d6..c377ae637a412 100644 --- a/lib/private/Files/Cache/Cache.php +++ b/lib/private/Files/Cache/Cache.php @@ -605,7 +605,9 @@ public function remove($file) { $this->removeChildren($entry); } - $this->eventDispatcher->dispatchTyped(new CacheEntryRemovedEvent($this->storage, $entry->getPath(), $entry->getId(), $this->getNumericStorageId())); + $event = new CacheEntryRemovedEvent($this->storage, $entry->getPath(), $entry->getId(), $this->getNumericStorageId()); + $this->eventDispatcher->dispatchTyped($event); + $this->eventDispatcher->dispatchTyped(new CacheEntriesRemovedEvent([$event])); } } @@ -676,8 +678,8 @@ private function removeChildren(ICacheEntry $entry) { $query->executeStatement(); } - $cacheEntryRemovedEvents = []; - foreach (array_chunk(array_combine($deletedIds, $deletedPaths), 1000) as $chunk) { + foreach (array_chunk(array_combine($deletedIds, $deletedPaths), 1000, true) as $chunk) { + $cacheEntryRemovedEvents = []; /** @var array $chunk */ foreach ($chunk as $fileId => $filePath) { $cacheEntryRemovedEvents[] = new CacheEntryRemovedEvent( @@ -899,10 +901,7 @@ private function getChildIds(int $storageId, string $path): array { * remove all entries for files that are stored on the storage from the cache */ public function clear() { - $query = $this->getQueryBuilder(); - $query->delete('filecache') - ->whereStorageId($this->getNumericStorageId()); - $query->executeStatement(); + Storage::removeFileCacheEntries($this->getNumericStorageId()); $query = $this->connection->getQueryBuilder(); $query->delete('storages') diff --git a/lib/private/Files/Cache/Storage.php b/lib/private/Files/Cache/Storage.php index 8eea54b58409d..99edb672a4aab 100644 --- a/lib/private/Files/Cache/Storage.php +++ b/lib/private/Files/Cache/Storage.php @@ -12,6 +12,7 @@ use OC\DB\Exceptions\DbalException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\Storage\IStorage; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IDBConnection; use OCP\Server; use Psr\Log\LoggerInterface; @@ -169,14 +170,11 @@ public static function cleanByMountId(int $mountId): void { $query->select('storage_id') ->from('mounts') ->where($query->expr()->eq('mount_id', $query->createNamedParameter($mountId, IQueryBuilder::PARAM_INT))); - $storageIds = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); - $storageIds = array_unique($storageIds); + $storageIds = array_unique(array_map(intval(...), $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN))); - $query = $db->getQueryBuilder(); - $query->delete('filecache') - ->where($query->expr()->in('storage', $query->createNamedParameter($storageIds, IQueryBuilder::PARAM_INT_ARRAY))) - ->runAcrossAllShards() - ->executeStatement(); + foreach ($storageIds as $storageId) { + self::removeFileCacheEntries($storageId); + } $query = $db->getQueryBuilder(); $query->delete('storages') @@ -194,4 +192,38 @@ public static function cleanByMountId(int $mountId): void { throw $exception; } } + + /** + * Remove the filecache entries of a storage together with their filecache_extended and metadata rows + */ + public static function removeFileCacheEntries(int $numericStorageId): void { + $db = Server::get(IDBConnection::class); + $metadataManager = Server::get(IFilesMetadataManager::class); + + while (true) { + $query = $db->getQueryBuilder(); + $query->select('fileid') + ->from('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($numericStorageId, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1000); + $fileIds = array_map(intval(...), $query->executeQuery()->fetchFirstColumn()); + if ($fileIds === []) { + return; + } + + $query = $db->getQueryBuilder(); + $query->delete('filecache_extended') + ->where($query->expr()->in('fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->hintShardKey('storage', $numericStorageId) + ->executeStatement(); + + $metadataManager->deleteMetadataForFiles($numericStorageId, $fileIds); + + $query = $db->getQueryBuilder(); + $query->delete('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($numericStorageId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->in('fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } + } } diff --git a/lib/private/FilesMetadata/Service/MetadataRequestService.php b/lib/private/FilesMetadata/Service/MetadataRequestService.php index c274c70812d12..b504df7b4948c 100644 --- a/lib/private/FilesMetadata/Service/MetadataRequestService.php +++ b/lib/private/FilesMetadata/Service/MetadataRequestService.php @@ -41,7 +41,7 @@ private function getStorageId(IFilesMetadata $filesMetadata): int { $query->select('storage') ->from('filecache') ->where($query->expr()->eq('fileid', $query->createNamedParameter($filesMetadata->getFileId(), IQueryBuilder::PARAM_INT))); - $storageId = $query->executeQuery()->fetchColumn(); + $storageId = (int)$query->executeQuery()->fetchOne(); if ($filesMetadata instanceof FilesMetadata) { $filesMetadata->setStorageId($storageId); diff --git a/tests/lib/Files/Cache/CacheTest.php b/tests/lib/Files/Cache/CacheTest.php index 39e05c590ee98..ef9ea652b14dd 100644 --- a/tests/lib/Files/Cache/CacheTest.php +++ b/tests/lib/Files/Cache/CacheTest.php @@ -9,20 +9,30 @@ namespace Test\Files\Cache; use OC\Files\Cache\Cache; +use OC\Files\Cache\CacheDependencies; use OC\Files\Cache\CacheEntry; +use OC\Files\Cache\QuerySearchHelper; use OC\Files\Cache\Wrapper\CacheJail; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchQuery; use OC\Files\Storage\Temporary; +use OC\SystemConfig; +use OC\User\DisplayNameCache; use OC\User\User; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Cache\CacheEntriesRemovedEvent; use OCP\Files\Cache\ICacheEntry; +use OCP\Files\IMimeTypeLoader; use OCP\Files\Search\ISearchComparison; +use OCP\Files\Storage\IStorage; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IDBConnection; use OCP\ITagManager; use OCP\IUser; use OCP\IUserManager; use OCP\Server; +use Psr\Log\LoggerInterface; class LongId extends Temporary { #[\Override] @@ -245,6 +255,122 @@ public function testRemoveRecursive(): void { } } + /** + * @return array{0: Cache, 1: \Closure(): list} + */ + private function cacheWithRecordedEvents(IStorage $storage): array { + $events = []; + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped') + ->willReturnCallback(function (object $event) use (&$events): void { + if ($event instanceof CacheEntriesRemovedEvent) { + $events[] = $event; + } + }); + + $dependencies = new CacheDependencies( + Server::get(IMimeTypeLoader::class), + Server::get(IDBConnection::class), + $dispatcher, + Server::get(QuerySearchHelper::class), + Server::get(SystemConfig::class), + Server::get(LoggerInterface::class), + Server::get(IFilesMetadataManager::class), + Server::get(DisplayNameCache::class), + ); + + return [new Cache($storage, $dependencies), function () use (&$events): array { + return $events; + }]; + } + + /** + * @param list $events + * @return list + */ + private function announcedFileIds(array $events): array { + $fileIds = []; + foreach ($events as $event) { + foreach ($event->getCacheEntryRemovedEvents() as $removed) { + $fileIds[] = $removed->getFileId(); + } + } + return $fileIds; + } + + public function testRemoveSingleFileAnnouncesBatchEvent(): void { + $storage = new Temporary([]); + [$cache, $recorded] = $this->cacheWithRecordedEvents($storage); + $cache->insert('', ['size' => 0, 'mtime' => 0, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]); + $fileId = $cache->put('foo.txt', ['size' => 1, 'mtime' => 20, 'mimetype' => 'text/plain']); + + $cache->remove('foo.txt'); + + $this->assertEquals([$fileId], $this->announcedFileIds($recorded())); + } + + public function testRemoveRecursiveAnnouncesRealFileIds(): void { + $storage = new Temporary([]); + [$cache, $recorded] = $this->cacheWithRecordedEvents($storage); + $cache->insert('', ['size' => 0, 'mtime' => 0, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]); + $folderData = ['size' => 100, 'mtime' => 50, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]; + $fileData = ['size' => 1000, 'mtime' => 20, 'mimetype' => 'text/plain']; + + $expected = [$cache->put('folder', $folderData)]; + $expected[] = $cache->put('folder/sub', $folderData); + $expected[] = $cache->put('folder/foo.txt', $fileData); + $expected[] = $cache->put('folder/sub/bar.txt', $fileData); + + $cache->remove('folder'); + + $announced = $this->announcedFileIds($recorded()); + sort($expected); + sort($announced); + $this->assertEquals($expected, $announced); + } + + public function testRemoveRecursiveAnnouncesEveryChildExactlyOnce(): void { + $storage = new Temporary([]); + [$cache, $recorded] = $this->cacheWithRecordedEvents($storage); + $cache->insert('', ['size' => 0, 'mtime' => 0, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]); + $fileData = ['size' => 1, 'mtime' => 20, 'mimetype' => 'text/plain']; + + $expected = [$cache->put('folder', ['size' => 0, 'mtime' => 50, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE])]; + for ($i = 0; $i < 1001; $i++) { + $expected[] = $cache->insert("folder/child$i.txt", $fileData); + } + + $cache->remove('folder'); + + $announced = $this->announcedFileIds($recorded()); + $this->assertCount(count($expected), $announced, 'every removed entry is announced exactly once'); + sort($expected); + sort($announced); + $this->assertEquals($expected, $announced); + } + + public function testClearRemovesExtendedAndMetadataEntries(): void { + $cache = new Cache($this->storage); + $fileId = $cache->put('foo', ['size' => 100, 'mtime' => 50, 'mimetype' => 'text/plain', 'upload_time' => 30]); + + $metadataManager = Server::get(IFilesMetadataManager::class); + $metadata = $metadataManager->getMetadata($fileId, true); + $metadata->setString('test-key', 'value', true); + $metadataManager->saveMetadata($metadata); + + $cache->clear(); + + $this->assertFalse($cache->inCache('foo')); + $connection = Server::get(IDBConnection::class); + foreach (['filecache_extended' => 'fileid', 'files_metadata' => 'file_id', 'files_metadata_index' => 'file_id'] as $table => $column) { + $query = $connection->getQueryBuilder(); + $query->select($column) + ->from($table) + ->where($query->expr()->eq($column, $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + $this->assertSame([], $query->executeQuery()->fetchFirstColumn(), "$table rows of the cleared storage remain"); + } + } + public static function folderDataProvider(): array { return [ ['folder'], diff --git a/tests/lib/Files/Cache/StorageTest.php b/tests/lib/Files/Cache/StorageTest.php new file mode 100644 index 0000000000000..7d8dfe98b8ff0 --- /dev/null +++ b/tests/lib/Files/Cache/StorageTest.php @@ -0,0 +1,67 @@ +getCache(); + $rootId = $cache->put('', ['size' => 0, 'mtime' => 0, 'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE]); + $fileId = $cache->put('foo.txt', ['size' => 1, 'mtime' => 1, 'mimetype' => 'text/plain', 'upload_time' => 25]); + + $metadataManager = Server::get(IFilesMetadataManager::class); + $metadata = $metadataManager->getMetadata($fileId, true); + $metadata->setString('test-key', 'value', true); + $metadataManager->saveMetadata($metadata); + + $mountId = random_int(100000000, 999999999); + $mountPoint = '/' . $this->getUniqueID('user') . '/files/mount/'; + $qb = Server::get(IDBConnection::class)->getQueryBuilder(); + $qb->insert('mounts') + ->values([ + 'storage_id' => $qb->createNamedParameter($cache->getNumericStorageId(), IQueryBuilder::PARAM_INT), + 'root_id' => $qb->createNamedParameter($rootId, IQueryBuilder::PARAM_INT), + 'user_id' => $qb->createNamedParameter('test'), + 'mount_point' => $qb->createNamedParameter($mountPoint), + 'mount_point_hash' => $qb->createNamedParameter(hash('xxh128', $mountPoint)), + 'mount_id' => $qb->createNamedParameter($mountId, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + + $this->assertSame(1, $this->countRows('filecache_extended', 'fileid', $fileId)); + $this->assertSame(1, $this->countRows('files_metadata', 'file_id', $fileId)); + $this->assertSame(1, $this->countRows('files_metadata_index', 'file_id', $fileId)); + + Storage::cleanByMountId($mountId); + + $this->assertSame(0, $this->countRows('filecache', 'fileid', $fileId)); + $this->assertSame(0, $this->countRows('filecache_extended', 'fileid', $fileId)); + $this->assertSame(0, $this->countRows('files_metadata', 'file_id', $fileId)); + $this->assertSame(0, $this->countRows('files_metadata_index', 'file_id', $fileId)); + } + + private function countRows(string $table, string $column, int $fileId): int { + $qb = Server::get(IDBConnection::class)->getQueryBuilder(); + $qb->select($qb->func()->count()) + ->from($table) + ->where($qb->expr()->eq($column, $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + return (int)$qb->executeQuery()->fetchOne(); + } +} diff --git a/tests/lib/FilesMetadata/FilesMetadataManagerTest.php b/tests/lib/FilesMetadata/FilesMetadataManagerTest.php index a349065f32f05..ee002877450eb 100644 --- a/tests/lib/FilesMetadata/FilesMetadataManagerTest.php +++ b/tests/lib/FilesMetadata/FilesMetadataManagerTest.php @@ -11,8 +11,13 @@ use OC\BackgroundJob\JobList; use OC\Files\Storage\Temporary; use OC\FilesMetadata\FilesMetadataManager; +use OC\FilesMetadata\Model\FilesMetadata; use OC\FilesMetadata\Service\IndexRequestService; use OC\FilesMetadata\Service\MetadataRequestService; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IFunctionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; @@ -94,4 +99,31 @@ public function testRefreshMetadata(): void { $this->assertEquals($file->getId(), $retrieved->getFileId()); $this->assertEquals('yes', $retrieved->getString('istest')); } + + public function testStoreCastsStorageIdLookedUpFromFilecache(): void { + // Oracle returns numeric columns as strings + $result = $this->createMock(IResult::class); + $result->method('fetchOne')->willReturn('42'); + $result->method('fetchColumn')->willReturn('42'); + + $qb = $this->createMock(IQueryBuilder::class); + foreach (['select', 'from', 'where', 'insert', 'setValue'] as $method) { + $qb->method($method)->willReturnSelf(); + } + $qb->method('expr')->willReturn($this->createMock(IExpressionBuilder::class)); + $qb->method('func')->willReturn($this->createMock(IFunctionBuilder::class)); + $qb->method('executeQuery')->willReturn($result); + $qb->expects($this->once()) + ->method('hintShardKey') + ->with('storage', $this->identicalTo(42)) + ->willReturnSelf(); + + $connection = $this->createMock(IDBConnection::class); + $connection->method('getQueryBuilder')->willReturn($qb); + + $metadata = new FilesMetadata(1); + (new MetadataRequestService($connection, $this->logger))->store($metadata); + + $this->assertSame(42, $metadata->getStorageId()); + } }