Pin a restore point and keep a note on it

Both live in the catalog on the location, so a new server sees them; the database holds
a local copy for the lists. The retention policy leaves a pinned restore point alone and
does not count it. Available on the restore page and as occ backup:pin and backup:note.
This commit is contained in:
2026-11-06 23:49:50 +01:00
parent 6540a9314a
commit 193f65940b
23 changed files with 464 additions and 9 deletions
+51 -1
View File
@@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Repository\Repository;
use OCP\IAppConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
@@ -33,6 +34,54 @@ final class PruneService {
) {
}
/** Characters of a note on a snapshot (the column holds 1000) */
public const NOTE_MAX = 500;
/** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */
public function setPinned(Target $target, string $snapshotId, bool $pinned): void {
$changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool {
$was = in_array($snapshotId, $repo->pinnedSnapshots(), true);
$repo->setPinned($snapshotId, $pinned);
return $was !== $pinned;
});
$this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned);
if ($changed) {
// Unpinning opens the way to removing it, so every administrator hears about that.
$this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned);
}
}
public function setNote(Target $target, string $snapshotId, string $note): void {
$note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note));
if (!mb_check_encoding($note, 'UTF-8')) {
throw new \InvalidArgumentException('A note must be valid UTF-8');
}
if (mb_strlen($note) > self::NOTE_MAX) {
throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters');
}
$this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool {
$repo->setSnapshotNote($snapshotId, $note);
return true;
});
$this->targets->syncSnapshot($target, $snapshotId, note: $note);
}
/**
* Write to the catalog next to a running backup or restore, which only takes it for a moment to
* commit. Not during a cleanup, which holds a repository opened earlier and writes from that.
*
* @param callable(Repository): bool $change
*/
private function changeCatalog(Target $target, callable $change): bool {
try {
return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300,
fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
fn (): bool => $change($this->targets->repository($target))));
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later');
}
}
public function untrash(Target $target, string $snapshotId): void {
$lease = $this->lockExclusive($target, 300);
try {
@@ -87,6 +136,7 @@ final class PruneService {
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($repo->snapshotNotes()[$snapshotId] ?? null);
try {
$this->snapshots->insert($s);
} catch (\Throwable) {
@@ -208,7 +258,7 @@ final class PruneService {
$meta = $repo->snapshotMeta($id);
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
}
$keep = $this->policy()->keep($times);
$keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots());
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
$purged = [];
+13
View File
@@ -28,6 +28,19 @@ final class RetentionPolicy {
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
}
/**
* keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not
* take the place of a daily, weekly or monthly one.
*
* @param array<string, int> $snapshots id => unix time
* @param list<string> $pinned
* @return array<string, list<string>>
*/
public function keepWithPinned(array $snapshots, array $pinned): array {
$pins = array_intersect_key($snapshots, array_flip($pinned));
return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']);
}
/**
* @param array<string, int> $snapshots id => unix time
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
+20
View File
@@ -208,7 +208,25 @@ final class TargetService {
return $result;
}
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
try {
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
} catch (DoesNotExistException) {
return;
}
if ($note !== null) {
$s->setNote($note !== '' ? $note : null);
}
if ($pinned !== null) {
$s->setPinned($pinned);
}
$this->snapshots->update($s);
}
private function adoptSnapshots(Target $target, Repository $repo): void {
$notes = $repo->snapshotNotes();
$pinned = array_flip($repo->pinnedSnapshots());
foreach ($repo->listSnapshots() as $snapshotId) {
try {
$this->snapshots->findOne($target->getId(), $snapshotId);
@@ -227,6 +245,8 @@ final class TargetService {
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($notes[$snapshotId] ?? null);
$s->setPinned(isset($pinned[$snapshotId]));
$this->snapshots->insert($s);
} catch (\Throwable $e) {
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);