diff --git a/CHANGELOG.md b/CHANGELOG.md
index f162337..5fca3ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,33 @@ Each heading starts with that ``, once: the app store shows a release t
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
shown under 1.0.0. The git tag of a release candidate goes after the date.
+## [1.0.2] - unreleased
+
+### Added
+- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
+ system cron, or the cron stopped, with a link to how to set one up. Backups, also the scheduled
+ ones, need it (#13).
+- Without a working system cron, a backup started with "Back up now" moves on while the NG Backup
+ page stays open (#13).
+- `--salvage` for `occ backup:restore` and `occ backup:restore:full`: restore whatever can be
+ read when a backup is damaged. Files that cannot be read or decrypted are left out and listed,
+ and the command exits with code 2. When the database cannot be restored, `backup:restore:full`
+ leaves the fresh database as it is and puts the data directory and config of the backup in
+ `ngb-salvage-` in the data directory, to put back by hand.
+- The trash of restore points has a tab of its own, "Bin", instead of being shown below the
+ backups.
+
+### Changed
+- A backup in its database phase shows how far it is, for example "running (db: 47/183 tables)",
+ and one that has not moved for 15 minutes says "waiting for background jobs" with when it last
+ took a step. Before, it only said "running (db)", which looked the same as being stuck (#13).
+- Full restores from S3 read the packs ahead, eight ranges at a time, as restores over SFTP do
+ since 1.0.0-rc.4. Over SMB the packs are read in 8 MiB reads instead of 1 MiB: over a line with
+ a 130 ms round trip, reading was four times faster in a test (18 instead of 4.5 MiB/s).
+- `occ backup:target:add` only estimates the time left for reading the index once enough of it
+ is in. The first file also carries the time to connect, which made it say "about 642 min left"
+ for something done within a minute.
+
## [1.0.1] - 2026-11-01
Fixes for what was reported after 1.0.0 (#22, #24) and small improvements. Nothing changes in
diff --git a/PLAN.md b/PLAN.md
index 7579e7e..a3c0ff3 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -392,6 +392,20 @@ in README "Known limitations":
gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een
productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0.
Problemen of meldingen van gebruikers: rc5.
+- **1.0.2** (plan akkoord 2026-11-04): punten 1, 2 en 5 hieronder, de Bin, nieuwe screenshots en
+ het polish-punt over de schatting van `target:add`. Besluiten 2026-11-04:
+ - `--salvage` haalt ook bij een kapotte database terug wat kan: bestanden zijn per gebruiker
+ gescheiden en het waardevolst. Kan de database niet terug (dump onleesbaar, of past niet op
+ deze installatie), dan blijft de verse database staan en gaan data en config van de backup
+ naar `/ngb-salvage-/`; vanaf daar is het handwerk. Exitcode 2.
+ - De DR-test met een backup mét tabelstructuur, zonder `--skip-missing-tables` (punt 3), is
+ nog niet gedaan en wordt ingepland vóór de release van 1.0.2.
+ Stand 2026-11-04 (lokaal op `polish`, niet gepusht): cron-waarschuwing + tick vanaf de pagina +
+ voortgang van de db-fase; `--salvage` voor `backup:restore` en `backup:restore:full`; S3
+ vooruitlezen (8 ranges tegelijk, met `SaveAs` naar de spool); SMB in leesacties van 8 MiB
+ (gemeten met netem, 130 ms RTT: 4,5 → 18 MiB/s per leesactie; een volledige restore over SMB
+ met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`. Nog te doen:
+ screenshots, DR-test, release.
- **1.0.1** (uitgebracht 2026-11-01): alleen de kleine punten (besluit 2026-10-30): 4, 6–13. Punten 1, 2 en 5 gaan naar
1.0.2 (README-roadmap aangepast); punt 3 is een test, ingepland voor de week van 2 november,
niet aangekondigd en los van de release. Voor 1.0.2 ook: nieuwe screenshots (het labelveld bij
diff --git a/README.md b/README.md
index 679f4c8..00987cc 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,8 @@ Need help restoring a backup after a crash? Ensembia offers paid restore assista
background job, so a backup only moves forward when that job runs. Choosing "Cron" under
**Administration settings → Basic settings → Background jobs** is not enough on its own: it
tells Nextcloud that the system will call it, so you also need a cron line that does. Without
- it a backup stays at "running (db)", and so does one started with "Back up now".
+ it scheduled backups do not run; the admin page and `occ backup:status` warn about it, and a
+ backup started with "Back up now" only moves on while the NG Backup page stays open.
`occ backup:run` in a terminal works without cron (it runs in the foreground).
- Regular server: `crontab -u www-data -e` and add
`*/5 * * * * php -f /var/www/nextcloud/cron.php` (your Nextcloud path).
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 10e7a5d..b058d68 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -24,6 +24,7 @@ return [
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
+ ['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
diff --git a/lib/Command/Status.php b/lib/Command/Status.php
index 9d89678..65a1260 100644
--- a/lib/Command/Status.php
+++ b/lib/Command/Status.php
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Format;
+use OCA\NgBackup\Service\CronHealth;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
@@ -29,6 +30,7 @@ final class Status extends Command {
private RunMapper $runs,
private VerifyService $verify,
private Schedule $schedule,
+ private CronHealth $cron,
) {
parent::__construct();
}
@@ -40,6 +42,10 @@ final class Status extends Command {
protected function execute(InputInterface $input, OutputInterface $output): int {
$zone = $this->schedule->get()['timezone'];
+ $cronProblem = CronHealth::describe($this->cron->check());
+ if ($cronProblem !== null) {
+ $output->writeln('' . $cronProblem . '');
+ }
if (!$this->keys->isInitialized()) {
$output->writeln('Key: not created (occ backup:key:init)');
} else {
@@ -57,7 +63,7 @@ final class Status extends Command {
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
foreach ($shown as $r) {
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
- $rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''),
+ $rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() === 'running' ? self::runningStatus($r) : $r->getStatus(),
Format::time($r->getStartedAt(), $zone), $r->getFinishedAt() ? Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
@@ -88,8 +94,24 @@ final class Status extends Command {
return sprintf('%.1f TiB', $b);
}
+ /** " 47/183 tables" in the database phase, " 68%" or " ~68%" in the files phase; empty without a figure */
private static function percent(\OCA\NgBackup\Db\Run $r): string {
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
- return $p === null ? '' : sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
+ if ($p === null) {
+ return '';
+ }
+ if (isset($p['tables'], $p['totalTables'])) {
+ return sprintf(': %d/%d tables', $p['tables'], $p['totalTables']);
+ }
+ return sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
+ }
+
+ /** "running (db: 47/183 tables)", or "waiting for background jobs (last step 12 min ago)" */
+ private static function runningStatus(\OCA\NgBackup\Db\Run $r): string {
+ $waiting = $r->getKind() === \OCA\NgBackup\Service\BackupService::KIND_FULL ? CronHealth::waitingFor($r) : null;
+ if ($waiting !== null) {
+ return 'waiting for background jobs (' . $r->getPhase() . self::percent($r) . ', last step ' . CronHealth::ago($waiting) . ')';
+ }
+ return 'running (' . $r->getPhase() . self::percent($r) . ')';
}
}
diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php
index 886e415..85a2683 100644
--- a/lib/Controller/AdminApiController.php
+++ b/lib/Controller/AdminApiController.php
@@ -48,6 +48,7 @@ final class AdminApiController extends Controller {
private \OCA\NgBackup\Service\KeySlotService $slots,
private \OCA\NgBackup\Service\PruneService $prune,
private \OCA\NgBackup\Service\Schedule $schedule,
+ private \OCA\NgBackup\Service\CronHealth $cron,
) {
parent::__construct(Application::APP_ID, $request);
}
@@ -75,9 +76,36 @@ final class AdminApiController extends Controller {
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
'schedule' => $this->schedule->get()['time'],
'scheduleTimezone' => $this->schedule->get()['timezone'],
+ 'cron' => $this->cron->check(),
]);
}
+ /** Seconds of backup work one tick() does: well within a normal web request. */
+ public const TICK_SECONDS = 10.0;
+
+ /**
+ * Move running backups forward from the open admin page, when background jobs do not run on a
+ * working system cron (#13). The page calls this while a backup runs, so "Back up now" works
+ * without cron as long as the page stays open; scheduled backups still need cron. With a
+ * working system cron this does nothing: the background job does the work.
+ */
+ public function tick(): JSONResponse {
+ if ($this->cron->check()['ok']) {
+ return new JSONResponse(['stepped' => 0]);
+ }
+ $deadline = microtime(true) + self::TICK_SECONDS;
+ $stepped = 0;
+ foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
+ if (microtime(true) >= $deadline) {
+ break;
+ }
+ // step() returns at once when the background job (or another tab) holds the run.
+ $this->backups->step($run, $deadline);
+ $stepped++;
+ }
+ return new JSONResponse(['stepped' => $stepped]);
+ }
+
#[PasswordConfirmationRequired]
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
try {
@@ -313,6 +341,7 @@ final class AdminApiController extends Controller {
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
- 'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
+ 'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r),
+ 'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null];
}
}
diff --git a/lib/Job/BackupRun.php b/lib/Job/BackupRun.php
index 2c9e136..e7cc3f1 100644
--- a/lib/Job/BackupRun.php
+++ b/lib/Job/BackupRun.php
@@ -176,12 +176,12 @@ final class BackupRun {
}
/**
- * progress() of a run, while it is in its files phase.
+ * progress() of a run in its files phase; in its database phase, the tables dumped so far.
*
- * @return array{fraction: float, approximate: bool}|null
+ * @return array{fraction: float, approximate: bool, tables?: int, totalTables?: int}|null
*/
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
- if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') {
+ if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || !in_array($run->getPhase(), ['db', 'files'], true)) {
return null;
}
try {
@@ -189,6 +189,14 @@ final class BackupRun {
} catch (\JsonException) {
return null;
}
+ if ($run->getPhase() === 'db') {
+ // Tables dumped so far, as of the last step (none known before the first step ends)
+ $tables = count($state['dump']['tables'] ?? []);
+ return $tables > 0
+ ? ['fraction' => min(1.0, ($state['dump']['i'] ?? 0) / $tables), 'approximate' => false,
+ 'tables' => (int)($state['dump']['i'] ?? 0), 'totalTables' => $tables]
+ : null;
+ }
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
}
diff --git a/lib/Service/CronHealth.php b/lib/Service/CronHealth.php
new file mode 100644
index 0000000..11d560c
--- /dev/null
+++ b/lib/Service/CronHealth.php
@@ -0,0 +1,97 @@
+coreString('backgroundjobs_mode', 'ajax');
+ $lastRun = (int)$this->coreString('lastcron', '0');
+ $stopped = $mode === 'cron' && $now - $lastRun > self::STOPPED_AFTER;
+ return [
+ 'mode' => $mode,
+ 'lastRun' => $lastRun,
+ 'ok' => $mode === 'cron' && !$stopped,
+ 'stopped' => $stopped,
+ 'docs' => $this->urls->linkToDocs('admin-background-jobs'),
+ ];
+ }
+
+ /**
+ * Seconds since a running backup took its last step, when that is long enough to say it waits
+ * for background jobs; null while it moves (or when it is not running).
+ */
+ public static function waitingFor(Run $run, ?int $now = null): ?int {
+ $idle = ($now ?? time()) - $run->getUpdatedAt();
+ return $run->getStatus() === Run::RUNNING && $idle >= self::WAITING_AFTER ? $idle : null;
+ }
+
+ /** One line for occ and the log; null when system cron runs. */
+ public static function describe(array $health, ?int $now = null): ?string {
+ if ($health['ok']) {
+ return null;
+ }
+ $last = $health['lastRun'] > 0 ? 'last ran ' . self::ago(($now ?? time()) - $health['lastRun']) : 'never ran';
+ $what = $health['stopped']
+ ? 'Background jobs are set to system cron, but cron has not run for over an hour (' . $last . ').'
+ : 'Background jobs do not run on a system cron: they are set to "' . ($health['mode'] === 'webcron' ? 'Webcron' : 'AJAX') . '" and ' . $last . '.';
+ return $what . ' Backups, also the scheduled ones, need a system cron that runs every 5 minutes: ' . $health['docs'];
+ }
+
+ /** "12 min ago" style, for durations from a minute to days */
+ public static function ago(int $seconds): string {
+ if ($seconds < 3600) {
+ return max(1, intdiv($seconds, 60)) . ' min ago';
+ }
+ if ($seconds < 172800) {
+ return intdiv($seconds, 3600) . ' h ago';
+ }
+ return intdiv($seconds, 86400) . ' days ago';
+ }
+
+ /** Core stores these with different value types across versions: read whatever is there. */
+ private function coreString(string $key, string $default): string {
+ try {
+ return $this->appConfig->getValueString('core', $key, $default);
+ } catch (AppConfigTypeConflictException) {
+ return (string)$this->appConfig->getValueInt('core', $key, (int)$default);
+ }
+ }
+}
diff --git a/screenshots/1-backups.png b/screenshots/1-backups.png
index 959607d..f5ad83a 100644
Binary files a/screenshots/1-backups.png and b/screenshots/1-backups.png differ
diff --git a/screenshots/2-restore.png b/screenshots/2-restore.png
index d8928b7..bee0170 100644
Binary files a/screenshots/2-restore.png and b/screenshots/2-restore.png differ
diff --git a/screenshots/3-settings.png b/screenshots/3-settings.png
index 2cf98da..61122a8 100644
Binary files a/screenshots/3-settings.png and b/screenshots/3-settings.png differ
diff --git a/screenshots/4-bin.png b/screenshots/4-bin.png
new file mode 100644
index 0000000..52ae8f4
Binary files /dev/null and b/screenshots/4-bin.png differ
diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue
index a70cd42..258d301 100644
--- a/src/AdminSettings.vue
+++ b/src/AdminSettings.vue
@@ -12,11 +12,22 @@
{{ error }}
+
+
+ {{ status.cron.stopped
+ ? t('ng_backup', 'Background jobs are set to system cron, but cron has not run since {time}.', { time: lastCron })
+ : t('ng_backup', 'Background jobs do not run on a system cron (they are set to "{mode}").', { mode: status.cron.mode === 'webcron' ? 'Webcron' : 'AJAX' }) }}
+ {{ t('ng_backup', 'Backups need a system cron that runs every 5 minutes, also the scheduled ones.') }}
+ {{ t('ng_backup', 'How to set up cron ↗') }}
+
+
{{ t('ng_backup', 'Until then, a backup started with "Back up now" only moves on while this page is open.') }}
-
- {{ n('ng_backup',
- 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n day before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
- 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n days before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
- status.key.deleteDelayDays) }}
-
-
+
+ {{ n('ng_backup',
+ 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this bin, where they stay for %n day before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
+ 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this bin, where they stay for %n days before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
+ status.key.deleteDelayDays) }}
+
+
{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }} ·
- {{ t('ng_backup', 'Removed backups are kept in the trash for {days} days', { days: status.key.deleteDelayDays }) }}
+ {{ t('ng_backup', 'Removed backups are kept in the Bin for {days} days', { days: status.key.deleteDelayDays }) }}
diff --git a/tests/Unit/Job/BackupProgressTest.php b/tests/Unit/Job/BackupProgressTest.php
index 4316a03..2c0e683 100644
--- a/tests/Unit/Job/BackupProgressTest.php
+++ b/tests/Unit/Job/BackupProgressTest.php
@@ -24,4 +24,25 @@ class BackupProgressTest extends TestCase {
$this->assertNull(BackupRun::progress(['phase' => 'files', 'estimate' => ['bytes' => 0, 'approximate' => true], 'stats' => ['bytes' => 1]]));
$this->assertNull(BackupRun::progress(['phase' => 'commit', 'estimate' => ['bytes' => 10, 'approximate' => false], 'stats' => ['bytes' => 1]]));
}
+
+ private static function runIn(string $phase, array $state, string $status = \OCA\NgBackup\Db\Run::RUNNING): \OCA\NgBackup\Db\Run {
+ $run = new \OCA\NgBackup\Db\Run();
+ $run->setStatus($status);
+ $run->setPhase($phase);
+ $run->setState(json_encode($state, JSON_THROW_ON_ERROR));
+ return $run;
+ }
+
+ public function testDatabasePhaseCountsTheTablesDumped(): void {
+ $run = self::runIn('db', ['dump' => ['tables' => array_fill(0, 183, 'oc_t'), 'i' => 47]]);
+ $this->assertSame(['fraction' => 47 / 183, 'approximate' => false, 'tables' => 47, 'totalTables' => 183], BackupRun::progressOf($run));
+ }
+
+ public function testDatabasePhaseHasNoFigureBeforeItsFirstStep(): void {
+ $this->assertNull(BackupRun::progressOf(self::runIn('db', ['files' => []])));
+ }
+
+ public function testNoProgressForAFinishedRun(): void {
+ $this->assertNull(BackupRun::progressOf(self::runIn('db', ['dump' => ['tables' => ['oc_a'], 'i' => 0]], \OCA\NgBackup\Db\Run::DONE)));
+ }
}
diff --git a/tests/Unit/Service/CronHealthTest.php b/tests/Unit/Service/CronHealthTest.php
new file mode 100644
index 0000000..0a8afce
--- /dev/null
+++ b/tests/Unit/Service/CronHealthTest.php
@@ -0,0 +1,88 @@
+createMock(IAppConfig::class);
+ $config->method('getValueString')->willReturnCallback(function (string $app, string $key, string $default) use ($mode, $lastCron, $lastCronIsInt): string {
+ if ($key === 'backgroundjobs_mode') {
+ return $mode;
+ }
+ if ($lastCronIsInt) {
+ throw new AppConfigTypeConflictException('stored as int');
+ }
+ return $lastCron === null ? $default : (string)$lastCron;
+ });
+ $config->method('getValueInt')->willReturn($lastCron ?? 0);
+ $urls = $this->createMock(IURLGenerator::class);
+ $urls->method('linkToDocs')->with('admin-background-jobs')->willReturn(self::DOCS);
+ return new CronHealth($config, $urls);
+ }
+
+ public function testSystemCronThatRanRecentlyIsFine(): void {
+ $check = $this->health('cron', self::NOW - 120)->check(self::NOW);
+ $this->assertTrue($check['ok']);
+ $this->assertFalse($check['stopped']);
+ $this->assertNull(CronHealth::describe($check, self::NOW));
+ }
+
+ public function testAjaxAndWebcronAreNotEnough(): void {
+ foreach (['ajax' => 'AJAX', 'webcron' => 'Webcron'] as $mode => $name) {
+ $check = $this->health($mode, self::NOW - 60)->check(self::NOW);
+ $this->assertFalse($check['ok'], $mode);
+ $this->assertFalse($check['stopped'], $mode);
+ $this->assertStringContainsString("they are set to \"$name\" and last ran 1 min ago.", (string)CronHealth::describe($check, self::NOW));
+ $this->assertStringContainsString(self::DOCS, (string)CronHealth::describe($check, self::NOW));
+ }
+ }
+
+ public function testSystemCronThatStoppedRunning(): void {
+ $check = $this->health('cron', self::NOW - 2 * 3600)->check(self::NOW);
+ $this->assertFalse($check['ok']);
+ $this->assertTrue($check['stopped']);
+ $this->assertStringContainsString('has not run for over an hour (last ran 2 h ago)', (string)CronHealth::describe($check, self::NOW));
+ }
+
+ public function testDefaultsToAjaxThatNeverRan(): void {
+ $check = $this->health('ajax', null)->check(self::NOW);
+ $this->assertSame(0, $check['lastRun']);
+ $this->assertStringContainsString('and never ran.', (string)CronHealth::describe($check, self::NOW));
+ }
+
+ public function testReadsALastRunStoredAsAnInteger(): void {
+ $check = $this->health('cron', self::NOW - 60, true)->check(self::NOW);
+ $this->assertSame(self::NOW - 60, $check['lastRun']);
+ $this->assertTrue($check['ok']);
+ }
+
+ public function testARunningBackupWaitsForBackgroundJobsWhenItDidNotMoveForAWhile(): void {
+ $run = new Run();
+ $run->setStatus(Run::RUNNING);
+ $run->setUpdatedAt(self::NOW - 12 * 60);
+ $this->assertNull(CronHealth::waitingFor($run, self::NOW));
+ $run->setUpdatedAt(self::NOW - CronHealth::WAITING_AFTER);
+ $this->assertSame(CronHealth::WAITING_AFTER, CronHealth::waitingFor($run, self::NOW));
+ $run->setStatus(Run::DONE);
+ $this->assertNull(CronHealth::waitingFor($run, self::NOW));
+ }
+
+ public function testAgo(): void {
+ $this->assertSame('1 min ago', CronHealth::ago(10));
+ $this->assertSame('12 min ago', CronHealth::ago(12 * 60));
+ $this->assertSame('3 h ago', CronHealth::ago(3 * 3600 + 5));
+ $this->assertSame('4 days ago', CronHealth::ago(4 * 86400));
+ }
+}
diff --git a/tests/integration/roundtrip.sh b/tests/integration/roundtrip.sh
index 4e25dec..0c9a071 100755
--- a/tests/integration/roundtrip.sh
+++ b/tests/integration/roundtrip.sh
@@ -25,7 +25,8 @@ ok "location added"
# Test data: a user with files.
$X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null"
-$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin"
+# large.bin (40 MB) fills more than one pack: a restore to a directory reads ahead over several ranges
+$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin; head -c 40000000 /dev/urandom > $DATA/$U/files/large.bin"
occ files:scan "$U" >/dev/null
SUM_BEFORE=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
@@ -56,10 +57,47 @@ occ backup:restore ci "$SNAP" "data/$U/files/Docs/f2.bin" -d /tmp/ngb-rt-one | g
$X sh -c "cmp -s $ONE/f2.bin $DATA/$U/files/Docs/f2.bin" || fail "the file restored to a directory differs"
$X sh -c "ls -A $ONE | grep -q ngb-restore" && fail "the temporary of a stopped restore was left"
ok "restore of one file to a directory; the temporary of a stopped restore removed"
+
+# All of the user's files to a directory: over S3 and SFTP the packs are read ahead
+occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-dir >/dev/null || fail "restore of a folder to a directory"
+SUM_DIR=$($X sh -c "cd /tmp/ngb-rt-dir/data/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
+[ "$SUM_BEFORE" = "$SUM_DIR" ] || fail "the files restored to a directory differ"
+$X sh -c "ls -A /tmp/ngb-rt-dir | grep -q ngb-restore-spool" && fail "the read-ahead spool was left in the directory"
+$X sh -c "rm -rf /tmp/ngb-rt-dir"
+ok "restore of all the user's files to a directory: identical, no spool left"
# not grep -q: it stops reading at the first match, and occ, still writing, then fails (pipefail)
occ backup:status | grep "started (" >/dev/null || fail "backup:status does not name the time zone"
ok "backup:status names the time zone of its times"
+# Without a system cron (#13): backup:status warns, and the open admin page moves a backup started
+# with "Back up now" forward through /api/tick. Nothing runs cron in this container, so only the
+# ticks below can finish this backup.
+occ background:ajax >/dev/null
+occ backup:status | grep "do not run on a system cron" >/dev/null || fail "backup:status gives no warning without system cron"
+ADM=ngbadmin-$RANDOM; ADM_PW="Tick-admin-pw-$RANDOM-2026"
+$X sh -c "OC_PASS='$ADM_PW' php occ user:add --password-from-env --group=admin $ADM >/dev/null"
+api() { $X curl -sf -u "$ADM:$ADM_PW" -H 'OCS-APIRequest: true' -H 'Content-Type: application/json' "$@"; }
+API=http://localhost/index.php/apps/ng_backup/api
+STATUS=$(api "$API/status") || fail "status API"
+echo "$STATUS" | php -r '$s = json_decode(stream_get_contents(STDIN), true); exit(!$s["cron"]["ok"] && $s["cron"]["mode"] === "ajax" && $s["cron"]["docs"] !== "" ? 0 : 1);' || fail "status API does not report the missing cron"
+TID=$(echo "$STATUS" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["targets"][0]["id"];')
+RUN=$(api -X POST -d "{\"targetId\": $TID, \"label\": \"page\"}" "$API/backups" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["id"];') || fail "start a backup through the API"
+for i in $(seq 1 60); do
+ api -X POST "$API/tick" >/dev/null || fail "tick"
+ RS=$(api "$API/status" | php -r '$s = json_decode(stream_get_contents(STDIN), true); foreach ($s["runs"] as $r) { if ($r["id"] == '"$RUN"') { echo $r["status"]; } }')
+ [ "$RS" = running ] || break
+done
+[ "$RS" = done ] || fail "the backup started through the API ended as '$RS' after $i ticks"
+occ backup:list ci | grep " page " >/dev/null || fail "the snapshot of the ticked backup is not listed"
+ok "without system cron: warning in backup:status and the API, backup finished by $i ticks of the open page"
+occ background:cron >/dev/null
+$X php -f /var/www/html/cron.php >/dev/null 2>&1 || true
+occ backup:status | grep "system cron" >/dev/null && fail "backup:status still warns after cron ran"
+sleep 8 # APCu app config cache of the web server
+api "$API/status" | php -r 'exit(json_decode(stream_get_contents(STDIN), true)["cron"]["ok"] ? 0 : 1);' || fail "the status API still reports a missing cron after cron ran"
+ok "with a system cron that ran: no warning in backup:status or the API"
+occ user:delete "$ADM" >/dev/null
+
# Checksum audit: shallow (index presence) and deep (download, decrypt, verify every blob).
occ backup:verify ci "$SNAP" | grep "^OK$" >/dev/null || fail "shallow verify"
occ backup:verify ci "$SNAP" --deep | grep "^OK$" >/dev/null || fail "deep verify"
@@ -71,13 +109,39 @@ ok "database dump and restore"
# Retention, trash and catalog.
occ backup:retention --last=1 --daily=0 --weekly=0 --monthly=0 >/dev/null
-occ backup:prune ci | grep "forgot 1" >/dev/null || fail "prune"
+occ backup:prune ci | grep "forgot 2" >/dev/null || fail "prune" # first and second; "page" (above) is the last
occ backup:trash ci | grep " first " >/dev/null || fail "trash"
TID=$(occ backup:trash ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')
occ backup:trash:restore ci "$TID" >/dev/null || fail "trash restore"
[ "$(occ backup:list ci | grep -c ' ci ')" = "2" ] || fail "snapshot not back from the trash"
ok "retention, trash and restore from trash"
+# Damaged data on the location (last: the location is not used after this). A byte flipped every
+# MiB of every pack hits big.bin (6 MB) for sure; a small file mostly survives. Without --salvage
+# the restore stops and points at --salvage; with it, the rest is restored and the damage listed.
+if [[ "$TARGET_ARGS" == local* ]]; then
+ # "second" went to the trash above: the backup of the page is still there
+ SNAP=$(occ backup:list ci | awk -F'|' '!f && / page /{gsub(/ /,"",$3); print $3; f=1}')
+ [ -n "$SNAP" ] || fail "the snapshot of the ticked backup is not listed"
+ $X php -r 'foreach (glob("/tmp/ngb-rt-target/repo/packs/*/*") as $p) { $h = fopen($p, "r+b"); for ($o = 4096; $o < filesize($p); $o += 1048576) { fseek($h, $o); $b = fread($h, 1); fseek($h, $o); fwrite($h, ~$b); } fclose($h); }'
+ set +e
+ OUT=$(occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-salvage 2>&1); CODE=$?
+ set -e
+ [ "$CODE" = 1 ] || { echo "$OUT"; fail "a restore of damaged data did not stop (exit $CODE)"; }
+ echo "$OUT" | grep -- "--salvage" >/dev/null || { echo "$OUT"; fail "the stopped restore does not point at --salvage"; }
+ set +e
+ OUT=$(occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-salvage --salvage 2>&1); CODE=$?
+ set -e
+ [ "$CODE" = 2 ] || { echo "$OUT"; fail "restore --salvage of damaged data exited with $CODE, not 2"; }
+ echo "$OUT" | grep "data/$U/files/big.bin: " >/dev/null || { echo "$OUT"; fail "the damaged big.bin is not listed"; }
+ N=$(echo "$OUT" | sed -n 's/^Restored \([0-9]*\) files.*/\1/p')
+ [ "${N:-0}" -ge 1 ] || { echo "$OUT"; fail "restore --salvage restored nothing"; }
+ $X sh -c "test ! -e /tmp/ngb-rt-salvage/data/$U/files/big.bin" || fail "the damaged big.bin was written anyway"
+ $X sh -c "find /tmp/ngb-rt-salvage -name '*.ngb-restore-*' | grep -q ." && fail "a temporary of a damaged file was left"
+ ok "damaged data: the restore stops and points at --salvage; with --salvage $N files restored and the damaged ones listed (exit 2)"
+ $X sh -c "rm -rf /tmp/ngb-rt-salvage"
+fi
+
# No errors caused by loading files_external while it is disabled (its tables may not exist).
if $X sh -c "grep -q 'external_mounts' $DATA/nextcloud.log 2>/dev/null"; then fail "files_external table errors in the log"; fi
ok "no files_external errors in the log"