Skip to content

Commit 6d2bffe

Browse files
committed
adds option not to raise when leaving context manager after lock expiration
1 parent 601c1aa commit 6d2bffe

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

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

14+
logger = logging.getLogger(__name__)
15+
1316

1417
class Lock:
1518
"""
@@ -85,6 +88,7 @@ def __init__(
8588
blocking: bool = True,
8689
blocking_timeout: Optional[Number] = None,
8790
thread_local: bool = True,
91+
raise_on_release_error: bool = True,
8892
):
8993
"""
9094
Create a new Lock instance named ``name`` using the Redis client
@@ -127,6 +131,11 @@ def __init__(
127131
token is *not* stored in thread local storage, then
128132
thread-1 would see the token value as "xyz" and would be
129133
able to successfully release the thread-2's lock.
134+
135+
``raise_on_release_error`` indicates whether to raise an exception when
136+
the lock is no longer owned when exiting the context manager. By default,
137+
this is True, meaning an exception will be raised. If False, the warning
138+
will be logged and the exception will be suppressed.
130139
131140
In some use cases it's necessary to disable thread local storage. For
132141
example, if you have code where one thread acquires a lock and passes
@@ -144,6 +153,7 @@ def __init__(
144153
self.blocking_timeout = blocking_timeout
145154
self.thread_local = bool(thread_local)
146155
self.local = threading.local() if self.thread_local else SimpleNamespace()
156+
self.raise_on_release_error = raise_on_release_error
147157
self.local.token = None
148158
self.register_scripts()
149159

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

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

168184
async def acquire(
169185
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
@@ -89,7 +89,7 @@ def __init__(self, message=None, lock_name=None):
8989

9090

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

9494
pass
9595

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)