M5: retention policy, prune with repacking, append-only locations
- RetentionPolicy (last/daily/weekly/monthly) and PruneService: forget snapshots the policy drops, delete packs nothing uses and repack mostly unused ones by copying encrypted blobs as-is; new packs are indexed before old ones are removed - a location lock keeps prune and backups apart; append-only locations are never pruned (cleanup via the location's lifecycle rules) - weekly prune from the background job; occ backup:prune [--dry-run], backup:retention, backup:target:append-only - tests: forget+prune frees space and the remaining snapshot restores identically, repack of mixed packs, dry-run preview, retention buckets
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\LocalBackend;
|
||||
use OCA\NgBackup\Crypto\KeyRing;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PruneTest extends TestCase {
|
||||
use TempDirTrait;
|
||||
|
||||
private function du(LocalBackend $b): int {
|
||||
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($b);
|
||||
$total = 0;
|
||||
foreach ($b->list('packs') as $p) {
|
||||
$total += filesize("$root/$p");
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function testForgetAndPruneFreesSpaceAndKeepsTheRest(): void {
|
||||
$src = $this->tempDir();
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
$keys = KeyRing::generate();
|
||||
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||
|
||||
file_put_contents("$src/keep.bin", random_bytes(6 * 1048576));
|
||||
file_put_contents("$src/old.bin", random_bytes(40 * 1048576));
|
||||
$old = $repo->backupDirectory($src);
|
||||
unlink("$src/old.bin");
|
||||
file_put_contents("$src/new.bin", random_bytes(3 * 1048576));
|
||||
$new = $repo->backupDirectory($src, $old['snapshot']);
|
||||
$expected = self::hashTree($src);
|
||||
$before = $this->du($backend);
|
||||
|
||||
$dry = $repo->prune(true);
|
||||
$this->assertSame(0, $dry['freedBytes'], 'nothing is unused while both snapshots exist');
|
||||
$preview = $repo->prune(true, 0.5, [$old['snapshot']]);
|
||||
$this->assertGreaterThan(35 * 1048576, $preview['freedBytes'], 'dry run can preview forgetting a snapshot');
|
||||
|
||||
$repo->forget($old['snapshot']);
|
||||
$plan = $repo->prune(true);
|
||||
$this->assertSame($plan, $repo->prune(true, 0.5, []));
|
||||
$this->assertGreaterThan(35 * 1048576, $plan['freedBytes']);
|
||||
$this->assertSame($before, $this->du($backend), 'dry run changes nothing');
|
||||
|
||||
$result = $repo->prune();
|
||||
$this->assertGreaterThan(0, $result['deleted'] + $result['repacked']);
|
||||
$this->assertLessThan($before - 35 * 1048576, $this->du($backend));
|
||||
|
||||
$reopened = Repository::openWithKey($backend, $keys);
|
||||
$target = $this->tempDir();
|
||||
$reopened->restore($new['snapshot'], $target);
|
||||
$this->assertSame($expected, self::hashTree($target));
|
||||
$this->assertSame([$new['snapshot']], $reopened->listSnapshots());
|
||||
}
|
||||
|
||||
public function testRepackKeepsUsedBlobsOfMixedPacks(): void {
|
||||
$src = $this->tempDir();
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
$keys = KeyRing::generate();
|
||||
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||
// Small files share packs: deleting most of them leaves mixed packs to repack.
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
file_put_contents("$src/f$i", random_bytes(500000));
|
||||
}
|
||||
$a = $repo->backupDirectory($src);
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
if ($i % 5 !== 0) {
|
||||
unlink("$src/f$i");
|
||||
}
|
||||
}
|
||||
$b = $repo->backupDirectory($src, $a['snapshot']);
|
||||
$repo->forget($a['snapshot']);
|
||||
$r = $repo->prune(false, 0.5);
|
||||
$this->assertGreaterThan(0, $r['repacked']);
|
||||
$target = $this->tempDir();
|
||||
Repository::openWithKey($backend, $keys)->restore($b['snapshot'], $target);
|
||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Service;
|
||||
|
||||
use OCA\NgBackup\Service\RetentionPolicy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RetentionPolicyTest extends TestCase {
|
||||
public function testDailyWeeklyMonthly(): void {
|
||||
$snaps = [];
|
||||
$start = strtotime('2026-01-01 03:00:00');
|
||||
for ($d = 0; $d < 120; $d++) {
|
||||
$snaps["d$d"] = $start + $d * 86400; // one per day for 120 days
|
||||
$snaps["d$d-b"] = $start + $d * 86400 + 3600; // and a second one an hour later
|
||||
}
|
||||
$keep = (new RetentionPolicy(2, 7, 4, 3))->keep($snaps);
|
||||
$this->assertArrayHasKey('d119-b', $keep, 'newest is always kept');
|
||||
$this->assertArrayHasKey('d119', $keep, 'last=2');
|
||||
$this->assertContains('daily', $keep['d113-b']);
|
||||
$this->assertArrayNotHasKey('d113', $keep, 'only the newest of a day');
|
||||
$this->assertLessThanOrEqual(2 + 7 + 4 + 3, count($keep));
|
||||
$months = array_unique(array_map(fn ($id) => date('Y-m', $snaps[$id]), array_keys(array_filter($keep, fn ($r) => in_array('monthly', $r, true)))));
|
||||
$this->assertCount(3, $months);
|
||||
}
|
||||
|
||||
public function testKeepsEverythingWhenFewSnapshots(): void {
|
||||
$keep = (new RetentionPolicy())->keep(['a' => 100, 'b' => 200]);
|
||||
$this->assertSame(['a', 'b'], array_values(array_intersect(['a', 'b'], array_keys($keep))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user