Skip to content

Commit 9ad0bb5

Browse files
committed
chore(cypress): Try to analyze failures
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
1 parent 214b0fe commit 9ad0bb5

5 files changed

Lines changed: 101 additions & 20 deletions

File tree

.github/workflows/integration-sqlite.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ jobs:
138138
env:
139139
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
140140

141+
- name: Print phpinfo
142+
run: php -i
143+
141144
- name: Set up dependencies
142145
run: |
143146
composer install

apps/files_versions/lib/Storage.php

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -426,20 +426,6 @@ private static function copyFileContents($view, $path1, $path2) {
426426
try {
427427
$view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
428428
} catch (\Throwable $e) {
429-
// DIAGNOSTIC: dump all currently-held file locks to identify the
430-
// concurrent holder blocking the restore (lock: -1 exclusive, >0 shared).
431-
try {
432-
$db = \OCP\Server::get(\OCP\IDBConnection::class);
433-
$q = $db->getQueryBuilder();
434-
$q->select('key', 'lock')->from('file_locks')
435-
->where($q->expr()->neq('lock', $q->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
436-
$held = $q->executeQuery()->fetchAll();
437-
\OCP\Server::get(\Psr\Log\LoggerInterface::class)->error(
438-
'[restore-lock-diag] target=' . $internalPath2 . ' heldLocks=' . json_encode($held),
439-
['app' => 'files_versions'],
440-
);
441-
} catch (\Throwable $ignore) {
442-
}
443429
// Acquiring the second lock can fail (e.g. the target file is
444430
// transiently locked by a concurrent job under load). Release the
445431
// first lock we already hold so a retry does not collide with a

apps/files_versions/lib/Versions/VersionManager.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed
3434

3535
public function __construct(
3636
private IEventDispatcher $dispatcher,
37+
private int $lockRetryBudgetMs = 10000,
38+
private int $lockRetryInitialBackoffMs = 100,
3739
) {
3840
}
3941

@@ -102,7 +104,7 @@ public function createVersion(IUser $user, FileInfo $file) {
102104
#[\Override]
103105
public function rollback(IVersion $version) {
104106
$backend = $version->getBackend();
105-
$result = self::handleAppLocks(fn (): ?bool => self::retryOnLock(fn (): ?bool => $backend->rollback($version)));
107+
$result = self::handleAppLocks(fn (): ?bool => $this->retryOnLock(fn (): ?bool => $backend->rollback($version)));
106108
// rollback doesn't have a return type yet and some implementations don't return anything
107109
if ($result === null || $result === true) {
108110
$this->dispatcher->dispatchTyped(new VersionRestoredEvent($version));
@@ -197,16 +199,14 @@ public function setMetadataValue(Node $node, int $revision, string $key, string
197199
* @return bool|null
198200
* @throws LockedException if the file stays locked for the whole budget
199201
*/
200-
private static function retryOnLock(callable $callback): ?bool {
201-
// Total time we are willing to wait for a concurrent lock to clear.
202-
$budgetMs = 15000;
202+
private function retryOnLock(callable $callback): ?bool {
203203
$waitedMs = 0;
204-
$backoffMs = 100;
204+
$backoffMs = $this->lockRetryInitialBackoffMs;
205205
for ($attempt = 1; ; $attempt++) {
206206
try {
207207
return $callback();
208208
} catch (LockedException $e) {
209-
if ($waitedMs >= $budgetMs) {
209+
if ($waitedMs >= $this->lockRetryBudgetMs) {
210210
throw $e;
211211
}
212212
Server::get(LoggerInterface::class)->debug(

apps/files_versions/tests/StorageTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88

99
namespace OCA\files_versions\tests;
1010

11+
use OC\Files\View;
1112
use OCA\Files_Versions\Expiration;
1213
use OCA\Files_Versions\Storage;
1314
use OCP\Files\IRootFolder;
1415
use OCP\Files\NotFoundException;
16+
use OCP\Lock\ILockingProvider;
17+
use OCP\Lock\LockedException;
1518
use OCP\Server;
1619
use Test\TestCase;
1720
use Test\Traits\UserTrait;
@@ -95,4 +98,42 @@ public function testExpireMaxAge(): void {
9598
$this->assertCount(0, Storage::getVersions('version_test', 'folder1/sub1/file3'));
9699
$this->assertCount(1, Storage::getVersions('version_test', 'folder2/file4'));
97100
}
101+
102+
/**
103+
* Regression test: when copyFileContents() fails to acquire the lock on the
104+
* target file, the lock already held on the source file must be released
105+
* before the exception propagates. Otherwise the source lock leaks and a
106+
* retry (or any later access) collides with a lock nobody owns anymore.
107+
*/
108+
public function testCopyFileContentsReleasesSourceLockWhenTargetLockFails(): void {
109+
$source = 'files/source.txt';
110+
$target = 'files/target.txt';
111+
$lockedException = new LockedException($target);
112+
113+
$storage = $this->createMock(\OC\Files\Storage\Storage::class);
114+
115+
$view = $this->createMock(View::class);
116+
$view->method('resolvePath')->willReturn([$storage, 'internal']);
117+
118+
// The source lock is acquired first and succeeds; acquiring the target
119+
// lock then throws (e.g. a concurrent holder under load).
120+
$view->expects($this->exactly(2))
121+
->method('lockFile')
122+
->willReturnCallback(function (string $path) use ($target, $lockedException): void {
123+
if ($path === $target) {
124+
throw $lockedException;
125+
}
126+
});
127+
128+
// The already-held source lock must be released exactly once, and the
129+
// never-acquired target lock must not be touched.
130+
$view->expects($this->once())
131+
->method('unlockFile')
132+
->with($source, ILockingProvider::LOCK_EXCLUSIVE);
133+
134+
$this->expectExceptionObject($lockedException);
135+
136+
$method = new \ReflectionMethod(Storage::class, 'copyFileContents');
137+
$method->invoke(null, $view, $source, $target);
138+
}
98139
}

apps/files_versions/tests/Versions/VersionManagerTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OCA\Files_Versions\Versions\VersionManager;
1717
use OCP\EventDispatcher\IEventDispatcher;
1818
use OCP\Files\Storage\IStorage;
19+
use OCP\Lock\LockedException;
1920
use PHPUnit\Framework\MockObject\MockObject;
2021
use Test\TestCase;
2122

@@ -141,4 +142,54 @@ public function testRollbackFailure(): void {
141142

142143
$this->assertFalse($manager->rollback($versionMock));
143144
}
145+
146+
public function testRollbackRetriesWhileFileIsTransientlyLocked(): void {
147+
$versionMock = $this->createMock(IVersion::class);
148+
$backendMock = $this->createMock(IVersionBackend::class);
149+
$versionMock->method('getBackend')->willReturn($backendMock);
150+
151+
// The live file is locked for the first two attempts (e.g. a concurrent
152+
// read holds a shared lock) and then the lock clears.
153+
$attempts = 0;
154+
$backendMock->expects($this->exactly(3))
155+
->method('rollback')
156+
->with($versionMock)
157+
->willReturnCallback(function () use (&$attempts): bool {
158+
$attempts++;
159+
if ($attempts < 3) {
160+
throw new LockedException('files/foo.txt');
161+
}
162+
return true;
163+
});
164+
165+
$dispatcherMock = $this->createMock(IEventDispatcher::class);
166+
$dispatcherMock->expects($this->once())
167+
->method('dispatchTyped')
168+
->with($this->isInstanceOf(VersionRestoredEvent::class));
169+
170+
// Tiny retry budget/backoff so the test exercises the retry without waiting.
171+
$manager = new VersionManager($dispatcherMock, 1000, 1);
172+
173+
$this->assertTrue($manager->rollback($versionMock));
174+
$this->assertSame(3, $attempts);
175+
}
176+
177+
public function testRollbackGivesUpAfterRetryBudgetExhausted(): void {
178+
$versionMock = $this->createMock(IVersion::class);
179+
$backendMock = $this->createMock(IVersionBackend::class);
180+
$versionMock->method('getBackend')->willReturn($backendMock);
181+
182+
// The live file stays locked for the whole retry budget.
183+
$backendMock->method('rollback')
184+
->with($versionMock)
185+
->willThrowException(new LockedException('files/foo.txt'));
186+
187+
$dispatcherMock = $this->createMock(IEventDispatcher::class);
188+
$dispatcherMock->expects($this->never())->method('dispatchTyped');
189+
190+
$manager = new VersionManager($dispatcherMock, 5, 1);
191+
192+
$this->expectException(LockedException::class);
193+
$manager->rollback($versionMock);
194+
}
144195
}

0 commit comments

Comments
 (0)