Skip to content

Commit 50943e0

Browse files
julianolmakxpetyaslavova
authored
feat: adds option not to raise exception when leaving context manager after lock expiration (#3531)
* adds option not to raise when leaving context manager after lock expiration * keep oroginal traceback Co-authored-by: Aarni Koskela <[email protected]> * improves error traceback * adds missing modifications * sort imports * run linter * adds catch for other possible exception * Update redis/lock.py to catch Both LockNotOwnedError and LockError in one except statement as LockError. Co-authored-by: Juliano Amadeu <[email protected]> * Update redis/asyncio/lock.py Co-authored-by: Juliano Amadeu <[email protected]> * fix linter errors --------- Co-authored-by: Aarni Koskela <[email protected]> Co-authored-by: petyaslavova <[email protected]>
1 parent 187d800 commit 50943e0

File tree

9 files changed

+121
-3
lines changed

9 files changed

+121
-3
lines changed

redis/asyncio/client.py

+7
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/cluster.py

+7
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ def lock(
839839
blocking_timeout: Optional[float] = None,
840840
lock_class: Optional[Type[Lock]] = None,
841841
thread_local: bool = True,
842+
raise_on_release_error: bool = True,
842843
) -> Lock:
843844
"""
844845
Return a new Lock object using key ``name`` that mimics
@@ -885,6 +886,11 @@ def lock(
885886
thread-1 would see the token value as "xyz" and would be
886887
able to successfully release the thread-2's lock.
887888
889+
``raise_on_release_error`` indicates whether to raise an exception when
890+
the lock is no longer owned when exiting the context manager. By default,
891+
this is True, meaning an exception will be raised. If False, the warning
892+
will be logged and the exception will be suppressed.
893+
888894
In some use cases it's necessary to disable thread local storage. For
889895
example, if you have code where one thread acquires a lock and passes
890896
that lock instance to a worker thread to release later. If thread
@@ -902,6 +908,7 @@ def lock(
902908
blocking=blocking,
903909
blocking_timeout=blocking_timeout,
904910
thread_local=thread_local,
911+
raise_on_release_error=raise_on_release_error,
905912
)
906913

907914

redis/asyncio/lock.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23
import threading
34
import uuid
45
from types import SimpleNamespace
@@ -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
@@ -128,6 +132,11 @@ def __init__(
128132
thread-1 would see the token value as "xyz" and would be
129133
able to successfully release the thread-2's lock.
130134
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.
139+
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
133142
that lock instance to a worker thread to release later. If thread
@@ -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,14 @@ 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 LockError:
179+
if self.raise_on_release_error:
180+
raise
181+
logger.warning(
182+
"Lock was unlocked or no longer owned when exiting context manager."
183+
)
167184

168185
async def acquire(
169186
self,

redis/client.py

+7
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/cluster.py

+7
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,7 @@ def lock(
822822
blocking_timeout=None,
823823
lock_class=None,
824824
thread_local=True,
825+
raise_on_release_error: bool = True,
825826
):
826827
"""
827828
Return a new Lock object using key ``name`` that mimics
@@ -868,6 +869,11 @@ def lock(
868869
thread-1 would see the token value as "xyz" and would be
869870
able to successfully release the thread-2's lock.
870871
872+
``raise_on_release_error`` indicates whether to raise an exception when
873+
the lock is no longer owned when exiting the context manager. By default,
874+
this is True, meaning an exception will be raised. If False, the warning
875+
will be logged and the exception will be suppressed.
876+
871877
In some use cases it's necessary to disable thread local storage. For
872878
example, if you have code where one thread acquires a lock and passes
873879
that lock instance to a worker thread to release later. If thread
@@ -885,6 +891,7 @@ def lock(
885891
blocking=blocking,
886892
blocking_timeout=blocking_timeout,
887893
thread_local=thread_local,
894+
raise_on_release_error=raise_on_release_error,
888895
)
889896

890897
def set_response_callback(self, command, callback):

redis/exceptions.py

+1-1
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

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import threading
23
import time as mod_time
34
import uuid
@@ -7,6 +8,8 @@
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,14 @@ def __exit__(
168178
exc_value: Optional[BaseException],
169179
traceback: Optional[TracebackType],
170180
) -> None:
171-
self.release()
181+
try:
182+
self.release()
183+
except LockError:
184+
if self.raise_on_release_error:
185+
raise
186+
logger.warning(
187+
"Lock was unlocked or no longer owned when exiting context manager."
188+
)
172189

173190
def acquire(
174191
self,

tests/test_asyncio/test_lock.py

+30
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,36 @@ 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_lock_not_owned_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+
147+
async def test_context_manager_not_raise_on_release_lock_error(self, r):
148+
try:
149+
async with self.get_lock(
150+
r, "foo", timeout=0.1, raise_on_release_error=False
151+
) as lock:
152+
lock.release()
153+
except LockError:
154+
pytest.fail("LockError should not have been raised")
155+
156+
with pytest.raises(LockError):
157+
async with self.get_lock(
158+
r, "foo", timeout=0.1, raise_on_release_error=True
159+
) as lock:
160+
lock.release()
161+
132162
async def test_high_sleep_small_blocking_timeout(self, r):
133163
lock1 = self.get_lock(r, "foo")
134164
assert await lock1.acquire(blocking=False)

tests/test_lock.py

+26
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,32 @@ 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_lock_not_owned_error(self, r):
137+
try:
138+
with self.get_lock(r, "foo", timeout=0.1, raise_on_release_error=False):
139+
time.sleep(0.15)
140+
except LockNotOwnedError:
141+
pytest.fail("LockNotOwnedError should not have been raised")
142+
143+
with pytest.raises(LockNotOwnedError):
144+
with self.get_lock(r, "foo", timeout=0.1, raise_on_release_error=True):
145+
time.sleep(0.15)
146+
147+
def test_context_manager_not_raise_on_release_lock_error(self, r):
148+
try:
149+
with self.get_lock(
150+
r, "foo", timeout=0.1, raise_on_release_error=False
151+
) as lock:
152+
lock.release()
153+
except LockError:
154+
pytest.fail("LockError should not have been raised")
155+
156+
with pytest.raises(LockError):
157+
with self.get_lock(
158+
r, "foo", timeout=0.1, raise_on_release_error=True
159+
) as lock:
160+
lock.release()
161+
136162
def test_high_sleep_small_blocking_timeout(self, r):
137163
lock1 = self.get_lock(r, "foo")
138164
assert lock1.acquire(blocking=False)

0 commit comments

Comments
 (0)