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))));
+ }
+}