aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorprovokateurin <kate@provokateurin.de>2025-07-21 11:06:42 +0200
committerprovokateurin <kate@provokateurin.de>2025-07-21 14:12:56 +0200
commit00eb35f56c75b6ea9955d372ddb60b40f3eec936 (patch)
tree2b90f143628f508330271fa320711ad52539c8ed
parenta1f4b59997df99af48e0c58a3a3526e6d9f5e90f (diff)
downloadnextcloud-server-feat/repair-step-deduplicate-mounts.tar.gz
nextcloud-server-feat/repair-step-deduplicate-mounts.zip
feat: Add repair step for deduplicating mountsfeat/repair-step-deduplicate-mounts
Signed-off-by: provokateurin <kate@provokateurin.de>
-rw-r--r--lib/composer/composer/autoload_classmap.php1
-rw-r--r--lib/composer/composer/autoload_static.php1
-rw-r--r--lib/private/Repair.php2
-rw-r--r--lib/private/Repair/DeduplicateMounts.php68
-rw-r--r--tests/lib/Repair/DeduplicateMountsTest.php158
5 files changed, 230 insertions, 0 deletions
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 16926e77775..efcaee303da 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -1930,6 +1930,7 @@ return array(
'OC\\Repair\\ClearGeneratedAvatarCacheJob' => $baseDir . '/lib/private/Repair/ClearGeneratedAvatarCacheJob.php',
'OC\\Repair\\Collation' => $baseDir . '/lib/private/Repair/Collation.php',
'OC\\Repair\\ConfigKeyMigration' => $baseDir . '/lib/private/Repair/ConfigKeyMigration.php',
+ 'OC\\Repair\\DeduplicateMounts' => $baseDir . '/lib/private/Repair/DeduplicateMounts.php',
'OC\\Repair\\Events\\RepairAdvanceEvent' => $baseDir . '/lib/private/Repair/Events/RepairAdvanceEvent.php',
'OC\\Repair\\Events\\RepairErrorEvent' => $baseDir . '/lib/private/Repair/Events/RepairErrorEvent.php',
'OC\\Repair\\Events\\RepairFinishEvent' => $baseDir . '/lib/private/Repair/Events/RepairFinishEvent.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index daa1a1940dd..353e78eba53 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -1971,6 +1971,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Repair\\ClearGeneratedAvatarCacheJob' => __DIR__ . '/../../..' . '/lib/private/Repair/ClearGeneratedAvatarCacheJob.php',
'OC\\Repair\\Collation' => __DIR__ . '/../../..' . '/lib/private/Repair/Collation.php',
'OC\\Repair\\ConfigKeyMigration' => __DIR__ . '/../../..' . '/lib/private/Repair/ConfigKeyMigration.php',
+ 'OC\\Repair\\DeduplicateMounts' => __DIR__ . '/../../..' . '/lib/private/Repair/DeduplicateMounts.php',
'OC\\Repair\\Events\\RepairAdvanceEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairAdvanceEvent.php',
'OC\\Repair\\Events\\RepairErrorEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairErrorEvent.php',
'OC\\Repair\\Events\\RepairFinishEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairFinishEvent.php',
diff --git a/lib/private/Repair.php b/lib/private/Repair.php
index 7fbf776d9a1..4c2e24482b6 100644
--- a/lib/private/Repair.php
+++ b/lib/private/Repair.php
@@ -19,6 +19,7 @@ use OC\Repair\CleanUpAbandonedApps;
use OC\Repair\ClearFrontendCaches;
use OC\Repair\ClearGeneratedAvatarCache;
use OC\Repair\Collation;
+use OC\Repair\DeduplicateMounts;
use OC\Repair\Events\RepairAdvanceEvent;
use OC\Repair\Events\RepairErrorEvent;
use OC\Repair\Events\RepairFinishEvent;
@@ -215,6 +216,7 @@ class Repair implements IOutput {
\OCP\Server::get(IDBConnection::class)
),
\OCP\Server::get(DeleteSchedulingObjects::class),
+ \OCP\Server::get(DeduplicateMounts::class),
];
}
diff --git a/lib/private/Repair/DeduplicateMounts.php b/lib/private/Repair/DeduplicateMounts.php
new file mode 100644
index 00000000000..c2f087aced3
--- /dev/null
+++ b/lib/private/Repair/DeduplicateMounts.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Repair;
+
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+
+class DeduplicateMounts implements IRepairStep {
+ public function __construct(
+ private readonly IDBConnection $connection,
+ private readonly IConfig $config,
+ ) {
+ }
+
+ public function getName(): string {
+ return 'Deduplicate mounts';
+ }
+
+ public function run(IOutput $output): void {
+ $threshold = $this->config->getSystemValueInt('repair_duplicate_mounts_threshold', 10);
+ if ($threshold < 1) {
+ $threshold = 1;
+ }
+
+ $this->connection->beginTransaction();
+
+ $selectQuery = $this->connection->getQueryBuilder();
+ $selectQuery
+ ->select('root_id', 'user_id', 'mount_point')
+ ->selectAlias($selectQuery->func()->min('id'), 'min_id')
+ ->from('mounts')
+ ->groupBy('root_id', 'user_id', 'mount_point')
+ ->having($selectQuery->expr()->gt($selectQuery->func()->count('*'), $selectQuery->createNamedParameter($threshold, IQueryBuilder::PARAM_INT)));
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery
+ ->delete('mounts')
+ ->where(
+ $deleteQuery->expr()->neq('id', $deleteQuery->createParameter('id')),
+ $deleteQuery->expr()->eq('root_id', $deleteQuery->createParameter('root_id')),
+ $deleteQuery->expr()->eq('user_id', $deleteQuery->createParameter('user_id')),
+ $deleteQuery->expr()->eq('mount_point', $deleteQuery->createParameter('mount_point')),
+ );
+
+ $result = $selectQuery->executeQuery();
+ while ($row = $result->fetch()) {
+ $deleteQuery
+ ->setParameter('id', $row['min_id'])
+ ->setParameter('root_id', $row['root_id'])
+ ->setParameter('user_id', $row['user_id'])
+ ->setParameter('mount_point', $row['mount_point'])
+ ->executeStatement();
+ }
+ $result->closeCursor();
+
+ $this->connection->commit();
+ }
+}
diff --git a/tests/lib/Repair/DeduplicateMountsTest.php b/tests/lib/Repair/DeduplicateMountsTest.php
new file mode 100644
index 00000000000..afb37e4cc27
--- /dev/null
+++ b/tests/lib/Repair/DeduplicateMountsTest.php
@@ -0,0 +1,158 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace Test\Repair;
+
+use OC\Repair\DeduplicateMounts;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\Migration\IOutput;
+use OCP\Server;
+use Test\TestCase;
+
+/**
+ * @group DB
+ *
+ * @see DeduplicateMounts
+ */
+class DeduplicateMountsTest extends TestCase {
+
+ private DeduplicateMounts $repair;
+ private IDBConnection $connection;
+ private IConfig $config;
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->connection = Server::get(IDBConnection::class);
+ $this->deleteAllMounts();
+
+ $this->config = $this->createMock(IConfig::class);
+ $this->repair = new DeduplicateMounts($this->connection, $this->config);
+ }
+
+ protected function tearDown(): void {
+ $this->deleteAllMounts();
+
+ parent::tearDown();
+ }
+
+ protected function deleteAllMounts(): void {
+ $this->connection->getQueryBuilder()->delete('mounts')->executeStatement();
+ }
+
+ public function testDeduplicateMounts(): void {
+ $rows = [
+ // Original mount
+ [
+ 'storage_id' => 1,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Duplicate mount 1
+ [
+ 'storage_id' => 2,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Duplicate mount 2
+ [
+ 'storage_id' => 3,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Different root_id
+ [
+ 'storage_id' => 4,
+ 'root_id' => 2,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Different user_id
+ [
+ 'storage_id' => 5,
+ 'root_id' => 1,
+ 'user_id' => 'user2',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Different mount_point
+ [
+ 'storage_id' => 6,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/2.txt/',
+ ],
+ ];
+
+ $qb = $this->connection->getQueryBuilder();
+ $qb->insert('mounts')
+ ->values([
+ 'storage_id' => $qb->createParameter('storage_id'),
+ 'root_id' => $qb->createParameter('root_id'),
+ 'user_id' => $qb->createParameter('user_id'),
+ 'mount_point' => $qb->createParameter('mount_point'),
+ ]);
+
+ foreach ($rows as $row) {
+ $qb
+ ->setParameter('storage_id', $row['storage_id'], IQueryBuilder::PARAM_INT)
+ ->setParameter('root_id', $row['root_id'], IQueryBuilder::PARAM_INT)
+ ->setParameter('user_id', $row['user_id'], IQueryBuilder::PARAM_STR)
+ ->setParameter('mount_point', $row['mount_point'], IQueryBuilder::PARAM_STR)
+ ->executeStatement();
+ }
+
+ $this->config
+ ->expects($this->once())
+ ->method('getSystemValueInt')
+ ->with('repair_duplicate_mounts_threshold', 10)
+ ->willReturn(1);
+
+ $output = $this->createMock(IOutput::class);
+ $this->repair->run($output);
+
+ $result = $this->connection->getQueryBuilder()
+ ->select('storage_id', 'root_id', 'user_id', 'mount_point')
+ ->from('mounts')
+ ->orderBy('storage_id', 'ASC')
+ ->executeQuery();
+
+ $this->assertEquals([
+ [
+ 'storage_id' => 1,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ // Duplicate mount 1 is removed
+ // Duplicate mount 2 is removed
+ [
+ 'storage_id' => 4,
+ 'root_id' => 2,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ [
+ 'storage_id' => 5,
+ 'root_id' => 1,
+ 'user_id' => 'user2',
+ 'mount_point' => '/user1/files/1.txt/',
+ ],
+ [
+ 'storage_id' => 6,
+ 'root_id' => 1,
+ 'user_id' => 'user1',
+ 'mount_point' => '/user1/files/2.txt/',
+ ],
+ ], $result->fetchAll());
+
+ $result->closeCursor();
+ }
+}