Skip to content

Commit 45e8e5c

Browse files
authored
Merge pull request kragniz#1109 from cenkalti/lock
Fix for missing DELETE events while waiting for lock
2 parents 833a733 + 1a29afa commit 45e8e5c

8 files changed

Lines changed: 156 additions & 94 deletions

File tree

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var/
2323
*.egg-info/
2424
.installed.cfg
2525
*.egg
26+
.direnv/
2627

2728
# PyInstaller
2829
# Usually these files are written by a python script from a template

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ RUN pip install -U tox
1313
RUN mkdir python-etcd3
1414
WORKDIR python-etcd3
1515
# Rebuild this layer .tox when tox.ini or requirements changes
16-
COPY tox.ini requirements.txt test-requirements.txt ./
16+
COPY tox.ini ./
17+
COPY requirements/base.txt requirements/test.txt ./requirements/
1718

1819
RUN tox -epy35 --notest
1920

etcd3/locks.py

Lines changed: 57 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import threading
2+
import time
13
import uuid
24

3-
import tenacity
4-
5-
from etcd3 import exceptions
5+
from etcd3 import events, exceptions
66

77

88
class Lock(object):
@@ -55,54 +55,64 @@ def acquire(self, timeout=10):
5555
:returns: True if the lock has been acquired, False otherwise.
5656
5757
"""
58-
stop = (
59-
tenacity.stop_never
60-
if timeout is None else tenacity.stop_after_delay(timeout)
61-
)
58+
if timeout is not None:
59+
deadline = time.time() + timeout
6260

63-
def wait(previous_attempt_number, delay_since_first_attempt):
64-
if timeout is None:
65-
remaining_timeout = None
66-
else:
67-
remaining_timeout = max(timeout - delay_since_first_attempt, 0)
68-
# TODO(jd): Wait for a DELETE event to happen: that'd mean the lock
69-
# has been released, rather than retrying on PUT events too
70-
try:
71-
self.etcd_client.watch_once(self.key, remaining_timeout)
72-
except exceptions.WatchTimedOut:
73-
pass
74-
return 0
75-
76-
@tenacity.retry(retry=tenacity.retry_never,
77-
stop=stop,
78-
wait=wait)
79-
def _acquire():
80-
# TODO: save the created revision so we can check it later to make
81-
# sure we still have the lock
82-
83-
self.lease = self.etcd_client.lease(self.ttl)
84-
85-
success, _ = self.etcd_client.transaction(
86-
compare=[
87-
self.etcd_client.transactions.create(self.key) == 0
88-
],
89-
success=[
90-
self.etcd_client.transactions.put(self.key, self.uuid,
91-
lease=self.lease)
92-
],
93-
failure=[
94-
self.etcd_client.transactions.get(self.key)
95-
]
96-
)
97-
if success is True:
61+
while True:
62+
if self._try_acquire():
9863
return True
99-
self.lease = None
100-
raise tenacity.TryAgain
10164

65+
if timeout is not None:
66+
remaining_timeout = max(deadline - time.time(), 0)
67+
if remaining_timeout == 0:
68+
return False
69+
else:
70+
remaining_timeout = None
71+
72+
self._wait_delete_event(remaining_timeout)
73+
74+
def _try_acquire(self):
75+
self.lease = self.etcd_client.lease(self.ttl)
76+
77+
success, metadata = self.etcd_client.transaction(
78+
compare=[
79+
self.etcd_client.transactions.create(self.key) == 0
80+
],
81+
success=[
82+
self.etcd_client.transactions.put(self.key, self.uuid,
83+
lease=self.lease)
84+
],
85+
failure=[
86+
self.etcd_client.transactions.get(self.key)
87+
]
88+
)
89+
if success is True:
90+
self.revision = metadata[0].response_put.header.revision
91+
return True
92+
self.revision = metadata[0][0][1].mod_revision
93+
self.lease = None
94+
return False
95+
96+
def _wait_delete_event(self, timeout):
10297
try:
103-
return _acquire()
104-
except tenacity.RetryError:
105-
return False
98+
event_iter, cancel = self.etcd_client.watch(
99+
self.key, start_revision=self.revision + 1)
100+
except exceptions.WatchTimedOut:
101+
return
102+
103+
if timeout is not None:
104+
timer = threading.Timer(timeout, cancel)
105+
timer.start()
106+
else:
107+
timer = None
108+
109+
for event in event_iter:
110+
if isinstance(event, events.DeleteEvent):
111+
if timer is not None:
112+
timer.cancel()
113+
114+
cancel()
115+
break
106116

107117
def release(self):
108118
"""Release the lock."""

requirements/base.in

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
grpcio>=1.2.0
2-
tenacity==5.0.2
3-
protobuf==3.6.1
2+
protobuf==3.6.1

requirements/base.txt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
# SHA1:c7df483d2d529decbe2ba01bd60823437a5199a3
1+
# SHA1:54fb4872dad11196602da52f5e2fb464827b7da8
22
#
33
# This file is autogenerated by pip-compile-multi
44
# To update, run:
55
#
66
# pip-compile-multi
77
#
8-
grpcio==1.27.1
9-
protobuf==3.6.1
10-
six==1.12.0 # via grpcio, protobuf, tenacity
11-
tenacity==6.1.0
8+
enum34==1.1.10 # via grpcio
9+
futures==3.3.0 # via grpcio
10+
grpcio==1.28.1 # via -r base.in
11+
protobuf==3.6.1 # via -r base.in
12+
six==1.14.0 # via grpcio, protobuf
1213

1314
# The following packages are considered to be unsafe in a requirements file:
14-
# setuptools==41.2.0 # via protobuf
15+
# setuptools

requirements/test.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ flake8-docstrings==1.3.0
1515
pydocstyle<4 # python2.7
1616
mock==2.0.0
1717
pifpaf
18+
tenacity==5.0.2

requirements/test.txt

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SHA1:6bbf20a51a6aeaf6f5aa1d3bb0f3bedfbdee7807
1+
# SHA1:ed7b4404625d5a47343a116a02b2f8baa7ceb80b
22
#
33
# This file is autogenerated by pip-compile-multi
44
# To update, run:
@@ -7,68 +7,83 @@
77
#
88
-r base.txt
99
alabaster==0.7.12 # via sphinx
10+
appdirs==1.4.3 # via virtualenv
1011
argparse==1.4.0 # via unittest2
1112
atomicwrites==1.3.0 # via pytest
12-
attrs==19.2.0 # via hypothesis, pytest
13+
attrs==19.3.0 # via hypothesis, pytest
1314
babel==2.8.0 # via sphinx
14-
bumpversion==0.5.3
15-
certifi==2019.9.11 # via requests
16-
cffi==1.12.3 # via xattr
15+
bumpversion==0.5.3 # via -r test.in
16+
certifi==2020.4.5.1 # via requests
17+
cffi==1.14.0 # via xattr
1718
chardet==3.0.4 # via requests
18-
click==7.0 # via pifpaf
19-
coverage==5.0.1
19+
click==7.1.1 # via pifpaf
20+
configparser==4.0.2 # via entrypoints, flake8, importlib-metadata, pydocstyle
21+
contextlib2==0.6.0.post1 # via importlib-metadata, importlib-resources, virtualenv, zipp
22+
coverage==5.0.4 # via -r test.in, pytest-cov
2023
daiquiri==2.1.1 # via pifpaf
21-
docutils==0.15.2 # via sphinx
24+
distlib==0.3.0 # via virtualenv
25+
docutils==0.16 # via sphinx
2226
entrypoints==0.3 # via flake8
2327
extras==1.0.0 # via testtools
24-
filelock==3.0.12 # via tox
28+
filelock==3.0.12 # via tox, virtualenv
2529
fixtures==3.0.0 # via pifpaf, testtools
26-
flake8-docstrings==1.3.0
27-
flake8-import-order==0.18.1
30+
flake8-docstrings==1.3.0 # via -r test.in
31+
flake8-import-order==0.18.1 # via -r test.in
2832
flake8-polyfill==1.0.2 # via flake8-docstrings
29-
flake8==3.7.8
30-
grpcio-tools==1.27.2
31-
hypothesis==4.56.3
32-
idna==2.8 # via requests
33-
imagesize==1.1.0 # via sphinx
34-
importlib-metadata==1.5.0 # via pluggy, pytest
33+
flake8==3.7.9 # via -r test.in, flake8-docstrings, flake8-polyfill
34+
funcsigs==1.0.2 # via mock, pytest
35+
functools32==3.2.3.post2 # via flake8
36+
grpcio-tools==1.28.1 # via -r test.in
37+
hypothesis==4.57.1 # via -r test.in
38+
idna==2.9 # via requests
39+
imagesize==1.2.0 # via sphinx
40+
importlib-metadata==1.6.0 # via importlib-resources, pluggy, pytest, virtualenv
41+
importlib-resources==1.4.0 # via virtualenv
3542
jinja2==2.11.1 # via pifpaf, sphinx
3643
linecache2==1.0.0 # via traceback2
3744
markupsafe==1.1.1 # via jinja2
3845
mccabe==0.6.1 # via flake8
39-
mock==2.0.0
40-
more-itertools==5.0.0
46+
mock==2.0.0 # via -r test.in
47+
monotonic==1.5 # via tenacity
48+
more-itertools==5.0.0 # via -r test.in, pytest
4149
packaging==20.3 # via pytest, sphinx
42-
pbr==5.4.3 # via fixtures, mock, pifpaf, testtools
43-
pifpaf==2.2.2
44-
pluggy==0.13.0 # via pytest, tox
45-
psutil==5.6.3 # via pifpaf
46-
py==1.8.0 # via pytest, tox
50+
pathlib2==2.3.5 # via importlib-metadata, importlib-resources, pytest, virtualenv
51+
pbr==5.4.5 # via fixtures, mock, pifpaf, testtools
52+
pifpaf==2.4.0 # via -r test.in
53+
pluggy==0.13.1 # via pytest, tox
54+
psutil==5.7.0 # via pifpaf
55+
py==1.8.1 # via pytest, tox
4756
pycodestyle==2.5.0 # via flake8, flake8-import-order
4857
pycparser==2.20 # via cffi
49-
pydocstyle==3.0.0
58+
pydocstyle==3.0.0 # via -r test.in, flake8-docstrings
5059
pyflakes==2.1.1 # via flake8
51-
pygments==2.4.2 # via sphinx
52-
pyparsing==2.4.6 # via packaging
53-
pytest-cov==2.7.1
54-
pytest==4.6.5
60+
pygments==2.5.2 # via sphinx
61+
pyparsing==2.4.7 # via packaging
62+
pytest-cov==2.8.1 # via -r test.in
63+
pytest==4.6.5 # via -r test.in, pytest-cov
64+
python-json-logger==0.1.11 # via daiquiri
5565
python-mimeparse==1.6.0 # via testtools
56-
pytz==2019.2 # via babel
57-
pyyaml==5.3.1
58-
requests==2.22.0 # via sphinx
66+
pytz==2019.3 # via babel
67+
pyyaml==5.1 # via -r test.in
68+
requests==2.23.0 # via sphinx
69+
scandir==1.10.0 # via pathlib2
70+
singledispatch==3.4.0.3 # via importlib-resources
5971
snowballstemmer==2.0.0 # via pydocstyle, sphinx
60-
sphinx==1.8.2
72+
sortedcontainers==2.1.0 # via hypothesis
73+
sphinx==1.8.2 # via -r test.in
6174
sphinxcontrib-websupport==1.1.2 # via sphinx
75+
tenacity==5.0.2 # via -r test.in
6276
testtools==2.4.0 # via fixtures
6377
toml==0.10.0 # via tox
64-
tox==3.14.5
78+
tox==3.5.3 # via -r test.in
6579
traceback2==1.4.0 # via testtools, unittest2
80+
typing==3.7.4.1 # via flake8, importlib-resources, sphinx
6681
unittest2==1.1.0 # via testtools
6782
urllib3==1.25.8 # via requests
68-
virtualenv==20.0.13 # via tox
83+
virtualenv==20.0.17 # via tox
6984
wcwidth==0.1.9 # via pytest
70-
xattr==0.9.6 # via pifpaf
71-
zipp==1.0.0 # via importlib-metadata
85+
xattr==0.9.7 # via pifpaf
86+
zipp==1.2.0 # via importlib-metadata, importlib-resources
7287

7388
# The following packages are considered to be unsafe in a requirements file:
74-
# setuptools==41.2.0 # via flake8-import-order, protobuf, sphinx, tox
89+
# setuptools

tests/test_etcd3.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,40 @@ def test_lock_acquire_none(self, etcd):
843843
# lock is not refreshed
844844
assert lock.acquire(None) is True
845845

846+
def test_lock_acquire_with_timeout(self, etcd):
847+
lock1 = etcd.lock('lock-10', ttl=10)
848+
lock2 = etcd.lock('lock-10', ttl=10)
849+
850+
original_watch = etcd.watch
851+
watch_called = [0]
852+
853+
def release_lock_before_watch(*args, **kwargs):
854+
watch_called[0] += 1
855+
# Simulates the case where key is expired before watch is called.
856+
# See https://github.com/kragniz/python-etcd3/issues/1107
857+
lock1.release()
858+
return original_watch(*args, **kwargs)
859+
860+
original_transaction = etcd.transaction
861+
transaction_called = [0]
862+
863+
def transaction_wrapper(*args, **kwargs):
864+
transaction_called[0] += 1
865+
return original_transaction(*args, **kwargs)
866+
867+
assert lock1.acquire() is True
868+
with mock.patch.object(etcd3.Etcd3Client, 'watch',
869+
wraps=release_lock_before_watch):
870+
with mock.patch.object(etcd3.Etcd3Client, 'transaction',
871+
wraps=transaction_wrapper):
872+
assert lock2.acquire(timeout=5) is True
873+
874+
# watch must be called only for lock2 once
875+
assert watch_called[0] == 1
876+
877+
# transaction must be called once for lock1, twice for lock2
878+
assert transaction_called[0] == 3
879+
846880
def test_internal_exception_on_internal_error(self, etcd):
847881
exception = self.MockedException(grpc.StatusCode.INTERNAL)
848882
kv_mock = mock.MagicMock()

0 commit comments

Comments
 (0)