Skip to content

Commit fc384a7

Browse files
committed
Adds optional interval support for all prune retention flags
Support is added for setting prune retention with either an int (keep n archives) or an interval (keep within). This works much like --keep-within currently does, but extends support to all retention filters. Additionally adds a generic --keep flag to take over (or live alongside) both --keep-last and --keep-within. --keep-last is no longer an alias of --keep-secondly, now keeps archives made on the same second. Timestamp comparison is made inclusive, 'within 5 seconds' now means 'current second and the preceding 5 seconds' to match intuitive understanding of intervals in the past. Comparisons against archive timestamp are made to use local timezone instead of UTC. Should be equal result in practice, but allows for easier testing with frozen local time.
1 parent 455e876 commit fc384a7

File tree

5 files changed

+264
-81
lines changed

5 files changed

+264
-81
lines changed

requirements.d/development.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ pytest
1010
pytest-xdist
1111
pytest-cov
1212
pytest-benchmark
13+
freezegun
1314
Cython
1415
pre-commit

src/borg/archiver/prune_cmd.py

Lines changed: 70 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import argparse
22
from collections import OrderedDict
3-
from datetime import datetime, timezone, timedelta
3+
from datetime import datetime
44
import logging
55
from operator import attrgetter
66
import os
@@ -9,7 +9,15 @@
99
from ..archive import Archive
1010
from ..cache import Cache
1111
from ..constants import * # NOQA
12-
from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error
12+
from ..helpers import (
13+
ArchiveFormatter,
14+
interval,
15+
int_or_interval,
16+
sig_int,
17+
ProgressIndicatorPercent,
18+
CommandError,
19+
Error,
20+
)
1321
from ..helpers import archivename_validator
1422
from ..manifest import Manifest
1523

@@ -18,16 +26,15 @@
1826
logger = create_logger()
1927

2028

21-
def prune_within(archives, seconds, kept_because):
22-
target = datetime.now(timezone.utc) - timedelta(seconds=seconds)
23-
kept_counter = 0
24-
result = []
25-
for a in archives:
26-
if a.ts > target:
27-
kept_counter += 1
28-
kept_because[a.id] = ("within", kept_counter)
29-
result.append(a)
30-
return result
29+
def unique_func():
30+
counter = 0
31+
32+
def inner(a):
33+
nonlocal counter
34+
counter += 1
35+
return counter
36+
37+
return inner
3138

3239

3340
def default_period_func(pattern):
@@ -77,6 +84,9 @@ def quarterly_3monthly_period_func(a):
7784

7885
PRUNING_PATTERNS = OrderedDict(
7986
[
87+
("within", unique_func()),
88+
("last", unique_func()),
89+
("keep", unique_func()),
8090
("secondly", default_period_func("%Y-%m-%d %H:%M:%S")),
8191
("minutely", default_period_func("%Y-%m-%d %H:%M")),
8292
("hourly", default_period_func("%Y-%m-%d %H")),
@@ -90,7 +100,12 @@ def quarterly_3monthly_period_func(a):
90100
)
91101

92102

93-
def prune_split(archives, rule, n, kept_because=None):
103+
def prune_split(archives, rule, n_or_interval, kept_because=None):
104+
if isinstance(n_or_interval, int):
105+
n, interval = n_or_interval, None
106+
else:
107+
n, interval = None, n_or_interval
108+
94109
last = None
95110
keep = []
96111
period_func = PRUNING_PATTERNS[rule]
@@ -104,24 +119,30 @@ def prune_split(archives, rule, n, kept_because=None):
104119
period = period_func(a)
105120
if period != last:
106121
last = period
107-
if a.id not in kept_because:
122+
if a.id not in kept_because and (interval is None or a.ts >= datetime.now().astimezone() - interval):
108123
keep.append(a)
109124
kept_because[a.id] = (rule, len(keep))
110125
if len(keep) == n:
111126
break
127+
112128
# Keep oldest archive if we didn't reach the target retention count
113-
if a is not None and len(keep) < n and a.id not in kept_because:
129+
if a is not None and (n is not None and len(keep) < n) and a.id not in kept_because:
114130
keep.append(a)
115131
kept_because[a.id] = (rule + "[oldest]", len(keep))
132+
116133
return keep
117134

118135

119136
class PruneMixIn:
120137
@with_repository(compatibility=(Manifest.Operation.DELETE,))
121138
def do_prune(self, args, repository, manifest):
122139
"""Prune repository archives according to specified rules"""
123-
if not any(
124-
(
140+
if all(
141+
e is None
142+
for e in (
143+
args.keep,
144+
args.within,
145+
args.last,
125146
args.secondly,
126147
args.minutely,
127148
args.hourly,
@@ -131,11 +152,10 @@ def do_prune(self, args, repository, manifest):
131152
args.quarterly_13weekly,
132153
args.quarterly_3monthly,
133154
args.yearly,
134-
args.within,
135155
)
136156
):
137157
raise CommandError(
138-
'At least one of the "keep-within", "keep-last", '
158+
'At least one of the "keep", "keep-within", "keep-last", '
139159
'"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", '
140160
'"keep-weekly", "keep-monthly", "keep-13weekly", "keep-3monthly", '
141161
'or "keep-yearly" settings must be specified.'
@@ -159,15 +179,11 @@ def do_prune(self, args, repository, manifest):
159179
# (<rulename>, <how many archives were kept by this rule so far >)
160180
kept_because = {}
161181

162-
# find archives which need to be kept because of the keep-within rule
163-
if args.within:
164-
keep += prune_within(archives, args.within, kept_because)
165-
166182
# find archives which need to be kept because of the various time period rules
167183
for rule in PRUNING_PATTERNS.keys():
168-
num = getattr(args, rule, None)
169-
if num is not None:
170-
keep += prune_split(archives, rule, num, kept_because)
184+
num_or_interval = getattr(args, rule, None)
185+
if num_or_interval is not None:
186+
keep += prune_split(archives, rule, num_or_interval, kept_because)
171187

172188
to_delete = set(archives) - set(keep)
173189
with Cache(repository, manifest, iec=args.iec) as cache:
@@ -310,81 +326,90 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
310326
help="keep all archives within this time interval",
311327
)
312328
subparser.add_argument(
313-
"--keep-last",
329+
"--keep-last", dest="last", type=int, action=Highlander, help="number of archives to keep"
330+
)
331+
subparser.add_argument(
332+
"--keep",
333+
dest="keep",
334+
type=int_or_interval,
335+
action=Highlander,
336+
help="number or time interval of archives to keep",
337+
)
338+
subparser.add_argument(
314339
"--keep-secondly",
315340
dest="secondly",
316-
type=int,
341+
type=int_or_interval,
317342
default=0,
318343
action=Highlander,
319-
help="number of secondly archives to keep",
344+
help="number or time interval of secondly archives to keep",
320345
)
321346
subparser.add_argument(
322347
"--keep-minutely",
323348
dest="minutely",
324-
type=int,
349+
type=int_or_interval,
325350
default=0,
326351
action=Highlander,
327-
help="number of minutely archives to keep",
352+
help="number or time interval of minutely archives to keep",
328353
)
329354
subparser.add_argument(
330355
"-H",
331356
"--keep-hourly",
332357
dest="hourly",
333-
type=int,
358+
type=int_or_interval,
334359
default=0,
335360
action=Highlander,
336-
help="number of hourly archives to keep",
361+
help="number or time interval of hourly archives to keep",
337362
)
338363
subparser.add_argument(
339364
"-d",
340365
"--keep-daily",
341366
dest="daily",
342-
type=int,
367+
type=int_or_interval,
343368
default=0,
344369
action=Highlander,
345-
help="number of daily archives to keep",
370+
help="number or time interval of daily archives to keep",
346371
)
347372
subparser.add_argument(
348373
"-w",
349374
"--keep-weekly",
350375
dest="weekly",
351-
type=int,
376+
type=int_or_interval,
352377
default=0,
353378
action=Highlander,
354-
help="number of weekly archives to keep",
379+
help="number or time interval of weekly archives to keep",
355380
)
356381
subparser.add_argument(
357382
"-m",
358383
"--keep-monthly",
359384
dest="monthly",
360-
type=int,
385+
type=int_or_interval,
361386
default=0,
362387
action=Highlander,
363-
help="number of monthly archives to keep",
388+
help="number or time interval of monthly archives to keep",
364389
)
365390
quarterly_group = subparser.add_mutually_exclusive_group()
366391
quarterly_group.add_argument(
367392
"--keep-13weekly",
368393
dest="quarterly_13weekly",
369-
type=int,
394+
type=int_or_interval,
370395
default=0,
371-
help="number of quarterly archives to keep (13 week strategy)",
396+
help="number or time interval of quarterly archives to keep (13 week strategy)",
372397
)
373398
quarterly_group.add_argument(
374399
"--keep-3monthly",
375400
dest="quarterly_3monthly",
376-
type=int,
401+
type=int_or_interval,
377402
default=0,
378-
help="number of quarterly archives to keep (3 month strategy)",
403+
help="number or time interval of quarterly archives to keep (3 month strategy)",
379404
)
380405
subparser.add_argument(
381406
"-y",
382407
"--keep-yearly",
383408
dest="yearly",
384-
type=int,
409+
type=int_or_interval,
385410
default=0,
386411
action=Highlander,
387-
help="number of yearly archives to keep",
412+
help="number or time interval of yearly archives to keep",
388413
)
389414
define_archive_filters_group(subparser, sort_by=False, first_last=False)
390415
subparser.add_argument(

src/borg/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
EXIT_SIGNAL_BASE = 128 # terminated due to signal, rc = 128 + sig_no
138138

139139
ISO_FORMAT_NO_USECS = "%Y-%m-%dT%H:%M:%S"
140+
ISO_FORMAT_NO_USECS_ZONE = ISO_FORMAT_NO_USECS + "%z"
140141
ISO_FORMAT = ISO_FORMAT_NO_USECS + ".%f"
141142

142143
DASHES = "-" * 78

0 commit comments

Comments
 (0)