SFTP: open the channel before the first fast read; backup step: survive a takeover by another process
This commit is contained in:
@@ -7,6 +7,20 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
|
||||
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
||||
instead carried by the app description, the git tag, the release, and this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- 0.9.4 could not open a repository at the root of an SFTP location (empty path): its faster
|
||||
SFTP reads sent their first request before phpseclib had opened the SFTP channel, which only
|
||||
happens on the first SFTP call after login. Every backup then failed with "No repository at
|
||||
this location". The channel is now opened first.
|
||||
- A long first backup could end as failed although its snapshot was complete: when its final
|
||||
step took longer than the step's lease (150 s with `occ backup:run`), the background job took
|
||||
the run over, finished it a second time and failed on the snapshot it had already recorded,
|
||||
overwriting the result. The lease now lasts at least 15 minutes, a step no longer overwrites a
|
||||
run that another process wrote in the meantime, and recording a snapshot that is already
|
||||
recorded is no longer an error.
|
||||
|
||||
## [0.9.4-beta.5] - 2026-10-09
|
||||
|
||||
Bugfix release: faster restores.
|
||||
|
||||
@@ -108,7 +108,13 @@ final class SftpReadStream {
|
||||
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||
// methods of these names neither public nor at all.
|
||||
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||
|| !is_callable([$sftp, '_realpath']) || !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|
||||
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
return null;
|
||||
}
|
||||
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
|
||||
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
|
||||
if ($sftp->pwd() === false) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||
|
||||
@@ -15,6 +15,7 @@ use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Job\BackupRun;
|
||||
use OCA\NgBackup\Repository\RepositoryException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
@@ -105,7 +106,10 @@ final class BackupService {
|
||||
*/
|
||||
public function step(Run $run, float $deadline): Run {
|
||||
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
|
||||
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
|
||||
// A step can run well past its deadline (the commit of a large snapshot writes its tree and
|
||||
// index), so the lease holds at least 15 minutes; should it still expire and another process
|
||||
// take the run over, the checks below keep the two from overwriting each other.
|
||||
$ttl = (int)min(3600, max(900, $deadline - microtime(true) + 600));
|
||||
try {
|
||||
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
||||
} catch (LockedException) {
|
||||
@@ -123,6 +127,7 @@ final class BackupService {
|
||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||
return $run;
|
||||
}
|
||||
$loadedAt = $run->getUpdatedAt();
|
||||
$target = $this->targets->get((string)$run->getTargetId());
|
||||
$repo = $this->targets->repository($target);
|
||||
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -179,8 +184,16 @@ final class BackupService {
|
||||
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
$current = $this->takenOver($run->getId(), $loadedAt);
|
||||
return $current ?? $this->runs->update($run);
|
||||
} catch (\Throwable $e) {
|
||||
// Another process that took the run over (after this step's lease expired) may have
|
||||
// finished it in the meantime: its result stands, this step's error does not.
|
||||
$current = isset($loadedAt) ? $this->takenOver($run->getId(), $loadedAt) : null;
|
||||
if ($current !== null) {
|
||||
$this->logger->warning('NG Backup run ' . $run->getId() . ': a step that another process had taken over ended with: ' . $e->getMessage());
|
||||
return $current;
|
||||
}
|
||||
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
$run->setStatus(Run::FAILED);
|
||||
$run->setError($e->getMessage());
|
||||
@@ -198,6 +211,15 @@ final class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The run as stored, when another process has written it since this step loaded it (updated_at
|
||||
* $loadedAt); null when it is still this step's to write.
|
||||
*/
|
||||
private function takenOver(int $runId, int $loadedAt): ?Run {
|
||||
$current = $this->runs->find($runId);
|
||||
return $current->getUpdatedAt() !== $loadedAt || $current->getStatus() !== Run::RUNNING ? $current : null;
|
||||
}
|
||||
|
||||
/** Run to completion in this process (occ, system cron), reporting progress. */
|
||||
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
||||
while ($run->getStatus() === Run::RUNNING) {
|
||||
@@ -224,6 +246,11 @@ final class BackupService {
|
||||
}
|
||||
|
||||
private function recordSnapshot(Run $run, array $files): void {
|
||||
try {
|
||||
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
|
||||
return; // already recorded, by an earlier attempt at this step
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
$s = new Snapshot();
|
||||
$s->setTargetId($run->getTargetId());
|
||||
$s->setSnapshotId($files['snapshot']);
|
||||
|
||||
+1
-1
@@ -395,7 +395,7 @@
|
||||
</file>
|
||||
<file src="lib/Service/BackupService.php">
|
||||
<InvalidOperand>
|
||||
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
|
||||
<code><![CDATA[$deadline - microtime(true) + 600]]></code>
|
||||
</InvalidOperand>
|
||||
<UndefinedClass>
|
||||
<code><![CDATA[\OC]]></code>
|
||||
|
||||
@@ -231,6 +231,22 @@ class SftpReadStreamTest extends TestCase {
|
||||
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||
}
|
||||
|
||||
public function testAFreshConnectionHasItsSftpChannelOpenedFirst(): void {
|
||||
// phpseclib 2 logs in at connect time but opens the SFTP channel on the first SFTP call.
|
||||
$sftp = new FakeSftpConnection(['/home/config' => 'repository config']);
|
||||
$this->assertFalse($sftp->initialised);
|
||||
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'config');
|
||||
$this->assertIsResource($fh);
|
||||
$this->assertSame('repository config', stream_get_contents($fh));
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
public function testAChannelThatCannotBeOpenedLeavesItToTheStoragesOwnStream(): void {
|
||||
$sftp = new FakeSftpConnection(['/home/config' => 'x'], channelFails: true);
|
||||
$this->assertNull(SftpReadStream::open(new FakeSftpStorage($sftp), 'config'));
|
||||
$this->assertSame(0, $sftp->sent, 'nothing may be sent on a connection without an SFTP channel');
|
||||
}
|
||||
|
||||
public function testAConnectionWithoutTheNeededMethodsIsNotUsed(): void {
|
||||
$storage = new class {
|
||||
public function getRoot(): string {
|
||||
@@ -379,6 +395,10 @@ final class FakeSftpConnection {
|
||||
public array $reads = [];
|
||||
/** @var array<string, string> */
|
||||
public array $openHandles = [];
|
||||
/** Whether the SFTP channel is open: phpseclib 2 opens it on the first SFTP call. */
|
||||
public bool $initialised = false;
|
||||
/** Packets sent */
|
||||
public int $sent = 0;
|
||||
/** @var array<int, array{int, string}> answers by request id, in the order the server sent them */
|
||||
private array $answers = [];
|
||||
private int $handles = 0;
|
||||
@@ -393,9 +413,18 @@ final class FakeSftpConnection {
|
||||
/** READ at or past this offset finds the connection gone (phpseclib returns false); -1: OPEN */
|
||||
private ?int $loseAt = null,
|
||||
private bool $closeFails = false,
|
||||
private bool $channelFails = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function pwd(): string|false {
|
||||
if ($this->channelFails) {
|
||||
return false;
|
||||
}
|
||||
$this->initialised = true;
|
||||
return '/home';
|
||||
}
|
||||
|
||||
public function file(string $path): ?string {
|
||||
return $this->files[$path] ?? null;
|
||||
}
|
||||
@@ -409,6 +438,10 @@ final class FakeSftpConnection {
|
||||
}
|
||||
|
||||
public function _send_sftp_packet(int $type, string $data, int $id = 1): bool {
|
||||
$this->sent++;
|
||||
if (!$this->initialised) {
|
||||
return false; // like phpseclib 2: no SFTP channel yet
|
||||
}
|
||||
switch ($type) {
|
||||
case 3: // OPEN
|
||||
if ($this->throwAt === -1) {
|
||||
|
||||
Reference in New Issue
Block a user