├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples └── readme.js ├── index.js ├── index.test.js ├── package.json ├── schema ├── failClosedConfig.js └── failOpenConfig.js ├── scripts └── injectExamples.js └── test └── countdown.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tristanls] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | package-lock.json 4 | .idea 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tristan Slominski 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamodb-lock-client 2 | 3 | _Stability: 1 - [Experimental](https://github.com/tristanls/stability-index#stability-1---experimental)_ 4 | 5 | [![NPM version](https://badge.fury.io/js/dynamodb-lock-client.png)](http://npmjs.org/package/dynamodb-lock-client) 6 | 7 | A general purpose distributed locking library with fencing tokens built for AWS DynamoDB. 8 | 9 | For AWS SDK v3 version go to: https://github.com/trilogy-group/dynamodb-lock-client-v3 10 | 11 | ## Contributors 12 | 13 | [@tristanls](https://github.com/tristanls), [@Jacob-Lynch](https://github.com/Jacob-Lynch), [@simlu](https://github.com/simlu), Lukas Siemon, [@tomyam1](https://github.com/tomyam1), [@deathgrindfreak](https://github.com/deathgrindfreak), [@jepetko](https://github.com/jepetko), [@fpronto](https://github.com/fpronto) 14 | 15 | ## Contents 16 | 17 | * [Installation](#installation) 18 | * [Usage](#usage) 19 | * [Tests](#tests) 20 | * [Documentation](#documentation) 21 | * [Setting up the lock table in DynamoDB](#setting-up-the-lock-table-in-dynamodb) 22 | * [DynamoDBLockClient](#dynamodblockclient) 23 | * [Releases](#releases) 24 | 25 | ## Installation 26 | 27 | npm install dynamodb-lock-client 28 | 29 | ## Usage 30 | 31 | To run the below example, run: 32 | 33 | npm run readme 34 | 35 | ```javascript 36 | "use strict"; 37 | 38 | const AWS = require("aws-sdk"); 39 | const DynamoDBLockClient = require("../index.js"); 40 | 41 | const dynamodb = new AWS.DynamoDB.DocumentClient( 42 | { 43 | region: "us-east-1" 44 | } 45 | ); 46 | 47 | // "fail closed": if process crashes and lock is not released, lock will 48 | // never be released (requires human intervention) 49 | const failClosedClient = new DynamoDBLockClient.FailClosed( 50 | { 51 | dynamodb, 52 | lockTable: "my-lock-table-name", 53 | partitionKey: "mylocks", 54 | acquirePeriodMs: 1e4 55 | } 56 | ); 57 | 58 | failClosedClient.acquireLock("my-fail-closed-lock", (error, lock) => 59 | { 60 | if (error) 61 | { 62 | return console.error(error) 63 | } 64 | console.log("acquired fail closed lock"); 65 | // do stuff 66 | lock.release(error => error ? console.error(error) : console.log("released fail closed lock")); 67 | } 68 | ); 69 | 70 | // "fail open": if process crashes and lock is not released, lock will 71 | // eventually expire after leaseDurationMs from last heartbeat 72 | // sent 73 | const failOpenClient = new DynamoDBLockClient.FailOpen( 74 | { 75 | dynamodb, 76 | lockTable: "my-lock-table-name", 77 | partitionKey: "mylocks", 78 | heartbeatPeriodMs: 3e3, 79 | leaseDurationMs: 1e4 80 | } 81 | ); 82 | 83 | failOpenClient.acquireLock("my-fail-open-lock", (error, lock) => 84 | { 85 | if (error) 86 | { 87 | return console.error(error) 88 | } 89 | console.log(`acquired fail open lock with fencing token ${lock.fencingToken}`); 90 | lock.on("error", error => console.error("failed to heartbeat!")); 91 | // do stuff 92 | lock.release(error => error ? console.error(error) : console.log("released fail open lock")); 93 | } 94 | ); 95 | 96 | ``` 97 | 98 | ## Tests 99 | 100 | At this time, test are implemented for FailOpen lock acquisition and release. 101 | 102 | ``` 103 | npm test 104 | ``` 105 | 106 | ## Documentation 107 | 108 | * [Setting up the lock table in DynamoDB](#setting-up-the-lock-table-in-dynamodb) 109 | * [DynamoDBLockClient](#dynamodblockclient) 110 | 111 | ### Setting up the lock table in DynamoDB 112 | 113 | #### Recommended 114 | 115 | The DynamoDB lock table needs to be created independently. The following is an example CloudFormation template that would create such a lock table: 116 | 117 | ```yaml 118 | AWSTemplateFormatVersion: "2010-09-09" 119 | 120 | Resources: 121 | 122 | DistributedLocksStore: 123 | Type: AWS::DynamoDB::Table 124 | Properties: 125 | AttributeDefinitions: 126 | - AttributeName: id 127 | AttributeType: S 128 | KeySchema: 129 | - AttributeName: id 130 | KeyType: HASH 131 | TableName: "distributed-locks-store" 132 | BillingMode: PAY_PER_REQUEST 133 | 134 | Outputs: 135 | 136 | DistributedLocksStore: 137 | Value: !GetAtt DistributedLocksStore.Arn 138 | ``` 139 | 140 | The template above would make your `config.partitionKey == "id"` and your `config.lockTable == "distributed-locks-store"`. 141 | 142 | You can choose to call your `config.partitionKey` any valid string except `fencingToken`, `leaseDurationMs`, `lockAcquiredTimeUnixMs`, `owner`, or `guid` (these attribute names are reserved for use by `DynamoDBLockClient` library). Your `config.partitionKey` has to correspond to the partition key (`HASH`) of the Primary Key of your DynamoDB table. 143 | 144 | #### Using sort key 145 | 146 | In some cases, you may be constrained to use a DynamoDB table that requires to specify a sort key. The following is an example CloudFormation template that would create such a lock table: 147 | 148 | ```yaml 149 | AWSTemplateFormatVersion: "2010-09-09" 150 | 151 | Resources: 152 | 153 | DistributedLocksStore: 154 | Type: AWS::DynamoDB::Table 155 | Properties: 156 | AttributeDefinitions: 157 | - AttributeName: id 158 | AttributeType: S 159 | - AttributeName: sortID 160 | AttributeType: S 161 | KeySchema: 162 | - AttributeName: id 163 | KeyType: HASH 164 | - AttributeName: sortID 165 | KeyType: RANGE 166 | TableName: "distributed-locks-store" 167 | BillingMode: PAY_PER_REQUEST 168 | 169 | Outputs: 170 | 171 | DistributedLocksStore: 172 | Value: !GetAtt DistributedLocksStore.Arn 173 | ``` 174 | 175 | The template above would make your `config.partitionKey == "id"`, `config.sortKey = "sortID"`, and your `config.lockTable == "distributed-locks-store"`. 176 | 177 | You can choose to call your `config.partitionKey` and `config.sortKey` any valid string except `fencingToken`, `leaseDurationMs`, `lockAcquiredTimeUnixMs`, `owner`, or `guid` (these attribute names are reserved for use by `DynamoDBLockClient` library). Your `config.partitionKey` has to correspond to the partition key (`HASH`) of the Primary Key of your DynamoDB table. Your `config.sortKey` has to correspond to the sort key (`RANGE`) of the Primary Key of your DynamoDB table. 178 | 179 | ### DynamoDBLockClient 180 | 181 | **Public API** 182 | 183 | * [new DynamoDBLockClient.FailClosed(config)](#new-dynamodblockclientfailclosedconfig) 184 | * [new DynamoDBLockClient.FailOpen(config)](#new-dynamodblockclientfailopenconfig) 185 | * [client.acquireLock(id, callback)](#clientacquirelockid-callback) 186 | * [lock.release(callback)](#lockreleasecallback) 187 | 188 | ### new DynamoDBLockClient.FailClosed(config) 189 | 190 | * `config`: _Object_ 191 | * `dynamodb`: _AWS.DynamoDB.DocumentClient_ Instance of AWS DynamoDB DocumentClient. 192 | * `lockTable`: _String_ Name of lock table to use. 193 | * `partitionKey`: _String_ Name of table partition key (hash key) to use. 194 | * `sortKey`: _String_ _(Default: undefined)_ Optional name of table sort key (range key) to use. If specified, all lock ids will be required to contain a `sortKey`. 195 | * `acquirePeriodMs`: _Number_ How long to wait for the lock before giving up. Whatever operation this lock is protecting should take less time than `acquirePeriodMs`. 196 | * `owner`: _String_ Customize owner name for lock (optional). 197 | * `retryCount`: _Number_ _(Default: 1)_ Number of times to retry lock acquisition after initial failure. No retries will occur if set to `0`. 198 | * Return: _Object_ Fail closed client. 199 | 200 | Creates a "fail closed" client that acquires "fail closed" locks. If process crashes and lock is not released, lock will never be released. This means that some sort of intervention will be required to put the system back into operational state if lock is held and a process crashes while holding the lock. 201 | 202 | ### new DynamoDBLockClient.FailOpen(config) 203 | 204 | * `config`: _Object_ 205 | * `dynamodb`: _AWS.DynamoDB.DocumentClient_ Instance of AWS DynamoDB DocumentClient. 206 | * `lockTable`: _String_ Name of lock table to use. 207 | * `partitionKey`: _String_ Name of table partition key (hash key) to use. 208 | * `sortKey`: _String_ _(Default: undefined)_ Optional name of table sort key (range key) to use. If specified, all lock ids will be required to contain a `sortKey`. 209 | * `heartbeatPeriodMs`: _Number_ _(Default: undefined)_ Optional period at which to send heartbeats in order to keep the lock locked. Providing this option will cause heartbeats to be sent. 210 | * `leaseDurationMs`: _Number_ The length of lock lease duration. If the lock is not renewed via a heartbeat within `leaseDurationMs` it will be automatically released. 211 | * `owner`: _String_ Customize owner name for lock (optional). 212 | * `retryCount`: _Number_ _(Default: 1)_ Number of times to retry lock acquisition after initial failure. No retries will occur if set to `0`. 213 | * `trustLocalTime`: _Boolean_ _(Default: false)_ If set to `true`, when the client retrieves an existing lock, it will use local time to determine if `leaseDurationMs` has elapsed (and shorten its wait time accordingly) instead of always waiting the full `leaseDurationMs` milliseconds before making an acquisition attempt. 214 | * Return: _Object_ Fail open client. 215 | 216 | Creates a "fail open" client that acquires "fail open" locks. If process crashes and lock is not released, lock will eventually expire after `leaseDurationMs` from last heartbeat sent (if any). This means that if process acquires a lock, goes to sleep for more than `leaseDurationMs`, and then wakes up assuming it still has a lock, then it can perform an operation ignoring other processes that may assume they have a lock on the operation. 217 | 218 | ### client.acquireLock(id, callback) 219 | 220 | * `id`: _String\|Buffer\|Number\|Object_ Unique identifier for the lock. If the type of `id` is _String\|Buffer\|Number_ the type must correspond to lock table's partition key type. If the type of `id` is _Object_, it is expected to have the following format: 221 | ``` 222 | { 223 | [config.partitionKey]: String|Buffer|Number, 224 | [config.sortKey]: String|Buffer|Number 225 | } 226 | ``` 227 | For example, if `config.partitionKey = "myPartitionKey"` and `config.sortKey = "mySortKey"` and partition key value is `id1234` and sort key value is `abcd`, then the _Object_ would be: 228 | ``` 229 | { 230 | myPartitionKey: "id1234", 231 | mySortKey: "abcd" 232 | } 233 | ``` 234 | Sort key part of `id` is only required if lock is configured with a sort key. The types of partition key and sort key must correspond to lock table's partition key and sort key types. 235 | * `callback`: _Function_ `(error, lock) => {}` 236 | * `error`: _Error_ Error, if any. 237 | * `lock`: _DynamoDBLockClient.Lock_ Successfully acquired lock object. Lock object is an instance of `EventEmitter`. If the `lock` is acquired via a fail open `client` configured to heartbeat, then the returned `lock` may emit an `error` event if a `heartbeat` operation fails. 238 | * `fencingToken`: _Integer_ **fail open locks only** Integer monotonically incremented with every "fail open" lock acquisition to be used for [fencing](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html#making-the-lock-safe-with-fencing). Heartbeats do not increment `fencingToken`. 239 | 240 | Attempts to acquire a lock. If lock acquisition fails, callback will be called with an `error` and `lock` will be falsy. If lock acquisition succeeds, callback will be called with `lock`, and `error` will be falsy. 241 | 242 | Fail closed client will attempt to acquire a lock. On failure, client will retry after `acquirePeriodMs` up to `retryCount` times. After `retryCount` failures, client will fail lock acquisition. On successful acquisition, lock will be locked until `lock.release()` is called successfuly. 243 | 244 | Fail open client will attempt to acquire a lock. On failure, if `trustLocalTime` is `false` (the default), client will retry after `leaseDurationMs`. If `trustLocalTime` is `true`, the client will retry after `Math.max(0, leaseDurationMs - (localTimeMs - lockAcquiredTimeMs))` where `localTimeMs` is "now" and `lockAcquiredTimeMs` is the lock acquisition time recorded in the retrieved lock. Lock acquisition will be retried up to `retryCount` times. After `retryCount` failures, client will fail lock acquisition. On successful acquisition, if `heartbeatPeriodMs` option is not specified (heartbeats off), lock will expire after `leaseDurartionMs`. If `heartbeatPeriodMs` option is specified, lock will be renewed at `heartbeatPeriodMs` intervals until `lock.release()` is called successfuly. Additionally, if `heartbeatPeriodMs` option is specified, lock may emit an `error` event if it fails a heartbeat operation. 245 | 246 | ### lock.release(callback) 247 | 248 | * `callback`: _Function_ `error => {}` 249 | * `error`: _Error_ Error, if any. No error implies successful lock release. 250 | 251 | Releases previously acquired lock. 252 | 253 | Fail closed lock is deleted, so that it can be acquired again. 254 | 255 | Fail open lock heartbeats stop, and its `leaseDurationMs` is set to 1 millisecond so that it expires "immediately". The datastructure is left in the datastore in order to provide continuity of `fencingToken` monotonicity guarantee. 256 | 257 | ## Releases 258 | 259 | We follow semantic versioning policy (see: [semver.org](http://semver.org/)): 260 | 261 | > Given a version number MAJOR.MINOR.PATCH, increment the: 262 | > 263 | >MAJOR version when you make incompatible API changes,
264 | >MINOR version when you add functionality in a backwards-compatible manner, and
265 | >PATCH version when you make backwards-compatible bug fixes. 266 | 267 | **caveat**: Major version zero is a special case indicating development version that may make incompatible API changes without incrementing MAJOR version. 268 | -------------------------------------------------------------------------------- /examples/readme.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const AWS = require("aws-sdk"); 4 | const DynamoDBLockClient = require("../index.js"); 5 | 6 | const dynamodb = new AWS.DynamoDB.DocumentClient( 7 | { 8 | region: "us-east-1" 9 | } 10 | ); 11 | 12 | // "fail closed": if process crashes and lock is not released, lock will 13 | // never be released (requires human intervention) 14 | const failClosedClient = new DynamoDBLockClient.FailClosed( 15 | { 16 | dynamodb, 17 | lockTable: "my-lock-table-name", 18 | partitionKey: "mylocks", 19 | acquirePeriodMs: 1e4 20 | } 21 | ); 22 | 23 | failClosedClient.acquireLock("my-fail-closed-lock", (error, lock) => 24 | { 25 | if (error) 26 | { 27 | return console.error(error) 28 | } 29 | console.log("acquired fail closed lock"); 30 | // do stuff 31 | lock.release(error => error ? console.error(error) : console.log("released fail closed lock")); 32 | } 33 | ); 34 | 35 | // "fail open": if process crashes and lock is not released, lock will 36 | // eventually expire after leaseDurationMs from last heartbeat 37 | // sent 38 | const failOpenClient = new DynamoDBLockClient.FailOpen( 39 | { 40 | dynamodb, 41 | lockTable: "my-lock-table-name", 42 | partitionKey: "mylocks", 43 | heartbeatPeriodMs: 3e3, 44 | leaseDurationMs: 1e4 45 | } 46 | ); 47 | 48 | failOpenClient.acquireLock("my-fail-open-lock", (error, lock) => 49 | { 50 | if (error) 51 | { 52 | return console.error(error) 53 | } 54 | console.log(`acquired fail open lock with fencing token ${lock.fencingToken}`); 55 | lock.on("error", error => console.error("failed to heartbeat!")); 56 | // do stuff 57 | lock.release(error => error ? console.error(error) : console.log("released fail open lock")); 58 | } 59 | ); 60 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const crypto = require("crypto"); 4 | const events = require("events"); 5 | const Joi = require("@hapi/joi"); 6 | const os = require("os"); 7 | const pkg = require("./package.json"); 8 | const util = require("util"); 9 | 10 | const FailClosed = function(config) 11 | { 12 | if(!(this instanceof FailClosed)) 13 | { 14 | return new FailClosed(config); 15 | } 16 | const self = this; 17 | 18 | // constant configuration 19 | self._config = config; 20 | 21 | const configValidationResult = FailClosed.schema.config.validate( 22 | self._config, 23 | { 24 | abortEarly: false, 25 | convert: false 26 | } 27 | ); 28 | if (configValidationResult.error) 29 | { 30 | throw configValidationResult.error; 31 | } 32 | self._retryCount = self._config.retryCount === undefined ? 1 : self._config.retryCount; 33 | }; 34 | 35 | FailClosed.schema = 36 | { 37 | config: require("./schema/failClosedConfig.js") 38 | }; 39 | 40 | FailClosed.prototype.acquireLock = function(id, callback) 41 | { 42 | const self = this; 43 | let partitionID, sortID; 44 | if (typeof id === "object") 45 | { 46 | partitionID = id[self._config.partitionKey]; 47 | sortID = id[self._config.sortKey]; 48 | } 49 | else 50 | { 51 | partitionID = id; 52 | } 53 | if (self._config.sortKey && sortID === undefined) 54 | { 55 | return callback(new Error("Lock ID is missing required sortKey value")); 56 | } 57 | const workflow = new events.EventEmitter(); 58 | // Register workflow event handlers 59 | workflow.on("start", dataBag => workflow.emit("acquire lock", dataBag)); 60 | workflow.on("acquire lock", dataBag => 61 | { 62 | const params = 63 | { 64 | TableName: self._config.lockTable, 65 | Item: 66 | { 67 | [self._config.partitionKey]: dataBag.partitionID, 68 | owner: dataBag.owner, 69 | guid: dataBag.guid 70 | }, 71 | ConditionExpression: buildAttributeNotExistsExpression(self), 72 | ExpressionAttributeNames: buildExpressionAttributeNames(self) 73 | }; 74 | if (self._config.sortKey) 75 | { 76 | params.Item[self._config.sortKey] = dataBag.sortID; 77 | } 78 | self._config.dynamodb.put(params, (error, data) => 79 | { 80 | if (error) 81 | { 82 | if (error.code === "ConditionalCheckFailedException") 83 | { 84 | if (dataBag.retryCount > 0) 85 | { 86 | return workflow.emit("retry acquire lock", dataBag); 87 | } 88 | else 89 | { 90 | const err = new Error("Failed to acquire lock."); 91 | err.code = "FailedToAcquireLock"; 92 | err.originalError = error; 93 | return callback(err); 94 | } 95 | } 96 | return callback(error); 97 | } 98 | return callback(undefined, new Lock( 99 | { 100 | dynamodb: self._config.dynamodb, 101 | guid: dataBag.guid, 102 | lockTable: self._config.lockTable, 103 | partitionID: dataBag.partitionID, 104 | partitionKey: self._config.partitionKey, 105 | sortID: dataBag.sortID, 106 | sortKey: self._config.sortKey, 107 | type: FailClosed 108 | } 109 | )); 110 | } 111 | ); 112 | } 113 | ); 114 | workflow.on("retry acquire lock", dataBag => 115 | { 116 | dataBag.retryCount--; 117 | setTimeout(() => workflow.emit("acquire lock", dataBag), self._config.acquirePeriodMs); 118 | } 119 | ); 120 | // Start the workflow 121 | workflow.emit("start", 122 | { 123 | partitionID, 124 | sortID, 125 | owner: self._config.owner || `${pkg.name}@${pkg.version}_${os.userInfo().username}@${os.hostname()}`, 126 | retryCount: self._retryCount, 127 | guid: crypto.randomBytes(64) 128 | } 129 | ) 130 | }; 131 | 132 | function buildAttributeExistsExpression(self) 133 | { 134 | let expr = "attribute_exists(#partitionKey)"; 135 | if (self._config.sortKey) 136 | { 137 | expr += " and attribute_exists(#sortKey)"; 138 | return `(${expr})`; 139 | } 140 | return expr; 141 | } 142 | 143 | function buildAttributeNotExistsExpression(self) 144 | { 145 | let expr = "attribute_not_exists(#partitionKey)"; 146 | if (self._config.sortKey) 147 | { 148 | expr += " and attribute_not_exists(#sortKey)"; 149 | return `(${expr})`; 150 | } 151 | return expr; 152 | } 153 | 154 | function buildExpressionAttributeNames(self) 155 | { 156 | const names = 157 | { 158 | "#partitionKey": self._config.partitionKey 159 | }; 160 | if (self._config.sortKey) 161 | { 162 | names["#sortKey"] = self._config.sortKey; 163 | } 164 | return names; 165 | } 166 | 167 | const FailOpen = function(config) 168 | { 169 | if(!(this instanceof FailOpen)) 170 | { 171 | return new FailOpen(config); 172 | } 173 | const self = this; 174 | 175 | // constant configuration 176 | self._config = config; 177 | 178 | const configValidationResult = FailOpen.schema.config.validate( 179 | self._config, 180 | { 181 | abortEarly: false, 182 | convert: false 183 | } 184 | ); 185 | if (configValidationResult.error) 186 | { 187 | throw configValidationResult.error; 188 | } 189 | self._retryCount = self._config.retryCount === undefined ? 1 : self._config.retryCount; 190 | }; 191 | 192 | FailOpen.schema = 193 | { 194 | config: require("./schema/failOpenConfig.js") 195 | }; 196 | 197 | FailOpen.prototype.acquireLock = function(id, callback) 198 | { 199 | const self = this; 200 | let partitionID, sortID; 201 | if (typeof id === "object") 202 | { 203 | partitionID = id[self._config.partitionKey]; 204 | sortID = id[self._config.sortKey]; 205 | } 206 | else 207 | { 208 | partitionID = id; 209 | } 210 | if (self._config.sortKey && sortID === undefined) 211 | { 212 | return callback(new Error("Lock ID is missing required sortKey value")); 213 | } 214 | const workflow = new events.EventEmitter(); 215 | // Register workflow event handlers 216 | workflow.on("start", dataBag => workflow.emit("check for existing lock", dataBag)); 217 | workflow.on("check for existing lock", dataBag => 218 | { 219 | const params = 220 | { 221 | TableName: self._config.lockTable, 222 | Key: 223 | { 224 | [self._config.partitionKey]: dataBag.partitionID 225 | }, 226 | ConsistentRead: true 227 | }; 228 | if (self._config.sortKey) 229 | { 230 | params.Key[self._config.sortKey] = dataBag.sortID; 231 | } 232 | self._config.dynamodb.get(params, (error, data) => 233 | { 234 | if (error) 235 | { 236 | return callback(error); 237 | } 238 | if (!data.Item) 239 | { 240 | dataBag.fencingToken = 1; 241 | return workflow.emit("acquire new lock", dataBag); 242 | } 243 | dataBag.lock = data.Item; 244 | dataBag.fencingToken = dataBag.lock.fencingToken + 1; 245 | const leaseDurationMs = parseInt(dataBag.lock.leaseDurationMs); 246 | let timeout; 247 | if (self._config.trustLocalTime) 248 | { 249 | const lockAcquiredTimeUnixMs = parseInt(dataBag.lock.lockAcquiredTimeUnixMs); 250 | const localTimeUnixMs = (new Date()).getTime(); 251 | timeout = Math.max(0, leaseDurationMs - (localTimeUnixMs - lockAcquiredTimeUnixMs)); 252 | } 253 | else 254 | { 255 | timeout = leaseDurationMs; 256 | } 257 | return setTimeout( 258 | () => workflow.emit("acquire existing lock", dataBag), 259 | timeout 260 | ); 261 | } 262 | ); 263 | } 264 | ); 265 | workflow.on("acquire new lock", dataBag => 266 | { 267 | const params = 268 | { 269 | TableName: self._config.lockTable, 270 | Item: 271 | { 272 | [self._config.partitionKey]: dataBag.partitionID, 273 | fencingToken: dataBag.fencingToken, 274 | leaseDurationMs: self._config.leaseDurationMs, 275 | owner: dataBag.owner, 276 | guid: dataBag.guid 277 | }, 278 | ConditionExpression: buildAttributeNotExistsExpression(self), 279 | ExpressionAttributeNames: buildExpressionAttributeNames(self) 280 | }; 281 | if (self._config.trustLocalTime) 282 | { 283 | params.Item.lockAcquiredTimeUnixMs = (new Date()).getTime(); 284 | } 285 | if (dataBag.sortID) 286 | { 287 | params.Item[self._config.sortKey] = dataBag.sortID; 288 | } 289 | self._config.dynamodb.put(params, (error, data) => 290 | { 291 | if (error) 292 | { 293 | if (error.code === "ConditionalCheckFailedException") 294 | { 295 | if (dataBag.retryCount > 0) 296 | { 297 | dataBag.retryCount--; 298 | return workflow.emit("check for existing lock", dataBag); 299 | } 300 | else 301 | { 302 | const err = new Error("Failed to acquire lock."); 303 | err.code = "FailedToAcquireLock"; 304 | err.originalError = error; 305 | return callback(err); 306 | } 307 | } 308 | return callback(error); 309 | } 310 | return workflow.emit("configure acquired lock", dataBag); 311 | } 312 | ); 313 | } 314 | ); 315 | workflow.on("acquire existing lock", dataBag => 316 | { 317 | const params = 318 | { 319 | TableName: self._config.lockTable, 320 | Item: 321 | { 322 | [self._config.partitionKey]: dataBag.partitionID, 323 | fencingToken: dataBag.fencingToken, 324 | leaseDurationMs: self._config.leaseDurationMs, 325 | owner: dataBag.owner, 326 | guid: dataBag.guid 327 | }, 328 | ConditionExpression: `${buildAttributeNotExistsExpression(self)} or (guid = :guid and fencingToken = :fencingToken)`, 329 | ExpressionAttributeNames: buildExpressionAttributeNames(self), 330 | ExpressionAttributeValues: 331 | { 332 | ":fencingToken": dataBag.lock.fencingToken, 333 | ":guid": dataBag.lock.guid 334 | } 335 | }; 336 | if (self._config.trustLocalTime) 337 | { 338 | params.Item.lockAcquiredTimeUnixMs = (new Date()).getTime(); 339 | } 340 | if (dataBag.sortID) 341 | { 342 | params.Item[self._config.sortKey] = dataBag.sortID; 343 | } 344 | self._config.dynamodb.put(params, (error, data) => 345 | { 346 | if (error) 347 | { 348 | if (error.code === "ConditionalCheckFailedException") 349 | { 350 | if (dataBag.retryCount > 0) 351 | { 352 | dataBag.retryCount--; 353 | return workflow.emit("check for existing lock", dataBag); 354 | } 355 | else 356 | { 357 | const err = new Error("Failed to acquire lock."); 358 | err.code = "FailedToAcquireLock"; 359 | err.originalError = error; 360 | return callback(err); 361 | } 362 | } 363 | return callback(error); 364 | } 365 | return workflow.emit("configure acquired lock", dataBag); 366 | } 367 | ); 368 | } 369 | ); 370 | workflow.on("configure acquired lock", dataBag => 371 | { 372 | return callback(undefined, new Lock( 373 | { 374 | dynamodb: self._config.dynamodb, 375 | fencingToken: dataBag.fencingToken, 376 | guid: dataBag.guid, 377 | heartbeatPeriodMs: self._config.heartbeatPeriodMs, 378 | leaseDurationMs: self._config.leaseDurationMs, 379 | lockTable: self._config.lockTable, 380 | owner: dataBag.owner, 381 | partitionID: dataBag.partitionID, 382 | partitionKey: self._config.partitionKey, 383 | sortID: dataBag.sortID, 384 | sortKey: self._config.sortKey, 385 | trustLocalTime: self._config.trustLocalTime, 386 | type: FailOpen 387 | } 388 | )); 389 | } 390 | ); 391 | // Start the workflow 392 | workflow.emit("start", 393 | { 394 | partitionID, 395 | sortID, 396 | owner: self._config.owner || `${pkg.name}@${pkg.version}_${os.userInfo().username}@${os.hostname()}`, 397 | retryCount: self._retryCount, 398 | guid: crypto.randomBytes(64) 399 | } 400 | ) 401 | }; 402 | 403 | const Lock = function(config) 404 | { 405 | const self = this; 406 | events.EventEmitter.call(self); 407 | 408 | // constant Lock configuration 409 | self._config = config; 410 | 411 | // variable properties 412 | self._guid = self._config.guid; 413 | self._released = false; 414 | 415 | // public properties 416 | self.fencingToken = self._config.fencingToken; 417 | 418 | if (self._config.heartbeatPeriodMs) 419 | { 420 | const refreshLock = function() 421 | { 422 | const newGuid = crypto.randomBytes(64); 423 | const params = 424 | { 425 | TableName: self._config.lockTable, 426 | Item: 427 | { 428 | [self._config.partitionKey]: self._config.partitionID, 429 | fencingToken: self._config.fencingToken, 430 | leaseDurationMs: self._config.leaseDurationMs, 431 | owner: self._config.owner, 432 | guid: newGuid 433 | }, 434 | ConditionExpression: `${buildAttributeExistsExpression(self)} and guid = :guid`, 435 | ExpressionAttributeNames: buildExpressionAttributeNames(self), 436 | ExpressionAttributeValues: 437 | { 438 | ":guid": self._guid 439 | } 440 | }; 441 | if (self._config.trustLocalTime) 442 | { 443 | params.Item.lockAcquiredTimeUnixMs = (new Date()).getTime(); 444 | } 445 | if (self._config.sortKey) 446 | { 447 | params.Item[self._config.sortKey] = self._config.sortID; 448 | } 449 | self._config.dynamodb.put(params, (error, data) => 450 | { 451 | if (error) 452 | { 453 | return self.emit("error", error); 454 | } 455 | self._guid = newGuid; 456 | if (!self._released) // See https://github.com/tristanls/dynamodb-lock-client/issues/1 457 | { 458 | self.heartbeatTimeout = setTimeout(refreshLock, self._config.heartbeatPeriodMs); 459 | } 460 | } 461 | ); 462 | }; 463 | self.heartbeatTimeout = setTimeout(refreshLock, self._config.heartbeatPeriodMs); 464 | } 465 | }; 466 | 467 | util.inherits(Lock, events.EventEmitter); 468 | 469 | Lock.prototype.release = function(callback) 470 | { 471 | const self = this; 472 | self._released = true; 473 | if (self.heartbeatTimeout) 474 | { 475 | clearTimeout(self.heartbeatTimeout); 476 | self.heartbeatTimeout = undefined; 477 | } 478 | if (self._config.type == FailOpen) 479 | { 480 | return self._releaseFailOpen(callback); 481 | } 482 | else 483 | { 484 | return self._releaseFailClosed(callback); 485 | } 486 | }; 487 | 488 | Lock.prototype._releaseFailClosed = function(callback) 489 | { 490 | const self = this; 491 | const params = 492 | { 493 | TableName: self._config.lockTable, 494 | Key: 495 | { 496 | [self._config.partitionKey]: self._config.partitionID 497 | }, 498 | ConditionExpression: `${buildAttributeExistsExpression(self)} and guid = :guid`, 499 | ExpressionAttributeNames: buildExpressionAttributeNames(self), 500 | ExpressionAttributeValues: 501 | { 502 | ":guid": self._guid 503 | } 504 | }; 505 | if (self._config.sortKey) 506 | { 507 | params.Key[self._config.sortKey] = self._config.sortID; 508 | } 509 | self._config.dynamodb.delete(params, (error, data) => 510 | { 511 | if (error && error.code === "ConditionalCheckFailedException") 512 | { 513 | const err = new Error("Failed to release lock."); 514 | err.code = "FailedToReleaseLock"; 515 | err.originalError = error; 516 | return callback(err); 517 | } 518 | return callback(error); 519 | } 520 | ); 521 | }; 522 | 523 | Lock.prototype._releaseFailOpen = function(callback) 524 | { 525 | const self = this; 526 | const params = 527 | { 528 | TableName: self._config.lockTable, 529 | Item: 530 | { 531 | [self._config.partitionKey]: self._config.partitionID, 532 | fencingToken: self._config.fencingToken, 533 | leaseDurationMs: 1, 534 | owner: self._config.owner, 535 | guid: self._guid 536 | }, 537 | ConditionExpression: `${buildAttributeExistsExpression(self)} and guid = :guid`, 538 | ExpressionAttributeNames: buildExpressionAttributeNames(self), 539 | ExpressionAttributeValues: 540 | { 541 | ":guid": self._guid 542 | } 543 | }; 544 | if (self._config.trustLocalTime) 545 | { 546 | params.Item.lockAcquiredTimeUnixMs = (new Date()).getTime(); 547 | } 548 | if (self._config.sortKey) 549 | { 550 | params.Item[self._config.sortKey] = self._config.sortID; 551 | } 552 | self._config.dynamodb.put(params, (error, data) => 553 | { 554 | if (error && error.code === "ConditionalCheckFailedException") 555 | { 556 | // another process may have claimed lock already 557 | return callback(); 558 | } 559 | return callback(error); 560 | } 561 | ); 562 | 563 | }; 564 | 565 | module.exports = 566 | { 567 | FailClosed, 568 | FailOpen, 569 | Lock 570 | }; 571 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const clone = require("clone"); 4 | const countdown = require("./test/countdown.js"); 5 | const crypto = require("crypto"); 6 | const DynamoDBLockClient = require("./index.js"); 7 | 8 | const LOCK_TABLE = "my-lock-table-name"; 9 | const PARTITION_KEY = "myPartitionKey"; 10 | const SORT_KEY = "mySortKey"; 11 | const HEARTBEAT_PERIOD_MS = 10; 12 | const LEASE_DURATION_MS = 100; 13 | const OWNER = "me"; 14 | const LOCK_ID = "lockID"; 15 | const SORT_ID = "sortID"; 16 | 17 | describe("FailClosed lock acquisition", () => 18 | { 19 | // TODO 20 | }); 21 | 22 | describe("FailClosed lock release", () => 23 | { 24 | // TODO 25 | }); 26 | 27 | describe("FailOpen lock acquisition", () => 28 | { 29 | let config, dynamodb; 30 | beforeEach(() => 31 | { 32 | config = 33 | { 34 | lockTable: LOCK_TABLE, 35 | partitionKey: PARTITION_KEY, 36 | heartbeatPeriodMs: HEARTBEAT_PERIOD_MS, 37 | leaseDurationMs: LEASE_DURATION_MS, 38 | owner: OWNER 39 | }; 40 | dynamodb = 41 | { 42 | delete: () => {}, 43 | get: () => {}, 44 | put: () => {} 45 | } 46 | } 47 | ); 48 | describe("using partitionKey", () => 49 | { 50 | describe("gets item from DynamoDB table", () => 51 | { 52 | test("if error, invokes callback with error", done => 53 | { 54 | const finish = countdown(done, 2); 55 | const error = new Error("boom"); 56 | config.dynamodb = Object.assign( 57 | dynamodb, 58 | { 59 | get(params, callback) 60 | { 61 | expect(params).toEqual( 62 | { 63 | TableName: LOCK_TABLE, 64 | Key: 65 | { 66 | [PARTITION_KEY]: LOCK_ID 67 | }, 68 | ConsistentRead: true 69 | } 70 | ); 71 | finish(); 72 | return callback(error); 73 | } 74 | } 75 | ); 76 | const failOpen = new DynamoDBLockClient.FailOpen(config); 77 | failOpen.acquireLock(LOCK_ID, (err, lock) => 78 | { 79 | expect(err).toBe(error); 80 | expect(lock).toBe(undefined); 81 | finish(); 82 | } 83 | ); 84 | } 85 | ); 86 | describe("no item present", () => 87 | { 88 | beforeEach(() => 89 | { 90 | dynamodb = Object.assign( 91 | dynamodb, 92 | { 93 | get: (_, callback) => callback(undefined, {}) 94 | } 95 | ); 96 | } 97 | ); 98 | describe("puts new item in DynamoDB table", () => 99 | { 100 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 101 | { 102 | const finish = countdown(done, 2); 103 | const error = new Error("boom"); 104 | config.dynamodb = Object.assign( 105 | dynamodb, 106 | { 107 | put(params, callback) 108 | { 109 | expect(params).toEqual( 110 | { 111 | TableName: LOCK_TABLE, 112 | Item: 113 | { 114 | [PARTITION_KEY]: LOCK_ID, 115 | fencingToken: 1, 116 | leaseDurationMs: LEASE_DURATION_MS, 117 | owner: OWNER, 118 | guid: expect.any(Buffer) 119 | }, 120 | ConditionExpression: `attribute_not_exists(#partitionKey)`, 121 | ExpressionAttributeNames: 122 | { 123 | "#partitionKey": PARTITION_KEY 124 | } 125 | } 126 | ); 127 | finish(); 128 | return callback(error); 129 | } 130 | } 131 | ); 132 | const failOpen = new DynamoDBLockClient.FailOpen(config); 133 | failOpen.acquireLock( 134 | { 135 | [PARTITION_KEY]: LOCK_ID 136 | }, 137 | (err, lock) => 138 | { 139 | expect(err).toBe(error); 140 | expect(lock).toBe(undefined); 141 | finish(); 142 | } 143 | ); 144 | } 145 | ); 146 | describe("trustLocalTime true, includes lockAcquiredTimeUnixMs parameter", () => 147 | { 148 | beforeEach(() => 149 | { 150 | config.trustLocalTime = true; 151 | } 152 | ); 153 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 154 | { 155 | const finish = countdown(done, 2); 156 | const error = new Error("boom"); 157 | config.dynamodb = Object.assign( 158 | dynamodb, 159 | { 160 | put(params, callback) 161 | { 162 | expect(params).toEqual( 163 | { 164 | TableName: LOCK_TABLE, 165 | Item: 166 | { 167 | [PARTITION_KEY]: LOCK_ID, 168 | fencingToken: 1, 169 | leaseDurationMs: LEASE_DURATION_MS, 170 | owner: OWNER, 171 | guid: expect.any(Buffer), 172 | lockAcquiredTimeUnixMs: expect.any(Number) 173 | }, 174 | ConditionExpression: `attribute_not_exists(#partitionKey)`, 175 | ExpressionAttributeNames: 176 | { 177 | "#partitionKey": PARTITION_KEY 178 | } 179 | } 180 | ); 181 | finish(); 182 | return callback(error); 183 | } 184 | } 185 | ); 186 | const failOpen = new DynamoDBLockClient.FailOpen(config); 187 | failOpen.acquireLock( 188 | { 189 | [PARTITION_KEY]: LOCK_ID 190 | }, 191 | (err, lock) => 192 | { 193 | expect(err).toBe(error); 194 | expect(lock).toBe(undefined); 195 | finish(); 196 | } 197 | ); 198 | } 199 | ); 200 | }); 201 | describe("if ConditionalCheckFailedException error", () => 202 | { 203 | const error = new Error("boom"); 204 | error.code = "ConditionalCheckFailedException"; 205 | beforeEach(() => 206 | { 207 | dynamodb = Object.assign( 208 | dynamodb, 209 | { 210 | put: (_, callback) => callback(error) 211 | } 212 | ); 213 | } 214 | ); 215 | describe("default retryCount", () => 216 | { 217 | test("retries to get item from DynamoDB table, if error, invokes callback with error", done => 218 | { 219 | let callCount = 0; 220 | const finish = countdown(done, 3); 221 | const err = new Error("boom"); 222 | config.dynamodb = Object.assign( 223 | dynamodb, 224 | { 225 | get(params, callback) 226 | { 227 | if (++callCount == 1) 228 | { 229 | finish(); 230 | return callback(undefined, {}); // no item 231 | } 232 | expect(params).toEqual( 233 | { 234 | TableName: LOCK_TABLE, 235 | Key: 236 | { 237 | [PARTITION_KEY]: LOCK_ID 238 | }, 239 | ConsistentRead: true 240 | } 241 | ); 242 | finish(); 243 | return callback(err); 244 | } 245 | } 246 | ); 247 | const failOpen = new DynamoDBLockClient.FailOpen(config); 248 | failOpen.acquireLock(LOCK_ID, (e, lock) => 249 | { 250 | expect(e).toBe(err); 251 | expect(lock).toBe(undefined); 252 | finish(); 253 | } 254 | ); 255 | } 256 | ); 257 | }); 258 | describe("out of retries", () => 259 | { 260 | beforeEach(() => 261 | { 262 | config.retryCount = 0; 263 | } 264 | ); 265 | test("invokes callback with FailedToAcquireLock error", done => 266 | { 267 | config.dynamodb = dynamodb 268 | const failOpen = new DynamoDBLockClient.FailOpen(config); 269 | failOpen.acquireLock( 270 | { 271 | [PARTITION_KEY]: LOCK_ID 272 | }, 273 | (err, lock) => 274 | { 275 | expect(err.message).toBe("Failed to acquire lock."); 276 | expect(err.code).toBe("FailedToAcquireLock"); 277 | expect(lock).toBe(undefined); 278 | done(); 279 | } 280 | ); 281 | } 282 | ); 283 | }); 284 | }); 285 | test("on success, returns configured Lock", done => 286 | { 287 | config.dynamodb = Object.assign( 288 | dynamodb, 289 | { 290 | put: (_, callback) => callback() 291 | } 292 | ); 293 | const failOpen = new DynamoDBLockClient.FailOpen(config); 294 | failOpen.acquireLock(LOCK_ID, (error, lock) => 295 | { 296 | expect(error).toBe(undefined); 297 | expect(lock).toEqual( 298 | expect.objectContaining( 299 | { 300 | _config: 301 | { 302 | dynamodb: config.dynamodb, 303 | fencingToken: 1, 304 | guid: expect.any(Buffer), 305 | heartbeatPeriodMs: HEARTBEAT_PERIOD_MS, 306 | leaseDurationMs: LEASE_DURATION_MS, 307 | lockTable: LOCK_TABLE, 308 | owner: OWNER, 309 | partitionID: LOCK_ID, 310 | partitionKey: PARTITION_KEY, 311 | type: DynamoDBLockClient.FailOpen 312 | }, 313 | _guid: expect.any(Buffer), 314 | _released: false, 315 | fencingToken: 1 316 | } 317 | ) 318 | ); 319 | done(); 320 | } 321 | ); 322 | } 323 | ); 324 | }); 325 | }); 326 | describe("item present", () => 327 | { 328 | const existingItem = 329 | { 330 | [PARTITION_KEY]: LOCK_ID, 331 | fencingToken: 42, 332 | leaseDurationMs: LEASE_DURATION_MS, 333 | owner: `not-${OWNER}`, 334 | guid: crypto.randomBytes(64) 335 | }; 336 | beforeEach(() => 337 | { 338 | dynamodb = Object.assign( 339 | dynamodb, 340 | { 341 | get: (_, callback) => callback(undefined, 342 | { 343 | Item: existingItem 344 | } 345 | ) 346 | } 347 | ); 348 | } 349 | ); 350 | describe("puts updated item in DynamoDB table", () => 351 | { 352 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 353 | { 354 | const finish = countdown(done, 2); 355 | const error = new Error("boom"); 356 | config.dynamodb = Object.assign( 357 | dynamodb, 358 | { 359 | put(params, callback) 360 | { 361 | expect(params).toEqual( 362 | { 363 | TableName: LOCK_TABLE, 364 | Item: 365 | { 366 | [PARTITION_KEY]: LOCK_ID, 367 | fencingToken: existingItem.fencingToken + 1, 368 | leaseDurationMs: LEASE_DURATION_MS, 369 | owner: OWNER, 370 | guid: expect.any(Buffer) 371 | }, 372 | ConditionExpression: `attribute_not_exists(#partitionKey) or (guid = :guid and fencingToken = :fencingToken)`, 373 | ExpressionAttributeNames: 374 | { 375 | "#partitionKey": PARTITION_KEY 376 | }, 377 | ExpressionAttributeValues: 378 | { 379 | ":fencingToken": existingItem.fencingToken, 380 | ":guid": existingItem.guid 381 | } 382 | } 383 | ); 384 | expect(params.Item.guid).not.toEqual(existingItem.guid); 385 | finish(); 386 | return callback(error); 387 | } 388 | } 389 | ); 390 | const failOpen = new DynamoDBLockClient.FailOpen(config); 391 | failOpen.acquireLock( 392 | { 393 | [PARTITION_KEY]: LOCK_ID 394 | }, 395 | (err, lock) => 396 | { 397 | expect(err).toBe(error); 398 | expect(lock).toBe(undefined); 399 | finish(); 400 | } 401 | ); 402 | } 403 | ); 404 | describe("trustLocalTime true, includes lockAcquiredTimeUnixMs parameter", () => 405 | { 406 | beforeEach(() => 407 | { 408 | config.trustLocalTime = true; 409 | } 410 | ); 411 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 412 | { 413 | const finish = countdown(done, 2); 414 | const error = new Error("boom"); 415 | config.dynamodb = Object.assign( 416 | dynamodb, 417 | { 418 | put(params, callback) 419 | { 420 | expect(params).toEqual( 421 | { 422 | TableName: LOCK_TABLE, 423 | Item: 424 | { 425 | [PARTITION_KEY]: LOCK_ID, 426 | fencingToken: existingItem.fencingToken + 1, 427 | leaseDurationMs: LEASE_DURATION_MS, 428 | owner: OWNER, 429 | guid: expect.any(Buffer), 430 | lockAcquiredTimeUnixMs: expect.any(Number) 431 | }, 432 | ConditionExpression: `attribute_not_exists(#partitionKey) or (guid = :guid and fencingToken = :fencingToken)`, 433 | ExpressionAttributeNames: 434 | { 435 | "#partitionKey": PARTITION_KEY 436 | }, 437 | ExpressionAttributeValues: 438 | { 439 | ":fencingToken": existingItem.fencingToken, 440 | ":guid": existingItem.guid 441 | } 442 | } 443 | ); 444 | finish(); 445 | return callback(error); 446 | } 447 | } 448 | ); 449 | const failOpen = new DynamoDBLockClient.FailOpen(config); 450 | failOpen.acquireLock( 451 | { 452 | [PARTITION_KEY]: LOCK_ID 453 | }, 454 | (err, lock) => 455 | { 456 | expect(err).toBe(error); 457 | expect(lock).toBe(undefined); 458 | finish(); 459 | } 460 | ); 461 | } 462 | ); 463 | }); 464 | describe("if ConditionalCheckFailedException error", () => 465 | { 466 | const error = new Error("boom"); 467 | error.code = "ConditionalCheckFailedException"; 468 | beforeEach(() => 469 | { 470 | dynamodb = Object.assign( 471 | dynamodb, 472 | { 473 | put: (_, callback) => callback(error) 474 | } 475 | ); 476 | } 477 | ); 478 | describe("default retryCount", () => 479 | { 480 | test("retries to get item from DynamoDB table, if error, invokes callback with error", done => 481 | { 482 | let callCount = 0; 483 | const finish = countdown(done, 3); 484 | const err = new Error("boom"); 485 | config.dynamodb = Object.assign( 486 | dynamodb, 487 | { 488 | get(params, callback) 489 | { 490 | if (++callCount == 1) 491 | { 492 | finish(); 493 | return callback(undefined, {}); // no item 494 | } 495 | expect(params).toEqual( 496 | { 497 | TableName: LOCK_TABLE, 498 | Key: 499 | { 500 | [PARTITION_KEY]: LOCK_ID 501 | }, 502 | ConsistentRead: true 503 | } 504 | ); 505 | finish(); 506 | return callback(err); 507 | } 508 | } 509 | ); 510 | const failOpen = new DynamoDBLockClient.FailOpen(config); 511 | failOpen.acquireLock(LOCK_ID, (e, lock) => 512 | { 513 | expect(e).toBe(err); 514 | expect(lock).toBe(undefined); 515 | finish(); 516 | } 517 | ); 518 | } 519 | ); 520 | }); 521 | describe("out of retries", () => 522 | { 523 | beforeEach(() => 524 | { 525 | config.retryCount = 0; 526 | } 527 | ); 528 | test("invokes callback with FailedToAcquireLock error", done => 529 | { 530 | config.dynamodb = dynamodb 531 | const failOpen = new DynamoDBLockClient.FailOpen(config); 532 | failOpen.acquireLock( 533 | { 534 | [PARTITION_KEY]: LOCK_ID 535 | }, 536 | (err, lock) => 537 | { 538 | expect(err.message).toBe("Failed to acquire lock."); 539 | expect(err.code).toBe("FailedToAcquireLock"); 540 | expect(lock).toBe(undefined); 541 | done(); 542 | } 543 | ); 544 | } 545 | ); 546 | }); 547 | }); 548 | test("on success, returns configured Lock", done => 549 | { 550 | let newGUID; 551 | config.dynamodb = Object.assign( 552 | dynamodb, 553 | { 554 | put(params, callback) 555 | { 556 | newGUID = params.Item.guid; 557 | return callback(); 558 | } 559 | } 560 | ); 561 | const failOpen = new DynamoDBLockClient.FailOpen(config); 562 | failOpen.acquireLock(LOCK_ID, (error, lock) => 563 | { 564 | expect(error).toBe(undefined); 565 | expect(lock).toEqual( 566 | expect.objectContaining( 567 | { 568 | _config: 569 | { 570 | dynamodb: config.dynamodb, 571 | fencingToken: existingItem.fencingToken + 1, 572 | guid: newGUID, 573 | heartbeatPeriodMs: HEARTBEAT_PERIOD_MS, 574 | leaseDurationMs: LEASE_DURATION_MS, 575 | lockTable: LOCK_TABLE, 576 | owner: OWNER, 577 | partitionID: LOCK_ID, 578 | partitionKey: PARTITION_KEY, 579 | type: DynamoDBLockClient.FailOpen 580 | }, 581 | _guid: newGUID, 582 | _released: false, 583 | fencingToken: existingItem.fencingToken + 1 584 | } 585 | ) 586 | ); 587 | done(); 588 | } 589 | ); 590 | } 591 | ); 592 | }); 593 | }); 594 | }); 595 | }); 596 | describe("using partitionKey and sortKey", () => 597 | { 598 | beforeEach(() => 599 | { 600 | config.sortKey = SORT_KEY; 601 | } 602 | ); 603 | test("invokes callback with error if configured with sortKey but sortKey value is not provided", done => 604 | { 605 | config.dynamodb = dynamodb; 606 | const failOpen = new DynamoDBLockClient.FailOpen(config); 607 | failOpen.acquireLock( 608 | { 609 | [PARTITION_KEY]: LOCK_ID 610 | }, 611 | (err, lock) => 612 | { 613 | expect(err).toEqual(new Error("Lock ID is missing required sortKey value")); 614 | done(); 615 | } 616 | ); 617 | } 618 | ); 619 | describe("gets item from DynamoDB table", () => 620 | { 621 | test("if error, invokes callback with error", done => 622 | { 623 | const finish = countdown(done, 2); 624 | const error = new Error("boom"); 625 | config.dynamodb = Object.assign( 626 | dynamodb, 627 | { 628 | get(params, callback) 629 | { 630 | expect(params).toEqual( 631 | { 632 | TableName: LOCK_TABLE, 633 | Key: 634 | { 635 | [PARTITION_KEY]: LOCK_ID, 636 | [SORT_KEY]: SORT_ID 637 | }, 638 | ConsistentRead: true 639 | } 640 | ); 641 | finish(); 642 | return callback(error); 643 | } 644 | } 645 | ); 646 | const failOpen = new DynamoDBLockClient.FailOpen(config); 647 | failOpen.acquireLock( 648 | { 649 | [PARTITION_KEY]: LOCK_ID, 650 | [SORT_KEY]: SORT_ID 651 | }, 652 | (err, lock) => 653 | { 654 | expect(err).toBe(error); 655 | expect(lock).toBe(undefined); 656 | finish(); 657 | } 658 | ); 659 | } 660 | ); 661 | describe("no item present", () => 662 | { 663 | beforeEach(() => 664 | { 665 | dynamodb = Object.assign( 666 | dynamodb, 667 | { 668 | get: (_, callback) => callback(undefined, {}) 669 | } 670 | ); 671 | } 672 | ); 673 | describe("puts new item in DynamoDB table", () => 674 | { 675 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 676 | { 677 | const finish = countdown(done, 2); 678 | const error = new Error("boom"); 679 | config.dynamodb = Object.assign( 680 | dynamodb, 681 | { 682 | put(params, callback) 683 | { 684 | expect(params).toEqual( 685 | { 686 | TableName: LOCK_TABLE, 687 | Item: 688 | { 689 | [PARTITION_KEY]: LOCK_ID, 690 | [SORT_KEY]: SORT_ID, 691 | fencingToken: 1, 692 | leaseDurationMs: LEASE_DURATION_MS, 693 | owner: OWNER, 694 | guid: expect.any(Buffer) 695 | }, 696 | ConditionExpression: `(attribute_not_exists(#partitionKey) and attribute_not_exists(#sortKey))`, 697 | ExpressionAttributeNames: 698 | { 699 | "#partitionKey": PARTITION_KEY, 700 | "#sortKey": SORT_KEY 701 | } 702 | } 703 | ); 704 | finish(); 705 | return callback(error); 706 | } 707 | } 708 | ); 709 | const failOpen = new DynamoDBLockClient.FailOpen(config); 710 | failOpen.acquireLock( 711 | { 712 | [PARTITION_KEY]: LOCK_ID, 713 | [SORT_KEY]: SORT_ID 714 | }, 715 | (err, lock) => 716 | { 717 | expect(err).toBe(error); 718 | expect(lock).toBe(undefined); 719 | finish(); 720 | } 721 | ); 722 | } 723 | ); 724 | describe("trustLocalTime true, includes lockAcquiredTimeUnixMs parameter", () => 725 | { 726 | beforeEach(() => 727 | { 728 | config.trustLocalTime = true; 729 | } 730 | ); 731 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 732 | { 733 | const finish = countdown(done, 2); 734 | const error = new Error("boom"); 735 | config.dynamodb = Object.assign( 736 | dynamodb, 737 | { 738 | put(params, callback) 739 | { 740 | expect(params).toEqual( 741 | { 742 | TableName: LOCK_TABLE, 743 | Item: 744 | { 745 | [PARTITION_KEY]: LOCK_ID, 746 | [SORT_KEY]: SORT_ID, 747 | fencingToken: 1, 748 | leaseDurationMs: LEASE_DURATION_MS, 749 | owner: OWNER, 750 | guid: expect.any(Buffer), 751 | lockAcquiredTimeUnixMs: expect.any(Number) 752 | }, 753 | ConditionExpression: `(attribute_not_exists(#partitionKey) and attribute_not_exists(#sortKey))`, 754 | ExpressionAttributeNames: 755 | { 756 | "#partitionKey": PARTITION_KEY, 757 | "#sortKey": SORT_KEY 758 | } 759 | } 760 | ); 761 | finish(); 762 | return callback(error); 763 | } 764 | } 765 | ); 766 | const failOpen = new DynamoDBLockClient.FailOpen(config); 767 | failOpen.acquireLock( 768 | { 769 | [PARTITION_KEY]: LOCK_ID, 770 | [SORT_KEY]: SORT_ID 771 | }, 772 | (err, lock) => 773 | { 774 | expect(err).toBe(error); 775 | expect(lock).toBe(undefined); 776 | finish(); 777 | } 778 | ); 779 | } 780 | ); 781 | }); 782 | describe("if ConditionalCheckFailedException error", () => 783 | { 784 | const error = new Error("boom"); 785 | error.code = "ConditionalCheckFailedException"; 786 | beforeEach(() => 787 | { 788 | dynamodb = Object.assign( 789 | dynamodb, 790 | { 791 | put: (_, callback) => callback(error) 792 | } 793 | ); 794 | } 795 | ); 796 | describe("default retryCount", () => 797 | { 798 | test("retries to get item from DynamoDB table, if error, invokes callback with error", done => 799 | { 800 | let callCount = 0; 801 | const finish = countdown(done, 3); 802 | const err = new Error("boom"); 803 | config.dynamodb = Object.assign( 804 | dynamodb, 805 | { 806 | get(params, callback) 807 | { 808 | if (++callCount == 1) 809 | { 810 | finish(); 811 | return callback(undefined, {}); // no item 812 | } 813 | expect(params).toEqual( 814 | { 815 | TableName: LOCK_TABLE, 816 | Key: 817 | { 818 | [PARTITION_KEY]: LOCK_ID, 819 | [SORT_KEY]: SORT_ID 820 | }, 821 | ConsistentRead: true 822 | } 823 | ); 824 | finish(); 825 | return callback(err); 826 | } 827 | } 828 | ); 829 | const failOpen = new DynamoDBLockClient.FailOpen(config); 830 | failOpen.acquireLock( 831 | { 832 | [PARTITION_KEY]: LOCK_ID, 833 | [SORT_KEY]: SORT_ID 834 | }, 835 | (e, lock) => 836 | { 837 | expect(e).toBe(err); 838 | expect(lock).toBe(undefined); 839 | finish(); 840 | } 841 | ); 842 | } 843 | ); 844 | }); 845 | describe("out of retries", () => 846 | { 847 | beforeEach(() => 848 | { 849 | config.retryCount = 0; 850 | } 851 | ); 852 | test("invokes callback with FailedToAcquireLock error", done => 853 | { 854 | config.dynamodb = dynamodb 855 | const failOpen = new DynamoDBLockClient.FailOpen(config); 856 | failOpen.acquireLock( 857 | { 858 | [PARTITION_KEY]: LOCK_ID, 859 | [SORT_KEY]: SORT_ID 860 | }, 861 | (err, lock) => 862 | { 863 | expect(err.message).toBe("Failed to acquire lock."); 864 | expect(err.code).toBe("FailedToAcquireLock"); 865 | expect(lock).toBe(undefined); 866 | done(); 867 | } 868 | ); 869 | } 870 | ); 871 | }); 872 | }); 873 | test("on success, returns configured Lock", done => 874 | { 875 | config.dynamodb = Object.assign( 876 | dynamodb, 877 | { 878 | put: (_, callback) => callback() 879 | } 880 | ); 881 | const failOpen = new DynamoDBLockClient.FailOpen(config); 882 | failOpen.acquireLock( 883 | { 884 | [PARTITION_KEY]: LOCK_ID, 885 | [SORT_KEY]: SORT_ID 886 | }, 887 | (error, lock) => 888 | { 889 | expect(error).toBe(undefined); 890 | expect(lock).toEqual( 891 | expect.objectContaining( 892 | { 893 | _config: 894 | { 895 | dynamodb: config.dynamodb, 896 | fencingToken: 1, 897 | guid: expect.any(Buffer), 898 | heartbeatPeriodMs: HEARTBEAT_PERIOD_MS, 899 | leaseDurationMs: LEASE_DURATION_MS, 900 | lockTable: LOCK_TABLE, 901 | owner: OWNER, 902 | partitionID: LOCK_ID, 903 | partitionKey: PARTITION_KEY, 904 | sortID: SORT_ID, 905 | sortKey: SORT_KEY, 906 | type: DynamoDBLockClient.FailOpen 907 | }, 908 | _guid: expect.any(Buffer), 909 | _released: false, 910 | fencingToken: 1 911 | } 912 | ) 913 | ); 914 | done(); 915 | } 916 | ); 917 | } 918 | ); 919 | }); 920 | }); 921 | describe("item present", () => 922 | { 923 | const existingItem = 924 | { 925 | [PARTITION_KEY]: LOCK_ID, 926 | [SORT_KEY]: SORT_ID, 927 | fencingToken: 42, 928 | leaseDurationMs: LEASE_DURATION_MS, 929 | owner: `not-${OWNER}`, 930 | guid: crypto.randomBytes(64) 931 | }; 932 | beforeEach(() => 933 | { 934 | dynamodb = Object.assign( 935 | dynamodb, 936 | { 937 | get: (_, callback) => callback(undefined, 938 | { 939 | Item: existingItem 940 | } 941 | ) 942 | } 943 | ); 944 | } 945 | ); 946 | describe("puts updated item in DynamoDB table", () => 947 | { 948 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 949 | { 950 | const finish = countdown(done, 2); 951 | const error = new Error("boom"); 952 | config.dynamodb = Object.assign( 953 | dynamodb, 954 | { 955 | put(params, callback) 956 | { 957 | expect(params).toEqual( 958 | { 959 | TableName: LOCK_TABLE, 960 | Item: 961 | { 962 | [PARTITION_KEY]: LOCK_ID, 963 | [SORT_KEY]: SORT_ID, 964 | fencingToken: existingItem.fencingToken + 1, 965 | leaseDurationMs: LEASE_DURATION_MS, 966 | owner: OWNER, 967 | guid: expect.any(Buffer) 968 | }, 969 | ConditionExpression: `(attribute_not_exists(#partitionKey) and attribute_not_exists(#sortKey)) or (guid = :guid and fencingToken = :fencingToken)`, 970 | ExpressionAttributeNames: 971 | { 972 | "#partitionKey": PARTITION_KEY, 973 | "#sortKey": SORT_KEY 974 | }, 975 | ExpressionAttributeValues: 976 | { 977 | ":fencingToken": existingItem.fencingToken, 978 | ":guid": existingItem.guid 979 | } 980 | } 981 | ); 982 | expect(params.Item.guid).not.toEqual(existingItem.guid); 983 | finish(); 984 | return callback(error); 985 | } 986 | } 987 | ); 988 | const failOpen = new DynamoDBLockClient.FailOpen(config); 989 | failOpen.acquireLock( 990 | { 991 | [PARTITION_KEY]: LOCK_ID, 992 | [SORT_KEY]: SORT_ID 993 | }, 994 | (err, lock) => 995 | { 996 | expect(err).toBe(error); 997 | expect(lock).toBe(undefined); 998 | finish(); 999 | } 1000 | ); 1001 | } 1002 | ); 1003 | describe("trustLocalTime true, includes lockAcquiredTimeUnixMs parameter", () => 1004 | { 1005 | beforeEach(() => 1006 | { 1007 | config.trustLocalTime = true; 1008 | } 1009 | ); 1010 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 1011 | { 1012 | const finish = countdown(done, 2); 1013 | const error = new Error("boom"); 1014 | config.dynamodb = Object.assign( 1015 | dynamodb, 1016 | { 1017 | put(params, callback) 1018 | { 1019 | expect(params).toEqual( 1020 | { 1021 | TableName: LOCK_TABLE, 1022 | Item: 1023 | { 1024 | [PARTITION_KEY]: LOCK_ID, 1025 | [SORT_KEY]: SORT_ID, 1026 | fencingToken: existingItem.fencingToken + 1, 1027 | leaseDurationMs: LEASE_DURATION_MS, 1028 | owner: OWNER, 1029 | guid: expect.any(Buffer), 1030 | lockAcquiredTimeUnixMs: expect.any(Number) 1031 | }, 1032 | ConditionExpression: `(attribute_not_exists(#partitionKey) and attribute_not_exists(#sortKey)) or (guid = :guid and fencingToken = :fencingToken)`, 1033 | ExpressionAttributeNames: 1034 | { 1035 | "#partitionKey": PARTITION_KEY, 1036 | "#sortKey": SORT_KEY 1037 | }, 1038 | ExpressionAttributeValues: 1039 | { 1040 | ":fencingToken": existingItem.fencingToken, 1041 | ":guid": existingItem.guid 1042 | } 1043 | } 1044 | ); 1045 | finish(); 1046 | return callback(error); 1047 | } 1048 | } 1049 | ); 1050 | const failOpen = new DynamoDBLockClient.FailOpen(config); 1051 | failOpen.acquireLock( 1052 | { 1053 | [PARTITION_KEY]: LOCK_ID, 1054 | [SORT_KEY]: SORT_ID 1055 | }, 1056 | (err, lock) => 1057 | { 1058 | expect(err).toBe(error); 1059 | expect(lock).toBe(undefined); 1060 | finish(); 1061 | } 1062 | ); 1063 | } 1064 | ); 1065 | }); 1066 | describe("if ConditionalCheckFailedException error", () => 1067 | { 1068 | const error = new Error("boom"); 1069 | error.code = "ConditionalCheckFailedException"; 1070 | beforeEach(() => 1071 | { 1072 | dynamodb = Object.assign( 1073 | dynamodb, 1074 | { 1075 | put: (_, callback) => callback(error) 1076 | } 1077 | ); 1078 | } 1079 | ); 1080 | describe("default retryCount", () => 1081 | { 1082 | test("retries to get item from DynamoDB table, if error, invokes callback with error", done => 1083 | { 1084 | let callCount = 0; 1085 | const finish = countdown(done, 3); 1086 | const err = new Error("boom"); 1087 | config.dynamodb = Object.assign( 1088 | dynamodb, 1089 | { 1090 | get(params, callback) 1091 | { 1092 | if (++callCount == 1) 1093 | { 1094 | finish(); 1095 | return callback(undefined, {}); // no item 1096 | } 1097 | expect(params).toEqual( 1098 | { 1099 | TableName: LOCK_TABLE, 1100 | Key: 1101 | { 1102 | [PARTITION_KEY]: LOCK_ID, 1103 | [SORT_KEY]: SORT_ID 1104 | }, 1105 | ConsistentRead: true 1106 | } 1107 | ); 1108 | finish(); 1109 | return callback(err); 1110 | } 1111 | } 1112 | ); 1113 | const failOpen = new DynamoDBLockClient.FailOpen(config); 1114 | failOpen.acquireLock( 1115 | { 1116 | [PARTITION_KEY]: LOCK_ID, 1117 | [SORT_KEY]: SORT_ID 1118 | }, 1119 | (e, lock) => 1120 | { 1121 | expect(e).toBe(err); 1122 | expect(lock).toBe(undefined); 1123 | finish(); 1124 | } 1125 | ); 1126 | } 1127 | ); 1128 | }); 1129 | describe("out of retries", () => 1130 | { 1131 | beforeEach(() => 1132 | { 1133 | config.retryCount = 0; 1134 | } 1135 | ); 1136 | test("invokes callback with FailedToAcquireLock error", done => 1137 | { 1138 | config.dynamodb = dynamodb 1139 | const failOpen = new DynamoDBLockClient.FailOpen(config); 1140 | failOpen.acquireLock( 1141 | { 1142 | [PARTITION_KEY]: LOCK_ID, 1143 | [SORT_KEY]: SORT_ID 1144 | }, 1145 | (err, lock) => 1146 | { 1147 | expect(err.message).toBe("Failed to acquire lock."); 1148 | expect(err.code).toBe("FailedToAcquireLock"); 1149 | expect(lock).toBe(undefined); 1150 | done(); 1151 | } 1152 | ); 1153 | } 1154 | ); 1155 | }); 1156 | }); 1157 | test("on success, returns configured Lock", done => 1158 | { 1159 | let newGUID; 1160 | config.dynamodb = Object.assign( 1161 | dynamodb, 1162 | { 1163 | put(params, callback) 1164 | { 1165 | newGUID = params.Item.guid; 1166 | return callback(); 1167 | } 1168 | } 1169 | ); 1170 | const failOpen = new DynamoDBLockClient.FailOpen(config); 1171 | failOpen.acquireLock( 1172 | { 1173 | [PARTITION_KEY]: LOCK_ID, 1174 | [SORT_KEY]: SORT_ID 1175 | }, 1176 | (error, lock) => 1177 | { 1178 | expect(error).toBe(undefined); 1179 | expect(lock).toEqual( 1180 | expect.objectContaining( 1181 | { 1182 | _config: 1183 | { 1184 | dynamodb: config.dynamodb, 1185 | fencingToken: existingItem.fencingToken + 1, 1186 | guid: newGUID, 1187 | heartbeatPeriodMs: HEARTBEAT_PERIOD_MS, 1188 | leaseDurationMs: LEASE_DURATION_MS, 1189 | lockTable: LOCK_TABLE, 1190 | owner: OWNER, 1191 | partitionID: LOCK_ID, 1192 | partitionKey: PARTITION_KEY, 1193 | sortID: SORT_ID, 1194 | sortKey: SORT_KEY, 1195 | type: DynamoDBLockClient.FailOpen 1196 | }, 1197 | _guid: newGUID, 1198 | _released: false, 1199 | fencingToken: existingItem.fencingToken + 1 1200 | } 1201 | ) 1202 | ); 1203 | done(); 1204 | } 1205 | ); 1206 | } 1207 | ); 1208 | }); 1209 | }); 1210 | }); 1211 | }); 1212 | }); 1213 | 1214 | describe("FailOpen lock release", () => 1215 | { 1216 | let config, dynamodb; 1217 | beforeEach(() => 1218 | { 1219 | config = 1220 | { 1221 | lockTable: LOCK_TABLE, 1222 | partitionKey: PARTITION_KEY, 1223 | heartbeatPeriodMs: 1e4, 1224 | leaseDurationMs: 1e5, 1225 | owner: OWNER 1226 | }; 1227 | dynamodb = 1228 | { 1229 | delete: () => {}, 1230 | get: () => {}, 1231 | put: () => {} 1232 | } 1233 | } 1234 | ); 1235 | describe("using partitionKey", () => 1236 | { 1237 | let guid, lock; 1238 | beforeEach(() => 1239 | { 1240 | guid = crypto.randomBytes(64); 1241 | lock = new DynamoDBLockClient.Lock( 1242 | { 1243 | dynamodb, 1244 | fencingToken: 42, 1245 | guid, 1246 | heartbeatPeriodMs: config.heartbeatPeriodMs, 1247 | leaseDurationMs: config.leaseDurationMs, 1248 | lockTable: config.lockTable, 1249 | owner: config.owner, 1250 | partitionID: LOCK_ID, 1251 | partitionKey: config.partitionKey, 1252 | type: DynamoDBLockClient.FailOpen 1253 | } 1254 | ); 1255 | } 1256 | ); 1257 | describe("puts updated item with leaseDurationMs set to 1", () => 1258 | { 1259 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 1260 | { 1261 | const finish = countdown(done, 2); 1262 | const error = new Error("boom"); 1263 | config.dynamodb = Object.assign( 1264 | dynamodb, 1265 | { 1266 | put(params, callback) 1267 | { 1268 | expect(params).toEqual( 1269 | { 1270 | TableName: LOCK_TABLE, 1271 | Item: 1272 | { 1273 | [PARTITION_KEY]: LOCK_ID, 1274 | fencingToken: 42, 1275 | leaseDurationMs: 1, 1276 | owner: OWNER, 1277 | guid 1278 | }, 1279 | ConditionExpression: `attribute_exists(#partitionKey) and guid = :guid`, 1280 | ExpressionAttributeNames: 1281 | { 1282 | "#partitionKey": PARTITION_KEY 1283 | }, 1284 | ExpressionAttributeValues: 1285 | { 1286 | ":guid": guid 1287 | } 1288 | } 1289 | ); 1290 | finish(); 1291 | return callback(error); 1292 | } 1293 | } 1294 | ); 1295 | expect(lock.heartbeatTimeout).not.toBe(undefined); 1296 | lock.release(err => 1297 | { 1298 | expect(err).toBe(error); 1299 | expect(lock.heartbeatTimeout).toBe(undefined); 1300 | finish(); 1301 | } 1302 | ); 1303 | } 1304 | ); 1305 | }); 1306 | }); 1307 | describe("using partitionKey and sortKey", () => 1308 | { 1309 | let guid, lock; 1310 | beforeEach(() => 1311 | { 1312 | config.sortKey = SORT_KEY; 1313 | guid = crypto.randomBytes(64); 1314 | lock = new DynamoDBLockClient.Lock( 1315 | { 1316 | dynamodb, 1317 | fencingToken: 42, 1318 | guid, 1319 | heartbeatPeriodMs: config.heartbeatPeriodMs, 1320 | leaseDurationMs: config.leaseDurationMs, 1321 | lockTable: config.lockTable, 1322 | owner: config.owner, 1323 | partitionID: LOCK_ID, 1324 | partitionKey: config.partitionKey, 1325 | sortID: SORT_ID, 1326 | sortKey: config.sortKey, 1327 | type: DynamoDBLockClient.FailOpen 1328 | } 1329 | ); 1330 | } 1331 | ); 1332 | test("if non-ConditionalCheckFailedException error, invokes callback with error", done => 1333 | { 1334 | const finish = countdown(done, 2); 1335 | const error = new Error("boom"); 1336 | config.dynamodb = Object.assign( 1337 | dynamodb, 1338 | { 1339 | put(params, callback) 1340 | { 1341 | expect(params).toEqual( 1342 | { 1343 | TableName: LOCK_TABLE, 1344 | Item: 1345 | { 1346 | [PARTITION_KEY]: LOCK_ID, 1347 | [SORT_KEY]: SORT_ID, 1348 | fencingToken: 42, 1349 | leaseDurationMs: 1, 1350 | owner: OWNER, 1351 | guid 1352 | }, 1353 | ConditionExpression: `(attribute_exists(#partitionKey) and attribute_exists(#sortKey)) and guid = :guid`, 1354 | ExpressionAttributeNames: 1355 | { 1356 | "#partitionKey": PARTITION_KEY, 1357 | "#sortKey": SORT_KEY 1358 | }, 1359 | ExpressionAttributeValues: 1360 | { 1361 | ":guid": guid 1362 | } 1363 | } 1364 | ); 1365 | finish(); 1366 | return callback(error); 1367 | } 1368 | } 1369 | ); 1370 | expect(lock.heartbeatTimeout).not.toBe(undefined); 1371 | lock.release(err => 1372 | { 1373 | expect(err).toBe(error); 1374 | expect(lock.heartbeatTimeout).toBe(undefined); 1375 | finish(); 1376 | } 1377 | ); 1378 | } 1379 | ); 1380 | }); 1381 | }); 1382 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamodb-lock-client", 3 | "version": "0.7.3", 4 | "description": "A general purpose distributed locking library built for AWS DynamoDB.", 5 | "scripts": { 6 | "inject-examples": "node scripts/injectExamples.js", 7 | "prepublishOnly": "npm run inject-examples", 8 | "readme": "node examples/readme.js", 9 | "test": "jest" 10 | }, 11 | "main": "index.js", 12 | "devDependencies": { 13 | "jest": "26.0.1" 14 | }, 15 | "dependencies": { 16 | "@hapi/joi": "17.1.1" 17 | }, 18 | "peerDependencies": { 19 | "aws-sdk": "^2.508.0" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git@github.com:tristanls/dynamodb-lock-client.git" 24 | }, 25 | "keywords": [ 26 | "dynamodb", 27 | "distributed", 28 | "lock" 29 | ], 30 | "contributors": [ 31 | "Tristan Slominski ", 32 | "Jacob Lynch", 33 | "simlu", 34 | "Lukas Siemon", 35 | "Tom Yam", 36 | "Cooper Bell ", 37 | "Katarina Golbang", 38 | "fpronto " 39 | ], 40 | "license": "MIT" 41 | } 42 | -------------------------------------------------------------------------------- /schema/failClosedConfig.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Joi = require("@hapi/joi"); 4 | 5 | const schema = Joi.object().keys( 6 | { 7 | owner: Joi.string(), 8 | dynamodb: Joi.object().keys( 9 | { 10 | delete: Joi.func().required(), 11 | get: Joi.func().required(), 12 | put: Joi.func().required() 13 | } 14 | ).unknown().required(), 15 | lockTable: Joi.string().required(), 16 | partitionKey: Joi.string().invalid("owner", "guid").required(), 17 | acquirePeriodMs: Joi.number().integer().min(0).required(), 18 | retryCount: Joi.number().integer().min(0) 19 | } 20 | ).required(); 21 | 22 | module.exports = schema; 23 | -------------------------------------------------------------------------------- /schema/failOpenConfig.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Joi = require("@hapi/joi"); 4 | 5 | const schema = Joi.object().keys( 6 | { 7 | owner: Joi.string(), 8 | dynamodb: Joi.object().keys( 9 | { 10 | delete: Joi.func().required(), 11 | get: Joi.func().required(), 12 | put: Joi.func().required() 13 | } 14 | ).unknown().required(), 15 | lockTable: Joi.string().required(), 16 | partitionKey: Joi.string().invalid("fencingToken", "leaseDurationMs", "lockAcquiredTimeUnixMs", "owner", "guid").required(), 17 | sortKey: Joi.string().invalid("fencingToken", "leaseDurationMs", "lockAcquiredTimeUnixMs", "owner", "guid"), 18 | heartbeatPeriodMs: Joi.number().integer().min(0), 19 | leaseDurationMs: Joi.number().integer().min(0).required(), 20 | trustLocalTime: Joi.boolean(), 21 | retryCount: Joi.number().integer().min(0) 22 | } 23 | ).required(); 24 | 25 | module.exports = schema; 26 | -------------------------------------------------------------------------------- /scripts/injectExamples.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | injectExamples.js - inject examples into the README 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2013-2017 Tristan Slominski 8 | 9 | Permission is hereby granted, free of charge, to any person 10 | obtaining a copy of this software and associated documentation 11 | files (the "Software"), to deal in the Software without 12 | restriction, including without limitation the rights to use, 13 | copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the 15 | Software is furnished to do so, subject to the following 16 | conditions: 17 | 18 | The above copyright notice and this permission notice shall be 19 | included in all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 22 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 23 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 24 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 25 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 26 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 27 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 28 | OTHER DEALINGS IN THE SOFTWARE. 29 | 30 | */ 31 | "use strict"; 32 | 33 | const fs = require("fs"); 34 | const path = require("path"); 35 | 36 | let readmeDoc = fs.readFileSync(path.join(__dirname, "..", "README.md")); 37 | let readmeScript = fs.readFileSync(path.join(__dirname, "..", "examples", "readme.js")); 38 | 39 | const useStrict = readmeScript.toString().match(/"use strict";/); 40 | readmeScript = readmeScript.toString().slice(useStrict.index); 41 | 42 | const replacement = `## Usage 43 | 44 | To run the below example, run: 45 | 46 | npm run readme 47 | 48 | \`\`\`javascript 49 | ${readmeScript} 50 | \`\`\` 51 | `; 52 | 53 | const usage = readmeDoc.toString().match(/## Usage/); 54 | const tests = readmeDoc.toString().match(/## Tests/); 55 | 56 | // some safety checks 57 | if (!usage) { 58 | console.error("Unable to find ## Usage in README"); 59 | process.exit(1); 60 | } 61 | 62 | if (!tests) { 63 | console.error("Unable to find ## Tests in README"); 64 | process.exit(1); 65 | } 66 | 67 | if (tests.index < usage.index) { 68 | console.error("## Tests is after ## Usage, existing"); 69 | process.exit(1); 70 | } 71 | 72 | const firstSlice = readmeDoc.toString().slice(0, usage.index); 73 | const secondSlice = readmeDoc.toString().slice(tests.index); 74 | 75 | readmeDoc = `${firstSlice}${replacement}\n${secondSlice}`; 76 | 77 | fs.writeFileSync(path.join(__dirname, "..", "README.md"), readmeDoc); 78 | -------------------------------------------------------------------------------- /test/countdown.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = (done, count) => 4 | { 5 | let doneCount = 0; 6 | return () => 7 | { 8 | doneCount++; 9 | if (doneCount == count) 10 | { 11 | done(); 12 | } 13 | } 14 | }; 15 | --------------------------------------------------------------------------------