diff --git a/CHANGELOG.md b/CHANGELOG.md index b93f283..cbbfe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ instead carried by the app description, the git tag, the release, and this file. overwriting the result. The lease now lasts at least 15 minutes, a step writes the run only if 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). ## [0.9.4-beta.5] - 2026-10-09 diff --git a/lib/Backend/SftpReadStream.php b/lib/Backend/SftpReadStream.php index 1c9ff13..751b900 100644 --- a/lib/Backend/SftpReadStream.php +++ b/lib/Backend/SftpReadStream.php @@ -67,6 +67,8 @@ final class SftpReadStream { public const MAX_FAILURES = 3; /** Set by stream_open() when the connection failed rather than the file. */ private static bool $openBroke = false; + /** Why the last stream_open() failed on the connection. */ + private static string $openCause = ''; /** @var resource|null set by PHP for stream wrappers */ public $context; @@ -135,7 +137,7 @@ final class SftpReadStream { return null; } if ($fh === false && self::openBroke()) { - throw new BackendException('SFTP connection failed while opening ' . $path); + throw new BackendException('SFTP connection failed while opening ' . $path . ': ' . self::$openCause); } return $fh; } @@ -339,14 +341,14 @@ final class SftpReadStream { } $id = self::nextId(); if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) { - return $this->connectionFailed(); + return $this->connectionFailed('the request could not be sent'); } $response = $this->sftp->_get_sftp_packet($id); - } catch (\Throwable) { - return $this->connectionFailed(); + } catch (\Throwable $e) { + return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage()); } if (!is_string($response)) { - return $this->connectionFailed(); + return $this->connectionFailed('no answer'); } if ($this->sftp->packet_type !== self::FXP_HANDLE) { return false; // an answer, but no handle: the file cannot be opened @@ -356,12 +358,37 @@ final class SftpReadStream { } /** Not a missing file but a connection that does not behave as expected. */ - private function connectionFailed(): bool { + private function connectionFailed(string $cause): bool { self::$failures++; self::$openBroke = true; + self::$openCause = $cause . self::connectionState($this->sftp); return false; } + /** What phpseclib knows about the connection, for an error message. */ + private static function connectionState(object $sftp): string { + $state = []; + try { + if (is_callable([$sftp, 'isConnected'])) { + $state[] = 'connected: ' . ($sftp->isConnected() ? 'yes' : 'no'); + } + if (is_callable([$sftp, 'getLastError'])) { + $last = (string)$sftp->getLastError(); + if ($last !== '') { + $state[] = 'ssh: ' . $last; + } + } + if (is_callable([$sftp, 'getLastSFTPError'])) { + $last = (string)$sftp->getLastSFTPError(); + if ($last !== '') { + $state[] = 'sftp: ' . $last; + } + } + } catch (\Throwable) { + } + return $state === [] ? '' : ' (' . implode('; ', $state) . ')'; + } + /** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */ private static function openBroke(): bool { return self::$openBroke; diff --git a/lib/Repository/BlobIndex.php b/lib/Repository/BlobIndex.php index a71faa6..ec05c55 100644 --- a/lib/Repository/BlobIndex.php +++ b/lib/Repository/BlobIndex.php @@ -126,14 +126,15 @@ final class BlobIndex { } 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 ($this->packs() as $packId => $_) { - $entries = []; - foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) { - if ($pack === $packId) { - $entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags]; - } - } + foreach ($byPack as $packId => $entries) { $this->cache->put($this->repositoryId, $packId, $entries); } return;