Skip to content

Commit 974a08c

Browse files
committed
feat: Add repair step for deduplicating mounts
Signed-off-by: provokateurin <kate@provokateurin.de>
1 parent dc48b6b commit 974a08c

5 files changed

Lines changed: 210 additions & 0 deletions

File tree

lib/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,7 @@
19301930
'OC\\Repair\\ClearGeneratedAvatarCacheJob' => $baseDir . '/lib/private/Repair/ClearGeneratedAvatarCacheJob.php',
19311931
'OC\\Repair\\Collation' => $baseDir . '/lib/private/Repair/Collation.php',
19321932
'OC\\Repair\\ConfigKeyMigration' => $baseDir . '/lib/private/Repair/ConfigKeyMigration.php',
1933+
'OC\\Repair\\DeduplicateMounts' => $baseDir . '/lib/private/Repair/DeduplicateMounts.php',
19331934
'OC\\Repair\\Events\\RepairAdvanceEvent' => $baseDir . '/lib/private/Repair/Events/RepairAdvanceEvent.php',
19341935
'OC\\Repair\\Events\\RepairErrorEvent' => $baseDir . '/lib/private/Repair/Events/RepairErrorEvent.php',
19351936
'OC\\Repair\\Events\\RepairFinishEvent' => $baseDir . '/lib/private/Repair/Events/RepairFinishEvent.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,6 +1971,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
19711971
'OC\\Repair\\ClearGeneratedAvatarCacheJob' => __DIR__ . '/../../..' . '/lib/private/Repair/ClearGeneratedAvatarCacheJob.php',
19721972
'OC\\Repair\\Collation' => __DIR__ . '/../../..' . '/lib/private/Repair/Collation.php',
19731973
'OC\\Repair\\ConfigKeyMigration' => __DIR__ . '/../../..' . '/lib/private/Repair/ConfigKeyMigration.php',
1974+
'OC\\Repair\\DeduplicateMounts' => __DIR__ . '/../../..' . '/lib/private/Repair/DeduplicateMounts.php',
19741975
'OC\\Repair\\Events\\RepairAdvanceEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairAdvanceEvent.php',
19751976
'OC\\Repair\\Events\\RepairErrorEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairErrorEvent.php',
19761977
'OC\\Repair\\Events\\RepairFinishEvent' => __DIR__ . '/../../..' . '/lib/private/Repair/Events/RepairFinishEvent.php',

lib/private/Repair.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OC\Repair\ClearFrontendCaches;
2020
use OC\Repair\ClearGeneratedAvatarCache;
2121
use OC\Repair\Collation;
22+
use OC\Repair\DeduplicateMounts;
2223
use OC\Repair\Events\RepairAdvanceEvent;
2324
use OC\Repair\Events\RepairErrorEvent;
2425
use OC\Repair\Events\RepairFinishEvent;
@@ -215,6 +216,7 @@ public static function getExpensiveRepairSteps() {
215216
\OCP\Server::get(IDBConnection::class)
216217
),
217218
\OCP\Server::get(DeleteSchedulingObjects::class),
219+
\OCP\Server::get(DeduplicateMounts::class),
218220
];
219221
}
220222

@@ -232,6 +234,7 @@ public static function getBeforeUpgradeRepairSteps() {
232234
new Collation(\OC::$server->getConfig(), \OC::$server->get(LoggerInterface::class), $connectionAdapter, true),
233235
new SaveAccountsTableData($connectionAdapter, $config),
234236
new DropAccountTermsTable($connectionAdapter),
237+
new DeduplicateMounts($connectionAdapter),
235238
];
236239

237240
return $steps;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Repair;
11+
12+
use OCP\DB\QueryBuilder\IQueryBuilder;
13+
use OCP\IDBConnection;
14+
use OCP\Migration\IOutput;
15+
use OCP\Migration\IRepairStep;
16+
17+
class DeduplicateMounts implements IRepairStep {
18+
public function __construct(
19+
private readonly IDBConnection $connection,
20+
) {
21+
}
22+
23+
public function getName(): string {
24+
return 'Deduplicate mounts';
25+
}
26+
27+
public function run(IOutput $output): void {
28+
$selectQuery = $this->connection->getQueryBuilder();
29+
$selectQuery
30+
->select('id', 'root_id', 'user_id', 'mount_point')
31+
->from('mounts');
32+
33+
$ids = [];
34+
$result = $selectQuery->executeQuery();
35+
while ($row = $result->fetch()) {
36+
// Hash user_id and mount_point to prevent accidental collisions.
37+
$key = $row['root_id'] . '-' . hash('xxh128', $row['user_id']) . '-' . hash('xxh128', $row['mount_point']);
38+
$ids[$key] ??= [];
39+
$ids[$key][] = $row['id'];
40+
}
41+
$result->closeCursor();
42+
43+
// Offset by 1 to retain one of the duplicate mounts
44+
$ids = array_merge(...array_values(array_map(static fn (array $ids) => array_slice($ids, 1), $ids)));
45+
46+
$deleteQuery = $this->connection->getQueryBuilder();
47+
$deleteQuery
48+
->delete('mounts')
49+
->where($deleteQuery->expr()->in('id', $deleteQuery->createParameter('ids')));
50+
51+
foreach (array_chunk($ids, 1000) as $chunk) {
52+
$deleteQuery->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
53+
$deleteQuery->executeStatement();
54+
}
55+
}
56+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace Test\Repair;
9+
10+
use OC\Repair\DeduplicateMounts;
11+
use OCP\DB\QueryBuilder\IQueryBuilder;
12+
use OCP\IDBConnection;
13+
use OCP\Migration\IOutput;
14+
use OCP\Server;
15+
use Test\TestCase;
16+
17+
/**
18+
* @group DB
19+
*
20+
* @see DeduplicateMounts
21+
*/
22+
class DeduplicateMountsTest extends TestCase {
23+
24+
private DeduplicateMounts $repair;
25+
private IDBConnection $connection;
26+
27+
protected function setUp(): void {
28+
parent::setUp();
29+
$this->connection = Server::get(IDBConnection::class);
30+
$this->deleteAllMounts();
31+
32+
$this->repair = new DeduplicateMounts($this->connection);
33+
}
34+
35+
protected function tearDown(): void {
36+
$this->deleteAllMounts();
37+
38+
parent::tearDown();
39+
}
40+
41+
protected function deleteAllMounts(): void {
42+
$this->connection->getQueryBuilder()->delete('mounts')->executeStatement();
43+
}
44+
45+
public function testDeduplicateMounts(): void {
46+
$rows = [
47+
// Original mount
48+
[
49+
'storage_id' => 1,
50+
'root_id' => 1,
51+
'user_id' => 'user1',
52+
'mount_point' => '/user1/files/1.txt/',
53+
],
54+
// Duplicate mount 1
55+
[
56+
'storage_id' => 2,
57+
'root_id' => 1,
58+
'user_id' => 'user1',
59+
'mount_point' => '/user1/files/1.txt/',
60+
],
61+
// Duplicate mount 2
62+
[
63+
'storage_id' => 3,
64+
'root_id' => 1,
65+
'user_id' => 'user1',
66+
'mount_point' => '/user1/files/1.txt/',
67+
],
68+
// Different root_id
69+
[
70+
'storage_id' => 4,
71+
'root_id' => 2,
72+
'user_id' => 'user1',
73+
'mount_point' => '/user1/files/1.txt/',
74+
],
75+
// Different user_id
76+
[
77+
'storage_id' => 5,
78+
'root_id' => 1,
79+
'user_id' => 'user2',
80+
'mount_point' => '/user1/files/1.txt/',
81+
],
82+
// Different mount_point
83+
[
84+
'storage_id' => 6,
85+
'root_id' => 1,
86+
'user_id' => 'user1',
87+
'mount_point' => '/user1/files/2.txt/',
88+
],
89+
];
90+
91+
$qb = $this->connection->getQueryBuilder();
92+
$qb->insert('mounts')
93+
->values([
94+
'storage_id' => $qb->createParameter('storage_id'),
95+
'root_id' => $qb->createParameter('root_id'),
96+
'user_id' => $qb->createParameter('user_id'),
97+
'mount_point' => $qb->createParameter('mount_point'),
98+
]);
99+
100+
foreach ($rows as $row) {
101+
$qb
102+
->setParameter('storage_id', $row['storage_id'], IQueryBuilder::PARAM_INT)
103+
->setParameter('root_id', $row['root_id'], IQueryBuilder::PARAM_INT)
104+
->setParameter('user_id', $row['user_id'], IQueryBuilder::PARAM_STR)
105+
->setParameter('mount_point', $row['mount_point'], IQueryBuilder::PARAM_STR)
106+
->executeStatement();
107+
}
108+
109+
$output = $this->createMock(IOutput::class);
110+
$this->repair->run($output);
111+
112+
$result = $this->connection->getQueryBuilder()
113+
->select('storage_id', 'root_id', 'user_id', 'mount_point')
114+
->from('mounts')
115+
->orderBy('storage_id', 'ASC')
116+
->executeQuery();
117+
118+
$this->assertEquals([
119+
[
120+
'storage_id' => 1,
121+
'root_id' => 1,
122+
'user_id' => 'user1',
123+
'mount_point' => '/user1/files/1.txt/',
124+
],
125+
// Duplicate mount 1 is removed
126+
// Duplicate mount 2 is removed
127+
[
128+
'storage_id' => 4,
129+
'root_id' => 2,
130+
'user_id' => 'user1',
131+
'mount_point' => '/user1/files/1.txt/',
132+
],
133+
[
134+
'storage_id' => 5,
135+
'root_id' => 1,
136+
'user_id' => 'user2',
137+
'mount_point' => '/user1/files/1.txt/',
138+
],
139+
[
140+
'storage_id' => 6,
141+
'root_id' => 1,
142+
'user_id' => 'user1',
143+
'mount_point' => '/user1/files/2.txt/',
144+
],
145+
], $result->fetchAll());
146+
147+
$result->closeCursor();
148+
}
149+
}

0 commit comments

Comments
 (0)