diff --git a/docs/man/borg-with-lock.1 b/docs/man/borg-with-lock.1 index 511e306e04..4e8c87f517 100644 --- a/docs/man/borg-with-lock.1 +++ b/docs/man/borg-with-lock.1 @@ -28,7 +28,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "borg-with-lock" "1" "2026-07-21" "" "borg backup tool" +.TH "borg-with-lock" "1" "2026-08-13" "" "borg backup tool" .SH Name borg-with-lock \- Runs a user-specified command with the repository lock held. .SH SYNOPSIS @@ -55,9 +55,9 @@ code as Borg\(aqs return code. .INDENT 0.0 .INDENT 3.5 If you copy a repository with the lock held, the lock will be present in -the copy. Before using Borg on the copy from a different host, -you need to run \fBborg break\-lock\fP on the copied repository, because -Borg is cautious and does not automatically remove stale locks made by a different host. +the copy. Borg removes such a leftover lock automatically, but only after +a safety period of about half an hour, so to use Borg on the copy right +away, first run \fBborg break\-lock\fP on the copied repository. .UNINDENT .UNINDENT .SH OPTIONS diff --git a/docs/usage/with-lock.rst.inc b/docs/usage/with-lock.rst.inc index 8e8db70bbd..fa5f148e5f 100644 --- a/docs/usage/with-lock.rst.inc +++ b/docs/usage/with-lock.rst.inc @@ -60,6 +60,6 @@ code as Borg's return code. .. note:: If you copy a repository with the lock held, the lock will be present in - the copy. Before using Borg on the copy from a different host, - you need to run ``borg break-lock`` on the copied repository, because - Borg is cautious and does not automatically remove stale locks made by a different host. \ No newline at end of file + the copy. Borg removes such a leftover lock automatically, but only after + a safety period of about half an hour, so to use Borg on the copy right + away, first run ``borg break-lock`` on the copied repository. \ No newline at end of file diff --git a/src/borg/archiver/lock_cmds.py b/src/borg/archiver/lock_cmds.py index 10c2751e1f..209535be0d 100644 --- a/src/borg/archiver/lock_cmds.py +++ b/src/borg/archiver/lock_cmds.py @@ -67,9 +67,9 @@ def build_parser_locks(self, subparsers, common_parser, mid_common_parser): .. note:: If you copy a repository with the lock held, the lock will be present in - the copy. Before using Borg on the copy from a different host, - you need to run ``borg break-lock`` on the copied repository, because - Borg is cautious and does not automatically remove stale locks made by a different host. + the copy. Borg removes such a leftover lock automatically, but only after + a safety period of about half an hour, so to use Borg on the copy right + away, first run ``borg break-lock`` on the copied repository. """ ) subparser = ArgumentParser( diff --git a/src/borg/storelocking.py b/src/borg/storelocking.py index f781862383..c6b90d89c5 100644 --- a/src/borg/storelocking.py +++ b/src/borg/storelocking.py @@ -150,7 +150,12 @@ def _get_locks(self): return {} for info in infos: key = info.name - content = self.store.load(f"locks/{key}") + try: + content = self.store.load(f"locks/{key}") + except ObjectNotFound: + # the lock vanished between our listing and loading it, e.g. it was released + # by its owner or another client killed it as stale - so just ignore it. + continue lock = json.loads(content.decode("utf-8")) lock["key"] = key lock["dt"] = datetime.datetime.fromisoformat(lock["time"]) diff --git a/src/borg/testsuite/storelocking_test.py b/src/borg/testsuite/storelocking_test.py index a5cb7345f9..cd05af0100 100644 --- a/src/borg/testsuite/storelocking_test.py +++ b/src/borg/testsuite/storelocking_test.py @@ -3,7 +3,7 @@ import pytest -from borgstore.store import Store +from borgstore.store import ObjectNotFound, Store from ..storelocking import Lock, NotLocked, LockTimeout @@ -156,6 +156,24 @@ def delete(name, *args, **kwargs): lock.refresh() assert len(list(lockstore.list("locks"))) == 0 # no new lock left behind + def test_lock_vanished_between_list_and_load(self, lockstore, monkeypatch): + # another client can delete a lock (e.g. kill it as stale, or its owner releases it) + # between our listing and our loading of it - such a lock must be skipped, not crash us. + foreign_key = Lock(lockstore, id=ID1)._create_lock(exclusive=True) + + orig_load = lockstore.load + + def load_vanished(name, *args, **kwargs): + if name == f"locks/{foreign_key}": + raise ObjectNotFound(name) + return orig_load(name, *args, **kwargs) + + monkeypatch.setattr(lockstore, "load", load_vanished) + lock = Lock(lockstore, exclusive=True, id=ID2) + assert foreign_key not in lock._get_locks() # skipped, no exception + lock.acquire() # the vanished exclusive lock must not block us + lock.release() + def test_migrate_lock(self, lockstore): old_id, new_id = ID1, ID2 assert old_id[1] != new_id[1] # different PIDs (like when doing daemonize())