Skip to content

Commit 0af72c6

Browse files
authored
Merge pull request #7 from chiradeep/implement_ttl
Use TTL feature of Dynamodb to automatically clear rows after last us…
2 parents 1c87a9e + 9ff23e2 commit 0af72c6

5 files changed

Lines changed: 57 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/dist/
33
/*.egg-info
44
/*.egg
5+
/*.vscode

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Although the code is general-purpose and can be used outside of AWS Lambda, note
6868
* Does not detect/prevent deadlocks. There is no spin lock, but the mutex user could create one by spinning until an acquire succeeds. Within a Lambda function, one should avoid taking more than 1 lock.
6969
* Not re-entrant. If a thread (e.g.,a lambda function) tries to re-acquire a lock it already holds, it will block
7070
* Not designed for speed. The DynamoDb table backing the locks is generally provisioned as low throughput (2 ops/sec)
71-
* No cleanup. Each named mutex gets a row in the Dynamodb table. These rows are never cleaned up even if nobody is using the mutex. It should be trivial to write a lambda to vaccuum these rows, however, given the small size of the row and the infinite capacity of DynamoDb, it is not likely to be a problem, if used for its intended purpose.
71+
* Cleanup - the mutex table is created with the [TTL](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html) feature. By default, the TTL is 2 days; after 2 days of disuse, the row created to hold the mutex should get automatically deleted. You can use set the `ttl_minutes` in the constructor to choose a different TTL
7272

7373

7474
# TODO

dyndbmutex/dyndbmutex.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def setup_logging():
2020

2121
DEFAULT_MUTEX_TABLE_NAME = 'Mutex'
2222
NO_HOLDER = '__empty__'
23-
23+
TWO_DAYS_IN_MINUTES = 2*24*60
2424

2525
class AcquireLockFailedError(Exception):
2626
pass
@@ -33,11 +33,12 @@ def timestamp_millis():
3333

3434
class MutexTable:
3535

36-
def __init__(self, region_name='us-west-2'):
36+
def __init__(self, region_name='us-west-2', ttl_minutes=TWO_DAYS_IN_MINUTES):
3737
self.dbresource = boto3.resource('dynamodb', region_name=region_name)
3838
self.dbclient = boto3.client('dynamodb', region_name=region_name)
3939
self.table_name = os.environ.get('DD_MUTEX_TABLE_NAME', DEFAULT_MUTEX_TABLE_NAME)
4040
logger.info("Mutex table name is " + self.table_name)
41+
self.ttl_minutes = ttl_minutes
4142
self.get_table()
4243

4344
def get_table(self):
@@ -53,7 +54,10 @@ def get_table(self):
5354

5455
def delete_table(self):
5556
self.dbclient.delete_table(TableName=self.table_name)
56-
logger.debug("Deleted table")
57+
logger.info("Deleted table " + self.table_name)
58+
59+
def get_lock(self, lockname):
60+
return self.get_table().get_item(Key={'lockname': lockname})
5761

5862
def create_table(self):
5963
try:
@@ -84,26 +88,37 @@ def create_table(self):
8488
else:
8589
logger.debug("Called create_table")
8690
table.wait_until_exists()
87-
logger.info("Created table")
91+
logger.info("Created table " + self.table_name)
92+
try:
93+
self.dbclient.update_time_to_live(
94+
TableName=self.table_name,
95+
TimeToLiveSpecification={
96+
'Enabled': True,
97+
'AttributeName': 'ttl'
98+
})
99+
except botocore.exceptions.ClientError as e:
100+
logger.error("Error setting TTL on table", exc_info=e)
88101
return table
89102

90103
def write_lock_item(self, lockname, caller, waitms):
91104
expire_ts = timestamp_millis() + waitms
105+
ttl = expire_ts + self.ttl_minutes*60
92106
logger.debug("Write_item: lockname=" + lockname + ", caller=" +
93107
caller + ", Expire time is " + str(expire_ts))
94108
try:
95109
self.get_table().put_item(
96110
Item={
97111
'lockname': lockname,
98112
'expire_ts': expire_ts,
99-
'holder': caller
113+
'holder': caller,
114+
'ttl': ttl
100115
},
101116
# TODO: adding Attr("holder").eq(caller) should make it re-entrant
102117
ConditionExpression=Attr("holder").eq(NO_HOLDER) | Attr('lockname').not_exists()
103118
)
104119
except botocore.exceptions.ClientError as e:
105120
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
106-
logger.warn("Write_item: lockname=" + lockname +
121+
logger.warning("Write_item: lockname=" + lockname +
107122
", caller=" + caller + ", lock is being held")
108123
return False
109124
logger.debug("Write_item: lockname=" + lockname +
@@ -122,7 +137,7 @@ def clear_lock_item(self, lockname, caller):
122137
)
123138
except botocore.exceptions.ClientError as e:
124139
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
125-
logger.warn("clear_lock_item: lockname=" + lockname + ", caller=" + caller +
140+
logger.warning("clear_lock_item: lockname=" + lockname + ", caller=" + caller +
126141
" release failed")
127142
return False
128143
logger.debug("clear_lock_item: lockname=" + lockname + ", caller=" + caller + " release succeeded")
@@ -143,7 +158,7 @@ def prune_expired(self, lockname, caller):
143158
)
144159
except botocore.exceptions.ClientError as e:
145160
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
146-
logger.warn("Prune: lockname=" + lockname + ", caller=" + caller +
161+
logger.warning("Prune: lockname=" + lockname + ", caller=" + caller +
147162
" Prune failed")
148163
return False
149164
logger.debug("Prune: lockname=" + lockname + ", caller=" + caller + " Prune succeeded")
@@ -153,13 +168,13 @@ def prune_expired(self, lockname, caller):
153168
class DynamoDbMutex:
154169

155170
def __init__(self, name, holder=None,
156-
timeoutms=30 * 1000, region_name='us-west-2'):
171+
timeoutms=30 * 1000, region_name='us-west-2', ttl_minutes=TWO_DAYS_IN_MINUTES):
157172
if holder is None:
158173
holder = str(uuid.uuid4())
159174
self.lockname = name
160175
self.holder = holder
161176
self.timeoutms = timeoutms
162-
self.table = MutexTable(region_name=region_name)
177+
self.table = MutexTable(region_name=region_name, ttl_minutes=ttl_minutes)
163178
self.locked = False
164179

165180
def lock(self):
@@ -184,6 +199,9 @@ def __exit__(self, exc_type, exc_value, traceback):
184199

185200
def is_locked(self):
186201
return self.locked
202+
203+
def get_raw_lock(self):
204+
return self.table.get_lock(self.lockname)
187205

188206
@staticmethod
189207
def delete_table(region_name='us-west-2'):

scripts/create-table.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ aws dynamodb create-table \
99
AttributeName=lockname,AttributeType=S \
1010
--key-schema AttributeName=lockname,KeyType=HASH \
1111
--provisioned-throughput ReadCapacityUnits=2,WriteCapacityUnits=2
12+
aws dynamodb update-time-to-live --table-name $MUTEX_TABLE_NAME --time-to-live-specification "Enabled=true, AttributeName=ttl"

test/test_mutex.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import unittest
33
import base64
44
import sys
5+
import warnings
56
import os
67
from os import path
78

@@ -22,6 +23,10 @@ def setUpClass(cls):
2223
setup_logging()
2324
super(TestDynamoDbMutex, cls).setUpClass()
2425

26+
def setUp(self):
27+
#python 3.6 emits warnings due to requests which can be ignoredz
28+
warnings.filterwarnings("ignore", category=ResourceWarning, message="unclosed.*<ssl.SSLSocket.*>")
29+
2530
def test_create(self):
2631
m = DynamoDbMutex(random_name(), "myself", 3 * 1000)
2732
assert(m.lock())
@@ -38,13 +43,13 @@ def test_timeout(self):
3843
m = DynamoDbMutex(random_name(), "myself", 3 * 1000)
3944
m.lock()
4045
time.sleep(5)
41-
assert(m.lock())
46+
self.assertTrue(m.lock())
4247
m.release()
4348

4449
def test_mutual_exclusion(self):
4550
m = DynamoDbMutex(random_name(), holder=random_name())
4651
m.lock()
47-
assert(m.lock() == False)
52+
self.assertFalse(m.lock())
4853
m.release()
4954

5055
def test_with(self):
@@ -55,7 +60,7 @@ def test_with(self):
5560
raise
5661
except:
5762
print("In exception handler")
58-
assert(m.is_locked() == False)
63+
self.assertFalse(m.is_locked())
5964

6065
def test_with_fail(self):
6166
name = random_name()
@@ -68,9 +73,9 @@ def test_with_fail(self):
6873
time.sleep(3)
6974
except AcquireLockFailedError:
7075
print("In exception handler")
71-
assert(m2.is_locked() == False)
76+
self.assertFalse(m2.is_locked())
7277
exceptionHappened = True
73-
assert(exceptionHappened)
78+
self.assertTrue(exceptionHappened)
7479

7580
def test_release_expired(self):
7681
name = random_name()
@@ -80,11 +85,24 @@ def test_release_expired(self):
8085
time.sleep(3)
8186
caller = "caller2"
8287
m2 = DynamoDbMutex(name=name, holder=caller, timeoutms=2 * 1000)
83-
assert(m2.lock())
88+
self.assertTrue(m2.lock())
8489
m1.release()
85-
assert(m2.is_locked())
90+
self.assertTrue(m2.is_locked())
8691
m2.release()
8792

93+
def test_ttl(self):
94+
os.environ['DD_MUTEX_TABLE_NAME'] = random_name()
95+
name = random_name()
96+
caller = "myself"
97+
m1 = DynamoDbMutex(name=name, holder=caller, timeoutms=2 * 1000, ttl_minutes=2)
98+
m1.lock()
99+
item = m1.get_raw_lock()
100+
diff = item['Item']['ttl'] - item['Item']['expire_ts']
101+
print (diff)
102+
self.assertTrue(diff > 0 and diff <= 2*60)
103+
DynamoDbMutex.delete_table()
104+
del os.environ['DD_MUTEX_TABLE_NAME']
105+
88106
def test_table_creation_race(self):
89107
old_table_name = os.environ.get('DD_MUTEX_TABLE_NAME')
90108
try:
@@ -95,7 +113,7 @@ def test_table_creation_race(self):
95113
def create_mutex(i):
96114
mutex_name = random_name()
97115
mutex = cls(name=mutex_name, holder='caller%i' % i)
98-
self.assertEquals(mutex.table.table_name, table_name)
116+
self.assertEqual(mutex.table.table_name, table_name)
99117
return mutex
100118

101119
num_threads = 2

0 commit comments

Comments
 (0)