From 38654317a2a75e5bd63c20763bb1f937d8a25097 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Fri, 2 Oct 2026 02:21:48 +0200 Subject: [PATCH] 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 --- appinfo/info.xml | 5 +- lib/BackgroundJob/BackupJob.php | 21 ++++ lib/Command/Prune.php | 54 +++++++++++ lib/Command/Retention.php | 54 +++++++++++ lib/Command/TargetAppendOnly.php | 41 ++++++++ lib/Db/Target.php | 4 + .../Version000003Date20261002020000.php | 28 ++++++ lib/Repository/BlobIndex.php | 20 ++++ lib/Repository/PackWriter.php | 10 ++ lib/Repository/Repository.php | 97 +++++++++++++++++++ lib/Service/BackupService.php | 3 + lib/Service/PruneService.php | 88 +++++++++++++++++ lib/Service/RetentionPolicy.php | 60 ++++++++++++ lib/Service/TargetService.php | 5 + tests/Unit/Repository/PruneTest.php | 85 ++++++++++++++++ tests/Unit/Service/RetentionPolicyTest.php | 32 ++++++ 16 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 lib/Command/Prune.php create mode 100644 lib/Command/Retention.php create mode 100644 lib/Command/TargetAppendOnly.php create mode 100644 lib/Migration/Version000003Date20261002020000.php create mode 100644 lib/Service/PruneService.php create mode 100644 lib/Service/RetentionPolicy.php create mode 100644 tests/Unit/Repository/PruneTest.php create mode 100644 tests/Unit/Service/RetentionPolicyTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 713ed9a..074a8b0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.5 + 0.0.6 agpl Dillard Blom NgBackup @@ -32,6 +32,9 @@ OCA\NgBackup\Command\ListSnapshots OCA\NgBackup\Command\Browse OCA\NgBackup\Command\Restore + OCA\NgBackup\Command\Prune + OCA\NgBackup\Command\Retention + OCA\NgBackup\Command\TargetAppendOnly OCA\NgBackup\Settings\Admin diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php index 86186fa..12f8519 100644 --- a/lib/BackgroundJob/BackupJob.php +++ b/lib/BackgroundJob/BackupJob.php @@ -26,6 +26,7 @@ class BackupJob extends TimedJob { public function __construct( ITimeFactory $time, private BackupService $backups, + private \OCA\NgBackup\Service\PruneService $prune, private TargetService $targets, private RunMapper $runs, private IAppConfig $appConfig, @@ -45,6 +46,26 @@ class BackupJob extends TimedJob { $run = $this->backups->step($run, min($deadline, microtime(true) + $budget)); } } + $this->weeklyPrune(); + } + + /** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */ + private function weeklyPrune(): void { + $lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', ''); + if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) { + return; + } + foreach ($this->targets->list() as $t) { + if ($t->getAppendOnly()) { + continue; + } + try { + $this->prune->apply($t); + } catch (\Throwable $e) { + $this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage()); + } + } + $this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W')); } /** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */ diff --git a/lib/Command/Prune.php b/lib/Command/Prune.php new file mode 100644 index 0000000..0bbd810 --- /dev/null +++ b/lib/Command/Prune.php @@ -0,0 +1,54 @@ +setName('backup:prune') + ->setDescription('Apply the retention policy: remove old snapshots and the data only they used') + ->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all that are not append-only)') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would be removed'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $name = $input->getArgument('target'); + $targets = $name === null ? array_filter($this->targets->list(), fn ($t) => !$t->getAppendOnly()) : [$this->targets->get($name)]; + $dry = (bool)$input->getOption('dry-run'); + $output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : '')); + $rc = 0; + foreach ($targets as $t) { + try { + $r = $this->prune->apply($t, $dry); + } catch (\Throwable $e) { + $output->writeln('' . $t->getName() . ': ' . $e->getMessage() . ''); + $rc = 1; + continue; + } + $p = $r['prune']; + $output->writeln(sprintf('%s: keep %d, %s %d snapshots; packs: %d, %s %d, repack %d, frees %.1f MiB', + $t->getName(), count($r['kept']), $dry ? 'would forget' : 'forgot', count($r['forgotten']), $p['packs'], + $dry ? 'would delete' : 'deleted', $p['deleted'], $p['repacked'], $p['freedBytes'] / 1048576)); + } + return $rc; + } +} diff --git a/lib/Command/Retention.php b/lib/Command/Retention.php new file mode 100644 index 0000000..425dc6c --- /dev/null +++ b/lib/Command/Retention.php @@ -0,0 +1,54 @@ +setName('backup:retention') + ->setDescription('Show or set how many snapshots are kept') + ->addOption('last', null, InputOption::VALUE_REQUIRED, 'Always keep the newest N') + ->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup') + ->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks') + ->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months') + ->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12"); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $p = $this->prune->policy()->toArray(); + $changed = false; + foreach (['last', 'daily', 'weekly', 'monthly'] as $k) { + if ($input->getOption($k) !== null) { + $p[$k] = max(0, (int)$input->getOption($k)); + $changed = true; + } + } + if ($changed) { + try { + $this->prune->setPolicy(RetentionPolicy::fromArray($p)); + } catch (\InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + } + $output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly'])); + return 0; + } +} diff --git a/lib/Command/TargetAppendOnly.php b/lib/Command/TargetAppendOnly.php new file mode 100644 index 0000000..4ddeaad --- /dev/null +++ b/lib/Command/TargetAppendOnly.php @@ -0,0 +1,41 @@ +setName('backup:target:append-only') + ->setDescription('Make a location append-only (NG Backup never deletes there) or normal again') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('state', InputArgument::REQUIRED, 'on or off') + ->setHelp("Append-only protects backups if this server is compromised: combine it with credentials\nwithout delete rights and a lifecycle rule or object lock on the location itself."); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $state = strtolower((string)$input->getArgument('state')); + if (!in_array($state, ['on', 'off'], true)) { + $output->writeln('state must be on or off'); + return 1; + } + $t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on'); + $output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.'); + return 0; + } +} diff --git a/lib/Db/Target.php b/lib/Db/Target.php index 71688c2..0cab9d6 100644 --- a/lib/Db/Target.php +++ b/lib/Db/Target.php @@ -22,6 +22,8 @@ use OCP\AppFramework\Db\Entity; * @method void setBasePath(string $v) * @method string|null getRepositoryId() * @method void setRepositoryId(?string $v) + * @method bool|null getAppendOnly() + * @method void setAppendOnly(bool $v) * @method int getCreatedAt() * @method void setCreatedAt(int $v) */ @@ -33,8 +35,10 @@ class Target extends Entity { protected string $basePath = ''; protected ?string $repositoryId = null; protected int $createdAt = 0; + protected ?bool $appendOnly = null; public function __construct() { $this->addType('createdAt', 'integer'); + $this->addType('appendOnly', 'boolean'); } } diff --git a/lib/Migration/Version000003Date20261002020000.php b/lib/Migration/Version000003Date20261002020000.php new file mode 100644 index 0000000..f9f359c --- /dev/null +++ b/lib/Migration/Version000003Date20261002020000.php @@ -0,0 +1,28 @@ +getTable('ngb_targets'); + if (!$t->hasColumn('append_only')) { + $t->addColumn('append_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + return $schema; + } + return null; + } +} diff --git a/lib/Repository/BlobIndex.php b/lib/Repository/BlobIndex.php index a5c3169..64d8a14 100644 --- a/lib/Repository/BlobIndex.php +++ b/lib/Repository/BlobIndex.php @@ -104,6 +104,26 @@ final class BlobIndex { } } + /** @return array> packId => blobs currently located in that pack */ + public function packs(): array { + $packs = []; + foreach ($this->blobs as $id => [$pack, $offset, $length]) { + $packs[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length]; + } + return $packs; + } + + /** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */ + public function removePack(string $packId): void { + $this->backend->delete('index/' . $packId); + $this->cache?->remove($this->repositoryId, [$packId]); + foreach ($this->blobs as $id => $loc) { + if ($loc[0] === $packId) { + unset($this->blobs[$id]); + } + } + } + public function count(): int { return count($this->blobs); } diff --git a/lib/Repository/PackWriter.php b/lib/Repository/PackWriter.php index eb6b515..7ad4d5e 100644 --- a/lib/Repository/PackWriter.php +++ b/lib/Repository/PackWriter.php @@ -49,6 +49,16 @@ final class PackWriter { return strlen($encrypted); } + /** Add an already encrypted blob as-is (repacking: no decryption needed, the blob is bound to its id, not to a pack). */ + public function addRaw(string $id, string $encrypted, int $rawLength, int $flags): void { + fwrite($this->buffer, $encrypted); + $this->entries[] = ['id' => $id, 'offset' => $this->size, 'length' => strlen($encrypted), 'raw' => $rawLength, 'flags' => $flags]; + $this->size += strlen($encrypted); + if ($this->size >= $this->targetSize) { + $this->flush(); + } + } + /** Upload the current pack (if any) and record it in the index. */ public function flush(): void { if ($this->entries === []) { diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 4c12d84..6c1e48f 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -273,6 +273,103 @@ final class Repository { return $data; } + /** Remove a snapshot (and its database dump). Its data is freed by the next prune(). */ + public function forget(string $snapshotId): void { + try { + $meta = $this->snapshotMeta($snapshotId); + } catch (\Throwable) { + $meta = []; + } + if (isset($meta['db']) && is_string($meta['db']) && str_starts_with($meta['db'], 'db/')) { + $this->backend->delete($meta['db']); + } + $this->backend->delete('snapshots/' . $snapshotId); + } + + /** + * Delete packs no snapshot or user export uses any more, and repack packs where more than + * $repackThreshold of the bytes are unused. Must not run while a backup to this repository + * is in progress (its blobs are not referenced by a snapshot yet); callers hold the lock. + * + * @return array{packs:int, deleted:int, repacked:int, freedBytes:int, keptBlobs:int, unusedBlobs:int} + */ + public function prune(bool $dryRun = false, float $repackThreshold = 0.5, array $ignoreSnapshots = []): array { + $used = []; + $ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first + foreach ($this->listSnapshots() as $sid) { + if (isset($ignore[$sid])) { + continue; + } + foreach ($this->streamEntries($sid) as $e) { + foreach ($e['b'] as $b) { + $used[$b] = true; + } + } + $meta = $this->snapshotMeta($sid); + if (isset($meta['db']) && $this->backend->exists($meta['db'])) { + foreach (json_decode($this->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) { + foreach ($t['blobs'] as $b) { + $used[$b] = true; + } + } + } + } + foreach ($this->backend->list('users') as $path) { + foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) { + foreach ($e['b'] ?? [] as $b) { + $used[$b] = true; + } + } + } + + $stats = ['packs' => 0, 'deleted' => 0, 'repacked' => 0, 'freedBytes' => 0, 'keptBlobs' => count($used), 'unusedBlobs' => 0]; + $packs = $this->index->packs(); + $stats['packs'] = count($packs); + $writer = null; + $toRemove = []; + foreach ($packs as $packId => $blobs) { + $total = 0; + $unused = 0; + foreach ($blobs as $b) { + $total += $b['length']; + if (!isset($used[$b['id']])) { + $unused += $b['length']; + $stats['unusedBlobs']++; + } + } + if ($unused === 0 || ($unused < $total && $unused / $total <= $repackThreshold)) { + continue; + } + $stats['freedBytes'] += $unused; + if ($unused === $total) { + $stats['deleted']++; + } else { + $stats['repacked']++; + if (!$dryRun) { + $writer ??= new PackWriter($this->backend, $this->cipher, $this->index); + $path = 'packs/' . substr($packId, 0, 2) . '/' . $packId; + foreach ($blobs as $b) { + if (isset($used[$b['id']])) { + [, , , $raw, $flags] = $this->index->get($b['id']); + $writer->addRaw($b['id'], $this->backend->getRange($path, $b['offset'], $b['length']), $raw, $flags); + } + } + } + } + $toRemove[] = $packId; + } + if ($dryRun) { + return $stats; + } + // New packs and their index entries are durable before any old pack is removed. + $writer?->flush(); + foreach ($toRemove as $packId) { + $this->index->removePack($packId); + $this->backend->delete('packs/' . substr($packId, 0, 2) . '/' . $packId); + } + return $stats; + } + /** @return list */ public function listSnapshots(): array { return array_map('basename', $this->backend->list('snapshots')); diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index 36fadb8..d54c30c 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -51,6 +51,9 @@ class BackupService { if ($this->config->getSystemValue('objectstore', null) !== null) { throw new \RuntimeException('Object storage as primary storage is not supported yet'); } + if ($this->locking->isLocked(PruneService::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE)) { + throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later'); + } $running = $this->runs->findRunning($target->getId(), self::KIND_FULL); if ($running !== []) { return $running[0]; diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php new file mode 100644 index 0000000..56b072e --- /dev/null +++ b/lib/Service/PruneService.php @@ -0,0 +1,88 @@ +getId(); + } + + public function policy(): RetentionPolicy { + return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []); + } + + public function setPolicy(RetentionPolicy $policy): void { + if ($policy->last < 1) { + throw new \InvalidArgumentException('Keep at least the last snapshot'); + } + $this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR)); + } + + /** + * @return array{kept: array>, forgotten: list, prune: array} + */ + public function apply(Target $target, bool $dryRun = false): array { + if ($target->getAppendOnly()) { + throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there'); + } + if ($this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) { + throw new \RuntimeException('A backup to ' . $target->getName() . ' is running; try again later'); + } + try { + $this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException) { + throw new \RuntimeException('Another backup or cleanup of ' . $target->getName() . ' is running'); + } + try { + $repo = $this->targets->repository($target); + $times = []; + foreach ($repo->listSnapshots() as $id) { + $meta = $repo->snapshotMeta($id); + $times[$id] = (int)strtotime((string)($meta['time'] ?? '@0')); + } + $keep = $this->policy()->keep($times); + $forget = array_values(array_diff(array_keys($times), array_keys($keep))); + if (!$dryRun) { + foreach ($forget as $id) { + $repo->forget($id); + try { + $this->snapshots->delete($this->snapshots->findOne($target->getId(), $id)); + } catch (\Throwable) { + } + } + } + // Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone. + $prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []); + return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune]; + } finally { + $this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE); + } + } +} diff --git a/lib/Service/RetentionPolicy.php b/lib/Service/RetentionPolicy.php new file mode 100644 index 0000000..fc4b1a7 --- /dev/null +++ b/lib/Service/RetentionPolicy.php @@ -0,0 +1,60 @@ + $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly]; + } + + /** + * @param array $snapshots id => unix time + * @return array> id => reasons it is kept (ids not in the result are removed) + */ + public function keep(array $snapshots): array { + arsort($snapshots); + $keep = []; + $i = 0; + foreach ($snapshots as $id => $time) { + if ($i++ < $this->last) { + $keep[$id][] = 'last'; + } + } + foreach (['daily' => 'Y-m-d', 'weekly' => 'o-W', 'monthly' => 'Y-m'] as $kind => $format) { + $seen = []; + foreach ($snapshots as $id => $time) { + $bucket = date($format, $time); + if (isset($seen[$bucket])) { + continue; + } + if (count($seen) >= $this->$kind) { + break; + } + $seen[$bucket] = true; + $keep[$id][] = $kind; + } + } + return $keep; + } +} diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index ed301ee..2a3e499 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -89,6 +89,11 @@ class TargetService { return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId); } + public function setAppendOnly(Target $target, bool $appendOnly): Target { + $target->setAppendOnly($appendOnly); + return $this->mapper->update($target); + } + /** Remove the target from NG Backup. The backups on the target itself are not touched. */ public function remove(Target $target): void { $this->mapper->delete($target); diff --git a/tests/Unit/Repository/PruneTest.php b/tests/Unit/Repository/PruneTest.php new file mode 100644 index 0000000..5a4f84a --- /dev/null +++ b/tests/Unit/Repository/PruneTest.php @@ -0,0 +1,85 @@ +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)); + } +} diff --git a/tests/Unit/Service/RetentionPolicyTest.php b/tests/Unit/Service/RetentionPolicyTest.php new file mode 100644 index 0000000..6dca2e4 --- /dev/null +++ b/tests/Unit/Service/RetentionPolicyTest.php @@ -0,0 +1,32 @@ +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)))); + } +}