Backups to SMB no longer crawl; add occ backup:cancel

Each 30-second step opened the location again and read its list of
index files. Over SMB that cost a request per file, so on a large
repository opening took longer than the step: each step stored one
4 MiB pack and the list grew further. A first backup of nc01 to a
Storage Box over SMB managed 12 GiB in 35 hours.

occ backup:run and system cron now keep the repository open for the
whole run (reopened when another process took a step or the location
was edited), opening no longer counts towards the step's time, and
SMB folders are listed in one request.

occ backup:cancel stops a running backup: no new step starts and the
step in progress finishes first. A run of an earlier version, which
does not hold back its next step, is marked cancelled after two
minutes unless it is recording its snapshot.
This commit is contained in:
2026-10-14 12:15:27 +02:00
parent 92e693cfe6
commit dfd3f38d8c
9 changed files with 336 additions and 10 deletions
@@ -10,6 +10,8 @@ use OCA\NgBackup\Tests\Unit\TempDirTrait;
use OCP\Files\Storage\IStorage;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../../stubs/SmbStorage.php';
/** On a remote storage every is_dir() is a round trip: listing index/ must not cost one per pack. */
class StorageBackendListTest extends TestCase {
use TempDirTrait;
@@ -54,4 +56,58 @@ class StorageBackendListTest extends TestCase {
$this->expectException(BackendException::class);
(new StorageBackend($storage, 'repo'))->listFiles('catalog');
}
public function testSmbFoldersAreListedInOneRequest(): void {
$calls = new \ArrayObject();
$share = new class($calls) {
public function __construct(private \ArrayObject $calls) {
}
public function dir(string $path): array {
$this->calls[] = "dir $path";
return array_map(fn (string $n) => new class($n, $this->calls) {
public function __construct(private string $n, private \ArrayObject $calls) {
}
public function getName(): string {
return $this->n;
}
public function isHidden(): bool {
$this->calls[] = "attributes {$this->n}";
return $this->n === 'desktop.ini';
}
}, ['b1', 'a2', '.hidden', 'desktop.ini', 'c3.part-0123abcd']);
}
};
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
(fn () => $this->share = $share)->call($storage);
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->expects($this->never())->method('opendir');
$this->assertSame(['index/a2', 'index/b1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
$this->assertSame(['dir repo/index', 'attributes desktop.ini', 'attributes c3.part-0123abcd'], $calls->getArrayCopy(),
'only names a repository does not use cost a request');
}
public function testSmbWithAccessControlListsIsListedTheUsualWay(): void {
$dir = $this->tempDir();
touch("$dir/a1");
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
(fn () => $this->checkAcl = true)->call($storage);
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->expects($this->once())->method('opendir')->willReturnCallback(fn () => opendir($dir));
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
}
public function testSmbFallsBackToTheUsualListing(): void {
$dir = $this->tempDir();
touch("$dir/a1");
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock(); // no share
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->method('opendir')->willReturnCallback(fn () => opendir($dir));
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
}
}