From 9c06ab2841335cfc82923a81c91cbe31cd0eebea Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Mon, 12 Oct 2026 01:58:54 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 15 +- lib/Backend/SftpReadStream.php | 16 +- lib/Backend/StorageBackend.php | 131 +++++++++++++++- lib/Db/DbIndexCache.php | 79 +++++++++- lib/Repository/BlobIndex.php | 32 ++-- lib/Repository/IIndexCache.php | 14 ++ lib/Service/TargetService.php | 4 +- .../Backend/StorageBackendReconnectTest.php | 146 ++++++++++++++++++ tests/Unit/Repository/IndexCacheTest.php | 38 +++++ 9 files changed, 442 insertions(+), 33 deletions(-) create mode 100644 tests/Unit/Backend/StorageBackendReconnectTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cbbfe87..c25dbc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 - snapshots/…": after downloading the index it refreshed the local index cache with a pass over - all blobs for every pack, which took 29 minutes for 1 TB (27,547 packs, 400,000 blobs). The - location dropped the idle connection in the meantime. It is one pass now (under a second). + snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that + carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went + 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 diff --git a/lib/Backend/SftpReadStream.php b/lib/Backend/SftpReadStream.php index 751b900..cd91855 100644 --- a/lib/Backend/SftpReadStream.php +++ b/lib/Backend/SftpReadStream.php @@ -362,6 +362,8 @@ final class SftpReadStream { self::$failures++; self::$openBroke = true; 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; } @@ -514,6 +516,9 @@ final class SftpReadStream { } catch (\Throwable) { $this->broken = true; } + if ($this->broken) { + self::disconnect($this->sftp); + } } $this->handle = ''; } @@ -595,19 +600,20 @@ final class SftpReadStream { private function drain(): void { $inFlight = $this->inFlight; $this->inFlight = []; - if ($this->broken) { - return; - } try { - foreach ($inFlight as [$id]) { + foreach ($this->broken ? [] : $inFlight as [$id]) { if (!is_string($this->sftp->_get_sftp_packet($id))) { $this->broken = true; - return; + break; } } } catch (\Throwable) { $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 { diff --git a/lib/Backend/StorageBackend.php b/lib/Backend/StorageBackend.php index 0b4818e..2ad83c4 100644 --- a/lib/Backend/StorageBackend.php +++ b/lib/Backend/StorageBackend.php @@ -19,12 +19,23 @@ final class StorageBackend implements IBackend { private bool $atomicPut; /** SFTP: read through SftpReadStream, which keeps several requests in flight. */ 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( private IStorage $storage, private string $base = '', bool $createBase = true, + private ?\Closure $reconnect = null, ) { + $this->lastUsed = microtime(true); $this->base = trim($base, '/'); self::validate($this->base, true); $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 { + // not repeated on a new connection: the stream is (partly) read + $this->idleCheck(); $expected = self::remaining($stream); $target = $this->abs($path); $this->mkdirs(dirname($target)); @@ -83,6 +96,11 @@ final class StorageBackend implements IBackend { } public function get(string $path) { + return $this->call(fn () => $this->open($path)); + } + + /** @return resource */ + private function open(string $path) { if ($this->sftpReads) { $fh = SftpReadStream::open($this->storage, $this->abs($path)); if (is_resource($fh)) { @@ -104,7 +122,7 @@ final class StorageBackend implements IBackend { $fast = null; if ($this->sftpReads) { $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)); } foreach ($chunk as $path) { @@ -128,6 +146,10 @@ final class StorageBackend implements IBackend { } 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); try { if (fseek($fh, $offset) !== 0) { @@ -160,22 +182,30 @@ final class StorageBackend implements IBackend { } 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 { - $result = []; - $this->listInto($this->abs($prefix), $result); + $abs = $this->abs($prefix); + $result = $this->call(function () use ($abs): array { + $result = []; + $this->listInto($abs, $result); + return $result; + }, fn (array $found) => $found === []); sort($result); $cut = $this->base === '' ? 0 : strlen($this->base) + 1; return array_map(fn ($p) => substr($p, $cut), $result); } public function delete(string $path): void { + $this->idleCheck(); $this->storage->unlink($this->abs($path)); } 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); $this->mkdirs(dirname($target)); if (!$this->storage->rename($this->abs($from), $target)) { @@ -185,6 +215,11 @@ final class StorageBackend implements IBackend { public function listFiles(string $dir): array { $abs = $this->abs($dir); + return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []); + } + + /** @return list */ + private function filesIn(string $dir, string $abs): array { if (!$this->storage->is_dir($abs)) { return []; // not created yet } @@ -205,6 +240,94 @@ final class StorageBackend implements IBackend { 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 { if (!$this->storage->is_dir($dir)) { return; diff --git a/lib/Db/DbIndexCache.php b/lib/Db/DbIndexCache.php index 4aace2f..e70ccbb 100644 --- a/lib/Db/DbIndexCache.php +++ b/lib/Db/DbIndexCache.php @@ -30,19 +30,84 @@ final class DbIndexCache implements IIndexCache { } 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> $packs */ + private function insertAll(string $repositoryId, array $packs): void { + foreach ($packs as $packId => $entries) { + $this->insert($repositoryId, $packId, $entries); + } + } + + /** @param list $entries */ + private function insert(string $repositoryId, string $packId, array $entries): void { $qb = $this->db->getQueryBuilder(); $qb->insert('ngb_index')->values([ 'repository_id' => $qb->createNamedParameter($repositoryId), 'pack_id' => $qb->createNamedParameter($packId), 'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)), ]); - try { - $qb->executeStatement(); - } catch (Exception $e) { - if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { - throw $e; - } - } + $qb->executeStatement(); } public function remove(string $repositoryId, array $packIds): void { diff --git a/lib/Repository/BlobIndex.php b/lib/Repository/BlobIndex.php index ec05c55..632fa32 100644 --- a/lib/Repository/BlobIndex.php +++ b/lib/Repository/BlobIndex.php @@ -28,6 +28,8 @@ final class BlobIndex { /** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */ private ?array $heldCacheWrites = null; 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) */ public int $downloaded = 0; @@ -107,11 +109,19 @@ final class BlobIndex { // the cached packs listed before each one are added first. $next = 0; $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) { $packId = basename($path); $entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR); $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++) { $this->add($order[$next], $cached[$order[$next]] ?? []); } @@ -125,20 +135,16 @@ final class BlobIndex { $this->add($order[$next], $cached[$order[$next]] ?? []); } if ($verify && $this->cache !== null) { - // Refresh the cache from what was just verified. - // One pass over the blobs: a pass per pack took half an hour on a 1 TB repository - // (27,547 packs x 400,000 blobs), long enough for the location to drop the idle - // connection before the check went on. - $byPack = []; - 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); - } + // Refresh the cache from what was just verified, as the index files list it: a blob in + // two packs is in both rows, so that removing one of the packs leaves the other right. + // In one transaction: one row at a time took more than 6 minutes on a 1 TB repository + // (27,547 packs), long enough for the location to drop the connection. + $this->cache->replaceAll($this->repositoryId, $toCache); return; } + if ($toCache !== []) { + $this->cache?->putMany($this->repositoryId, $toCache); + } $stale = array_keys(array_diff_key($cached, $present)); if ($stale !== []) { $this->cache?->remove($this->repositoryId, $stale); diff --git a/lib/Repository/IIndexCache.php b/lib/Repository/IIndexCache.php index 1a7cff0..a665f7e 100644 --- a/lib/Repository/IIndexCache.php +++ b/lib/Repository/IIndexCache.php @@ -15,6 +15,20 @@ interface IIndexCache { /** @param list $entries */ public function put(string $repositoryId, string $packId, array $entries): void; + /** + * put() for many packs at once (one database transaction). + * + * @param array> $packs packId => entries + */ + public function putMany(string $repositoryId, array $packs): void; + + /** + * Replace everything cached for the repository with $packs (one database transaction). + * + * @param array> $packs packId => entries + */ + public function replaceAll(string $repositoryId, array $packs): void; + /** @param list $packIds */ public function remove(string $repositoryId, array $packIds): void; } diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index b3ecffa..b581301 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -17,6 +17,7 @@ use OCA\NgBackup\Db\TargetMapper; use OCA\NgBackup\Repository\Repository; use OCP\App\IAppManager; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Files\Storage\IStorage; use OCP\Security\ICrypto; 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 { - 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; diff --git a/tests/Unit/Backend/StorageBackendReconnectTest.php b/tests/Unit/Backend/StorageBackendReconnectTest.php new file mode 100644 index 0000000..161fa0a --- /dev/null +++ b/tests/Unit/Backend/StorageBackendReconnectTest.php @@ -0,0 +1,146 @@ + */ + 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; + } +} diff --git a/tests/Unit/Repository/IndexCacheTest.php b/tests/Unit/Repository/IndexCacheTest.php index 73ecdd3..3383d63 100644 --- a/tests/Unit/Repository/IndexCacheTest.php +++ b/tests/Unit/Repository/IndexCacheTest.php @@ -20,6 +20,8 @@ class IndexCacheTest extends TestCase { 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->data[$r] = []; $this->putMany($r, $packs); } }; $src = $this->tempDir(); for ($i = 0; $i < 12; $i++) { @@ -65,6 +67,8 @@ class IndexCacheTest extends TestCase { $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->data[$r] = []; $this->putMany($r, $packs); } }; $backend = new LocalBackend($this->tempDir()); $keys = KeyRing::generate(); @@ -88,4 +92,38 @@ class IndexCacheTest extends TestCase { $this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data))); $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]); + } }