Skip to content

Commit 72d5248

Browse files
i2h3claude
andcommitted
fix: handle duplicate key on trash unique constraint during concurrent deletes
The unique constraint on oc_group_folders_trash (folder_id, name, deleted_time) uses second-granularity timestamps. When multiple files with the same base name inside the same group folder are deleted within the same second (e.g. by the desktop client issuing bulk DELETEs), the INSERT in TrashManager::addTrashItem() fails with a unique constraint violation. Because the file has already been physically moved to trash storage before the INSERT, the failed DB record leaves the file orphaned — resulting in data loss. Insert the trash record before moving the file, using a for-loop that retries with an incremented deleted_time on constraint collision. This way the on-disk trash name is constructed from the confirmed timestamp and no renames are needed. If the subsequent file move fails, the DB record is cleaned up. This follows the existing pattern in VersionsBackend.php for handling the same class of constraint violation. Fixes: #4014 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Iva Horn <iva.horn@nextcloud.com>
1 parent de5b16b commit 72d5248

File tree

2 files changed

+78
-10
lines changed

2 files changed

+78
-10
lines changed

lib/Trash/TrashBackend.php

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -260,21 +260,39 @@ public function moveToTrash(IStorage $storage, string $internalPath): bool {
260260

261261
$trashFolder = $this->setupTrashFolder($folder, $storage->getUser());
262262
$trashStorage = $trashFolder->getStorage();
263+
264+
$originalLocation = $internalPath;
265+
if ($storage->instanceOfStorage(ISharedStorage::class)) {
266+
/** @var Jail $jail */
267+
$jail = $storage->getWrapperStorage();
268+
$originalLocation = $jail->getUnjailedPath($originalLocation);
269+
}
270+
271+
$deletedBy = $this->userSession->getUser();
272+
273+
// Insert the trash record before moving the file so that the
274+
// on-disk name already matches the confirmed deleted_time.
275+
// The unique constraint on (folder_id, name, deleted_time) uses
276+
// second granularity, so concurrent deletes of same-named files
277+
// can collide. Retry with an incremented timestamp on conflict.
263278
$time = time();
279+
for ($retry = 0; $retry < 5; $retry++) {
280+
try {
281+
$this->trashManager->addTrashItem($folderId, $name, $time, $originalLocation, $fileEntry->getId(), $deletedBy?->getUID() ?? '');
282+
break;
283+
} catch (\OCP\DB\Exception $e) {
284+
if ($e->getReason() === \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
285+
$time++;
286+
} else {
287+
throw $e;
288+
}
289+
}
290+
}
291+
264292
$trashName = $name . '.d' . $time;
265293
$targetInternalPath = $trashFolder->getInternalPath() . '/' . $trashName;
266294
$result = $trashStorage->moveFromStorage($storage, $internalPath, $targetInternalPath);
267295
if ($result) {
268-
$originalLocation = $internalPath;
269-
if ($storage->instanceOfStorage(ISharedStorage::class)) {
270-
/** @var Jail $jail */
271-
$jail = $storage->getWrapperStorage();
272-
$originalLocation = $jail->getUnjailedPath($originalLocation);
273-
}
274-
275-
$deletedBy = $this->userSession->getUser();
276-
$this->trashManager->addTrashItem($folderId, $name, $time, $originalLocation, $fileEntry->getId(), $deletedBy?->getUID() ?? '');
277-
278296
// some storage backends (object/encryption) can either already move the cache item or cause the target to be scanned
279297
// so we only conditionally do the cache move here
280298
if (!$trashStorage->getCache()->inCache($targetInternalPath)) {
@@ -285,6 +303,8 @@ public function moveToTrash(IStorage $storage, string $internalPath): bool {
285303
$storage->getCache()->remove($internalPath);
286304
}
287305
} else {
306+
// Move failed — clean up the DB record to avoid an orphaned trash entry
307+
$this->trashManager->removeItem($folderId, $name, $time);
288308
throw new \Exception('Failed to move Team folder item to trash');
289309
}
290310

tests/Trash/TrashBackendTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use OCA\GroupFolders\Folder\FolderManager;
1919
use OCA\GroupFolders\Mount\GroupFolderStorage;
2020
use OCA\GroupFolders\Trash\TrashBackend;
21+
use OCA\GroupFolders\Trash\TrashManager;
2122
use OCP\Constants;
2223
use OCP\Files\Folder;
2324
use OCP\Files\IRootFolder;
@@ -36,6 +37,7 @@ class TrashBackendTest extends TestCase {
3637
private string $folderName;
3738
private TrashBackend $trashBackend;
3839
private FolderManager $folderManager;
40+
private TrashManager $trashManager;
3941
private RuleManager $ruleManager;
4042
private int $folderId;
4143
private Folder $managerUserFolder;
@@ -59,6 +61,7 @@ public function setUp(): void {
5961
$groupBackend->addToGroup('normal', 'gf_normal');
6062

6163
$this->trashBackend = \OCP\Server::get(TrashBackend::class);
64+
$this->trashManager = \OCP\Server::get(TrashManager::class);
6265
$this->folderManager = \OCP\Server::get(FolderManager::class);
6366
$this->ruleManager = \OCP\Server::get(RuleManager::class);
6467

@@ -284,4 +287,49 @@ public function testWrongOriginalLocation(): void {
284287
$this->trashBackend->restoreItem($trashedOfUserA[0]);
285288
$this->assertTrue($userAFolder->nodeExists('D/E/F/G'));
286289
}
290+
291+
public function testMoveToTrashSameNameDifferentSubfolders(): void {
292+
$this->loginAsUser('manager');
293+
294+
// Create two subfolders each containing a file with the same name
295+
$folderA = $this->managerUserFolder->newFolder("{$this->folderName}/SubA");
296+
$folderB = $this->managerUserFolder->newFolder("{$this->folderName}/SubB");
297+
$fileA = $folderA->newFile('Readme.md', 'content A');
298+
$fileB = $folderB->newFile('Readme.md', 'content B');
299+
300+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubA/Readme.md"));
301+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubB/Readme.md"));
302+
303+
// Delete both files — they share the same base name within the same
304+
// group folder which previously triggered a unique constraint violation
305+
// on (folder_id, name, deleted_time) when both deletes hit the same second.
306+
$this->trashBackend->moveToTrash($fileA->getStorage(), $fileA->getInternalPath());
307+
$this->trashBackend->moveToTrash($fileB->getStorage(), $fileB->getInternalPath());
308+
309+
$this->assertFalse($this->managerUserFolder->nodeExists("{$this->folderName}/SubA/Readme.md"));
310+
$this->assertFalse($this->managerUserFolder->nodeExists("{$this->folderName}/SubB/Readme.md"));
311+
312+
// Both files must appear in the trash
313+
$trashItems = $this->trashBackend->listTrashRoot($this->managerUser);
314+
$readmeItems = array_values(array_filter($trashItems, fn ($item) => $item->getName() === 'Readme.md'));
315+
$this->assertCount(2, $readmeItems, 'Both Readme.md files should be in the trash');
316+
317+
// The two trash entries must have distinct deleted_time values
318+
$times = array_map(fn ($item) => $item->getDeletedTime(), $readmeItems);
319+
$this->assertCount(2, array_unique($times), 'Trash entries should have distinct deleted_time values');
320+
321+
// Verify the DB records exist
322+
$dbRows = $this->trashManager->listTrashForFolders([$this->folderId]);
323+
$dbReadmeRows = array_values(array_filter($dbRows, fn ($row) => $row['name'] === 'Readme.md'));
324+
$this->assertCount(2, $dbReadmeRows, 'Both Readme.md entries should exist in oc_group_folders_trash');
325+
326+
// Restore both items and verify files come back
327+
foreach ($readmeItems as $item) {
328+
$this->trashBackend->restoreItem($item);
329+
}
330+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubA/Readme.md"));
331+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubB/Readme.md"));
332+
333+
$this->logout();
334+
}
287335
}

0 commit comments

Comments
 (0)