From 88f470ea8ead3c9381333458b3de7dd92a1f19b1 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 7 Jul 2026 00:15:09 +0200 Subject: [PATCH] locking: do not leave a lock behind when exclusive acquire times out When Lock.acquire() for an exclusive lock had created its lock and then timed out waiting for non-exclusive locks to go away, it raised LockTimeout without deleting the lock it had just created. That zombie exclusive lock then blocked all other clients until it expired as stale (up to 30 minutes) or until a client on the same host noticed the owning process was dead. Clients on other hosts were locked out for the full staleness period. Delete our lock before giving up. Co-Authored-By: Claude Fable 5 --- src/borg/storelocking.py | 3 +++ src/borg/testsuite/storelocking_test.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/borg/storelocking.py b/src/borg/storelocking.py index a81759c703..8f9e89106f 100644 --- a/src/borg/storelocking.py +++ b/src/borg/storelocking.py @@ -189,6 +189,9 @@ def acquire(self): return self time.sleep(self.other_locks_go_away_delay) logger.debug("LOCK-ACQUIRE: timeout while waiting for non-exclusive locks to go away.") + # we won't get the exclusive lock, so do not leave our lock behind: + # it would needlessly block other clients until it expired as stale. + self._delete_lock(key, ignore_not_found=True, update_last_refresh=True) break # timeout else: logger.debug("LOCK-ACQUIRE: someone else also created an exclusive lock, deleting ours.") diff --git a/src/borg/testsuite/storelocking_test.py b/src/borg/testsuite/storelocking_test.py index 8667d79348..361bccdff3 100644 --- a/src/borg/testsuite/storelocking_test.py +++ b/src/borg/testsuite/storelocking_test.py @@ -45,6 +45,17 @@ def test_exclusive_lock(self, lockstore): with pytest.raises(LockTimeout): Lock(lockstore, exclusive=True, id=ID2).acquire() + def test_exclusive_lock_timeout_leaves_no_lock(self, lockstore): + # When acquiring an exclusive lock times out because a non-exclusive lock does not go away, + # the not-acquired exclusive lock must not stay behind in the store: it would block all + # other clients (even on other hosts) until it expired as stale. + with Lock(lockstore, exclusive=False, id=ID1) as shared_lock: + with pytest.raises(LockTimeout): + Lock(lockstore, exclusive=True, id=ID2).acquire() + locks = shared_lock._get_locks() + assert len(locks) == 1 # only the non-exclusive lock of ID1 is left + assert not any(lock["exclusive"] for lock in locks.values()) + def test_double_nonexclusive_lock_succeeds(self, lockstore): with Lock(lockstore, exclusive=False, id=ID1): with Lock(lockstore, exclusive=False, id=ID2):