SFTP: replace a dropped connection; write the index cache in one transaction

A Hetzner Storage Box drops an SFTP connection that carried a lot of data
and then stayed quiet for a few minutes; files_external keeps using the dead
client, on which every file looks missing. StorageBackend now checks the
connection after a minute without requests, and replaces a connection that
is dead after a failed request (once per request; put, move and delete are
not repeated). SftpReadStream closes a connection it left in an unknown state.

The verify cache refresh took more than 6 minutes on PostgreSQL for 27,547
packs, one autocommitted insert per pack: putMany()/replaceAll() write in one
transaction. After a verify the cache holds the index files as they are, so a
blob in two packs stays cached for both.
This commit is contained in:
2026-10-12 01:58:54 +02:00
parent d659322e60
commit 9c06ab2841
9 changed files with 442 additions and 33 deletions
+12 -3
View File
@@ -49,9 +49,18 @@ instead carried by the app description, the git tag, the release, and this file.
no other process wrote it since the step loaded it (in one database statement), and recording no other process wrote it since the step loaded it (in one database statement), and recording
a snapshot that is already recorded is no longer an error. a snapshot that is already recorded is no longer an error.
- `occ backup:verify` on a large repository failed with "SFTP connection failed while opening - `occ backup:verify` on a large repository failed with "SFTP connection failed while opening
snapshots/…": after downloading the index it refreshed the local index cache with a pass over snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that
all blobs for every pack, which took 29 minutes for 1 TB (27,547 packs, 400,000 blobs). The carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went
location dropped the idle connection in the meantime. It is one pass now (under a second). quiet after downloading the index: it refreshed the local index cache, first in 29 minutes (a
pass over all blobs for every pack: 27,547 packs, 400,000 blobs for 1 TB), then still in more
than 6 minutes (a database transaction per pack on PostgreSQL). Nextcloud's SFTP storage keeps
using a dropped connection, on which every file looks missing. Now:
- an SFTP connection that was not used for a minute is checked first, and one that turns out
to be dead after a failed request is replaced, once per request; uploads, moves and deletes
are only checked first, never repeated;
- the index cache is written in one transaction (27,547 packs in 11 seconds on a test server, against about a minute row by row);
- after a check the cache holds the index files as they are: before, a blob that was in two
packs was only cached for one of them.
## [0.9.4-beta.5] - 2026-10-09 ## [0.9.4-beta.5] - 2026-10-09
+11 -5
View File
@@ -362,6 +362,8 @@ final class SftpReadStream {
self::$failures++; self::$failures++;
self::$openBroke = true; self::$openBroke = true;
self::$openCause = $cause . self::connectionState($this->sftp); self::$openCause = $cause . self::connectionState($this->sftp);
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
return false; return false;
} }
@@ -514,6 +516,9 @@ final class SftpReadStream {
} catch (\Throwable) { } catch (\Throwable) {
$this->broken = true; $this->broken = true;
} }
if ($this->broken) {
self::disconnect($this->sftp);
}
} }
$this->handle = ''; $this->handle = '';
} }
@@ -595,19 +600,20 @@ final class SftpReadStream {
private function drain(): void { private function drain(): void {
$inFlight = $this->inFlight; $inFlight = $this->inFlight;
$this->inFlight = []; $this->inFlight = [];
if ($this->broken) {
return;
}
try { try {
foreach ($inFlight as [$id]) { foreach ($this->broken ? [] : $inFlight as [$id]) {
if (!is_string($this->sftp->_get_sftp_packet($id))) { if (!is_string($this->sftp->_get_sftp_packet($id))) {
$this->broken = true; $this->broken = true;
return; break;
} }
} }
} catch (\Throwable) { } catch (\Throwable) {
$this->broken = true; $this->broken = true;
} }
if ($this->broken) {
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
}
} }
private static function chunk(object $sftp): int { private static function chunk(object $sftp): int {
+126 -3
View File
@@ -19,12 +19,23 @@ final class StorageBackend implements IBackend {
private bool $atomicPut; private bool $atomicPut;
/** SFTP: read through SftpReadStream, which keeps several requests in flight. */ /** SFTP: read through SftpReadStream, which keeps several requests in flight. */
private bool $sftpReads; private bool $sftpReads;
/** Seconds without a request after which an SFTP connection is checked before it is used. */
public const IDLE_CHECK = 60;
private float $lastUsed;
/** Times the storage was built anew after its connection died (for tests and diagnostics). */
public int $reconnects = 0;
/**
* @param (\Closure():IStorage)|null $reconnect builds the same storage anew, with a new
* connection
*/
public function __construct( public function __construct(
private IStorage $storage, private IStorage $storage,
private string $base = '', private string $base = '',
bool $createBase = true, bool $createBase = true,
private ?\Closure $reconnect = null,
) { ) {
$this->lastUsed = microtime(true);
$this->base = trim($base, '/'); $this->base = trim($base, '/');
self::validate($this->base, true); self::validate($this->base, true);
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class); $this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
@@ -40,6 +51,8 @@ final class StorageBackend implements IBackend {
} }
public function put(string $path, $stream): void { public function put(string $path, $stream): void {
// not repeated on a new connection: the stream is (partly) read
$this->idleCheck();
$expected = self::remaining($stream); $expected = self::remaining($stream);
$target = $this->abs($path); $target = $this->abs($path);
$this->mkdirs(dirname($target)); $this->mkdirs(dirname($target));
@@ -83,6 +96,11 @@ final class StorageBackend implements IBackend {
} }
public function get(string $path) { public function get(string $path) {
return $this->call(fn () => $this->open($path));
}
/** @return resource */
private function open(string $path) {
if ($this->sftpReads) { if ($this->sftpReads) {
$fh = SftpReadStream::open($this->storage, $this->abs($path)); $fh = SftpReadStream::open($this->storage, $this->abs($path));
if (is_resource($fh)) { if (is_resource($fh)) {
@@ -104,7 +122,7 @@ final class StorageBackend implements IBackend {
$fast = null; $fast = null;
if ($this->sftpReads) { if ($this->sftpReads) {
$abs = array_map(fn (string $p): string => $this->abs($p), $chunk); $abs = array_map(fn (string $p): string => $this->abs($p), $chunk);
$fast = SftpReadStream::readMany($this->storage, $abs); $fast = $this->call(fn () => SftpReadStream::readMany($this->storage, $abs));
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs)); $fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
} }
foreach ($chunk as $path) { foreach ($chunk as $path) {
@@ -128,6 +146,10 @@ final class StorageBackend implements IBackend {
} }
public function getRange(string $path, int $offset, int $length): string { public function getRange(string $path, int $offset, int $length): string {
return $this->call(fn () => $this->readRange($path, $offset, $length));
}
private function readRange(string $path, int $offset, int $length): string {
$fh = $this->get($path); $fh = $this->get($path);
try { try {
if (fseek($fh, $offset) !== 0) { if (fseek($fh, $offset) !== 0) {
@@ -160,22 +182,30 @@ final class StorageBackend implements IBackend {
} }
public function exists(string $path): bool { public function exists(string $path): bool {
return $this->storage->file_exists($this->abs($path)); $abs = $this->abs($path);
return $this->call(fn () => $this->storage->file_exists($abs), fn (bool $found) => !$found);
} }
public function list(string $prefix): array { public function list(string $prefix): array {
$abs = $this->abs($prefix);
$result = $this->call(function () use ($abs): array {
$result = []; $result = [];
$this->listInto($this->abs($prefix), $result); $this->listInto($abs, $result);
return $result;
}, fn (array $found) => $found === []);
sort($result); sort($result);
$cut = $this->base === '' ? 0 : strlen($this->base) + 1; $cut = $this->base === '' ? 0 : strlen($this->base) + 1;
return array_map(fn ($p) => substr($p, $cut), $result); return array_map(fn ($p) => substr($p, $cut), $result);
} }
public function delete(string $path): void { public function delete(string $path): void {
$this->idleCheck();
$this->storage->unlink($this->abs($path)); $this->storage->unlink($this->abs($path));
} }
public function move(string $from, string $to): void { public function move(string $from, string $to): void {
// not repeated on a new connection: the server may have done it before the connection died
$this->idleCheck();
$target = $this->abs($to); $target = $this->abs($to);
$this->mkdirs(dirname($target)); $this->mkdirs(dirname($target));
if (!$this->storage->rename($this->abs($from), $target)) { if (!$this->storage->rename($this->abs($from), $target)) {
@@ -185,6 +215,11 @@ final class StorageBackend implements IBackend {
public function listFiles(string $dir): array { public function listFiles(string $dir): array {
$abs = $this->abs($dir); $abs = $this->abs($dir);
return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []);
}
/** @return list<string> */
private function filesIn(string $dir, string $abs): array {
if (!$this->storage->is_dir($abs)) { if (!$this->storage->is_dir($abs)) {
return []; // not created yet return []; // not created yet
} }
@@ -205,6 +240,94 @@ final class StorageBackend implements IBackend {
return $result; return $result;
} }
/**
* Run $op; when it throws, or $failed says its result may come from a dead connection, and
* the SFTP connection turns out to be dead, run it once more on a new connection.
*
* @template T
* @param \Closure():T $op
* @param (\Closure(T):bool)|null $failed
* @return T
*/
private function call(\Closure $op, ?\Closure $failed = null): mixed {
$this->idleCheck();
try {
$result = $op();
} catch (\Throwable $e) {
if (!$this->reopenIfDead()) {
throw $e;
}
return $op();
}
if ($failed !== null && $failed($result) && $this->reopenIfDead()) {
return $op();
}
return $result;
}
/**
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
* quiet for a few minutes, while phpseclib still takes it for open; files_external never
* builds a new one, and every request on it fails as if the file were missing. So after
* such a pause the connection is checked (one round trip) before it is used again.
*/
private function idleCheck(): void {
$now = microtime(true);
if ($this->reconnect !== null && $this->sftpReads && $now - $this->lastUsed >= self::IDLE_CHECK
&& $this->alive(true) === false) {
$this->reopen();
}
$this->lastUsed = microtime(true);
}
private function reopenIfDead(): bool {
if ($this->reconnect === null || !$this->sftpReads || $this->alive(false) !== false) {
return false;
}
$this->reopen();
return true;
}
/**
* Whether the storage's SFTP connection is alive; null when it cannot tell.
* $ask: send a request instead of trusting what phpseclib saw last.
*/
private function alive(bool $ask): ?bool {
try {
if (!is_callable([$this->storage, 'getConnection'])) {
return null;
}
$sftp = $this->storage->getConnection();
if (!is_object($sftp) || !is_callable([$sftp, 'isConnected'])) {
return null;
}
if (!$sftp->isConnected()) {
return false;
}
if (!$ask || !is_callable([$sftp, 'stat'])) {
return true;
}
if (is_callable([$sftp, 'clearStatCache'])) {
$sftp->clearStatCache(); // or stat() answers from memory
}
$sftp->stat('.');
return (bool)$sftp->isConnected();
} catch (\Throwable) {
return false;
}
}
private function reopen(): void {
/** @var \Closure():IStorage $reconnect */
$reconnect = $this->reconnect;
$this->storage = $reconnect();
$this->reconnects++;
$this->lastUsed = microtime(true);
if (function_exists('OCP\Log\logger')) {
\OCP\Log\logger('ng_backup')->info('The SFTP connection to the backup location was lost; reconnected');
}
}
private function listInto(string $dir, array &$result): void { private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) { if (!$this->storage->is_dir($dir)) {
return; return;
+71 -6
View File
@@ -30,19 +30,84 @@ final class DbIndexCache implements IIndexCache {
} }
public function put(string $repositoryId, string $packId, array $entries): void { public function put(string $repositoryId, string $packId, array $entries): void {
try {
$this->insert($repositoryId, $packId, $entries);
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
/**
* In one transaction: on PostgreSQL with its default settings every statement of its own
* waits for a disk sync, which made 27,547 rows take more than 6 minutes. Rows already there
* are replaced. Should the transaction fail (another process writing the same rows), the
* rows are written one by one, keeping rows that are already there.
*/
public function putMany(string $repositoryId, array $packs): void {
if ($packs === []) {
return;
}
try {
$this->transaction(function () use ($repositoryId, $packs): void {
$this->remove($repositoryId, array_keys($packs));
$this->insertAll($repositoryId, $packs);
});
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
throw $e; // a transaction of the caller's, which may be spoilt now
}
foreach ($packs as $packId => $entries) {
$this->put($repositoryId, $packId, $entries);
}
}
}
/** All or nothing: when it fails, the cache stays as it was. */
public function replaceAll(string $repositoryId, array $packs): void {
$this->transaction(function () use ($repositoryId, $packs): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
$qb->executeStatement();
$this->insertAll($repositoryId, $packs);
});
}
/** Run $write in a transaction of its own, or as part of the caller's when there is one. */
private function transaction(\Closure $write): void {
if ($this->db->inTransaction()) {
$write();
return;
}
$this->db->beginTransaction();
try {
$write();
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
/** @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs */
private function insertAll(string $repositoryId, array $packs): void {
foreach ($packs as $packId => $entries) {
$this->insert($repositoryId, $packId, $entries);
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function insert(string $repositoryId, string $packId, array $entries): void {
$qb = $this->db->getQueryBuilder(); $qb = $this->db->getQueryBuilder();
$qb->insert('ngb_index')->values([ $qb->insert('ngb_index')->values([
'repository_id' => $qb->createNamedParameter($repositoryId), 'repository_id' => $qb->createNamedParameter($repositoryId),
'pack_id' => $qb->createNamedParameter($packId), 'pack_id' => $qb->createNamedParameter($packId),
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)), 'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
]); ]);
try {
$qb->executeStatement(); $qb->executeStatement();
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
} }
public function remove(string $repositoryId, array $packIds): void { public function remove(string $repositoryId, array $packIds): void {
+19 -13
View File
@@ -28,6 +28,8 @@ final class BlobIndex {
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */ /** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
private ?array $heldCacheWrites = null; private ?array $heldCacheWrites = null;
private string $repositoryId = ''; private string $repositoryId = '';
/** Index files written to the cache per database transaction while loading. */
private const CACHE_LOT = 256;
/** @var int index files downloaded by the last load() (for tests and diagnostics) */ /** @var int index files downloaded by the last load() (for tests and diagnostics) */
public int $downloaded = 0; public int $downloaded = 0;
@@ -107,11 +109,19 @@ final class BlobIndex {
// the cached packs listed before each one are added first. // the cached packs listed before each one are added first.
$next = 0; $next = 0;
$total = count($fetch); $total = count($fetch);
$toCache = []; // written to the cache a few hundred at a time, each lot in one transaction
foreach ($this->backend->getMany($fetch) as $path => $data) { foreach ($this->backend->getMany($fetch) as $path => $data) {
$packId = basename($path); $packId = basename($path);
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR); $entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
$this->downloaded++; $this->downloaded++;
$this->cache?->put($this->repositoryId, $packId, $entries); if ($this->cache !== null) {
$toCache[$packId] = $entries;
// a check writes the whole cache afresh at the end
if (!$verify && count($toCache) >= self::CACHE_LOT) {
$this->cache->putMany($this->repositoryId, $toCache);
$toCache = [];
}
}
for (; $next < count($order) && $order[$next] !== $packId; $next++) { for (; $next < count($order) && $order[$next] !== $packId; $next++) {
$this->add($order[$next], $cached[$order[$next]] ?? []); $this->add($order[$next], $cached[$order[$next]] ?? []);
} }
@@ -125,20 +135,16 @@ final class BlobIndex {
$this->add($order[$next], $cached[$order[$next]] ?? []); $this->add($order[$next], $cached[$order[$next]] ?? []);
} }
if ($verify && $this->cache !== null) { if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified. // Refresh the cache from what was just verified, as the index files list it: a blob in
// One pass over the blobs: a pass per pack took half an hour on a 1 TB repository // two packs is in both rows, so that removing one of the packs leaves the other right.
// (27,547 packs x 400,000 blobs), long enough for the location to drop the idle // In one transaction: one row at a time took more than 6 minutes on a 1 TB repository
// connection before the check went on. // (27,547 packs), long enough for the location to drop the connection.
$byPack = []; $this->cache->replaceAll($this->repositoryId, $toCache);
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
$byPack[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
}
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
foreach ($byPack as $packId => $entries) {
$this->cache->put($this->repositoryId, $packId, $entries);
}
return; return;
} }
if ($toCache !== []) {
$this->cache?->putMany($this->repositoryId, $toCache);
}
$stale = array_keys(array_diff_key($cached, $present)); $stale = array_keys(array_diff_key($cached, $present));
if ($stale !== []) { if ($stale !== []) {
$this->cache?->remove($this->repositoryId, $stale); $this->cache?->remove($this->repositoryId, $stale);
+14
View File
@@ -15,6 +15,20 @@ interface IIndexCache {
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */ /** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
public function put(string $repositoryId, string $packId, array $entries): void; public function put(string $repositoryId, string $packId, array $entries): void;
/**
* put() for many packs at once (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function putMany(string $repositoryId, array $packs): void;
/**
* Replace everything cached for the repository with $packs (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function replaceAll(string $repositoryId, array $packs): void;
/** @param list<string> $packIds */ /** @param list<string> $packIds */
public function remove(string $repositoryId, array $packIds): void; public function remove(string $repositoryId, array $packIds): void;
} }
+3 -1
View File
@@ -17,6 +17,7 @@ use OCA\NgBackup\Db\TargetMapper;
use OCA\NgBackup\Repository\Repository; use OCA\NgBackup\Repository\Repository;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Files\Storage\IStorage;
use OCP\Security\ICrypto; use OCP\Security\ICrypto;
use OCP\Server; use OCP\Server;
@@ -279,7 +280,8 @@ final class TargetService {
} }
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend { private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath, $createBase); $create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
return new StorageBackend($create(), $basePath, $createBase, $create);
} }
private ?ExternalStorageFactory $factory = null; private ?ExternalStorageFactory $factory = null;
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Backend;
use OCA\NgBackup\Backend\BackendException;
use OCA\NgBackup\Backend\StorageBackend;
use OCP\Files\Storage\IStorage;
use PHPUnit\Framework\TestCase;
/**
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
* quiet for a few minutes; files_external keeps using the dead client, on which every file
* looks missing. The backend builds the storage anew instead.
*/
class StorageBackendReconnectTest extends TestCase {
/** @var list<ReconnectFakeSftp> */
private array $connections = [];
/** New connections come up dead (the location is gone). */
private bool $deadOnArrival = false;
private function storage(): IStorage {
$sftp = new ReconnectFakeSftp();
$sftp->dropped = $this->deadOnArrival;
$this->connections[] = $sftp;
$storage = $this->createMock(SftpLikeStorage::class);
$storage->method('instanceOfStorage')->willReturnCallback(fn (string $class): bool => $class === \OCA\Files_External\Lib\Storage\SFTP::class);
$storage->method('getConnection')->willReturn($sftp);
$storage->method('is_dir')->willReturn(true);
$storage->method('file_exists')->willReturnCallback(fn (string $path): bool => $sftp->request() && $path === 'repo/config');
$storage->method('fopen')->willReturnCallback(function (string $path) use ($sftp) {
if (!$sftp->request() || $path !== 'repo/config') {
return false;
}
$fh = fopen('php://memory', 'w+b');
fwrite($fh, 'config');
rewind($fh);
return $fh;
});
return $storage;
}
private function backend(bool $reconnect = true): StorageBackend {
return new StorageBackend($this->storage(), 'repo', false, $reconnect ? fn (): IStorage => $this->storage() : null);
}
private static function idle(StorageBackend $backend): void {
(new \ReflectionProperty($backend, 'lastUsed'))->setValue($backend, microtime(true) - StorageBackend::IDLE_CHECK - 1);
}
public function testADeadConnectionIsReplacedAfterAPause(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true;
self::idle($backend);
$this->assertTrue($backend->exists('config'));
$this->assertSame(1, $backend->reconnects);
$this->assertSame(1, $this->connections[0]->requests, 'only the check went over the old connection');
}
public function testAPauseOnALiveConnectionCostsOneCheck(): void {
$backend = $this->backend();
self::idle($backend);
$this->assertTrue($backend->exists('config'));
$this->assertSame(0, $backend->reconnects);
$this->assertSame(2, $this->connections[0]->requests);
$this->assertTrue($backend->exists('config'));
$this->assertSame(3, $this->connections[0]->requests, 'no check without a pause');
}
public function testAFailureOnAConnectionThatJustDiedIsRepeated(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true; // without a pause: not checked first
$this->assertTrue($backend->exists('config'));
$fh = $backend->get('config');
$this->assertSame('config', stream_get_contents($fh));
$this->assertSame(1, $backend->reconnects);
}
public function testAMissingFileOnALiveConnectionIsJustMissing(): void {
$backend = $this->backend();
$this->assertFalse($backend->exists('nothing'));
try {
$backend->get('nothing');
$this->fail('missing');
} catch (BackendException $e) {
$this->assertStringContainsString('Not found', $e->getMessage());
}
$this->assertSame(0, $backend->reconnects);
$this->assertCount(1, $this->connections);
}
public function testANewConnectionThatFailsToo(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true;
$backend->exists('config'); // reconnects
$this->connections[1]->dropped = true;
$this->deadOnArrival = true;
self::idle($backend);
try {
$backend->get('config');
$this->fail('no connection');
} catch (BackendException) {
}
$this->assertSame(3, $backend->reconnects, 'once after the pause, once after the failure, no more');
}
public function testWithoutAWayToReconnectNothingChanges(): void {
$backend = $this->backend(false);
$this->connections[0]->dropped = true;
self::idle($backend);
$this->assertFalse($backend->exists('config'));
$this->assertCount(1, $this->connections);
}
}
interface SftpLikeStorage extends IStorage {
public function getConnection(): object;
}
/** The public phpseclib calls the backend uses to tell a dead connection. */
final class ReconnectFakeSftp {
public bool $dropped = false;
private bool $connected = true;
public int $requests = 0;
public function isConnected(): bool {
return $this->connected;
}
public function clearStatCache(): void {
}
public function stat(string $path): array|false {
return $this->request() ? ['type' => 2] : false;
}
/** A request: false (and from then on disconnected) when the server has dropped the connection. */
public function request(): bool {
$this->requests++;
if ($this->dropped) {
$this->connected = false;
}
return $this->connected;
}
}
+38
View File
@@ -20,6 +20,8 @@ class IndexCacheTest extends TestCase {
public function all(string $r): array { return $this->data[$r] ?? []; } public function all(string $r): array { return $this->data[$r] ?? []; }
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; } public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } } public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
}; };
$src = $this->tempDir(); $src = $this->tempDir();
for ($i = 0; $i < 12; $i++) { for ($i = 0; $i < 12; $i++) {
@@ -65,6 +67,8 @@ class IndexCacheTest extends TestCase {
$this->data[$r][$p] = $e; $this->data[$r][$p] = $e;
} }
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } } public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
}; };
$backend = new LocalBackend($this->tempDir()); $backend = new LocalBackend($this->tempDir());
$keys = KeyRing::generate(); $keys = KeyRing::generate();
@@ -88,4 +92,38 @@ class IndexCacheTest extends TestCase {
$this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data))); $this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data)));
$this->assertSame(0, Repository::openWithKey($backend, $keys, $cache)->indexDownloads()); $this->assertSame(0, Repository::openWithKey($backend, $keys, $cache)->indexDownloads());
} }
/** After a check the cache holds every index file as it is, also a blob that is in two packs. */
public function testACheckCachesTheIndexFilesAsTheyAre(): void {
$cache = new class implements IIndexCache {
public array $data = [];
public int $replaced = 0;
public function all(string $r): array { return $this->data[$r] ?? []; }
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->replaced++; $this->data[$r] = $packs; }
};
$backend = new LocalBackend($this->tempDir());
$cipher = new \OCA\NgBackup\Crypto\StreamCipher(KeyRing::generate());
$index = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
$index->setCache($cache, 'r');
$blob = static fn (string $id, int $offset): array => ['id' => $id, 'offset' => $offset, 'length' => 10, 'raw' => 8, 'flags' => 0];
$index->addPack('pack1', [$blob('a', 0), $blob('shared', 10)]);
$index->addPack('pack2', [$blob('shared', 0), $blob('b', 10)]);
$cache->data = ['r' => ['gone' => [$blob('x', 0)]]];
$index->load(true);
$this->assertSame(1, $cache->replaced);
$this->assertSame(['pack1', 'pack2'], array_keys($cache->data['r']));
$this->assertCount(2, $cache->data['r']['pack1'], 'the shared blob stays in the row of its first pack');
// the second pack goes: the shared blob is still found in the first
$index->removePack('pack2');
$again = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
$again->setCache($cache, 'r');
$again->load();
$this->assertSame(0, $again->downloaded);
$this->assertSame('pack1', $again->get('shared')[0]);
}
} }