Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions docs/misc/prune-example-interval.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ days going back to late 2025. Today, `borg create` took a little longer than
usual. It's 16:12 now and you run `borg prune`.

You want Borg to keep one archive per day for one week, four weekly archives,
one archive per month for five months, and two yearly backups. For that, you
one archive per month for six months, and two yearly backups. For that, you
use the following command:

```
borg prune \
--keep-daily 1w \
--keep-weekly 4 \
--keep-monthly 5m \
--keep-monthly 6m \
--keep-yearly 2 \
--from '2026-06-04 16:00'
```
Expand Down Expand Up @@ -49,6 +49,7 @@ archives kept by the `--keep-weekly` rule are marked by a "w" to the right,
archives kept by the `--keep-monthly` rule are marked by a "m" to the right,
archives kept by the `--keep-yearly` rule are marked by a "y" to the right, and
archives kept by the `--from` rule are marked by a "x" to the right.
An "n" marks a day when no backup was taken.


Calendar view
Expand All @@ -68,20 +69,20 @@ Calendar view
12 13 14 15 16 17 18 9 10 11 12 13 14 15 9 10 11 12 13 14 15
19 20 21 22 23 24 25 16 17 18 19 20 21 22 16 17 18 19 20 21 22
26 27 28 29 30 31m 23 24 25 26 27 28m 23 24 25 26 27 28 29
30m31
30m31n

April May June
1 2 3 4 5 1 2 3w 1d 2d 3 4x
1 2 3 4 5 1 2 3w 1d 2d 3n 4x
6 7 8 9 10 11 12 4 5 6 7 8 9 10w
13 14 15 16 17 18 19 11 12 13 14 15 16 17w
20 21 22 23 24 25 26 18 19 20 21 22 23w24
20 21 22 23 24 25 26 18 19 20 21 22 23w24n
27 28 29 30m 25 26 27 28d29d30d31d


List view
---------

--keep-daily 1w --keep-weekly 4 --keep-monthly 5m --keep-yearly 2
--keep-daily 1w --keep-weekly 4 --keep-monthly 6m --keep-yearly 2
--------------------------------------------------------------------------------
1. 2025-11-15 (oldest)
1. 2026-06-02 1. 2026-05-23 1. 2026-04-30
Expand Down Expand Up @@ -114,7 +115,7 @@ later reference time, e.g. `--from '2026-06-04 23:59:59'`.
2026-05-31 is considered not only by `--keep-daily`, but by `--keep-weekly`
and `--keep-monthly`, too. The archive is effectively kept by `--keep-daily`,
but how this affects other rules differs between *count*- and *interval*-based
policies. For *interval*-based rules like `--keep-monthly 5m` it has no effect:
policies. For *interval*-based rules like `--keep-monthly 6m` it has no effect:
The rule simply keeps one archive fewer in that case.

For *count*-based rules like `--keep-weekly 4` it has an effect: The policy
Expand All @@ -132,24 +133,17 @@ Since 2026-05-24 and 2026-03-31 were skipped, Borg substitutes the next best
candidate for each: 2026-05-23 fills the week 21 weekly slot, and 2026-03-30
fills the March monthly slot.

The implementation of `--keep-monthly 5m` is somewhat special: Borg defines a
month as a fixed 31-day period, independent of the actual calendar dates
involved. As a result, `5m` corresponds to 5 × 31 = 155 days. The archive from
2025-12-31 16:00 is exactly 155 days older than the reference time and is
therefore retained by `--keep-monthly`.

As a result, there are no true yearly candidates. In the absence of a better
candidate, `--keep-yearly 2` only matches the oldest archive, 2025-11-15.
The `--keep-monthly 6m` window starts on 2025-12-04 at 16:00.
The archive from 2025-12-31 falls within this window and is kept by
`--keep-monthly`.

Since interval rules define time windows rather than competing for a fixed
number of slots, their interplay is simpler than count-based rules. An archive
is kept by an interval rule as long as it falls within the specified window;
the next rule simply considers whatever remains.

Intervals and counts can be mixed freely. Yearly retention in this example is
done by retention count instead of intervals. A count rule paired with interval
rules behaves just as it would if all preceding rules were also counts:
Archives already kept by earlier rules are excluded from consideration. In this
example, every yearly slot is consumed by earlier rules; no true yearly
candidate remains, so the oldest archive at 2025-11-15 is kept to preserve
rolling backup semantics.
Intervals and counts can be mixed freely. Archives already kept by earlier
rules do not count again toward a count-based rule. Here, `--keep-monthly`
keeps 2025-12-31, so it does not fill a `--keep-yearly` slot. The yearly
rule keeps 2025-11-15 as the oldest remaining archive to preserve rolling
backup semantics.
17 changes: 12 additions & 5 deletions docs/usage/general/date-time.rst.inc
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,20 @@ a number of years (e.g. ``2y``), months (e.g. ``12m``), weeks (e.g. ``2w``),
days (e.g. ``7d``), hours (e.g. ``8H``), minutes (e.g. ``30M``), or seconds
(e.g. ``150S``).

Weeks, days, hours, minutes, and seconds have fixed lengths. For example,
``1w`` is 7 days and ``1d`` is ``86400S``. Months and years are interpreted
relative to a reference timestamp. ``1m`` can be shorter than, equal to,
or longer than ``30d``. ``1y`` spans 365 or 366 days and always matches
12 months.

Months and years follow the calendar. For example, ``12m`` before
2026-07-22 is 2025-07-22. ``3y`` before 2026-07-22 is 2023-07-22.
The time of day is preserved. If the target month has fewer days, the date
is clamped to its last day. One month before 2024-03-31 is 2024-02-29.
One year before 2024-02-29 is 2023-02-28.

The ``borg prune`` ``--keep-*`` retention options accept either a plain count
(e.g. ``--keep-daily 7``, keeping up to 7 daily archives) or a time interval
(e.g. ``--keep-daily 7d``, keeping one daily archive per day within a 7-day window).
When using interval-based retention, ``--from`` may be specified to set the
reference timestamp for the interval (defaults to the current time).

Please note that Borg treats months (e.g. ``12m``) as fixed 31-day periods
rather than calendar months. As a result, ``12m`` corresponds to
12 × 31 = 372 days. Similarly, years (e.g. ``2y``) are treated as fixed
365-day periods and do not take leap years into account.
73 changes: 27 additions & 46 deletions src/borg/archiver/prune_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import math
from functools import partial, wraps
import os
from itertools import count, combinations
from itertools import count
from ._common import with_repository, Highlander, archive_match_patterns
from ..constants import * # NOQA
from ..helpers import ArchiveFormatter, ProgressIndicatorPercent, CommandError, Error
from ..helpers import archivename_validator, int_or_interval, sig_int, timestamp
from ..helpers import GroupBySpec
from ..helpers import json_print, basic_json_data
from ..helpers.argparsing import ArgumentParser
from ..helpers.time import calculate_relative_offset
from ..manifest import AI_GROUP_BY_KEYS, ArchiveInfo, format_group_key, group_archives

from ..logger import create_logger
Expand Down Expand Up @@ -198,7 +199,19 @@ class PruneMixIn:
@with_repository()
def do_prune(self, args, repository, manifest):
"""Prune archives according to specified rules."""
self._validate_prune_args(args)
from_timestamp = getattr(args, PRUNE_FROM.key)
base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone()

keep_args = {rule.key: getattr(args, rule.key) for rule in PRUNING_RULES if getattr(args, rule.key) is not None}
for name, value in keep_args.items():
if not isinstance(value, str):
continue
try:
cutoff = calculate_relative_offset(value, base_timestamp, earlier=True)
except (ValueError, OverflowError) as error:
raise CommandError(f"Invalid retention interval {value!r} for {name}: {error}") from None
keep_args[name] = base_timestamp - cutoff
self._validate_prune_args(keep_args)

match = archive_match_patterns(args)
archives = manifest.archives.list(match=match, sort_by=["ts"], reverse=True)
Expand All @@ -210,16 +223,12 @@ def do_prune(self, args, repository, manifest):
group_by = tuple(args.group_by.split(",")) if args.group_by else ()
groups = group_archives(archives, group_by)

# All groups use the same reference timestamp, so interval based rules do not drift apart.
from_timestamp = getattr(args, PRUNE_FROM.key)
base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone()

# Archives to keep along with the rule that ensured them being kept
keep = {}
group_of = {}
group_summaries = []
for key, group in groups.items():
group_keep = self._compute_keep(group, args, base_timestamp)
group_keep = self._compute_keep(group, args, base_timestamp, keep_args)
keep |= group_keep
group_of.update(dict.fromkeys(group, key))
group_summaries.append((key, len(group), len(group_keep)))
Expand Down Expand Up @@ -309,7 +318,7 @@ def do_prune(self, args, repository, manifest):
if sig_int:
raise Error("Got Ctrl-C / SIGINT.")

def _compute_keep(self, archives, args, base_timestamp):
def _compute_keep(self, archives, args, base_timestamp, keep_args):
"""
Apply the retention rules to *archives* (sorted by timestamp, newest first).

Expand All @@ -332,26 +341,20 @@ def _compute_keep(self, archives, args, base_timestamp):
# Apply each retention rule to all candidate archives. The
# `previously_kept` parameter prevents later (coarser-grained) rules
# from double-counting archives already retained by earlier rules.
active_rules = [
(rule, getattr(args, rule.key)) for rule in PRUNING_RULES if getattr(args, rule.key) is not None
]
active_rules = [(rule, keep_args[rule.key]) for rule in PRUNING_RULES if rule.key in keep_args]
for rule, n_or_interval in active_rules:
keep |= prune(
archives=candidate_archives,
rule=rule,
n_or_interval=n_or_interval,
base_timestamp=base_timestamp,
keep_oldest=(
rule == active_rules[-1][0]
), # Activate keep_oldest rule only for the largest active interval
keep_oldest=rule == active_rules[-1][0],
previously_kept=frozenset(keep),
)

return keep

def _validate_prune_args(self, args):
keep_args = {rule.key: getattr(args, rule.key) for rule in PRUNING_RULES if getattr(args, rule.key) is not None}

def _validate_prune_args(self, keep_args):
if len(keep_args) == 0:
raise CommandError(
'At least one of the "keep", "keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", '
Expand All @@ -366,26 +369,6 @@ def _validate_prune_args(self, args):
"At least one must be non-zero."
)

def lo_hi_mismatch_errmsg(lo_arg, lo_val, hi_arg, hi_val):
return (
f"The combination of \"{lo_arg}='{lo_val}'\" and \"{hi_arg}='{hi_val}'\" is invalid. It is effectively "
f"useless since every archive matched by {hi_arg} would have already been matched by {lo_arg}."
)

interval_args = [(arg, val) for arg, val in keep_args.items() if isinstance(val, timedelta) or val == -1]
for (lo_arg, lo_val), (hi_arg, hi_val) in combinations(interval_args, 2):
if hi_val == -1:
# 'Infinity' is always bigger
continue

if lo_val == -1 or lo_val >= hi_val:
raise CommandError(lo_hi_mismatch_errmsg(lo_arg, lo_val, hi_arg, hi_val))

int_args = [(arg, val) for arg, val in keep_args.items() if isinstance(val, int)]
for (lo_arg, lo_val), (hi_arg, hi_val) in combinations(int_args, 2):
if lo_val == -1:
raise CommandError(lo_hi_mismatch_errmsg(lo_arg, lo_val, hi_arg, hi_val))

def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
from ._common import process_epilog
from ._common import define_archive_filters_group
Expand Down Expand Up @@ -465,7 +448,8 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
that time window. When ``--from`` is given together with an interval
retention, the interval is measured backwards from that timestamp
instead of from the current time. See ``Date and Time`` docs for exact
INTERVAL format.
INTERVAL format. Months and years follow the calendar. One month before
2024-03-31 is 2024-02-29, at the same time of day.

The ``--keep-secondly``, ``--keep-minutely``, ``--keep-hourly``,
``--keep-daily``, ``--keep-weekly``, ``--keep-monthly``,
Expand Down Expand Up @@ -498,14 +482,11 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
schemes, where the earliest backup in a retention window should survive until
the next tier's interval naturally replaces it.

When using interval-based pruning with multiple ``--keep-*`` options,
the intervals must be specified in increasing length matching the
periods chosen. For example, ``--keep-daily 7d --keep-weekly 4w`` is
valid, but ``--keep-daily 30d --keep-weekly 7d`` is not, because the
weekly interval is already covered by the daily one and so the weekly
interval is effectively useless. An error is emitted upon running
``borg prune`` if such a combination of flags is given. The order of
flags on the command line is not significant.
Retention rules may overlap. For example,
``--keep-daily 30d --keep-weekly 7d`` is allowed. The weekly rule does
not keep additional archives because the daily rule already covers
its time window. The order of flags on the command line does not
change the order in which the rules are applied.


A practical approach for recurring backups is to use rules
Expand Down
2 changes: 1 addition & 1 deletion src/borg/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper
from .parseformat import octal_int, bin_to_hex, hex_to_bin, safe_encode, safe_decode
from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd
from .parseformat import eval_escapes, decode_dict, interval, int_or_interval, nonnegative_seconds
from .parseformat import eval_escapes, decode_dict, int_or_interval, nonnegative_seconds
from .parseformat import (
PathSpec,
FilesystemPathSpec,
Expand Down
51 changes: 5 additions & 46 deletions src/borg/helpers/parseformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import uuid
from pathlib import Path
from typing import ClassVar, Any, TYPE_CHECKING, Literal
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from functools import lru_cache, partial
from hashlib import sha256
from string import Formatter
Expand Down Expand Up @@ -141,58 +141,17 @@ def nonnegative_seconds(s):
return seconds


def interval(s):
"""Parse an interval string (e.g. ``7d``, ``2w``, ``30M``) into a timedelta.

Supported units: y (years, 365d), m (months, 31d), w (weeks), d (days),
H (hours), M (minutes), S (seconds). The value must be nonnegative.
"""
if isinstance(s, timedelta):
return s

seconds_in_a_minute = 60
seconds_in_an_hour = 60 * seconds_in_a_minute
seconds_in_a_day = 24 * seconds_in_an_hour
seconds_in_a_week = 7 * seconds_in_a_day
seconds_in_a_month = 31 * seconds_in_a_day
seconds_in_a_year = 365 * seconds_in_a_day
multiplier = dict(
y=seconds_in_a_year,
m=seconds_in_a_month,
w=seconds_in_a_week,
d=seconds_in_a_day,
H=seconds_in_an_hour,
M=seconds_in_a_minute,
S=1,
)

if s.endswith(tuple(multiplier.keys())):
number = s[:-1]
suffix = s[-1]
else:
raise ArgumentTypeError(f'Unexpected time unit "{s[-1]}": choose from {", ".join(multiplier)}')

try:
seconds = int(number) * multiplier[suffix]
except ValueError:
seconds = -1

if seconds < 0:
raise ArgumentTypeError(f'Invalid number "{number}": expected nonnegative integer')

return timedelta(seconds=seconds)


def int_or_interval(s):
"""Parse *s* as an :class:`int` or, failing that, as an interval string.

Returns :class:`int` if *s* can be parsed as an integer (e.g. ``"7"``),
or :class:`datetime.timedelta` if *s* is a valid interval (e.g. ``"7d"``).
or the validated interval string (e.g. ``"7d"``). Months and years need a
reference timestamp before they can be converted to a duration.

Raises :class:`ArgumentTypeError` if *s* is neither an integer nor
a valid interval.
"""
if isinstance(s, (int, timedelta)):
if isinstance(s, int):
return s

# Explicitly check 'all' as a shortcut to 'infinite' sentinel value `-1`.
Expand All @@ -205,7 +164,7 @@ def int_or_interval(s):
pass

try:
return interval(s)
return relative_time_marker_validator(s)
except ArgumentTypeError as e:
raise ArgumentTypeError(f"Value is neither an integer nor an interval: {e}")

Expand Down
Loading
Loading