Skip to content

Commit 54d0b13

Browse files
committed
adds option not to raise when leaving context manager after lock expiration
1 parent 577b4ac commit 54d0b13

File tree

7 files changed

+78
-3
lines changed

7 files changed

+78
-3
lines changed

redis/asyncio/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ def lock(
478478
blocking_timeout: Optional[float] = None,
479479
lock_class: Optional[Type[Lock]] = None,
480480
thread_local: bool = True,
481+
raise_on_release_error: bool = True,
481482
) -> Lock:
482483
"""
483484
Return a new Lock object using key ``name`` that mimics
@@ -524,6 +525,11 @@ def lock(
524525
thread-1 would see the token value as "xyz" and would be
525526
able to successfully release the thread-2's lock.
526527
528+
``raise_on_release_error`` indicates whether to raise an exception when
529+
the lock is no longer owned when exiting the context manager. By default,
530+
this is True, meaning an exception will be raised. If False, the warning
531+
will be logged and the exception will be suppressed.
532+
527533
In some use cases it's necessary to disable thread local storage. For
528534
example, if you have code where one thread acquires a lock and passes
529535
that lock instance to a worker thread to release later. If thread
@@ -541,6 +547,7 @@ def lock(
541547
blocking=blocking,
542548
blocking_timeout=blocking_timeout,
543549
thread_local=thread_local,
550+
raise_on_release_error=raise_on_release_error,
544551
)
545552

546553
def pubsub(self, **kwargs) -> "PubSub":

redis/asyncio/lock.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import threading
33
import uuid
4+
import logging
45
from types import SimpleNamespace
56
from typing import TYPE_CHECKING, Awaitable, Optional, Union
67

@@ -9,6 +10,8 @@
910
if TYPE_CHECKING:
1011
from redis.asyncio import Redis, RedisCluster
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
class Lock:
1417
"""
@@ -84,6 +87,7 @@ def __init__(
8487
blocking: bool = True,
8588
blocking_timeout: Optional[float] = None,
8689
thread_local: bool = True,
90+
raise_on_release_error: bool = True,
8791
):
8892
"""
8993
Create a new Lock instance named ``name`` using the Redis client
@@ -126,6 +130,11 @@ def __init__(
126130
token is *not* stored in thread local storage, then
127131
thread-1 would see the token value as "xyz" and would be
128132
able to successfully release the thread-2's lock.
133+
134+
``raise_on_release_error`` indicates whether to raise an exception when
135+
the lock is no longer owned when exiting the context manager. By default,
136+
this is True, meaning an exception will be raised. If False, the warning
137+
will be logged and the exception will be suppressed.
129138
130139
In some use cases it's necessary to disable thread local storage. For
131140
example, if you have code where one thread acquires a lock and passes
@@ -143,6 +152,7 @@ def __init__(
143152
self.blocking_timeout = blocking_timeout
144153
self.thread_local = bool(thread_local)
145154
self.local = threading.local() if self.thread_local else SimpleNamespace()
155+
self.raise_on_release_error = raise_on_release_error
146156
self.local.token = None
147157
self.register_scripts()
148158

@@ -162,7 +172,13 @@ async def __aenter__(self):
162172
raise LockError("Unable to acquire lock within the time specified")
163173

164174
async def __aexit__(self, exc_type, exc_value, traceback):
165-
await self.release()
175+
try:
176+
await self.release()
177+
except LockNotOwnedError as e:
178+
if self.raise_on_release_error:
179+
raise e
180+
logger.warning("Lock was no longer owned when exiting context manager.")
181+
166182

167183
async def acquire(
168184
self,

redis/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ def lock(
473473
blocking_timeout: Optional[float] = None,
474474
lock_class: Union[None, Any] = None,
475475
thread_local: bool = True,
476+
raise_on_release_error: bool = True,
476477
):
477478
"""
478479
Return a new Lock object using key ``name`` that mimics
@@ -519,6 +520,11 @@ def lock(
519520
thread-1 would see the token value as "xyz" and would be
520521
able to successfully release the thread-2's lock.
521522
523+
``raise_on_release_error`` indicates whether to raise an exception when
524+
the lock is no longer owned when exiting the context manager. By default,
525+
this is True, meaning an exception will be raised. If False, the warning
526+
will be logged and the exception will be suppressed.
527+
522528
In some use cases it's necessary to disable thread local storage. For
523529
example, if you have code where one thread acquires a lock and passes
524530
that lock instance to a worker thread to release later. If thread
@@ -536,6 +542,7 @@ def lock(
536542
blocking=blocking,
537543
blocking_timeout=blocking_timeout,
538544
thread_local=thread_local,
545+
raise_on_release_error=raise_on_release_error,
539546
)
540547

541548
def pubsub(self, **kwargs):

redis/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def __init__(self, message=None, lock_name=None):
8888

8989

9090
class LockNotOwnedError(LockError):
91-
"Error trying to extend or release a lock that is (no longer) owned"
91+
"Error trying to extend or release a lock that is not owned (anymore)"
9292
pass
9393

9494

redis/lock.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import threading
22
import time as mod_time
33
import uuid
4+
import logging
45
from types import SimpleNamespace, TracebackType
56
from typing import Optional, Type
67

78
from redis.exceptions import LockError, LockNotOwnedError
89
from redis.typing import Number
910

11+
logger = logging.getLogger(__name__)
12+
1013

1114
class Lock:
1215
"""
@@ -82,6 +85,7 @@ def __init__(
8285
blocking: bool = True,
8386
blocking_timeout: Optional[Number] = None,
8487
thread_local: bool = True,
88+
raise_on_release_error: bool = True,
8589
):
8690
"""
8791
Create a new Lock instance named ``name`` using the Redis client
@@ -125,6 +129,11 @@ def __init__(
125129
thread-1 would see the token value as "xyz" and would be
126130
able to successfully release the thread-2's lock.
127131
132+
``raise_on_release_error`` indicates whether to raise an exception when
133+
the lock is no longer owned when exiting the context manager. By default,
134+
this is True, meaning an exception will be raised. If False, the warning
135+
will be logged and the exception will be suppressed.
136+
128137
In some use cases it's necessary to disable thread local storage. For
129138
example, if you have code where one thread acquires a lock and passes
130139
that lock instance to a worker thread to release later. If thread
@@ -140,6 +149,7 @@ def __init__(
140149
self.blocking = blocking
141150
self.blocking_timeout = blocking_timeout
142151
self.thread_local = bool(thread_local)
152+
self.raise_on_release_error = raise_on_release_error
143153
self.local = threading.local() if self.thread_local else SimpleNamespace()
144154
self.local.token = None
145155
self.register_scripts()
@@ -168,7 +178,12 @@ def __exit__(
168178
exc_value: Optional[BaseException],
169179
traceback: Optional[TracebackType],
170180
) -> None:
171-
self.release()
181+
try:
182+
self.release()
183+
except LockNotOwnedError as e:
184+
if self.raise_on_release_error:
185+
raise e
186+
logger.warning("Lock was no longer owned when exiting context manager.")
172187

173188
def acquire(
174189
self,

tests/test_asyncio/test_lock.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ async def test_context_manager_raises_when_locked_not_acquired(self, r):
129129
async with self.get_lock(r, "foo", blocking_timeout=0.1):
130130
pass
131131

132+
async def test_context_manager_not_raise_on_release_error(self, r):
133+
try:
134+
async with self.get_lock(
135+
r, "foo", timeout=0.1, raise_on_release_error=False
136+
):
137+
await asyncio.sleep(0.15)
138+
except LockNotOwnedError:
139+
pytest.fail("LockNotOwnedError should not have been raised")
140+
141+
with pytest.raises(LockNotOwnedError):
142+
async with self.get_lock(
143+
r, "foo", timeout=0.1, raise_on_release_error=True
144+
):
145+
await asyncio.sleep(0.15)
146+
132147
async def test_high_sleep_small_blocking_timeout(self, r):
133148
lock1 = self.get_lock(r, "foo")
134149
assert await lock1.acquire(blocking=False)

tests/test_lock.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ def test_context_manager_raises_when_locked_not_acquired(self, r):
133133
with self.get_lock(r, "foo", blocking_timeout=0.1):
134134
pass
135135

136+
def test_context_manager_not_raise_on_release_error(self, r):
137+
try:
138+
with self.get_lock(
139+
r, "foo", timeout=0.1, raise_on_release_error=False
140+
):
141+
time.sleep(0.15)
142+
except LockNotOwnedError:
143+
pytest.fail("LockNotOwnedError should not have been raised")
144+
145+
with pytest.raises(LockNotOwnedError):
146+
with self.get_lock(
147+
r, "foo", timeout=0.1, raise_on_release_error=True
148+
):
149+
time.sleep(0.15)
150+
136151
def test_high_sleep_small_blocking_timeout(self, r):
137152
lock1 = self.get_lock(r, "foo")
138153
assert lock1.acquire(blocking=False)

0 commit comments

Comments
 (0)