dynamodb-persistent-lock 0.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dynamodb_persistent_lock-0.0.2/PKG-INFO +66 -0
- dynamodb_persistent_lock-0.0.2/README.rst +57 -0
- dynamodb_persistent_lock-0.0.2/pyproject.toml +15 -0
- dynamodb_persistent_lock-0.0.2/setup.cfg +4 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock/__init__.py +1 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock/dynamodb_persistent_lock.py +314 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock/tests/__init__.py +5 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock/tests/test_dynamodb_persistent_lock.py +73 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock.egg-info/PKG-INFO +66 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock.egg-info/SOURCES.txt +10 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock.egg-info/dependency_links.txt +1 -0
- dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dynamodb-persistent-lock
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: DynamoDB Persistent Lock
|
|
5
|
+
Author-email: Tim Heiko <175552092+timheiko@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0 AND MIT
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/x-rst
|
|
9
|
+
|
|
10
|
+
========================
|
|
11
|
+
DynamoDB Persistent Lock
|
|
12
|
+
========================
|
|
13
|
+
|
|
14
|
+
Getting Started
|
|
15
|
+
---------------
|
|
16
|
+
|
|
17
|
+
1. Install the library
|
|
18
|
+
|
|
19
|
+
.. code-block:: bash
|
|
20
|
+
|
|
21
|
+
% python3 -m pip install dynamodb-persistent-lock
|
|
22
|
+
|
|
23
|
+
2. Usage
|
|
24
|
+
|
|
25
|
+
Non-async DynamoDB local download:
|
|
26
|
+
|
|
27
|
+
.. code-block:: python
|
|
28
|
+
|
|
29
|
+
from dynamodb_persistent_lock.dynamodb_persistent_lock import (
|
|
30
|
+
DynamoDBPersistentLockFactory,
|
|
31
|
+
DynamoDBPersistentLockClient,
|
|
32
|
+
)
|
|
33
|
+
from datetime import timedelta
|
|
34
|
+
...
|
|
35
|
+
# Create a lock factory
|
|
36
|
+
lock_factory = DynamoDBPersistentLockFactory(
|
|
37
|
+
table_name="locks",
|
|
38
|
+
region_name="eu-central-1",
|
|
39
|
+
partition_key_name="lock_key",
|
|
40
|
+
sort_key_name="sort_key",
|
|
41
|
+
record_version_number_name="rvn",
|
|
42
|
+
heartbeat_period=timedelta(seconds=10),
|
|
43
|
+
ttl_attribute_name="expire_at",
|
|
44
|
+
ttl_heartbeat_multiplier=3,
|
|
45
|
+
)
|
|
46
|
+
lock_factory.ensure_table()
|
|
47
|
+
...
|
|
48
|
+
# Open a lock client
|
|
49
|
+
lock_client = lock_factory.open_lock_client()
|
|
50
|
+
try:
|
|
51
|
+
lock = lock_client.try_acquire_lock("my_lock_key")
|
|
52
|
+
...
|
|
53
|
+
# Lock-guarded code here.
|
|
54
|
+
finally:
|
|
55
|
+
lock_client.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Features
|
|
59
|
+
--------
|
|
60
|
+
* Creates a new lock, if there is none, in DynamoDB table.
|
|
61
|
+
* Reacquires stale existing lock based on the ttl column value.
|
|
62
|
+
* Keeps extending the ttl of an acquired lock with a given heartbeat rate.
|
|
63
|
+
|
|
64
|
+
Credits
|
|
65
|
+
-------
|
|
66
|
+
* `Building Distributed Locks with the DynamoDB Lock Client <https://aws.amazon.com/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/>`_.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
========================
|
|
2
|
+
DynamoDB Persistent Lock
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
Getting Started
|
|
6
|
+
---------------
|
|
7
|
+
|
|
8
|
+
1. Install the library
|
|
9
|
+
|
|
10
|
+
.. code-block:: bash
|
|
11
|
+
|
|
12
|
+
% python3 -m pip install dynamodb-persistent-lock
|
|
13
|
+
|
|
14
|
+
2. Usage
|
|
15
|
+
|
|
16
|
+
Non-async DynamoDB local download:
|
|
17
|
+
|
|
18
|
+
.. code-block:: python
|
|
19
|
+
|
|
20
|
+
from dynamodb_persistent_lock.dynamodb_persistent_lock import (
|
|
21
|
+
DynamoDBPersistentLockFactory,
|
|
22
|
+
DynamoDBPersistentLockClient,
|
|
23
|
+
)
|
|
24
|
+
from datetime import timedelta
|
|
25
|
+
...
|
|
26
|
+
# Create a lock factory
|
|
27
|
+
lock_factory = DynamoDBPersistentLockFactory(
|
|
28
|
+
table_name="locks",
|
|
29
|
+
region_name="eu-central-1",
|
|
30
|
+
partition_key_name="lock_key",
|
|
31
|
+
sort_key_name="sort_key",
|
|
32
|
+
record_version_number_name="rvn",
|
|
33
|
+
heartbeat_period=timedelta(seconds=10),
|
|
34
|
+
ttl_attribute_name="expire_at",
|
|
35
|
+
ttl_heartbeat_multiplier=3,
|
|
36
|
+
)
|
|
37
|
+
lock_factory.ensure_table()
|
|
38
|
+
...
|
|
39
|
+
# Open a lock client
|
|
40
|
+
lock_client = lock_factory.open_lock_client()
|
|
41
|
+
try:
|
|
42
|
+
lock = lock_client.try_acquire_lock("my_lock_key")
|
|
43
|
+
...
|
|
44
|
+
# Lock-guarded code here.
|
|
45
|
+
finally:
|
|
46
|
+
lock_client.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Features
|
|
50
|
+
--------
|
|
51
|
+
* Creates a new lock, if there is none, in DynamoDB table.
|
|
52
|
+
* Reacquires stale existing lock based on the ttl column value.
|
|
53
|
+
* Keeps extending the ttl of an acquired lock with a given heartbeat rate.
|
|
54
|
+
|
|
55
|
+
Credits
|
|
56
|
+
-------
|
|
57
|
+
* `Building Distributed Locks with the DynamoDB Lock Client <https://aws.amazon.com/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/>`_.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
description = "DynamoDB Persistent Lock"
|
|
3
|
+
name = "dynamodb-persistent-lock"
|
|
4
|
+
license = {text = "Apache-2.0 AND MIT"}
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Tim Heiko", email = "175552092+timheiko@users.noreply.github.com"},
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">= 3.14"
|
|
9
|
+
readme = "README.rst"
|
|
10
|
+
dynamic = [
|
|
11
|
+
"version",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.dynamic]
|
|
15
|
+
version = {attr = "dynamodb_persistent_lock.__version__"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.2"
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import boto3
|
|
3
|
+
from botocore.exceptions import ClientError
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
import time
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from threading import Event, Thread
|
|
12
|
+
from typing import Mapping
|
|
13
|
+
|
|
14
|
+
logger = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DYNAMODB = "dynamodb"
|
|
17
|
+
DEFAULT_PARTITION_KEY_NAME = "lock_key"
|
|
18
|
+
DEFAULT_SORT_KEY_NAME = "sort_key"
|
|
19
|
+
DEFAULT_RECORD_VERSION_NUMBER_KEY_NAME = "rvn"
|
|
20
|
+
DEFAULT_HEARTBEAT_PERIOD = timedelta(seconds=5)
|
|
21
|
+
DEFAULT_TTL_ATTRIBUTE_NAME = "expire_at"
|
|
22
|
+
DEFAULT_TTL_HEARTBEAT_MULTIPLIER = 2
|
|
23
|
+
|
|
24
|
+
DEFAULT_READ_CAPACITY = 3
|
|
25
|
+
DEFAULT_WRITE_CAPACITY = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(repr=True)
|
|
29
|
+
class DynamoDBLock:
|
|
30
|
+
lock_key: str
|
|
31
|
+
sort_key: str
|
|
32
|
+
heartbeat_period: timedelta
|
|
33
|
+
ttl_heartbeat_multiplier: int = DEFAULT_TTL_HEARTBEAT_MULTIPLIER
|
|
34
|
+
record_version_number: str = field(default_factory=lambda: str(uuid.uuid7()))
|
|
35
|
+
now: Decimal | None = None
|
|
36
|
+
ttl: Decimal | None = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
if self.ttl is None:
|
|
40
|
+
self.now = Decimal(int(time.time()))
|
|
41
|
+
self.ttl = self.now + Decimal(
|
|
42
|
+
int(
|
|
43
|
+
self.heartbeat_period.total_seconds()
|
|
44
|
+
* self.ttl_heartbeat_multiplier
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_key(self):
|
|
49
|
+
return f"<{self.lock_key}|{self.sort_key}>"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class DynamoDBPersistentLockFactory:
|
|
54
|
+
table_name: str
|
|
55
|
+
region_name: str
|
|
56
|
+
endpoint_url: str | None = None
|
|
57
|
+
partition_key_name: str = DEFAULT_PARTITION_KEY_NAME
|
|
58
|
+
sort_key_name: str = DEFAULT_SORT_KEY_NAME
|
|
59
|
+
ttl_attribute_name: str = DEFAULT_TTL_ATTRIBUTE_NAME
|
|
60
|
+
record_version_number_name: str = DEFAULT_RECORD_VERSION_NUMBER_KEY_NAME
|
|
61
|
+
heartbeat_period: timedelta = DEFAULT_HEARTBEAT_PERIOD
|
|
62
|
+
ttl_heartbeat_multiplier: int = DEFAULT_TTL_HEARTBEAT_MULTIPLIER
|
|
63
|
+
|
|
64
|
+
read_capacity: int = DEFAULT_READ_CAPACITY
|
|
65
|
+
write_capacity: int = DEFAULT_WRITE_CAPACITY
|
|
66
|
+
dynamodb_resource: "boto3.resources.factory.dynamodb.ServiceResource" = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if self.dynamodb_resource is None:
|
|
70
|
+
self.dynamodb_resource = boto3.resource(
|
|
71
|
+
service_name=DYNAMODB,
|
|
72
|
+
region_name=self.region_name,
|
|
73
|
+
endpoint_url=self.endpoint_url,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def open_lock_client(self):
|
|
77
|
+
return DynamoDBPersistentLockClient(
|
|
78
|
+
heartbeat_period=self.heartbeat_period,
|
|
79
|
+
table=self.dynamodb_resource.Table(self.table_name),
|
|
80
|
+
partition_key_name=self.partition_key_name,
|
|
81
|
+
sort_key_name=self.sort_key_name,
|
|
82
|
+
record_version_number_name=self.record_version_number_name,
|
|
83
|
+
ttl_heartbeat_multiplier=self.ttl_heartbeat_multiplier,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def ensure_table(self):
|
|
87
|
+
try:
|
|
88
|
+
logger.info(f"❓ Check if DynamoDB table <{self.table_name}> exists")
|
|
89
|
+
self.dynamodb_resource.meta.client.describe_table(TableName=self.table_name)
|
|
90
|
+
logger.info(f"✅ DynamoDB table <{self.table_name}> exists")
|
|
91
|
+
except ClientError as e:
|
|
92
|
+
logger.info(f"❌ DynamoDB table <{self.table_name}> does not exist")
|
|
93
|
+
logger.info(f"Create DynamoDB table <{self.table_name}>")
|
|
94
|
+
table = self.dynamodb_resource.create_table(
|
|
95
|
+
TableName=self.table_name,
|
|
96
|
+
KeySchema=[
|
|
97
|
+
{"AttributeName": self.partition_key_name, "KeyType": "HASH"},
|
|
98
|
+
{"AttributeName": self.sort_key_name, "KeyType": "RANGE"},
|
|
99
|
+
],
|
|
100
|
+
AttributeDefinitions=[
|
|
101
|
+
{"AttributeName": self.partition_key_name, "AttributeType": "S"},
|
|
102
|
+
{"AttributeName": self.sort_key_name, "AttributeType": "S"},
|
|
103
|
+
],
|
|
104
|
+
BillingMode="PAY_PER_REQUEST",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
logger.info(f"⏳ Creating DynamoDB table <{self.table_name}>")
|
|
108
|
+
table.wait_until_exists()
|
|
109
|
+
logger.info(f"✅ Created DynamoDB table <{self.table_name}>")
|
|
110
|
+
|
|
111
|
+
logger.info(f"Enabling TTL on <{self.ttl_attribute_name}>")
|
|
112
|
+
response = self.dynamodb_resource.meta.client.update_time_to_live(
|
|
113
|
+
TableName=self.table_name,
|
|
114
|
+
TimeToLiveSpecification={
|
|
115
|
+
"Enabled": True,
|
|
116
|
+
"AttributeName": self.ttl_attribute_name,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
|
|
120
|
+
logger.info("✅ TTL has been successfully enabled.")
|
|
121
|
+
else:
|
|
122
|
+
logger.error(
|
|
123
|
+
f"❌ Failed to enable TTL, status code {response['ResponseMetadata']['HTTPStatusCode']}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class DynamoDBPersistentLockClient:
|
|
129
|
+
heartbeat_period: timedelta
|
|
130
|
+
table: "boto3.resources.factory.dynamodb.Table"
|
|
131
|
+
owner_name: str | None = None
|
|
132
|
+
partition_key_name: str = DEFAULT_PARTITION_KEY_NAME
|
|
133
|
+
sort_key_name: str = DEFAULT_SORT_KEY_NAME
|
|
134
|
+
ttl_attribute_name: str = DEFAULT_TTL_ATTRIBUTE_NAME
|
|
135
|
+
ttl_heartbeat_multiplier: int = DEFAULT_TTL_HEARTBEAT_MULTIPLIER
|
|
136
|
+
record_version_number_name: str = DEFAULT_RECORD_VERSION_NUMBER_KEY_NAME
|
|
137
|
+
|
|
138
|
+
locks: Mapping[str, Event] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
def __post_init__(self):
|
|
141
|
+
logger.info("table %s", type(self.table))
|
|
142
|
+
self.owner_name = self.owner_name or f"{os.uname().nodename}-{uuid.uuid7()}"
|
|
143
|
+
|
|
144
|
+
def try_acquire_lock(self, lock_key: str, sort_key: str = "-"):
|
|
145
|
+
lock = self._try_acquire_lock(lock_key=lock_key, sort_key=sort_key)
|
|
146
|
+
if lock is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
self._start_heartbeat(lock)
|
|
150
|
+
return lock
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
for lock_key, (event, _) in self.locks.items():
|
|
154
|
+
logger.info(f"Stopping lock heartbeat for {lock_key}")
|
|
155
|
+
event.set()
|
|
156
|
+
|
|
157
|
+
for lock_key, (_, thread) in self.locks.items():
|
|
158
|
+
logger.info(f"Waiting for lock {lock_key} heartbeat to exit")
|
|
159
|
+
thread.join()
|
|
160
|
+
|
|
161
|
+
def _try_acquire_lock(
|
|
162
|
+
self, lock_key: str, sort_key: str = "-"
|
|
163
|
+
) -> DynamoDBLock | None:
|
|
164
|
+
lock = DynamoDBLock(
|
|
165
|
+
lock_key=lock_key,
|
|
166
|
+
sort_key=sort_key,
|
|
167
|
+
heartbeat_period=self.heartbeat_period,
|
|
168
|
+
ttl_heartbeat_multiplier=self.ttl_heartbeat_multiplier,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
return self._try_create_lock(lock)
|
|
173
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException as e:
|
|
174
|
+
return self._try_reacquire_existing_lock(lock)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(e)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
return lock
|
|
180
|
+
|
|
181
|
+
def _try_create_lock(self, lock: DynamoDBLock) -> DynamoDBLock:
|
|
182
|
+
logger.info(f"❓ Trying to acquire the lock {lock.to_key()}.")
|
|
183
|
+
item = {
|
|
184
|
+
"Item": {
|
|
185
|
+
self.partition_key_name: lock.lock_key,
|
|
186
|
+
self.sort_key_name: lock.sort_key,
|
|
187
|
+
"owner_name": self.owner_name,
|
|
188
|
+
self.record_version_number_name: lock.record_version_number,
|
|
189
|
+
self.ttl_attribute_name: lock.ttl,
|
|
190
|
+
},
|
|
191
|
+
"ConditionExpression": "NOT(attribute_exists(#pk) AND attribute_exists(#sk))",
|
|
192
|
+
"ExpressionAttributeNames": {
|
|
193
|
+
"#pk": self.partition_key_name,
|
|
194
|
+
"#sk": self.sort_key_name,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
self.table.put_item(**item)
|
|
198
|
+
logger.info(f"✅ The lock {lock.to_key()} has been acquired: {lock}")
|
|
199
|
+
return lock
|
|
200
|
+
|
|
201
|
+
def _read_existing_lock(self, lock: DynamoDBLock) -> DynamoDBLock:
|
|
202
|
+
logger.info(f"❓ Reading existing lock {lock.to_key()}: {lock}")
|
|
203
|
+
query = {
|
|
204
|
+
"Key": {
|
|
205
|
+
self.partition_key_name: lock.lock_key,
|
|
206
|
+
self.sort_key_name: lock.sort_key,
|
|
207
|
+
},
|
|
208
|
+
"ConsistentRead": True,
|
|
209
|
+
}
|
|
210
|
+
existing_item = self.table.get_item(**query)["Item"]
|
|
211
|
+
return DynamoDBLock(
|
|
212
|
+
lock_key=lock.lock_key,
|
|
213
|
+
sort_key=lock.sort_key,
|
|
214
|
+
heartbeat_period=lock.heartbeat_period,
|
|
215
|
+
ttl=Decimal(existing_item.get(self.ttl_attribute_name)),
|
|
216
|
+
record_version_number=existing_item.get(self.record_version_number_name),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _try_reacquire_existing_lock(self, new_lock: DynamoDBLock) -> DynamoDBLock:
|
|
220
|
+
logger.warning(f"❌ The lock {new_lock.to_key()} already exists.")
|
|
221
|
+
|
|
222
|
+
existing_lock = self._read_existing_lock(new_lock)
|
|
223
|
+
logger.warning(f"❌ The lock {new_lock.to_key()} already exists.")
|
|
224
|
+
existing_lock = self._read_existing_lock(new_lock)
|
|
225
|
+
|
|
226
|
+
logger.info(
|
|
227
|
+
f"❓ Checking the expiration of the existing lock {new_lock.to_key()}."
|
|
228
|
+
)
|
|
229
|
+
if existing_lock.ttl > new_lock.now:
|
|
230
|
+
logger.error(f"❌ The existing lock {new_lock.to_key()} has not expired.")
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
logger.info(
|
|
234
|
+
f"❓ Trying to re-acquired the existing expired lock {new_lock.to_key()}."
|
|
235
|
+
)
|
|
236
|
+
self._update_lock(existing_lock=existing_lock, new_lock=new_lock)
|
|
237
|
+
logger.info(f"✅ Re-acquired the existing expired lock {new_lock.to_key()}.")
|
|
238
|
+
return new_lock
|
|
239
|
+
|
|
240
|
+
def _update_lock(
|
|
241
|
+
self,
|
|
242
|
+
existing_lock: DynamoDBLock,
|
|
243
|
+
new_lock: DynamoDBLock,
|
|
244
|
+
) -> DynamoDBLock:
|
|
245
|
+
update_query = {
|
|
246
|
+
"Key": {
|
|
247
|
+
self.partition_key_name: new_lock.lock_key,
|
|
248
|
+
self.sort_key_name: new_lock.sort_key,
|
|
249
|
+
},
|
|
250
|
+
"UpdateExpression": "SET #rvn = :new_rvn, #owner = :owner, #ttl = :ttl",
|
|
251
|
+
"ConditionExpression": "#rvn = :old_rvn",
|
|
252
|
+
"ExpressionAttributeNames": {
|
|
253
|
+
"#ttl": self.ttl_attribute_name,
|
|
254
|
+
"#rvn": self.record_version_number_name,
|
|
255
|
+
"#owner": "owner_name",
|
|
256
|
+
},
|
|
257
|
+
"ExpressionAttributeValues": {
|
|
258
|
+
":ttl": new_lock.ttl,
|
|
259
|
+
":owner": self.owner_name,
|
|
260
|
+
":old_rvn": existing_lock.record_version_number,
|
|
261
|
+
":new_rvn": new_lock.record_version_number,
|
|
262
|
+
},
|
|
263
|
+
"ReturnValues": "NONE",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
self.table.update_item(**update_query)
|
|
268
|
+
return new_lock
|
|
269
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException as e:
|
|
270
|
+
logger.error(e)
|
|
271
|
+
|
|
272
|
+
def _delete_lock(self, existing_lock: DynamoDBLock) -> None:
|
|
273
|
+
delete_query = {
|
|
274
|
+
"Key": {
|
|
275
|
+
self.partition_key_name: existing_lock.lock_key,
|
|
276
|
+
self.sort_key_name: existing_lock.sort_key,
|
|
277
|
+
},
|
|
278
|
+
"ConditionExpression": "#rvn = :rvn",
|
|
279
|
+
"ExpressionAttributeNames": {
|
|
280
|
+
"#rvn": self.record_version_number_name,
|
|
281
|
+
},
|
|
282
|
+
"ExpressionAttributeValues": {
|
|
283
|
+
":rvn": existing_lock.record_version_number,
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
try:
|
|
287
|
+
logger.info(f"Deleting the lock of {existing_lock.to_key()}")
|
|
288
|
+
self.table.delete_item(**delete_query)
|
|
289
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException as e:
|
|
290
|
+
logger.error(e)
|
|
291
|
+
|
|
292
|
+
def _send_heartbeat(self, existing_lock: DynamoDBLock, event: Event) -> None:
|
|
293
|
+
while not event.wait(existing_lock.heartbeat_period.total_seconds()):
|
|
294
|
+
logger.info(f"⏳ Extending an existing lock: {existing_lock.to_key()}")
|
|
295
|
+
new_lock = DynamoDBLock(
|
|
296
|
+
lock_key=existing_lock.lock_key,
|
|
297
|
+
sort_key=existing_lock.sort_key,
|
|
298
|
+
heartbeat_period=existing_lock.heartbeat_period,
|
|
299
|
+
ttl_heartbeat_multiplier=existing_lock.ttl_heartbeat_multiplier,
|
|
300
|
+
)
|
|
301
|
+
self._update_lock(existing_lock=existing_lock, new_lock=new_lock)
|
|
302
|
+
existing_lock = new_lock
|
|
303
|
+
|
|
304
|
+
self._delete_lock(existing_lock=existing_lock)
|
|
305
|
+
|
|
306
|
+
def _start_heartbeat(self, existing_lock: DynamoDBLock) -> None:
|
|
307
|
+
event = Event()
|
|
308
|
+
thread = Thread(
|
|
309
|
+
name=f"DynamoDb-Persistent-Lock-on-<{existing_lock.to_key()}>",
|
|
310
|
+
target=self._send_heartbeat,
|
|
311
|
+
args=(existing_lock, event),
|
|
312
|
+
)
|
|
313
|
+
self.locks[existing_lock.to_key()] = (event, thread)
|
|
314
|
+
thread.start()
|
dynamodb_persistent_lock-0.0.2/src/dynamodb_persistent_lock/tests/test_dynamodb_persistent_lock.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
from dynamodb_persistent_lock.dynamodb_persistent_lock import (
|
|
5
|
+
DynamoDBPersistentLockFactory,
|
|
6
|
+
DynamoDBPersistentLockClient,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from dynamodb_local import download_dynamodb, start_dynamodb_local, DynamoDBLocalServer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def factory(endpoint_url: str) -> DynamoDBPersistentLockFactory:
|
|
14
|
+
lock_factory = DynamoDBPersistentLockFactory(
|
|
15
|
+
table_name="locks",
|
|
16
|
+
region_name="eu-central-1",
|
|
17
|
+
endpoint_url=endpoint_url,
|
|
18
|
+
partition_key_name="lock_key",
|
|
19
|
+
sort_key_name="sort_key",
|
|
20
|
+
record_version_number_name="rvn",
|
|
21
|
+
heartbeat_period=timedelta(seconds=10),
|
|
22
|
+
ttl_attribute_name="expire_at",
|
|
23
|
+
ttl_heartbeat_multiplier=3,
|
|
24
|
+
)
|
|
25
|
+
lock_factory.ensure_table()
|
|
26
|
+
return lock_factory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def endpoint_url() -> str:
|
|
31
|
+
dynamodb_local_server = start_dynamodb_local(parent_dir="tmp/DynamoDBLocal")
|
|
32
|
+
|
|
33
|
+
yield dynamodb_local_server.endpoint
|
|
34
|
+
|
|
35
|
+
dynamodb_local_server.shutdown()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def lock_client(factory: DynamoDBPersistentLockFactory) -> DynamoDBPersistentLockClient:
|
|
40
|
+
client = factory.open_lock_client()
|
|
41
|
+
yield client
|
|
42
|
+
client.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_create_lock_client(factory: DynamoDBPersistentLockFactory):
|
|
46
|
+
client = factory.open_lock_client()
|
|
47
|
+
|
|
48
|
+
assert client is not None
|
|
49
|
+
|
|
50
|
+
client.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.slow
|
|
54
|
+
def test_acquire_lock_once(lock_client: DynamoDBPersistentLockClient):
|
|
55
|
+
lock_client.heartbeat_period = timedelta(seconds=1)
|
|
56
|
+
lock_client.owner_name = "test_acquire_lock_once"
|
|
57
|
+
|
|
58
|
+
lock = lock_client.try_acquire_lock("my_lock_key")
|
|
59
|
+
|
|
60
|
+
assert lock is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_acquire_lock_twice(lock_client: DynamoDBPersistentLockClient):
|
|
64
|
+
lock_client.heartbeat_period = timedelta(seconds=5)
|
|
65
|
+
lock_client.owner_name = "test_acquire_lock_twice"
|
|
66
|
+
|
|
67
|
+
lock1 = lock_client.try_acquire_lock("my_lock_key")
|
|
68
|
+
|
|
69
|
+
assert lock1 is not None
|
|
70
|
+
|
|
71
|
+
lock2 = lock_client.try_acquire_lock("my_lock_key")
|
|
72
|
+
|
|
73
|
+
assert lock2 is None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dynamodb-persistent-lock
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: DynamoDB Persistent Lock
|
|
5
|
+
Author-email: Tim Heiko <175552092+timheiko@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0 AND MIT
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/x-rst
|
|
9
|
+
|
|
10
|
+
========================
|
|
11
|
+
DynamoDB Persistent Lock
|
|
12
|
+
========================
|
|
13
|
+
|
|
14
|
+
Getting Started
|
|
15
|
+
---------------
|
|
16
|
+
|
|
17
|
+
1. Install the library
|
|
18
|
+
|
|
19
|
+
.. code-block:: bash
|
|
20
|
+
|
|
21
|
+
% python3 -m pip install dynamodb-persistent-lock
|
|
22
|
+
|
|
23
|
+
2. Usage
|
|
24
|
+
|
|
25
|
+
Non-async DynamoDB local download:
|
|
26
|
+
|
|
27
|
+
.. code-block:: python
|
|
28
|
+
|
|
29
|
+
from dynamodb_persistent_lock.dynamodb_persistent_lock import (
|
|
30
|
+
DynamoDBPersistentLockFactory,
|
|
31
|
+
DynamoDBPersistentLockClient,
|
|
32
|
+
)
|
|
33
|
+
from datetime import timedelta
|
|
34
|
+
...
|
|
35
|
+
# Create a lock factory
|
|
36
|
+
lock_factory = DynamoDBPersistentLockFactory(
|
|
37
|
+
table_name="locks",
|
|
38
|
+
region_name="eu-central-1",
|
|
39
|
+
partition_key_name="lock_key",
|
|
40
|
+
sort_key_name="sort_key",
|
|
41
|
+
record_version_number_name="rvn",
|
|
42
|
+
heartbeat_period=timedelta(seconds=10),
|
|
43
|
+
ttl_attribute_name="expire_at",
|
|
44
|
+
ttl_heartbeat_multiplier=3,
|
|
45
|
+
)
|
|
46
|
+
lock_factory.ensure_table()
|
|
47
|
+
...
|
|
48
|
+
# Open a lock client
|
|
49
|
+
lock_client = lock_factory.open_lock_client()
|
|
50
|
+
try:
|
|
51
|
+
lock = lock_client.try_acquire_lock("my_lock_key")
|
|
52
|
+
...
|
|
53
|
+
# Lock-guarded code here.
|
|
54
|
+
finally:
|
|
55
|
+
lock_client.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Features
|
|
59
|
+
--------
|
|
60
|
+
* Creates a new lock, if there is none, in DynamoDB table.
|
|
61
|
+
* Reacquires stale existing lock based on the ttl column value.
|
|
62
|
+
* Keeps extending the ttl of an acquired lock with a given heartbeat rate.
|
|
63
|
+
|
|
64
|
+
Credits
|
|
65
|
+
-------
|
|
66
|
+
* `Building Distributed Locks with the DynamoDB Lock Client <https://aws.amazon.com/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/>`_.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.rst
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/dynamodb_persistent_lock/__init__.py
|
|
4
|
+
src/dynamodb_persistent_lock/dynamodb_persistent_lock.py
|
|
5
|
+
src/dynamodb_persistent_lock.egg-info/PKG-INFO
|
|
6
|
+
src/dynamodb_persistent_lock.egg-info/SOURCES.txt
|
|
7
|
+
src/dynamodb_persistent_lock.egg-info/dependency_links.txt
|
|
8
|
+
src/dynamodb_persistent_lock.egg-info/top_level.txt
|
|
9
|
+
src/dynamodb_persistent_lock/tests/__init__.py
|
|
10
|
+
src/dynamodb_persistent_lock/tests/test_dynamodb_persistent_lock.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dynamodb_persistent_lock
|