Files
nextcloud-ng_backup/lib/Job/BackupRun.php
T
dillard cc09d74942
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
Commit full-backup snapshots to the catalog under the catalog lock
A full backup opened its repository at the start of a step and wrote the
catalog at the end of it, without the catalog lock. A per-user export or
restore that wrote the catalog in between could lose its entry, or the
snapshot could lose its own.

The final step now only stores the snapshot object and moves the run to a
new 'commit' phase. BackupService then records the snapshot under the
catalog lock, from a repository opened inside that lock; if the lock is
busy, the next step retries the commit. Repository::writeSnapshot() keeps
its old behaviour as putSnapshot() plus recordSnapshot().
2026-10-06 17:58:21 +02:00

157 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Job;
use OCA\NgBackup\Repository\Repository;
/**
* A file backup as a resumable state machine, for environments with short PHP time limits.
* Each step works until its deadline, uploads what it has (pack + tree segment) and returns
* the new state; a large file is resumed at the next blob.
*
* The state holds no file list: the position is a cursor in TreeWalker order, and the previous
* snapshot is merge-joined through ParentCursor, so memory and state size stay small however
* many files there are. The snapshot is written last; a killed step is simply redone.
*/
final class BackupRun {
/**
* @param string|array<string, string> $roots one directory, or name => directory (walked in order)
* @param list<string> $exclude logical path prefixes to skip
* @param array<string, mixed> $meta extra fields for the snapshot meta record
*/
public static function start(string|array $roots, ?string $parent = null, string $label = '', array $exclude = [], array $meta = []): array {
$roots = is_string($roots) ? ['' => rtrim($roots, '/')] : $roots;
uksort($roots, fn ($a, $b) => TreeWalker::compare((string)$a, (string)$b)); // walk order = compare order
return [
'snapshot' => bin2hex(random_bytes(16)),
'roots' => $roots,
'exclude' => $exclude,
'parent' => $parent,
'label' => $label,
'meta' => $meta,
'started' => gmdate('c'),
'phase' => 'files',
'after' => null,
'parentPos' => 0,
'cur' => null,
'segments' => [],
'steps' => 0,
'stats' => ['files' => 0, 'bytes' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
];
}
/** Run one step until $deadline (microtime(true)); returns the new state. */
public static function step(Repository $repo, array $state, float $deadline): array {
$state['steps']++;
if ($state['phase'] === 'files') {
$walker = new TreeWalker($state['roots'], $state['exclude']);
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
$stats = $state['stats'];
$lines = [];
$finishedWalk = true;
foreach ($walker->walk($state['after']) as $logical => $path) {
$version = Repository::fileVersion($path);
if ($version === 'missing') {
continue; // deleted while walking
}
[$size, $mtime] = array_map('intval', explode(':', $version, 3));
$prev = $parent->find($logical);
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
$blobs = $prev['b'];
$stats['reused']++;
} else {
$cur = $state['cur'];
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
if ($cur !== null && $cur['path'] === $logical) {
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
}
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
}
$fh = fopen($path, 'rb');
if ($fh === false) {
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
$state['cur'] = null;
continue;
}
fseek($fh, $cur['offset']);
$complete = true;
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
$cur['blobs'][] = $repo->storeData($data, $stats);
$cur['offset'] += strlen($data);
if (microtime(true) >= $deadline && !feof($fh)) {
$complete = false;
break;
}
}
fclose($fh);
if (Repository::fileVersion($path) !== $cur['version']) {
// Changed while reading: read it again from the start (now or next step).
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
$state['cur'] = null;
$finishedWalk = false;
break;
}
if (!$complete) {
$state['cur'] = $cur;
$finishedWalk = false;
break;
}
$blobs = $cur['blobs'];
$state['cur'] = null;
$stats['read']++;
}
$lines[] = json_encode(['p' => $logical, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$stats['files']++;
$stats['bytes'] += $size;
$state['after'] = $logical;
if (microtime(true) >= $deadline) {
$finishedWalk = false;
break;
}
}
if ($lines !== []) {
$writer = $repo->blobWriter($stats);
$writer->write(implode("\n", $lines) . "\n");
$state['segments'][] = $writer->finish();
}
$repo->flushPacks();
$state['parentPos'] = $parent->position();
$state['stats'] = $stats;
if ($finishedWalk) {
$state['phase'] = 'finish';
}
}
if ($state['phase'] === 'finish') {
$segments = $state['segments'];
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
(function () use ($repo, $segments) {
foreach ($segments as $blobs) {
yield from $repo->readLines($blobs);
}
})());
$state['phase'] = 'commit';
}
return $state;
}
private static function readFull($fh, int $length): string {
$buffer = '';
while (strlen($buffer) < $length && !feof($fh)) {
$buffer .= (string)fread($fh, $length - strlen($buffer));
}
return $buffer;
}
}