Merge branch 'fix/sftp-fresh-connection' into polish

This commit is contained in:
2026-10-11 17:16:33 +02:00
3 changed files with 45 additions and 13 deletions
+4
View File
@@ -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
+33 -6
View File
@@ -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;
+8 -7
View File
@@ -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;