-
Notifications
You must be signed in to change notification settings - Fork 53
/
redlock.py
773 lines (672 loc) Β· 28 KB
/
redlock.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
# --------------------------------------------------------------------------- #
# redlock.py #
# #
# Copyright Β© 2015-2024, Rajiv Bakulesh Shah, original author. #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at: #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# --------------------------------------------------------------------------- #
'''Distributed Redis-powered lock.
This algorithm safely and reliably provides a mutually-exclusive locking
primitive to protect a resource shared across threads, processes, and even
machines, without a single point of failure.
Rationale and algorithm description:
http://redis.io/topics/distlock
Reference implementations:
https://github.com/antirez/redlock-rb
https://github.com/SPSCommerce/redlock-py
Lua scripting:
https://github.com/andymccurdy/redis-py#lua-scripting
'''
# TODO: Remove the following import after deferred evaluation of annotations
# because the default.
# 1. https://docs.python.org/3/whatsnew/3.7.html#whatsnew37-pep563
# 2. https://www.python.org/dev/peps/pep-0563/
# 3. https://www.python.org/dev/peps/pep-0649/
from __future__ import annotations
import concurrent.futures
import contextlib
import functools
import math
import random
import time
import uuid
from types import TracebackType
from typing import Any
from typing import Callable
from typing import ClassVar
from typing import Iterable
from typing import Literal
from typing import Tuple
from typing import Type
from typing import cast
from typing import overload
from redis import Redis
from redis import RedisError
from redis.asyncio import Redis as AIORedis
from redis.commands.core import Script
from .annotations import F
from .base import Primitive
from .base import logger
from .exceptions import ExtendUnlockedLock
from .exceptions import QuorumNotAchieved
from .exceptions import ReleaseUnlockedLock
from .exceptions import TooManyExtensions
from .executor import BailOutExecutor
from .timer import ContextTimer
class Scripts:
'''Mixin class to define/register Lua scripts for Redis.
Note that we only have to register these Lua scripts once -- so we do it on
the first instantiation of Redlock.
'''
__slots__: Tuple[str, ...] = tuple()
_acquired_script: ClassVar[Script | None] = None
_extend_script: ClassVar[Script | None] = None
_release_script: ClassVar[Script | None] = None
def __init__(self,
*,
key: str,
masters: Iterable[Redis | AIORedis] = frozenset(),
raise_on_redis_errors: bool = False,
) -> None:
super().__init__( # type: ignore
key=key,
masters=masters,
raise_on_redis_errors=raise_on_redis_errors,
)
self.__register_acquired_script()
self.__register_extend_script()
self.__register_release_script()
# Preserve the Open-Closed Principle with name mangling.
# https://youtu.be/miGolgp9xq8?t=2086
# https://stackoverflow.com/a/38534939
def __register_acquired_script(self) -> None:
if self._acquired_script is None:
class_name = self.__class__.__qualname__
logger.info('Registering %s._acquired_script', class_name)
master = next(iter(self.masters)) # type: ignore
# Available since Redis 2.6.0:
self.__class__._acquired_script = master.register_script('''
if redis.call('get', KEYS[1]) == ARGV[1] then
local pttl = redis.call('pttl', KEYS[1])
return (pttl > 0) and pttl or 0
else
return 0
end
''')
def __register_extend_script(self) -> None:
if self._extend_script is None:
class_name = self.__class__.__qualname__
logger.info('Registering %s._extend_script', class_name)
master = next(iter(self.masters)) # type: ignore
# Available since Redis 2.6.0:
self.__class__._extend_script = master.register_script('''
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
''')
def __register_release_script(self) -> None:
if self._release_script is None:
class_name = self.__class__.__qualname__
logger.info('Registering %s._release_script', class_name)
master = next(iter(self.masters)) # type: ignore
# Available since Redis 2.6.0:
self.__class__._release_script = master.register_script('''
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
''')
class Redlock(Scripts, Primitive):
'''Distributed Redis-powered lock.
This algorithm safely and reliably provides a mutually-exclusive locking
primitive to protect a resource shared across threads, processes, and even
machines, without a single point of failure.
Rationale and algorithm description:
http://redis.io/topics/distlock
Usage:
>>> from redis import Redis
>>> redis = Redis()
>>> printer_lock = Redlock(key='printer', masters={redis}, auto_release_time=.2)
>>> bool(printer_lock.locked())
False
>>> if printer_lock.acquire():
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> printer_lock.release()
>>> bool(printer_lock.locked())
False
Redlocks time out (by default, after 10 seconds). You should take care to
ensure that your critical section completes well within the timeout. The
reasons that Redlocks time out are to preserve "liveness"
(http://redis.io/topics/distlock#liveness-arguments) and to avoid deadlocks
(in the event that a process dies inside a critical section before it
releases its lock).
>>> if printer_lock.acquire():
... # Critical section - print stuff here.
... time.sleep(printer_lock.auto_release_time)
>>> bool(printer_lock.locked())
False
If 10 seconds isn't enough to complete executing your critical section,
then you can specify your own timeout:
>>> printer_lock = Redlock(key='printer', masters={redis}, auto_release_time=.2)
>>> if printer_lock.acquire():
... # Critical section - print stuff here.
... time.sleep(printer_lock.auto_release_time / 2)
>>> bool(printer_lock.locked())
True
>>> time.sleep(printer_lock.auto_release_time / 2)
>>> bool(printer_lock.locked())
False
You can use a Redlock as a context manager:
>>> with Redlock(key='printer', masters={redis}) as printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
>>> with printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
'''
__slots__ = (
'auto_release_time',
'num_extensions',
'context_manager_blocking',
'context_manager_timeout',
'_uuid',
'_extension_num',
)
_KEY_PREFIX: ClassVar[str] = 'redlock'
_AUTO_RELEASE_TIME: ClassVar[float] = 10
_CLOCK_DRIFT_FACTOR: ClassVar[float] = 0.01
_RETRY_DELAY: ClassVar[float] = .2
_NUM_EXTENSIONS: ClassVar[int] = 3
def __init__(self,
*,
key: str,
masters: Iterable[Redis] = frozenset(),
raise_on_redis_errors: bool = False,
auto_release_time: float = _AUTO_RELEASE_TIME,
num_extensions: int = _NUM_EXTENSIONS,
context_manager_blocking: bool = True,
context_manager_timeout: float = -1,
) -> None:
'''Initialize a Redlock.
Keyword arguments:
key -- a string that identifies your resource
masters -- the Redis clients used to achieve quorum for this
Redlock's state
raise_on_redis_errors -- whether to raise the QuorumIsImplssible
exception when too many Redis masters throw errors
auto_release_time -- the timeout in seconds by which to
automatically release this Redlock, unless it's already been
released
num_extensions -- the number of times that this Redlock's lease can
be extended
context_manager_blocking -- when using this Redlock as a context
manager, whether to block when acquiring
context_manager_timeout -- if context_manager_blocking, how long to
wait when acquiring before giving up and raising the
QuorumNotAchieved exception
'''
if not context_manager_blocking and context_manager_timeout != -1:
raise ValueError("can't specify a timeout for a non-blocking call")
super().__init__(
key=key,
masters=masters,
raise_on_redis_errors=raise_on_redis_errors,
)
self.auto_release_time = auto_release_time
self.num_extensions = num_extensions
self.context_manager_blocking = context_manager_blocking
self.context_manager_timeout = context_manager_timeout
self._uuid = ''
self._extension_num = 0
def __acquire_master(self, master: Redis) -> bool:
acquired = master.set(
self.key,
self._uuid,
px=int(self.auto_release_time * 1000),
nx=True,
)
return bool(acquired)
def __acquired_master(self, master: Redis) -> int:
if self._uuid:
ttl: int = cast(Script, self._acquired_script)(
keys=(self.key,),
args=(self._uuid,),
client=master,
)
else:
ttl = 0
return ttl
def __extend_master(self, master: Redis) -> bool:
auto_release_time_ms = int(self.auto_release_time * 1000)
extended = cast(Script, self._extend_script)(
keys=(self.key,),
args=(self._uuid, auto_release_time_ms),
client=master,
)
return bool(extended)
def __release_master(self, master: Redis) -> bool:
released = cast(Script, self._release_script)(
keys=(self.key,),
args=(self._uuid,),
client=master,
)
return bool(released)
def __drift(self) -> float:
return self.auto_release_time * self._CLOCK_DRIFT_FACTOR + .002
def _acquire_masters(self,
*,
raise_on_redis_errors: bool | None = None,
) -> bool:
self._uuid = str(uuid.uuid4())
self._extension_num = 0
with ContextTimer() as timer, BailOutExecutor() as executor:
futures = set()
for master in self.masters:
future = executor.submit(self.__acquire_master, master)
futures.add(future)
num_masters_acquired, redis_errors = 0, []
for future in concurrent.futures.as_completed(futures):
try:
num_masters_acquired += future.result()
except RedisError as error:
redis_errors.append(error)
logger.exception(
'%s.__acquire_masters() caught %s',
self.__class__.__qualname__,
error.__class__.__qualname__,
)
else:
if num_masters_acquired > len(self.masters) // 2:
validity_time = self.auto_release_time
validity_time -= self.__drift()
validity_time -= timer.elapsed() / 1000
if validity_time > 0:
return True
with contextlib.suppress(ReleaseUnlockedLock):
self.__release(raise_on_redis_errors=raise_on_redis_errors)
self._check_enough_masters_up(raise_on_redis_errors, redis_errors)
return False
__acquire_masters = _acquire_masters
def acquire(self,
*,
blocking: bool = True,
timeout: float = -1,
raise_on_redis_errors: bool | None = None,
) -> bool:
'''Lock the lock.
If blocking is True and timeout is -1, then wait for as long as
necessary to acquire the lock. Return True.
>>> from redis import Redis
>>> redis = Redis()
>>> printer_lock_1 = Redlock(key='printer', masters={redis}, auto_release_time=.2)
>>> printer_lock_1.acquire()
True
>>> timer = ContextTimer()
>>> timer.start()
>>> printer_lock_2 = Redlock(key='printer', masters={redis})
>>> printer_lock_2.acquire()
True
>>> timer.elapsed() > printer_lock_1.auto_release_time * 1000
True
>>> printer_lock_2.release()
If blocking is True and timeout is not -1, then wait for up to timeout
seconds to acquire the lock. Return True if the lock was acquired;
False if it wasn't.
>>> printer_lock_1.acquire()
True
>>> printer_lock_2.acquire(timeout=.5)
True
>>> printer_lock_2.release()
>>> printer_lock_1.acquire()
True
>>> printer_lock_2.acquire(timeout=.1)
False
>>> with contextlib.suppress(ReleaseUnlockedLock):
... printer_lock_1.release()
If blocking is False and timeout is -1, then try just once right now to
acquire the lock. Return True if the lock was acquired; False if it
wasn't.
>>> printer_lock_1.acquire()
True
>>> printer_lock_2.acquire(blocking=False)
False
>>> printer_lock_1.release()
'''
acquire_masters = functools.partial(
self.__acquire_masters,
raise_on_redis_errors=raise_on_redis_errors,
)
if blocking:
enqueued = False
with ContextTimer() as timer:
while timeout == -1 or timer.elapsed() / 1000 < timeout:
if acquire_masters():
if enqueued:
self.__log_time_enqueued(timer, acquired=True)
return True
enqueued = True
delay = random.uniform(0, self._RETRY_DELAY) # nosec
time.sleep(delay)
if enqueued: # pragma: no cover
self.__log_time_enqueued(timer, acquired=False)
return False # pragma: no cover
if timeout == -1:
return acquire_masters()
raise ValueError("can't specify a timeout for a non-blocking call")
__acquire = acquire
def __log_time_enqueued(self, timer: ContextTimer, *, acquired: bool) -> None:
key_suffix = self.key.split(':', maxsplit=1)[1]
time_enqueued = math.ceil(timer.elapsed())
logger.info(
'source=pottery sample#redlock.enqueued.%s=%dms sample#redlock.acquired.%s=%d',
key_suffix,
time_enqueued,
key_suffix,
acquired,
)
def locked(self, *, raise_on_redis_errors: bool | None = None) -> float:
'''How much longer we'll hold the lock (unless we extend or release it).
If we don't currently hold the lock, then this method returns 0.
>>> from redis import Redis
>>> redis = Redis()
>>> printer_lock_1 = Redlock(key='printer', masters={redis})
>>> printer_lock_1.locked()
0
>>> printer_lock_2 = Redlock(key='printer', masters={redis})
>>> printer_lock_2.acquire()
True
>>> printer_lock_1.locked()
0
>>> printer_lock_2.release()
If we do currently hold the lock, then this method returns the current
lease's Time To Live (TTL) in ms.
>>> printer_lock_1.acquire()
True
>>> 9 < printer_lock_1.locked() < 10
True
>>> printer_lock_1.release()
'''
with ContextTimer() as timer, BailOutExecutor() as executor:
futures = set()
for master in self.masters:
future = executor.submit(self.__acquired_master, master)
futures.add(future)
ttls, redis_errors = [], []
for future in concurrent.futures.as_completed(futures):
try:
ttl = future.result() / 1000
except RedisError as error:
redis_errors.append(error)
logger.exception(
'%s.locked() caught %s',
self.__class__.__qualname__,
error.__class__.__qualname__,
)
else:
if ttl:
ttls.append(ttl)
if len(ttls) > len(self.masters) // 2: # pragma: no cover
validity_time = min(ttls)
validity_time -= self.__drift()
validity_time -= timer.elapsed() / 1000
return max(validity_time, 0)
self._check_enough_masters_up(raise_on_redis_errors, redis_errors)
return 0
def extend(self, *, raise_on_redis_errors: bool | None = None) -> None:
'''Extend our hold on the lock (if we currently hold it).
Usage:
>>> from redis import Redis
>>> redis = Redis()
>>> printer_lock = Redlock(key='printer', masters={redis})
>>> printer_lock.acquire()
True
>>> 9 < printer_lock.locked() < 10
True
>>> time.sleep(1)
>>> 8 < printer_lock.locked() < 9
True
>>> printer_lock.extend()
>>> 9 < printer_lock.locked() < 10
True
>>> printer_lock.release()
'''
if self._extension_num >= self.num_extensions:
raise TooManyExtensions(self.key, self.masters)
with BailOutExecutor() as executor:
futures = set()
for master in self.masters:
future = executor.submit(self.__extend_master, master)
futures.add(future)
num_masters_extended, redis_errors = 0, []
for future in concurrent.futures.as_completed(futures):
try:
num_masters_extended += future.result()
except RedisError as error:
redis_errors.append(error)
logger.exception(
'%s.extend() caught %s',
self.__class__.__qualname__,
error.__class__.__qualname__,
)
else:
if num_masters_extended > len(self.masters) // 2:
self._extension_num += 1
return
self._check_enough_masters_up(raise_on_redis_errors, redis_errors)
raise ExtendUnlockedLock(
self.key,
self.masters,
redis_errors=redis_errors,
)
def release(self, *, raise_on_redis_errors: bool | None = None) -> None:
'''Unlock the lock.
Usage:
>>> from redis import Redis
>>> redis = Redis()
>>> printer_lock = Redlock(key='printer', masters={redis})
>>> bool(printer_lock.locked())
False
>>> printer_lock.acquire()
True
>>> bool(printer_lock.locked())
True
>>> printer_lock.release()
>>> bool(printer_lock.locked())
False
'''
with BailOutExecutor() as executor:
futures = set()
for master in self.masters:
future = executor.submit(self.__release_master, master)
futures.add(future)
num_masters_released, redis_errors = 0, []
for future in concurrent.futures.as_completed(futures):
try:
num_masters_released += future.result()
except RedisError as error:
redis_errors.append(error)
logger.exception(
'%s.release() caught %s',
self.__class__.__qualname__,
error.__class__.__qualname__,
)
else:
if num_masters_released > len(self.masters) // 2:
return
self._check_enough_masters_up(raise_on_redis_errors, redis_errors)
raise ReleaseUnlockedLock(
self.key,
self.masters,
redis_errors=redis_errors,
)
__release = release
def __enter__(self) -> Redlock:
'''You can use a Redlock as a context manager.
Usage:
>>> from redis import Redis
>>> redis = Redis()
>>> with Redlock(key='printer', masters={redis}) as printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
>>> with printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
'''
acquired = self.__acquire(
blocking=self.context_manager_blocking,
timeout=self.context_manager_timeout,
)
if acquired:
return self
raise QuorumNotAchieved(self.key, self.masters)
@overload
def __exit__(self,
exc_type: None,
exc_value: None,
exc_traceback: None,
) -> Literal[False]:
raise NotImplementedError
@overload
def __exit__(self,
exc_type: Type[BaseException],
exc_value: BaseException,
exc_traceback: TracebackType,
) -> Literal[False]:
raise NotImplementedError
def __exit__(self,
exc_type: Type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> Literal[False]:
'''You can use a Redlock as a context manager.
Usage:
>>> from redis import Redis
>>> redis = Redis()
>>> with Redlock(key='printer', masters={redis}) as printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
>>> with printer_lock:
... # Critical section - print stuff here.
... print('printer_lock is locked')
printer_lock is locked
>>> bool(printer_lock.locked())
False
'''
self.__release()
return False
def __repr__(self) -> str:
return f'<{self.__class__.__qualname__} key={self.key}>'
def synchronize(*,
key: str,
masters: Iterable[Redis] = frozenset(),
raise_on_redis_errors: bool = False,
auto_release_time: float = Redlock._AUTO_RELEASE_TIME,
blocking: bool = True,
timeout: float = -1,
) -> Callable[[F], F]:
'''Decorator to synchronize a function's execution across threads.
synchronize() is a decorator that allows only one thread to execute a
function at a time. Under the hood, synchronize() uses a Redlock. See
help(Redlock) for more details.
Keyword arguments:
key -- a string that identifies your resource
masters -- the Redis clients used to achieve quorum for this Redlock's
state
raise_on_redis_errors -- whether to raise the QuorumIsImplssible
exception when too many Redis masters throw errors
auto_release_time -- the timeout in seconds by which to automatically
release this Redlock, unless it's already been released
num_extensions -- the number of times that this Redlock's lease can be
extended
context_manager_blocking -- when using this Redlock as a context
manager, whether to block when acquiring
context_manager_timeout -- if context_manager_blocking, how long to wait
when acquiring before giving up and raising the QuorumNotAchieved
exception
Usage:
>>> @synchronize(key='synchronized-func', auto_release_time=1.5)
... def func():
... # Only one thread can execute this function at a time.
... return True
...
>>> func()
True
'''
RedlockFactory = functools.partial(
Redlock,
key=key,
masters=masters,
raise_on_redis_errors=raise_on_redis_errors,
auto_release_time=auto_release_time,
context_manager_blocking=blocking,
context_manager_timeout=timeout,
)
def decorator(func: F) -> F:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
redlock = RedlockFactory()
waiting_timer, holding_timer = ContextTimer(), ContextTimer()
try:
waiting_timer.start()
with redlock:
waiting_timer.stop()
holding_timer.start()
return_value = func(*args, **kwargs)
holding_timer.stop()
finally:
_log_synchronize(func, redlock, waiting_timer, holding_timer)
return return_value
return cast(F, wrapper)
return decorator
def _log_synchronize(func: F,
redlock: Redlock,
waiting_timer: ContextTimer,
holding_timer: ContextTimer,
) -> None:
try:
logger.info(
'%s() waited for %s for %d ms; held for %d ms',
func.__qualname__,
redlock.key,
waiting_timer.elapsed(),
holding_timer.elapsed(),
)
except RuntimeError:
# holding_timer.elapsed() threw a RuntimeError, which means that
# holding_timer never started, which means that we never acquired the
# lock / entered the critical section.
logger.info(
'%s() waited for %s for %d ms; never acquired lock',
func.__qualname__,
redlock.key,
waiting_timer.elapsed(),
)