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 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 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
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 ## [0.9.4-beta.5] - 2026-10-09
+33 -6
View File
@@ -67,6 +67,8 @@ final class SftpReadStream {
public const MAX_FAILURES = 3; public const MAX_FAILURES = 3;
/** Set by stream_open() when the connection failed rather than the file. */ /** Set by stream_open() when the connection failed rather than the file. */
private static bool $openBroke = false; 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 */ /** @var resource|null set by PHP for stream wrappers */
public $context; public $context;
@@ -135,7 +137,7 @@ final class SftpReadStream {
return null; return null;
} }
if ($fh === false && self::openBroke()) { 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; return $fh;
} }
@@ -339,14 +341,14 @@ final class SftpReadStream {
} }
$id = self::nextId(); $id = self::nextId();
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) { 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); $response = $this->sftp->_get_sftp_packet($id);
} catch (\Throwable) { } catch (\Throwable $e) {
return $this->connectionFailed(); return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
} }
if (!is_string($response)) { if (!is_string($response)) {
return $this->connectionFailed(); return $this->connectionFailed('no answer');
} }
if ($this->sftp->packet_type !== self::FXP_HANDLE) { if ($this->sftp->packet_type !== self::FXP_HANDLE) {
return false; // an answer, but no handle: the file cannot be opened 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. */ /** 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::$failures++;
self::$openBroke = true; self::$openBroke = true;
self::$openCause = $cause . self::connectionState($this->sftp);
return false; 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. */ /** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
private static function openBroke(): bool { private static function openBroke(): bool {
return self::$openBroke; return self::$openBroke;
+7 -6
View File
@@ -126,14 +126,15 @@ final class BlobIndex {
} }
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.
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId))); // One pass over the blobs: a pass per pack took half an hour on a 1 TB repository
foreach ($this->packs() as $packId => $_) { // (27,547 packs x 400,000 blobs), long enough for the location to drop the idle
$entries = []; // connection before the check went on.
$byPack = [];
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) { foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
if ($pack === $packId) { $byPack[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
$entries[] = ['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); $this->cache->put($this->repositoryId, $packId, $entries);
} }
return; return;