├── .codeclimate.yml ├── .fossa.yml ├── .github ├── dependabot.yml └── workflows │ ├── branches.yml │ └── pull-requests.yml ├── .gitignore ├── .mocharc.yaml ├── .prettierrc ├── .snyk ├── .yarnrc.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── eslint.config.mjs ├── package.json ├── setup-redis-servers.sh ├── src ├── Lock.ts ├── RedisMultiSemaphore.ts ├── RedisMutex.ts ├── RedisSemaphore.ts ├── RedlockMultiSemaphore.ts ├── RedlockMutex.ts ├── RedlockSemaphore.ts ├── errors │ ├── LostLockError.ts │ └── TimeoutError.ts ├── index.ts ├── misc.ts ├── multiSemaphore │ ├── acquire │ │ ├── index.ts │ │ └── lua.ts │ ├── refresh │ │ ├── index.ts │ │ └── lua.ts │ └── release │ │ ├── index.ts │ │ └── lua.ts ├── mutex │ ├── acquire.ts │ ├── refresh.ts │ └── release.ts ├── redlockMultiSemaphore │ ├── acquire.ts │ ├── refresh.ts │ └── release.ts ├── redlockMutex │ ├── acquire.ts │ ├── refresh.ts │ └── release.ts ├── redlockSemaphore │ ├── acquire.ts │ ├── refresh.ts │ └── release.ts ├── semaphore │ ├── acquire │ │ ├── index.ts │ │ └── lua.ts │ ├── refresh │ │ ├── index.ts │ │ └── lua.ts │ └── release.ts ├── types.ts └── utils │ ├── createEval.ts │ ├── index.ts │ └── redlock.ts ├── test ├── init.test.ts ├── redisClient.ts ├── setup.ts ├── shell.test.ts ├── shell.ts ├── src │ ├── Lock.test.ts │ ├── RedisMultiSemaphore.test.ts │ ├── RedisMutex.test.ts │ ├── RedisSemaphore.test.ts │ ├── RedlockMultiSemaphore.test.ts │ ├── RedlockMutex.test.ts │ ├── RedlockSemaphore.test.ts │ ├── index.test.ts │ ├── multiSemaphore │ │ ├── acquire │ │ │ ├── index.test.ts │ │ │ └── internal.test.ts │ │ ├── refresh │ │ │ └── index.test.ts │ │ └── release │ │ │ └── index.test.ts │ ├── mutex │ │ ├── acquire.test.ts │ │ ├── refresh.test.ts │ │ └── release.test.ts │ ├── redlockMutex │ │ ├── acquire.test.ts │ │ ├── refresh.test.ts │ │ └── release.test.ts │ ├── semaphore │ │ ├── acquire │ │ │ ├── index.test.ts │ │ │ └── internal.test.ts │ │ ├── refresh │ │ │ └── index.test.ts │ │ └── release.test.ts │ └── utils │ │ ├── eval.test.ts │ │ ├── index.test.ts │ │ └── redlock.test.ts └── unhandledRejection.ts ├── tsconfig.build-commonjs.json ├── tsconfig.build-es.json ├── tsconfig.json └── yarn.lock /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | plugins: 3 | eslint: 4 | enabled: true 5 | channel: 'eslint-6' 6 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | # Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) 2 | # Visit https://fossa.com to learn more 3 | 4 | version: 2 5 | cli: 6 | server: https://app.fossa.com 7 | fetcher: custom 8 | project: git@github.com:swarthy/redis-semaphore.git 9 | analyze: 10 | modules: 11 | - name: . 12 | type: npm 13 | target: . 14 | path: . 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | -------------------------------------------------------------------------------- /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | name: CI (push) 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | integration-test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x] 16 | 17 | env: 18 | COVERALLS_REPO_TOKEN: '${{ secrets.COVERALLS_REPO_TOKEN }}' 19 | COVERALLS_GIT_BRANCH: '${{ github.ref }}' 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Enable Corepack 24 | run: corepack enable 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'yarn' 31 | 32 | - run: yarn install --immutable 33 | 34 | - run: docker compose up -d redis1 redis2 redis3 35 | - run: docker compose run waiter 36 | 37 | - run: yarn build 38 | - run: yarn lint 39 | - run: yarn test-ci-with-coverage 40 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | name: CI (PR) 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | integration-test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Enable Corepack 20 | run: corepack enable 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'yarn' 27 | 28 | - run: yarn install --immutable 29 | 30 | - run: docker compose up -d redis1 redis2 redis3 31 | - run: docker compose run waiter 32 | 33 | - run: yarn build 34 | - run: yarn lint 35 | - run: yarn test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | *.pid.lock 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # Bower dependency directory (https://bower.io/) 24 | bower_components 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules/ 34 | jspm_packages/ 35 | 36 | # Typescript v1 declaration files 37 | typings/ 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional eslint cache 43 | .eslintcache 44 | 45 | # Optional REPL history 46 | .node_repl_history 47 | 48 | # Output of 'npm pack' 49 | *.tgz 50 | 51 | # Yarn Integrity file 52 | .yarn-integrity 53 | 54 | # Yarn 55 | .pnp.* 56 | .yarn/* 57 | !.yarn/patches 58 | !.yarn/plugins 59 | !.yarn/releases 60 | !.yarn/sdks 61 | !.yarn/versions 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | es 67 | lib 68 | /.idea/ 69 | -------------------------------------------------------------------------------- /.mocharc.yaml: -------------------------------------------------------------------------------- 1 | extension: ts 2 | recursive: true 3 | timeout: 5s 4 | require: 5 | - '@swc-node/register' 6 | - test/setup.ts 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.1 3 | ignore: {} 4 | patch: {} 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | defaultSemverRangePrefix: '' 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### redis-semaphore@5.6.2 2 | - Fixed implicit import from `src` 3 | - Removed `src` folder from NPM package 4 | 5 | ### redis-semaphore@5.6.1 6 | - Removed `module` field from `package.json` 7 | 8 | ### redis-semaphore@5.6.0 9 | - Added interface compatible client support (ex. `ioredis-mock`) 10 | - Removed `instanceof Redis` validation in constructor 11 | - `ioredis` marked as optional peerDependency, explicit `ioredis` install is required now 12 | 13 | ### redis-semaphore@5.5.1 14 | - Fix race condition for refresh started before release and finished after release 15 | 16 | ### redis-semaphore@5.5.0 17 | 18 | - Added `identifier` constructor option. 19 | - Added `acquiredExternally` constructor option. 20 | - Option `externallyAcquiredIdentifier` **DEPRECATED**. 21 | - Option `identifierSuffix` **DEPRECATED**. 22 | 23 | ### redis-semaphore@5.4.0 24 | 25 | - Added `identifierSuffix` option, usefull for tracing app instance which locked resource 26 | 27 | ### redis-semaphore@5.3.1 28 | 29 | - Fixed reacquire expired resource in refresh 30 | 31 | ### redis-semaphore@5.3.0 32 | 33 | - Added `stopRefresh` method 34 | - Added `externallyAcquiredIdentifier` optional constructor option 35 | - Removed `uuid` dependency 36 | 37 | ### redis-semaphore@5.2.0 38 | 39 | - Added `acquireAttemptsLimit` method 40 | 41 | ### redis-semaphore@5.1.0 42 | 43 | - Added `tryAcquire` 44 | 45 | ### redis-semaphore@5.0.0 46 | 47 | - **Breadking change:** Drop Node.js v10.x, v12.x support 48 | - Added `ioredis@5` support 49 | 50 | ### redis-semaphore@4.1.0 51 | 52 | - Added `.isAcquired` property on all locks 53 | - Added `onLostLock` constructor option. By default throws unhandled error. 54 | 55 | ### redis-semaphore@4.0.0 56 | 57 | - **Breaking change:** `Mutex`, `Semaphore`, `MultiSemaphore` not longer support `Cluster`. For multi-node case use `Redlock*` instead. 58 | - Added `RedlockMutex`, `RedlockSemaphore`, `RedlockMultiSemaphore` 59 | - Internals refactored 60 | 61 | ### redis-semaphore@3.2.0 62 | 63 | - Added `MultiSemaphore` 64 | 65 | ### redis-semaphore@3.0.0 66 | 67 | - **Breaking change:** `FairSemaphore` has been removed. Use `Semaphore` instead (has the same "fairness") 68 | - the `acquire` method in `Semaphore` no longer returns a boolean. Instead, it throws an error if it cannot acquire, and if it doesn't throw, you can assume it worked. 69 | - Internal code has been cleaned up 70 | - Added more test, include synthetic node unsynchroned clocks 71 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | RUN npm i -g @swarthy/wait-for@2.0.2 3 | VOLUME /app 4 | WORKDIR /app 5 | USER node 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Mochalin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis-semaphore 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Build status][ci-image]][ci-url] 5 | ![FOSSA Status][typescript-image] 6 | [![Coverage Status][coverage-image]][coverage-url] 7 | [![Maintainability][codeclimate-image]][codeclimate-url] 8 | [![Known Vulnerabilities][snyk-image]][snyk-url] 9 | [![FOSSA Status][fossa-badge-image]][fossa-badge-url] 10 | 11 | [Mutex]() and [Semaphore]() implementations based on [Redis](https://redis.io/) ready for distributed systems 12 | 13 | ## Features 14 | 15 | - Fail-safe (all actions performed by LUA scripts (atomic)) 16 | 17 | ## Usage 18 | 19 | ### Installation 20 | 21 | ```bash 22 | npm install --save redis-semaphore ioredis 23 | # or 24 | yarn add redis-semaphore ioredis 25 | ``` 26 | 27 | ioredis is the officially supported Redis client. This library's test code runs on it. 28 | 29 | Users of other Redis clients should ensure ioredis-compatible API (see src/types.ts) when creating lock objects. 30 | 31 | ### Mutex 32 | 33 | > See [RedisLabs: Locks with timeouts](https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-2-distributed-locking/6-2-5-locks-with-timeouts/) 34 | 35 | ##### new Mutex(redisClient, key [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8, identifier = crypto.randomUUID() }]) 36 | 37 | - `redisClient` - **required**, configured `redis` client 38 | - `key` - **required**, key for locking resource (final key in redis: `mutex:`) 39 | - `options` - _optional_ 40 | - `lockTimeout` - _optional_ ms, time after mutex will be auto released (expired) 41 | - `acquireTimeout` - _optional_ ms, max timeout for `.acquire()` call 42 | - `acquireAttemptsLimit` - _optional_ max number of attempts to be made in `.acquire()` call 43 | - `retryInterval` - _optional_ ms, time between acquire attempts if resource locked 44 | - `refreshInterval` - _optional_ ms, auto-refresh interval; to disable auto-refresh behaviour set `0` 45 | - `identifier` - _optional_ uuid, custom mutex identifier. Must be unique between parallel executors, otherwise multiple locks with same identifier *can* be treated as the same lock holder. Override only if you know what you are doing (see `acquiredExternally` option). 46 | - `acquiredExternally` - _optional_ `true`, If `identifier` provided and `acquiredExternally` is `true` then `_refresh` will be used instead of `_acquire` in `.tryAcquire()`/`.acquire()`. Useful for lock sharing between processes: acquire in scheduler, refresh and release in handler. 47 | - `onLockLost` - _optional_ function, called when lock loss is detected due refresh cycle; default onLockLost throws unhandled LostLockError 48 | 49 | #### Example 50 | 51 | ```javascript 52 | const Mutex = require('redis-semaphore').Mutex 53 | const Redis = require('ioredis') 54 | 55 | // TypeScript 56 | // import { Mutex } from 'redis-semaphore' 57 | // import Redis from 'ioredis' 58 | 59 | const redisClient = new Redis() 60 | 61 | async function doSomething() { 62 | const mutex = new Mutex(redisClient, 'lockingResource') 63 | await mutex.acquire() 64 | try { 65 | // critical code 66 | } finally { 67 | await mutex.release() 68 | } 69 | } 70 | ``` 71 | 72 | #### Example with lost lock handling 73 | 74 | ```javascript 75 | async function doSomething() { 76 | const mutex = new Mutex(redisClient, 'lockingResource', { 77 | // By default onLockLost throws unhandled LostLockError 78 | onLockLost(err) { 79 | console.error(err) 80 | } 81 | }) 82 | await mutex.acquire() 83 | try { 84 | while (mutex.isAcquired) { 85 | // critical cycle iteration 86 | } 87 | } finally { 88 | // It's safe to always call release, because if lock is no longer belongs to this mutex, .release() will have no effect 89 | await mutex.release() 90 | } 91 | } 92 | ``` 93 | 94 | #### Example with optional lock 95 | 96 | ```javascript 97 | async function doSomething() { 98 | const mutex = new Mutex(redisClient, 'lockingResource', { 99 | acquireAttemptsLimit: 1 100 | }) 101 | const lockAcquired = await mutex.tryAcquire() 102 | if (!lockAcquired) { 103 | return 104 | } 105 | try { 106 | while (mutex.isAcquired) { 107 | // critical cycle iteration 108 | } 109 | } finally { 110 | // It's safe to always call release, because if lock is no longer belongs to this mutex, .release() will have no effect 111 | await mutex.release() 112 | } 113 | } 114 | ``` 115 | 116 | #### Example with temporary refresh 117 | 118 | ```javascript 119 | async function doSomething() { 120 | const mutex = new Mutex(redisClient, 'lockingResource', { 121 | lockTimeout: 120000, 122 | refreshInterval: 15000 123 | }) 124 | const lockAcquired = await mutex.tryAcquire() 125 | if (!lockAcquired) { 126 | return 127 | } 128 | try { 129 | // critical cycle iteration 130 | } finally { 131 | // We want to let lock expire over time after operation is finished 132 | await mutex.stopRefresh() 133 | } 134 | } 135 | ``` 136 | 137 | #### Example with dynamically adjusting existing lock 138 | 139 | ```javascript 140 | const Mutex = require('redis-semaphore').Mutex 141 | const Redis = require('ioredis') 142 | 143 | // TypeScript 144 | // import { Mutex } from 'redis-semaphore' 145 | // import Redis from 'ioredis' 146 | 147 | const redisClient = new Redis() 148 | 149 | // This creates an original lock 150 | const preMutex = new Mutex(redisClient, 'lockingResource', { 151 | lockTimeout: 10 * 1e3, // lock for 10s 152 | refreshInterval: 0 153 | }); 154 | 155 | // This modifies lock with a new TTL and starts refresh 156 | const mutex = new Mutex(redisClient, 'lockingResource', { 157 | identifier: preMutex.identifier, 158 | acquiredExternally: true, // required in this case 159 | lockTimeout: 30 * 60 * 1e3, // lock for 30min 160 | refreshInterval: 60 * 1e3 161 | }); 162 | 163 | ``` 164 | 165 | #### Example with shared lock between scheduler and handler apps 166 | 167 | ```javascript 168 | const Mutex = require('redis-semaphore').Mutex 169 | const Redis = require('ioredis') 170 | 171 | // TypeScript 172 | // import { Mutex } from 'redis-semaphore' 173 | // import Redis from 'ioredis' 174 | 175 | const redisClient = new Redis() 176 | 177 | // scheduler app code 178 | async function every10MinutesCronScheduler() { 179 | const mutex = new Mutex(redisClient, 'lockingResource', { 180 | lockTimeout: 30 * 60 * 1e3, // lock for 30min 181 | refreshInterval: 0 182 | }) 183 | if (await mutex.tryAcquire()) { 184 | someQueue.publish({ mutexIdentifier: mutex.identifier }) 185 | } else { 186 | logger.info('Job already scheduled. Do nothing in current cron cycle') 187 | } 188 | } 189 | 190 | // handler app code 191 | async function queueHandler(queueMessageData) { 192 | const { mutexIdentifier } = queueMessageData 193 | const mutex = new Mutex(redisClient, 'lockingResource', { 194 | lockTimeout: 10 * 1e3, // 10sec 195 | identifier: mutexIdentifier, 196 | acquiredExternally: true // required in this case 197 | }) 198 | 199 | // actually will do `refresh` with new lockTimeout instead of acquire 200 | // if mutex was locked by another process or lock was expired - exception will be thrown (default refresh behavior) 201 | await mutex.acquire() 202 | 203 | try { 204 | // critical code 205 | } finally { 206 | await mutex.release() 207 | } 208 | } 209 | ``` 210 | 211 | ### Semaphore 212 | 213 | > See [RedisLabs: Basic counting sempahore](https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/6-3-1-building-a-basic-counting-semaphore/) 214 | 215 | This implementation is slightly different from the algorithm described in the book, but the main idea has not changed. 216 | 217 | `zrank` check replaced with `zcard`, so now it is fair as [RedisLabs: Fair semaphore](https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/6-3-2-fair-semaphores/) (see tests). 218 | 219 | In edge cases (node time difference is greater than `lockTimeout`) both algorithms are not fair due cleanup stage (removing expired members from sorted set), so `FairSemaphore` API has been removed (it's safe to replace it with `Semaphore`). 220 | 221 | Most reliable way to use: `lockTimeout` is greater than possible node clock differences, `refreshInterval` is not 0 and is less enough than `lockTimeout` (by default is `lockTimeout * 0.8`) 222 | 223 | ##### new Semaphore(redisClient, key, maxCount [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) 224 | 225 | - `redisClient` - **required**, configured `redis` client 226 | - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) 227 | - `maxCount` - **required**, maximum simultaneously resource usage count 228 | - `options` _optional_ See `Mutex` options 229 | 230 | #### Example 231 | 232 | ```javascript 233 | const Semaphore = require('redis-semaphore').Semaphore 234 | const Redis = require('ioredis') 235 | 236 | // TypeScript 237 | // import { Semaphore } from 'redis-semaphore' 238 | // import Redis from 'ioredis' 239 | 240 | const redisClient = new Redis() 241 | 242 | async function doSomething() { 243 | const semaphore = new Semaphore(redisClient, 'lockingResource', 5) 244 | await semaphore.acquire() 245 | try { 246 | // maximum 5 simultaneously executions 247 | } finally { 248 | await semaphore.release() 249 | } 250 | } 251 | ``` 252 | 253 | ### MultiSemaphore 254 | 255 | Same as `Semaphore` with one difference - MultiSemaphore will try to acquire multiple permits instead of one. 256 | 257 | `MultiSemaphore` and `Semaphore` shares same key namespace and can be used together (see test/src/RedisMultiSemaphore.test.ts). 258 | 259 | ##### new MultiSemaphore(redisClient, key, maxCount, permits [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) 260 | 261 | - `redisClient` - **required**, configured `redis` client 262 | - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) 263 | - `maxCount` - **required**, maximum simultaneously resource usage count 264 | - `permits` - **required**, number of acquiring permits 265 | - `options` _optional_ See `Mutex` options 266 | 267 | #### Example 268 | 269 | ```javascript 270 | const MultiSemaphore = require('redis-semaphore').MultiSemaphore 271 | const Redis = require('ioredis') 272 | 273 | // TypeScript 274 | // import { MultiSemaphore } from 'redis-semaphore' 275 | // import Redis from 'ioredis' 276 | 277 | const redisClient = new Redis() 278 | 279 | async function doSomething() { 280 | const semaphore = new MultiSemaphore(redisClient, 'lockingResource', 5, 2) 281 | 282 | await semaphore.acquire() 283 | try { 284 | // make 2 parallel calls to remote service which allow only 5 simultaneously calls 285 | } finally { 286 | await semaphore.release() 287 | } 288 | } 289 | ``` 290 | 291 | ### RedlockMutex 292 | 293 | Distributed `Mutex` version 294 | 295 | > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) 296 | 297 | ##### new RedlockMutex(redisClients, key [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) 298 | 299 | - `redisClients` - **required**, array of configured `redis` client connected to independent nodes 300 | - `key` - **required**, key for locking resource (final key in redis: `mutex:`) 301 | - `options` _optional_ See `Mutex` options 302 | 303 | #### Example 304 | 305 | ```javascript 306 | const RedlockMutex = require('redis-semaphore').RedlockMutex 307 | const Redis = require('ioredis') 308 | 309 | // TypeScript 310 | // import { RedlockMutex } from 'redis-semaphore' 311 | // import Redis from 'ioredis' 312 | 313 | const redisClients = [ 314 | new Redis('127.0.0.1:6377'), 315 | new Redis('127.0.0.1:6378'), 316 | new Redis('127.0.0.1:6379') 317 | ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." 318 | 319 | async function doSomething() { 320 | const mutex = new RedlockMutex(redisClients, 'lockingResource') 321 | await mutex.acquire() 322 | try { 323 | // critical code 324 | } finally { 325 | await mutex.release() 326 | } 327 | } 328 | ``` 329 | 330 | ### RedlockSemaphore 331 | 332 | Distributed `Semaphore` version 333 | 334 | > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) 335 | 336 | ##### new RedlockSemaphore(redisClients, key, maxCount [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) 337 | 338 | - `redisClients` - **required**, array of configured `redis` client connected to independent nodes 339 | - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) 340 | - `maxCount` - **required**, maximum simultaneously resource usage count 341 | - `options` _optional_ See `Mutex` options 342 | 343 | #### Example 344 | 345 | ```javascript 346 | const RedlockSemaphore = require('redis-semaphore').RedlockSemaphore 347 | const Redis = require('ioredis') 348 | 349 | // TypeScript 350 | // import { RedlockSemaphore } from 'redis-semaphore' 351 | // import Redis from 'ioredis' 352 | 353 | const redisClients = [ 354 | new Redis('127.0.0.1:6377'), 355 | new Redis('127.0.0.1:6378'), 356 | new Redis('127.0.0.1:6379') 357 | ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." 358 | 359 | async function doSomething() { 360 | const semaphore = new Semaphore(redisClients, 'lockingResource', 5) 361 | await semaphore.acquire() 362 | try { 363 | // maximum 5 simultaneously executions 364 | } finally { 365 | await semaphore.release() 366 | } 367 | } 368 | ``` 369 | 370 | ### RedlockMultiSemaphore 371 | 372 | Distributed `MultiSemaphore` version 373 | 374 | > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) 375 | 376 | ##### new RedlockMultiSemaphore(redisClients, key, maxCount, permits [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) 377 | 378 | - `redisClients` - **required**, array of configured `redis` client connected to independent nodes 379 | - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) 380 | - `maxCount` - **required**, maximum simultaneously resource usage count 381 | - `permits` - **required**, number of acquiring permits 382 | - `options` _optional_ See `Mutex` options 383 | 384 | #### Example 385 | 386 | ```javascript 387 | const RedlockMultiSemaphore = require('redis-semaphore').RedlockMultiSemaphore 388 | const Redis = require('ioredis') 389 | 390 | // TypeScript 391 | // import { RedlockMultiSemaphore } from 'redis-semaphore' 392 | // import Redis from 'ioredis' 393 | 394 | const redisClients = [ 395 | new Redis('127.0.0.1:6377'), 396 | new Redis('127.0.0.1:6378'), 397 | new Redis('127.0.0.1:6379') 398 | ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." 399 | 400 | async function doSomething() { 401 | const semaphore = new RedlockMultiSemaphore( 402 | redisClients, 403 | 'lockingResource', 404 | 5, 405 | 2 406 | ) 407 | 408 | await semaphore.acquire() 409 | try { 410 | // make 2 parallel calls to remote service which allow only 5 simultaneously calls 411 | } finally { 412 | await semaphore.release() 413 | } 414 | } 415 | ``` 416 | 417 | ## Development 418 | 419 | ```shell 420 | yarn --immutable 421 | ./setup-redis-servers.sh 422 | yarn dev 423 | ``` 424 | 425 | ## License 426 | 427 | MIT 428 | 429 | [![FOSSA Status][fossa-large-image]][fossa-large-url] 430 | 431 | [npm-image]: https://img.shields.io/npm/v/redis-semaphore.svg?style=flat-square 432 | [npm-url]: https://npmjs.org/package/redis-semaphore 433 | [ci-image]: https://github.com/swarthy/redis-semaphore/actions/workflows/branches.yml/badge.svg 434 | [ci-url]: https://github.com/swarthy/redis-semaphore/actions/workflows/branches.yml 435 | [codeclimate-image]: https://api.codeclimate.com/v1/badges/02778c96bb5983eb150c/maintainability 436 | [codeclimate-url]: https://codeclimate.com/github/swarthy/redis-semaphore/maintainability 437 | [snyk-image]: https://snyk.io/test/npm/redis-semaphore/badge.svg 438 | [snyk-url]: https://snyk.io/test/npm/redis-semaphore 439 | [coverage-image]: https://coveralls.io/repos/github/swarthy/redis-semaphore/badge.svg?branch=master 440 | [coverage-url]: https://coveralls.io/r/swarthy/redis-semaphore?branch=master 441 | [fossa-badge-image]: https://app.fossa.com/api/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git.svg?type=shield 442 | [fossa-badge-url]: https://app.fossa.com/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git?ref=badge_shield 443 | [fossa-large-image]: https://app.fossa.com/api/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git.svg?type=large 444 | [fossa-large-url]: https://app.fossa.com/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git?ref=badge_large 445 | [typescript-image]: https://badgen.net/npm/types/tslib 446 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | waiter: 5 | image: node:alpine 6 | volumes: 7 | - ./:/app 8 | working_dir: /app 9 | command: > 10 | sh -c " 11 | corepack enable && 12 | yarn wait-for --redis redis://redis1 && 13 | yarn wait-for --redis redis://redis2 && 14 | yarn wait-for --redis redis://redis3 && 15 | echo 'All redis instances ready!' 16 | " 17 | 18 | redis1: 19 | image: redis:alpine 20 | ports: 21 | - 6001:6379 22 | 23 | redis2: 24 | image: redis:alpine 25 | ports: 26 | - 6002:6379 27 | 28 | redis3: 29 | image: redis:alpine 30 | ports: 31 | - 6003:6379 32 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@typescript-eslint/eslint-plugin' 2 | import typescriptParser from '@typescript-eslint/parser' 3 | import nodePlugin from 'eslint-plugin-node' 4 | 5 | export default [ 6 | { 7 | ignores: ['lib/**', 'es/**', 'coverage/**', '.nyc_output/**'] 8 | }, 9 | { 10 | files: ['**/*.ts'], 11 | languageOptions: { 12 | parser: typescriptParser, 13 | parserOptions: { 14 | ecmaVersion: 2024, 15 | sourceType: 'module', 16 | project: './tsconfig.json', 17 | ecmaFeatures: { 18 | jsx: false 19 | } 20 | }, 21 | globals: { 22 | console: true, 23 | process: true, 24 | setTimeout: true, 25 | clearTimeout: true, 26 | setInterval: true, 27 | clearInterval: true 28 | } 29 | }, 30 | plugins: { 31 | '@typescript-eslint': typescript 32 | }, 33 | rules: { 34 | ...typescript.configs['recommended'].rules, 35 | ...typescript.configs['recommended-requiring-type-checking'].rules, 36 | '@typescript-eslint/explicit-function-return-type': 'error', 37 | '@typescript-eslint/no-explicit-any': 'error', 38 | '@typescript-eslint/no-unused-vars': [ 39 | 'error', 40 | { argsIgnorePattern: '^_' } 41 | ], 42 | '@typescript-eslint/no-unused-expressions': 'off', 43 | '@typescript-eslint/no-misused-promises': 'off', 44 | '@typescript-eslint/require-await': 'off', 45 | 'no-unused-expressions': 'off' 46 | } 47 | }, 48 | { 49 | files: ['test/**/*.ts'], 50 | languageOptions: { 51 | globals: { 52 | describe: true, 53 | it: true, 54 | before: true, 55 | after: true, 56 | beforeEach: true, 57 | afterEach: true, 58 | mocha: true 59 | } 60 | }, 61 | rules: { 62 | '@typescript-eslint/no-explicit-any': 'off', 63 | '@typescript-eslint/no-unsafe-assignment': 'off', 64 | '@typescript-eslint/no-unsafe-member-access': 'off', 65 | '@typescript-eslint/no-unsafe-call': 'off', 66 | '@typescript-eslint/no-unsafe-return': 'off', 67 | '@typescript-eslint/no-unsafe-argument': 'off', 68 | '@typescript-eslint/await-thenable': 'off', 69 | '@typescript-eslint/explicit-function-return-type': 'off', 70 | 'no-unused-vars': 'off' 71 | } 72 | } 73 | ] 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-semaphore", 3 | "version": "5.6.2", 4 | "description": "Distributed mutex and semaphore based on Redis", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "lint": "eslint --ext .js,.ts .", 8 | "test": "mocha", 9 | "test-ci-with-coverage": "nyc mocha && nyc report --reporter=text-lcov | coveralls", 10 | "coverage-html": "nyc mocha && nyc report --reporter=html", 11 | "converalls": "nyc mocha && nyc report --reporter=text-lcov | coveralls", 12 | "dev": "mocha -w", 13 | "build": "yarn build-commonjs", 14 | "build-commonjs": "rm -rf lib && yarn tsc -b tsconfig.build-commonjs.json", 15 | "build-es": "rm -rf es && yarn tsc -b tsconfig.build-es.json", 16 | "preversion": "yarn lint && yarn test && yarn build" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git@github.com:swarthy/redis-semaphore.git" 21 | }, 22 | "keywords": [ 23 | "redis", 24 | "redlock", 25 | "mutex", 26 | "semaphore" 27 | ], 28 | "author": "Alexander Mochalin (horroshow@mail.ru)", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@swarthy/wait-for": "^2.1.1", 32 | "@swc-node/register": "1.10.10", 33 | "@swc/core": "1.11.11", 34 | "@types/chai": "^4.3.20", 35 | "@types/chai-as-promised": "^7.1.8", 36 | "@types/debug": "^4.1.12", 37 | "@types/ioredis-mock": "^8.2.5", 38 | "@types/mocha": "^10.0.10", 39 | "@types/node": "22.13.11", 40 | "@types/sinon": "^17.0.4", 41 | "@types/sinon-chai": "^3.2.12", 42 | "@typescript-eslint/eslint-plugin": "8.27.0", 43 | "@typescript-eslint/parser": "8.27.0", 44 | "benchmark": "^2.1.4", 45 | "chai": "4.5.0", 46 | "chai-as-promised": "7.1.2", 47 | "coveralls": "^3.1.1", 48 | "eslint": "9.23.0", 49 | "eslint-plugin-node": "11.1.0", 50 | "ioredis": "5.6.0", 51 | "ioredis-mock": "8.9.0", 52 | "mocha": "11.1.0", 53 | "mocha-lcov-reporter": "^1.3.0", 54 | "nyc": "^17.1.0", 55 | "sinon": "19.0.4", 56 | "sinon-chai": "3.7.0", 57 | "snyk": "1.1296.0", 58 | "ts-node": "^10.9.2", 59 | "typescript": "^5.8.2" 60 | }, 61 | "engines": { 62 | "node": ">= 14.17.0" 63 | }, 64 | "peerDependencies": { 65 | "ioredis": "^4.1.0 || ^5" 66 | }, 67 | "peerDependenciesMeta": { 68 | "ioredis": { 69 | "optional": true 70 | } 71 | }, 72 | "dependencies": { 73 | "debug": "^4.4.0" 74 | }, 75 | "packageManager": "yarn@4.1.0+sha256.81a00df816059803e6b5148acf03ce313cad36b7f6e5af6efa040a15981a6ffb", 76 | "files": [ 77 | "lib/" 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /setup-redis-servers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker compose up -d redis1 redis2 redis3 3 | -------------------------------------------------------------------------------- /src/Lock.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import * as crypto from 'node:crypto' 3 | import LostLockError from './errors/LostLockError' 4 | import TimeoutError from './errors/TimeoutError' 5 | import { defaultOnLockLost, defaultTimeoutOptions } from './misc' 6 | import { AcquireOptions, LockLostCallback, LockOptions } from './types' 7 | 8 | const REFRESH_INTERVAL_COEF = 0.8 9 | 10 | const debug = createDebug('redis-semaphore:instance') 11 | 12 | export abstract class Lock { 13 | protected abstract _kind: string 14 | protected abstract _key: string 15 | protected _identifier: string 16 | protected _acquireOptions: AcquireOptions 17 | protected _refreshTimeInterval: number 18 | protected _refreshInterval?: ReturnType 19 | protected _refreshing = false 20 | protected _acquired = false 21 | protected _acquiredExternally = false 22 | protected _onLockLost: LockLostCallback 23 | 24 | protected abstract _refresh(): Promise 25 | protected abstract _acquire(): Promise 26 | protected abstract _release(): Promise 27 | 28 | constructor({ 29 | lockTimeout = defaultTimeoutOptions.lockTimeout, 30 | acquireTimeout = defaultTimeoutOptions.acquireTimeout, 31 | acquireAttemptsLimit = defaultTimeoutOptions.acquireAttemptsLimit, 32 | retryInterval = defaultTimeoutOptions.retryInterval, 33 | refreshInterval = Math.round(lockTimeout * REFRESH_INTERVAL_COEF), 34 | onLockLost = defaultOnLockLost, 35 | externallyAcquiredIdentifier, 36 | identifierSuffix, 37 | identifier, 38 | acquiredExternally 39 | }: LockOptions = defaultTimeoutOptions) { 40 | if ( 41 | identifier !== undefined && 42 | (!identifier || typeof identifier !== 'string') 43 | ) { 44 | throw new Error('identifier must be not empty random string') 45 | } 46 | if (acquiredExternally && !identifier) { 47 | throw new Error( 48 | 'acquiredExternally=true meanless without custom identifier' 49 | ) 50 | } 51 | if (externallyAcquiredIdentifier && (identifier || acquiredExternally)) { 52 | throw new Error( 53 | 'Invalid usage. Use custom identifier and acquiredExternally: true' 54 | ) 55 | } 56 | this._identifier = 57 | identifier || 58 | externallyAcquiredIdentifier || 59 | this.getIdentifier(identifierSuffix) 60 | this._acquiredExternally = 61 | !!acquiredExternally || !!externallyAcquiredIdentifier 62 | this._acquireOptions = { 63 | lockTimeout, 64 | acquireTimeout, 65 | acquireAttemptsLimit, 66 | retryInterval, 67 | identifier: this._identifier 68 | } 69 | this._refreshTimeInterval = refreshInterval 70 | this._onLockLost = onLockLost 71 | } 72 | 73 | get identifier(): string { 74 | return this._identifier 75 | } 76 | 77 | get isAcquired(): boolean { 78 | return this._acquired 79 | } 80 | 81 | private getIdentifier(identifierSuffix: string | undefined): string { 82 | const uuid = crypto.randomUUID() 83 | return identifierSuffix ? `${uuid}-${identifierSuffix}` : uuid 84 | } 85 | 86 | private _startRefresh(): void { 87 | this._refreshInterval = setInterval( 88 | this._processRefresh, 89 | this._refreshTimeInterval 90 | ) 91 | this._refreshInterval.unref() 92 | } 93 | 94 | stopRefresh(): void { 95 | if (this._refreshInterval) { 96 | debug( 97 | `clear refresh interval ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` 98 | ) 99 | clearInterval(this._refreshInterval) 100 | } 101 | } 102 | 103 | private _processRefresh = async (): Promise => { 104 | if (this._refreshing) { 105 | debug( 106 | `already refreshing ${this._kind} (key: ${this._key}, identifier: ${this._identifier}) (skip)` 107 | ) 108 | return 109 | } 110 | this._refreshing = true 111 | try { 112 | debug( 113 | `refresh ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` 114 | ) 115 | const refreshed = await this._refresh() 116 | if (!refreshed) { 117 | if (!this._acquired) { 118 | debug( 119 | `refresh ${this._kind} (key: ${this._key}, identifier: ${this._identifier} failed, but lock was purposefully released` 120 | ) 121 | return 122 | } 123 | this._acquired = false 124 | this.stopRefresh() 125 | const lockLostError = new LostLockError( 126 | `Lost ${this._kind} for key ${this._key}` 127 | ) 128 | this._onLockLost(lockLostError) 129 | } 130 | } finally { 131 | this._refreshing = false 132 | } 133 | } 134 | 135 | async acquire(): Promise { 136 | debug(`acquire ${this._kind} (key: ${this._key})`) 137 | const acquired = await this.tryAcquire() 138 | if (!acquired) { 139 | throw new TimeoutError(`Acquire ${this._kind} ${this._key} timeout`) 140 | } 141 | } 142 | 143 | async tryAcquire(): Promise { 144 | debug(`tryAcquire ${this._kind} (key: ${this._key})`) 145 | const acquired = this._acquiredExternally 146 | ? await this._refresh() 147 | : await this._acquire() 148 | if (!acquired) { 149 | return false 150 | } 151 | this._acquired = true 152 | this._acquiredExternally = false 153 | if (this._refreshTimeInterval > 0) { 154 | this._startRefresh() 155 | } 156 | return true 157 | } 158 | 159 | async release(): Promise { 160 | debug( 161 | `release ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` 162 | ) 163 | if (this._refreshTimeInterval > 0) { 164 | this.stopRefresh() 165 | } 166 | if (this._acquired || this._acquiredExternally) { 167 | await this._release() 168 | } 169 | this._acquired = false 170 | this._acquiredExternally = false 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/RedisMultiSemaphore.ts: -------------------------------------------------------------------------------- 1 | import { acquireSemaphore } from './multiSemaphore/acquire' 2 | import { refreshSemaphore } from './multiSemaphore/refresh' 3 | import { releaseSemaphore } from './multiSemaphore/release' 4 | import RedisSemaphore from './RedisSemaphore' 5 | import { LockOptions, RedisClient } from './types' 6 | 7 | export default class RedisMultiSemaphore extends RedisSemaphore { 8 | protected _kind = 'multi-semaphore' 9 | protected _permits: number 10 | 11 | constructor( 12 | client: RedisClient, 13 | key: string, 14 | limit: number, 15 | permits: number, 16 | options?: LockOptions 17 | ) { 18 | super(client, key, limit, options) 19 | if (!permits) { 20 | throw new Error('"permits" is required') 21 | } 22 | if (typeof permits !== 'number') { 23 | throw new Error('"permits" must be a number') 24 | } 25 | this._permits = permits 26 | } 27 | 28 | protected async _refresh(): Promise { 29 | return await refreshSemaphore( 30 | this._client, 31 | this._key, 32 | this._limit, 33 | this._permits, 34 | this._acquireOptions 35 | ) 36 | } 37 | 38 | protected async _acquire(): Promise { 39 | return await acquireSemaphore( 40 | this._client, 41 | this._key, 42 | this._limit, 43 | this._permits, 44 | this._acquireOptions 45 | ) 46 | } 47 | 48 | protected async _release(): Promise { 49 | await releaseSemaphore( 50 | this._client, 51 | this._key, 52 | this._permits, 53 | this._identifier 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/RedisMutex.ts: -------------------------------------------------------------------------------- 1 | import type { RedisClient } from './types' 2 | 3 | import { Lock } from './Lock' 4 | import { acquireMutex } from './mutex/acquire' 5 | import { refreshMutex } from './mutex/refresh' 6 | import { releaseMutex } from './mutex/release' 7 | import { LockOptions } from './types' 8 | 9 | export default class RedisMutex extends Lock { 10 | protected _kind = 'mutex' 11 | protected _key: string 12 | protected _client: RedisClient 13 | 14 | constructor(client: RedisClient, key: string, options?: LockOptions) { 15 | super(options) 16 | if (!client) { 17 | throw new Error('"client" is required') 18 | } 19 | if (!key) { 20 | throw new Error('"key" is required') 21 | } 22 | if (typeof key !== 'string') { 23 | throw new Error('"key" must be a string') 24 | } 25 | this._client = client 26 | this._key = `mutex:${key}` 27 | } 28 | 29 | protected async _refresh(): Promise { 30 | return await refreshMutex( 31 | this._client, 32 | this._key, 33 | this._identifier, 34 | this._acquireOptions.lockTimeout 35 | ) 36 | } 37 | 38 | protected async _acquire(): Promise { 39 | return await acquireMutex(this._client, this._key, this._acquireOptions) 40 | } 41 | 42 | protected async _release(): Promise { 43 | await releaseMutex(this._client, this._key, this._identifier) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/RedisSemaphore.ts: -------------------------------------------------------------------------------- 1 | import RedisMutex from './RedisMutex' 2 | import { acquireSemaphore } from './semaphore/acquire' 3 | import { refreshSemaphore } from './semaphore/refresh' 4 | import { releaseSemaphore } from './semaphore/release' 5 | import { LockOptions, RedisClient } from './types' 6 | 7 | export default class RedisSemaphore extends RedisMutex { 8 | protected _kind = 'semaphore' 9 | protected _limit: number 10 | 11 | constructor( 12 | client: RedisClient, 13 | key: string, 14 | limit: number, 15 | options?: LockOptions 16 | ) { 17 | super(client, key, options) 18 | if (!limit) { 19 | throw new Error('"limit" is required') 20 | } 21 | if (typeof limit !== 'number') { 22 | throw new Error('"limit" must be a number') 23 | } 24 | this._key = `semaphore:${key}` 25 | this._limit = limit 26 | } 27 | 28 | protected async _refresh(): Promise { 29 | return await refreshSemaphore( 30 | this._client, 31 | this._key, 32 | this._limit, 33 | this._acquireOptions 34 | ) 35 | } 36 | 37 | protected async _acquire(): Promise { 38 | return await acquireSemaphore( 39 | this._client, 40 | this._key, 41 | this._limit, 42 | this._acquireOptions 43 | ) 44 | } 45 | 46 | protected async _release(): Promise { 47 | await releaseSemaphore(this._client, this._key, this._identifier) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/RedlockMultiSemaphore.ts: -------------------------------------------------------------------------------- 1 | import { acquireRedlockMultiSemaphore } from './redlockMultiSemaphore/acquire' 2 | import { refreshRedlockMultiSemaphore } from './redlockMultiSemaphore/refresh' 3 | import { releaseRedlockMultiSemaphore } from './redlockMultiSemaphore/release' 4 | import RedlockSemaphore from './RedlockSemaphore' 5 | import { LockOptions, RedisClient } from './types' 6 | 7 | export default class RedlockMultiSemaphore extends RedlockSemaphore { 8 | protected _kind = 'redlock-multi-semaphore' 9 | protected _permits: number 10 | 11 | constructor( 12 | clients: RedisClient[], 13 | key: string, 14 | limit: number, 15 | permits: number, 16 | options?: LockOptions 17 | ) { 18 | super(clients, key, limit, options) 19 | if (!permits) { 20 | throw new Error('"permits" is required') 21 | } 22 | if (typeof permits !== 'number') { 23 | throw new Error('"permits" must be a number') 24 | } 25 | this._permits = permits 26 | } 27 | 28 | protected async _refresh(): Promise { 29 | return await refreshRedlockMultiSemaphore( 30 | this._clients, 31 | this._key, 32 | this._limit, 33 | this._permits, 34 | this._acquireOptions 35 | ) 36 | } 37 | 38 | protected async _acquire(): Promise { 39 | return await acquireRedlockMultiSemaphore( 40 | this._clients, 41 | this._key, 42 | this._limit, 43 | this._permits, 44 | this._acquireOptions 45 | ) 46 | } 47 | 48 | protected async _release(): Promise { 49 | await releaseRedlockMultiSemaphore( 50 | this._clients, 51 | this._key, 52 | this._permits, 53 | this._identifier 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/RedlockMutex.ts: -------------------------------------------------------------------------------- 1 | import { Lock } from './Lock' 2 | import { defaultTimeoutOptions } from './misc' 3 | import { acquireRedlockMutex } from './redlockMutex/acquire' 4 | import { refreshRedlockMutex } from './redlockMutex/refresh' 5 | import { releaseRedlockMutex } from './redlockMutex/release' 6 | import { LockOptions, RedisClient } from './types' 7 | 8 | export default class RedlockMutex extends Lock { 9 | protected _kind = 'redlock-mutex' 10 | protected _key: string 11 | protected _clients: RedisClient[] 12 | 13 | constructor( 14 | clients: RedisClient[], 15 | key: string, 16 | options: LockOptions = defaultTimeoutOptions 17 | ) { 18 | super(options) 19 | if (!clients || !Array.isArray(clients)) { 20 | throw new Error('"clients" array is required') 21 | } 22 | if (!key) { 23 | throw new Error('"key" is required') 24 | } 25 | if (typeof key !== 'string') { 26 | throw new Error('"key" must be a string') 27 | } 28 | this._clients = clients 29 | this._key = `mutex:${key}` 30 | } 31 | 32 | protected async _refresh(): Promise { 33 | return await refreshRedlockMutex( 34 | this._clients, 35 | this._key, 36 | this._identifier, 37 | this._acquireOptions.lockTimeout 38 | ) 39 | } 40 | 41 | protected async _acquire(): Promise { 42 | return await acquireRedlockMutex( 43 | this._clients, 44 | this._key, 45 | this._acquireOptions 46 | ) 47 | } 48 | 49 | protected async _release(): Promise { 50 | await releaseRedlockMutex(this._clients, this._key, this._identifier) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/RedlockSemaphore.ts: -------------------------------------------------------------------------------- 1 | import RedlockMutex from './RedlockMutex' 2 | import { acquireRedlockSemaphore } from './redlockSemaphore/acquire' 3 | import { refreshRedlockSemaphore } from './redlockSemaphore/refresh' 4 | import { releaseRedlockSemaphore } from './redlockSemaphore/release' 5 | import { LockOptions, RedisClient } from './types' 6 | 7 | export default class RedlockSemaphore extends RedlockMutex { 8 | protected _kind = 'redlock-semaphore' 9 | protected _limit: number 10 | 11 | constructor( 12 | clients: RedisClient[], 13 | key: string, 14 | limit: number, 15 | options?: LockOptions 16 | ) { 17 | super(clients, key, options) 18 | if (!limit) { 19 | throw new Error('"limit" is required') 20 | } 21 | if (typeof limit !== 'number') { 22 | throw new Error('"limit" must be a number') 23 | } 24 | this._key = `semaphore:${key}` 25 | this._limit = limit 26 | } 27 | 28 | protected async _refresh(): Promise { 29 | return await refreshRedlockSemaphore( 30 | this._clients, 31 | this._key, 32 | this._limit, 33 | this._acquireOptions 34 | ) 35 | } 36 | 37 | protected async _acquire(): Promise { 38 | return await acquireRedlockSemaphore( 39 | this._clients, 40 | this._key, 41 | this._limit, 42 | this._acquireOptions 43 | ) 44 | } 45 | 46 | protected async _release(): Promise { 47 | await releaseRedlockSemaphore(this._clients, this._key, this._identifier) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/errors/LostLockError.ts: -------------------------------------------------------------------------------- 1 | export default class LostLockError extends Error {} 2 | -------------------------------------------------------------------------------- /src/errors/TimeoutError.ts: -------------------------------------------------------------------------------- 1 | export default class TimeoutError extends Error {} 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import MultiSemaphore from './RedisMultiSemaphore' 2 | import Mutex from './RedisMutex' 3 | import Semaphore from './RedisSemaphore' 4 | import RedlockMultiSemaphore from './RedlockMultiSemaphore' 5 | import RedlockMutex from './RedlockMutex' 6 | import RedlockSemaphore from './RedlockSemaphore' 7 | import LostLockError from './errors/LostLockError' 8 | import TimeoutError from './errors/TimeoutError' 9 | 10 | export { defaultTimeoutOptions } from './misc' 11 | 12 | export { 13 | Mutex, 14 | Semaphore, 15 | MultiSemaphore, 16 | RedlockMutex, 17 | RedlockSemaphore, 18 | RedlockMultiSemaphore, 19 | LostLockError, 20 | TimeoutError 21 | } 22 | 23 | export type { LockLostCallback, TimeoutOptions, LockOptions } from './types' 24 | -------------------------------------------------------------------------------- /src/misc.ts: -------------------------------------------------------------------------------- 1 | import LostLockError from './errors/LostLockError' 2 | 3 | export const defaultTimeoutOptions = { 4 | lockTimeout: 10000, 5 | acquireTimeout: 10000, 6 | acquireAttemptsLimit: Number.POSITIVE_INFINITY, 7 | retryInterval: 10 8 | } 9 | 10 | export function defaultOnLockLost(err: LostLockError): never { 11 | throw err 12 | } 13 | -------------------------------------------------------------------------------- /src/multiSemaphore/acquire/index.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../../types' 3 | import { delay } from '../../utils' 4 | import { acquireLua } from './lua' 5 | 6 | const debug = createDebug('redis-semaphore:multi-semaphore:acquire') 7 | 8 | export interface Options { 9 | identifier: string 10 | lockTimeout: number 11 | acquireTimeout: number 12 | acquireAttemptsLimit: number 13 | retryInterval: number 14 | } 15 | 16 | export async function acquireSemaphore( 17 | client: RedisClient, 18 | key: string, 19 | limit: number, 20 | permits: number, 21 | options: Options 22 | ): Promise { 23 | const { 24 | identifier, 25 | lockTimeout, 26 | acquireTimeout, 27 | acquireAttemptsLimit, 28 | retryInterval 29 | } = options 30 | let attempt = 0 31 | const end = Date.now() + acquireTimeout 32 | let now 33 | while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { 34 | debug(key, identifier, limit, lockTimeout, 'attempt', attempt) 35 | const result = await acquireLua(client, [ 36 | key, 37 | limit, 38 | permits, 39 | identifier, 40 | lockTimeout, 41 | now 42 | ]) 43 | debug(key, 'result', typeof result, result) 44 | if (result === 1) { 45 | debug(key, identifier, 'acquired') 46 | return true 47 | } else { 48 | await delay(retryInterval) 49 | } 50 | } 51 | debug(key, identifier, limit, lockTimeout, 'timeout or reach limit') 52 | return false 53 | } 54 | -------------------------------------------------------------------------------- /src/multiSemaphore/acquire/lua.ts: -------------------------------------------------------------------------------- 1 | import { createEval } from '../../utils/index' 2 | 3 | export const acquireLua = createEval< 4 | [string, number, number, string, number, number], 5 | 0 | 1 6 | >( 7 | ` 8 | local key = KEYS[1] 9 | local limit = tonumber(ARGV[1]) 10 | local permits = tonumber(ARGV[2]) 11 | local identifier = ARGV[3] 12 | local lockTimeout = tonumber(ARGV[4]) 13 | local now = tonumber(ARGV[5]) 14 | local expiredTimestamp = now - lockTimeout 15 | local args = {} 16 | 17 | redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) 18 | 19 | if (redis.call('zcard', key) + permits) <= limit then 20 | for i=0, permits - 1 do 21 | table.insert(args, now) 22 | table.insert(args, identifier .. '_' .. i) 23 | end 24 | redis.call('zadd', key, unpack(args)) 25 | redis.call('pexpire', key, lockTimeout) 26 | return 1 27 | else 28 | return 0 29 | end`, 30 | 1 31 | ) 32 | -------------------------------------------------------------------------------- /src/multiSemaphore/refresh/index.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../../types' 3 | import { refreshLua } from './lua' 4 | 5 | const debug = createDebug('redis-semaphore:multi-semaphore:refresh') 6 | 7 | export interface Options { 8 | identifier: string 9 | lockTimeout: number 10 | } 11 | 12 | export async function refreshSemaphore( 13 | client: RedisClient, 14 | key: string, 15 | limit: number, 16 | permits: number, 17 | options: Options 18 | ): Promise { 19 | const { identifier, lockTimeout } = options 20 | const now = Date.now() 21 | debug(key, identifier, now) 22 | const result = await refreshLua(client, [ 23 | key, 24 | limit, 25 | permits, 26 | identifier, 27 | lockTimeout, 28 | now 29 | ]) 30 | debug('result', typeof result, result) 31 | return result === 1 32 | } 33 | -------------------------------------------------------------------------------- /src/multiSemaphore/refresh/lua.ts: -------------------------------------------------------------------------------- 1 | import { createEval } from '../../utils/index' 2 | 3 | export const refreshLua = createEval< 4 | [string, number, number, string, number, number], 5 | 0 | 1 6 | >( 7 | ` 8 | local key = KEYS[1] 9 | local limit = tonumber(ARGV[1]) 10 | local permits = tonumber(ARGV[2]) 11 | local identifier = ARGV[3] 12 | local lockTimeout = tonumber(ARGV[4]) 13 | local now = tonumber(ARGV[5]) 14 | local expiredTimestamp = now - lockTimeout 15 | local args = {} 16 | 17 | redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) 18 | 19 | if redis.call('zscore', key, identifier .. '_0') then 20 | for i=0, permits - 1 do 21 | table.insert(args, now) 22 | table.insert(args, identifier .. '_' .. i) 23 | end 24 | redis.call('zadd', key, unpack(args)) 25 | redis.call('pexpire', key, lockTimeout) 26 | return 1 27 | else 28 | return 0 29 | end`, 30 | 1 31 | ) 32 | -------------------------------------------------------------------------------- /src/multiSemaphore/release/index.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../../types' 3 | import { releaseLua } from './lua' 4 | 5 | const debug = createDebug('redis-semaphore:multi-semaphore:release') 6 | 7 | export interface Options { 8 | identifier: string 9 | lockTimeout: number 10 | now: number 11 | } 12 | 13 | export async function releaseSemaphore( 14 | client: RedisClient, 15 | key: string, 16 | permits: number, 17 | identifier: string 18 | ): Promise { 19 | debug(key, identifier, permits) 20 | const result = await releaseLua(client, [key, permits, identifier]) 21 | debug('result', typeof result, result) 22 | } 23 | -------------------------------------------------------------------------------- /src/multiSemaphore/release/lua.ts: -------------------------------------------------------------------------------- 1 | import { createEval } from '../../utils/index' 2 | 3 | export const releaseLua = createEval<[string, number, string], number>( 4 | ` 5 | local key = KEYS[1] 6 | local permits = tonumber(ARGV[1]) 7 | local identifier = ARGV[2] 8 | local args = {} 9 | 10 | for i=0, permits - 1 do 11 | table.insert(args, identifier .. '_' .. i) 12 | end 13 | 14 | return redis.call('zrem', key, unpack(args)) 15 | `, 16 | 1 17 | ) 18 | -------------------------------------------------------------------------------- /src/mutex/acquire.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../types' 3 | import { delay } from '../utils' 4 | 5 | const debug = createDebug('redis-semaphore:mutex:acquire') 6 | 7 | export interface Options { 8 | identifier: string 9 | lockTimeout: number 10 | acquireTimeout: number 11 | acquireAttemptsLimit: number 12 | retryInterval: number 13 | } 14 | 15 | export async function acquireMutex( 16 | client: RedisClient, 17 | key: string, 18 | options: Options 19 | ): Promise { 20 | const { 21 | identifier, 22 | lockTimeout, 23 | acquireTimeout, 24 | acquireAttemptsLimit, 25 | retryInterval 26 | } = options 27 | let attempt = 0 28 | const end = Date.now() + acquireTimeout 29 | while (Date.now() < end && ++attempt <= acquireAttemptsLimit) { 30 | debug(key, identifier, 'attempt', attempt) 31 | const result = await client.set(key, identifier, 'PX', lockTimeout, 'NX') 32 | debug('result', typeof result, result) 33 | if (result === 'OK') { 34 | debug(key, identifier, 'acquired') 35 | return true 36 | } else { 37 | await delay(retryInterval) 38 | } 39 | } 40 | debug(key, identifier, 'timeout or reach limit') 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /src/mutex/refresh.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../types' 3 | import { createEval } from '../utils/index' 4 | 5 | const debug = createDebug('redis-semaphore:mutex:refresh') 6 | 7 | export const expireIfEqualLua = createEval<[string, string, number], 0 | 1>( 8 | ` 9 | local key = KEYS[1] 10 | local identifier = ARGV[1] 11 | local lockTimeout = ARGV[2] 12 | 13 | local value = redis.call('get', key) 14 | 15 | if value == identifier then 16 | redis.call('pexpire', key, lockTimeout) 17 | return 1 18 | end 19 | 20 | return 0 21 | `, 22 | 1 23 | ) 24 | 25 | export async function refreshMutex( 26 | client: RedisClient, 27 | key: string, 28 | identifier: string, 29 | lockTimeout: number 30 | ): Promise { 31 | debug(key, identifier) 32 | const result = await expireIfEqualLua(client, [key, identifier, lockTimeout]) 33 | debug('result', typeof result, result) 34 | 35 | // support options.stringNumbers 36 | return +result === 1 37 | } 38 | -------------------------------------------------------------------------------- /src/mutex/release.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { createEval } from '../utils/index' 3 | 4 | import type { RedisClient } from '../types' 5 | 6 | const debug = createDebug('redis-semaphore:mutex:release') 7 | 8 | export const delIfEqualLua = createEval<[string, string], 0 | 1>( 9 | ` 10 | local key = KEYS[1] 11 | local identifier = ARGV[1] 12 | 13 | if redis.call('get', key) == identifier then 14 | return redis.call('del', key) 15 | end 16 | 17 | return 0 18 | `, 19 | 1 20 | ) 21 | 22 | export async function releaseMutex( 23 | client: RedisClient, 24 | key: string, 25 | identifier: string 26 | ): Promise { 27 | debug(key, identifier) 28 | const result = await delIfEqualLua(client, [key, identifier]) 29 | debug('result', typeof result, result) 30 | } 31 | -------------------------------------------------------------------------------- /src/redlockMultiSemaphore/acquire.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { acquireLua } from '../multiSemaphore/acquire/lua' 3 | import { RedisClient } from '../types' 4 | import { delay } from '../utils' 5 | import { getQuorum, smartSum } from '../utils/redlock' 6 | 7 | const debug = createDebug('redis-semaphore:redlock-multi-semaphore:acquire') 8 | 9 | export interface Options { 10 | identifier: string 11 | lockTimeout: number 12 | acquireTimeout: number 13 | acquireAttemptsLimit: number 14 | retryInterval: number 15 | } 16 | 17 | export async function acquireRedlockMultiSemaphore( 18 | clients: RedisClient[], 19 | key: string, 20 | limit: number, 21 | permits: number, 22 | options: Options 23 | ): Promise { 24 | const { 25 | identifier, 26 | lockTimeout, 27 | acquireTimeout, 28 | acquireAttemptsLimit, 29 | retryInterval 30 | } = options 31 | let attempt = 0 32 | const end = Date.now() + acquireTimeout 33 | const quorum = getQuorum(clients.length) 34 | let now: number 35 | while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { 36 | debug(key, identifier, 'attempt', attempt) 37 | const promises = clients.map(client => 38 | acquireLua(client, [key, limit, permits, identifier, lockTimeout, now]) 39 | .then(result => +result) 40 | .catch(() => 0) 41 | ) 42 | const results = await Promise.all(promises) 43 | if (results.reduce(smartSum, 0) >= quorum) { 44 | debug(key, identifier, 'acquired') 45 | return true 46 | } else { 47 | const promises = clients.map(client => 48 | client.zrem(key, identifier).catch(() => 0) 49 | ) 50 | await Promise.all(promises) 51 | await delay(retryInterval) 52 | } 53 | } 54 | debug(key, identifier, 'timeout or reach limit') 55 | return false 56 | } 57 | -------------------------------------------------------------------------------- /src/redlockMultiSemaphore/refresh.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { acquireLua } from '../multiSemaphore/acquire/lua' 3 | import { refreshLua } from '../multiSemaphore/refresh/lua' 4 | import { releaseLua } from '../multiSemaphore/release/lua' 5 | import { RedisClient } from '../types' 6 | import { getQuorum, smartSum } from '../utils/redlock' 7 | 8 | const debug = createDebug('redis-semaphore:redlock-semaphore:refresh') 9 | 10 | interface Options { 11 | identifier: string 12 | lockTimeout: number 13 | } 14 | 15 | export async function refreshRedlockMultiSemaphore( 16 | clients: RedisClient[], 17 | key: string, 18 | limit: number, 19 | permits: number, 20 | options: Options 21 | ): Promise { 22 | const { identifier, lockTimeout } = options 23 | const now = Date.now() 24 | debug(key, identifier, now) 25 | const quorum = getQuorum(clients.length) 26 | const promises = clients.map(client => 27 | refreshLua(client, [key, limit, permits, identifier, lockTimeout, now]) 28 | .then(result => +result) 29 | .catch(() => 0) 30 | ) 31 | const results = await Promise.all(promises) 32 | debug('results', results) 33 | const refreshedCount = results.reduce(smartSum, 0) 34 | if (refreshedCount >= quorum) { 35 | debug(key, identifier, 'refreshed') 36 | if (refreshedCount < clients.length) { 37 | debug(key, identifier, 'try to acquire on failed nodes') 38 | const promises = results 39 | .reduce((failedClients, result, index) => { 40 | if (!result) { 41 | failedClients.push(clients[index]) 42 | } 43 | return failedClients 44 | }, []) 45 | .map(client => 46 | acquireLua(client, [ 47 | key, 48 | limit, 49 | permits, 50 | identifier, 51 | lockTimeout, 52 | now 53 | ]) 54 | .then(result => +result) 55 | .catch(() => 0) 56 | ) 57 | const acquireResults = await Promise.all(promises) 58 | debug(key, identifier, 'acquire on failed nodes results', acquireResults) 59 | } 60 | return true 61 | } else { 62 | const promises = clients.map(client => 63 | releaseLua(client, [key, permits, identifier]).catch(() => 0) 64 | ) 65 | await Promise.all(promises) 66 | return false 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/redlockMultiSemaphore/release.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { releaseLua } from '../multiSemaphore/release/lua' 3 | import { RedisClient } from '../types' 4 | 5 | const debug = createDebug('redis-semaphore:redlock-mutex:release') 6 | 7 | export async function releaseRedlockMultiSemaphore( 8 | clients: RedisClient[], 9 | key: string, 10 | permits: number, 11 | identifier: string 12 | ): Promise { 13 | debug(key, identifier) 14 | const promises = clients.map(client => 15 | releaseLua(client, [key, permits, identifier]).catch(() => 0) 16 | ) 17 | const results = await Promise.all(promises) 18 | debug('results', results) 19 | } 20 | -------------------------------------------------------------------------------- /src/redlockMutex/acquire.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { delIfEqualLua } from '../mutex/release' 3 | import { RedisClient } from '../types' 4 | import { delay } from '../utils' 5 | import { getQuorum, smartSum } from '../utils/redlock' 6 | 7 | const debug = createDebug('redis-semaphore:redlock-mutex:acquire') 8 | 9 | export interface Options { 10 | identifier: string 11 | lockTimeout: number 12 | acquireTimeout: number 13 | acquireAttemptsLimit: number 14 | retryInterval: number 15 | } 16 | 17 | export async function acquireRedlockMutex( 18 | clients: RedisClient[], 19 | key: string, 20 | options: Options 21 | ): Promise { 22 | const { 23 | identifier, 24 | lockTimeout, 25 | acquireTimeout, 26 | acquireAttemptsLimit, 27 | retryInterval 28 | } = options 29 | let attempt = 0 30 | const end = Date.now() + acquireTimeout 31 | const quorum = getQuorum(clients.length) 32 | while (Date.now() < end && ++attempt <= acquireAttemptsLimit) { 33 | debug(key, identifier, 'attempt', attempt) 34 | const promises = clients.map(client => 35 | client 36 | .set(key, identifier, 'PX', lockTimeout, 'NX') 37 | .then(result => (result === 'OK' ? 1 : 0)) 38 | .catch(() => 0) 39 | ) 40 | const results = await Promise.all(promises) 41 | if (results.reduce(smartSum, 0) >= quorum) { 42 | debug(key, identifier, 'acquired') 43 | return true 44 | } else { 45 | const promises = clients.map(client => 46 | delIfEqualLua(client, [key, identifier]).catch(() => 0) 47 | ) 48 | await Promise.all(promises) 49 | await delay(retryInterval) 50 | } 51 | } 52 | debug(key, identifier, 'timeout or reach limit') 53 | return false 54 | } 55 | -------------------------------------------------------------------------------- /src/redlockMutex/refresh.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { expireIfEqualLua } from '../mutex/refresh' 3 | import { delIfEqualLua } from '../mutex/release' 4 | import { RedisClient } from '../types' 5 | import { getQuorum, smartSum } from '../utils/redlock' 6 | 7 | const debug = createDebug('redis-semaphore:redlock-mutex:refresh') 8 | 9 | export async function refreshRedlockMutex( 10 | clients: RedisClient[], 11 | key: string, 12 | identifier: string, 13 | lockTimeout: number 14 | ): Promise { 15 | debug(key, identifier) 16 | const quorum = getQuorum(clients.length) 17 | const promises = clients.map(client => 18 | expireIfEqualLua(client, [key, identifier, lockTimeout]) 19 | .then(result => +result) 20 | .catch(() => 0) 21 | ) 22 | const results = await Promise.all(promises) 23 | debug('results', results) 24 | const refreshedCount = results.reduce(smartSum, 0) 25 | if (refreshedCount >= quorum) { 26 | debug(key, identifier, 'refreshed') 27 | if (refreshedCount < clients.length) { 28 | debug(key, identifier, 'try to acquire on failed nodes') 29 | const promises = results 30 | .reduce((failedClients, result, index) => { 31 | if (!result) { 32 | failedClients.push(clients[index]) 33 | } 34 | return failedClients 35 | }, []) 36 | .map(client => 37 | client 38 | .set(key, identifier, 'PX', lockTimeout, 'NX') 39 | .then(result => (result === 'OK' ? 1 : 0)) 40 | .catch(() => 0) 41 | ) 42 | const acquireResults = await Promise.all(promises) 43 | debug(key, identifier, 'acquire on failed nodes results', acquireResults) 44 | } 45 | return true 46 | } else { 47 | const promises = clients.map(client => 48 | delIfEqualLua(client, [key, identifier]).catch(() => 0) 49 | ) 50 | await Promise.all(promises) 51 | return false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/redlockMutex/release.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { delIfEqualLua } from '../mutex/release' 3 | 4 | import type { RedisClient } from '../types' 5 | 6 | const debug = createDebug('redis-semaphore:redlock-mutex:release') 7 | 8 | export async function releaseRedlockMutex( 9 | clients: RedisClient[], 10 | key: string, 11 | identifier: string 12 | ): Promise { 13 | debug(key, identifier) 14 | const promises = clients.map(client => 15 | delIfEqualLua(client, [key, identifier]).catch(() => 0) 16 | ) 17 | const results = await Promise.all(promises) 18 | debug('results', results) 19 | } 20 | -------------------------------------------------------------------------------- /src/redlockSemaphore/acquire.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { acquireLua } from '../semaphore/acquire/lua' 3 | import { RedisClient } from '../types' 4 | import { delay } from '../utils' 5 | import { getQuorum, smartSum } from '../utils/redlock' 6 | 7 | const debug = createDebug('redis-semaphore:redlock-semaphore:acquire') 8 | 9 | export interface Options { 10 | identifier: string 11 | lockTimeout: number 12 | acquireTimeout: number 13 | acquireAttemptsLimit: number 14 | retryInterval: number 15 | } 16 | 17 | export async function acquireRedlockSemaphore( 18 | clients: RedisClient[], 19 | key: string, 20 | limit: number, 21 | options: Options 22 | ): Promise { 23 | const { 24 | identifier, 25 | lockTimeout, 26 | acquireTimeout, 27 | acquireAttemptsLimit, 28 | retryInterval 29 | } = options 30 | let attempt = 0 31 | const end = Date.now() + acquireTimeout 32 | const quorum = getQuorum(clients.length) 33 | let now: number 34 | while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { 35 | debug(key, identifier, 'attempt', attempt) 36 | const promises = clients.map(client => 37 | acquireLua(client, [key, limit, identifier, lockTimeout, now]) 38 | .then(result => +result) 39 | .catch(() => 0) 40 | ) 41 | const results = await Promise.all(promises) 42 | if (results.reduce(smartSum, 0) >= quorum) { 43 | debug(key, identifier, 'acquired') 44 | return true 45 | } else { 46 | const promises = clients.map(client => 47 | client.zrem(key, identifier).catch(() => 0) 48 | ) 49 | await Promise.all(promises) 50 | await delay(retryInterval) 51 | } 52 | } 53 | debug(key, identifier, 'timeout or reach limit') 54 | return false 55 | } 56 | -------------------------------------------------------------------------------- /src/redlockSemaphore/refresh.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { acquireLua } from '../semaphore/acquire/lua' 3 | import { refreshLua } from '../semaphore/refresh/lua' 4 | import { getQuorum, smartSum } from '../utils/redlock' 5 | 6 | import type { RedisClient } from '../types' 7 | 8 | const debug = createDebug('redis-semaphore:redlock-semaphore:refresh') 9 | 10 | interface Options { 11 | identifier: string 12 | lockTimeout: number 13 | } 14 | 15 | export async function refreshRedlockSemaphore( 16 | clients: RedisClient[], 17 | key: string, 18 | limit: number, 19 | options: Options 20 | ): Promise { 21 | const { identifier, lockTimeout } = options 22 | const now = Date.now() 23 | debug(key, identifier, now) 24 | const quorum = getQuorum(clients.length) 25 | const promises = clients.map(client => 26 | refreshLua(client, [key, limit, identifier, lockTimeout, now]) 27 | .then(result => +result) 28 | .catch(() => 0) 29 | ) 30 | const results = await Promise.all(promises) 31 | debug('results', results) 32 | const refreshedCount = results.reduce(smartSum, 0) 33 | if (refreshedCount >= quorum) { 34 | debug(key, identifier, 'refreshed') 35 | if (refreshedCount < clients.length) { 36 | debug(key, identifier, 'try to acquire on failed nodes') 37 | const promises = results 38 | .reduce((failedClients, result, index) => { 39 | if (!result) { 40 | failedClients.push(clients[index]) 41 | } 42 | return failedClients 43 | }, []) 44 | .map(client => 45 | acquireLua(client, [key, limit, identifier, lockTimeout, now]) 46 | .then(result => +result) 47 | .catch(() => 0) 48 | ) 49 | const acquireResults = await Promise.all(promises) 50 | debug(key, identifier, 'acquire on failed nodes results', acquireResults) 51 | } 52 | return true 53 | } else { 54 | const promises = clients.map(client => 55 | client.zrem(key, identifier).catch(() => 0) 56 | ) 57 | await Promise.all(promises) 58 | return false 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/redlockSemaphore/release.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | 3 | import type { RedisClient } from '../types' 4 | 5 | const debug = createDebug('redis-semaphore:redlock-mutex:release') 6 | 7 | export async function releaseRedlockSemaphore( 8 | clients: RedisClient[], 9 | key: string, 10 | identifier: string 11 | ): Promise { 12 | debug(key, identifier) 13 | const promises = clients.map(client => 14 | client.zrem(key, identifier).catch(() => 0) 15 | ) 16 | const results = await Promise.all(promises) 17 | debug('results', results) 18 | } 19 | -------------------------------------------------------------------------------- /src/semaphore/acquire/index.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../../types' 3 | import { delay } from '../../utils' 4 | import { acquireLua } from './lua' 5 | 6 | const debug = createDebug('redis-semaphore:semaphore:acquire') 7 | 8 | export interface Options { 9 | identifier: string 10 | lockTimeout: number 11 | acquireTimeout: number 12 | acquireAttemptsLimit: number 13 | retryInterval: number 14 | } 15 | 16 | export async function acquireSemaphore( 17 | client: RedisClient, 18 | key: string, 19 | limit: number, 20 | options: Options 21 | ): Promise { 22 | const { 23 | identifier, 24 | lockTimeout, 25 | acquireTimeout, 26 | acquireAttemptsLimit, 27 | retryInterval 28 | } = options 29 | let attempt = 0 30 | const end = Date.now() + acquireTimeout 31 | let now 32 | while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { 33 | debug(key, identifier, limit, lockTimeout, 'attempt', attempt) 34 | const result = await acquireLua(client, [ 35 | key, 36 | limit, 37 | identifier, 38 | lockTimeout, 39 | now 40 | ]) 41 | debug(key, 'result', typeof result, result) 42 | // support options.stringNumbers 43 | if (+result === 1) { 44 | debug(key, identifier, 'acquired') 45 | return true 46 | } else { 47 | await delay(retryInterval) 48 | } 49 | } 50 | debug(key, identifier, limit, lockTimeout, 'timeout or reach limit') 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /src/semaphore/acquire/lua.ts: -------------------------------------------------------------------------------- 1 | import { createEval } from '../../utils/index' 2 | 3 | export const acquireLua = createEval< 4 | [string, number, string, number, number], 5 | 0 | 1 6 | >( 7 | ` 8 | local key = KEYS[1] 9 | local limit = tonumber(ARGV[1]) 10 | local identifier = ARGV[2] 11 | local lockTimeout = tonumber(ARGV[3]) 12 | local now = tonumber(ARGV[4]) 13 | local expiredTimestamp = now - lockTimeout 14 | 15 | redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) 16 | 17 | if redis.call('zcard', key) < limit then 18 | redis.call('zadd', key, now, identifier) 19 | redis.call('pexpire', key, lockTimeout) 20 | return 1 21 | else 22 | return 0 23 | end`, 24 | 1 25 | ) 26 | -------------------------------------------------------------------------------- /src/semaphore/refresh/index.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../../types' 3 | import { refreshLua } from './lua' 4 | 5 | const debug = createDebug('redis-semaphore:semaphore:refresh') 6 | 7 | export interface Options { 8 | identifier: string 9 | lockTimeout: number 10 | } 11 | 12 | export async function refreshSemaphore( 13 | client: RedisClient, 14 | key: string, 15 | limit: number, 16 | options: Options 17 | ): Promise { 18 | const { identifier, lockTimeout } = options 19 | const now = Date.now() 20 | debug(key, identifier, now) 21 | const result = await refreshLua(client, [ 22 | key, 23 | limit, 24 | identifier, 25 | lockTimeout, 26 | now 27 | ]) 28 | debug('result', typeof result, result) 29 | // support options.stringNumbers 30 | return +result === 1 31 | } 32 | -------------------------------------------------------------------------------- /src/semaphore/refresh/lua.ts: -------------------------------------------------------------------------------- 1 | import { createEval } from '../../utils/index' 2 | 3 | export const refreshLua = createEval< 4 | [string, number, string, number, number], 5 | 0 | 1 6 | >( 7 | ` 8 | local key = KEYS[1] 9 | local limit = tonumber(ARGV[1]) 10 | local identifier = ARGV[2] 11 | local lockTimeout = tonumber(ARGV[3]) 12 | local now = tonumber(ARGV[4]) 13 | local expiredTimestamp = now - lockTimeout 14 | 15 | redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) 16 | 17 | if redis.call('zscore', key, identifier) then 18 | redis.call('zadd', key, now, identifier) 19 | redis.call('pexpire', key, lockTimeout) 20 | return 1 21 | else 22 | return 0 23 | end`, 24 | 1 25 | ) 26 | -------------------------------------------------------------------------------- /src/semaphore/release.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { RedisClient } from '../types' 3 | 4 | const debug = createDebug('redis-semaphore:semaphore:release') 5 | 6 | export async function releaseSemaphore( 7 | client: RedisClient, 8 | key: string, 9 | identifier: string 10 | ): Promise { 11 | debug(key, identifier) 12 | const result = await client.zrem(key, identifier) 13 | debug('result', typeof result, result) 14 | } 15 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import LostLockError from './errors/LostLockError' 2 | import { Lock } from './Lock' 3 | 4 | import type * as ioredis from 'ioredis' 5 | 6 | /** 7 | * ioredis-like Redis client 8 | */ 9 | export type RedisClient = Pick< 10 | ioredis.Redis, 11 | 'eval' | 'evalsha' | 'get' | 'set' | 'zrem' 12 | > & 13 | Partial> 14 | 15 | export interface LockLostCallback { 16 | (this: Lock, err: LostLockError): void 17 | } 18 | 19 | export interface TimeoutOptions { 20 | lockTimeout?: number 21 | acquireTimeout?: number 22 | acquireAttemptsLimit?: number 23 | retryInterval?: number 24 | refreshInterval?: number 25 | } 26 | 27 | export interface LockOptions extends TimeoutOptions { 28 | /** 29 | * @deprecated Use `identifier` + `acquiredExternally: true` instead. Will be removed in next major release. 30 | */ 31 | externallyAcquiredIdentifier?: string 32 | 33 | /** 34 | * @deprecated Provide custom `identifier` instead. Will be removed in next major release. 35 | */ 36 | identifierSuffix?: string 37 | 38 | /** 39 | * Identifier of lock. By default is `crypto.randomUUID()`. 40 | * 41 | * Must be unique between parallel executors otherwise locks with same identifier *can* be treated as the same lock holder. 42 | * 43 | * Override only if you know what you are doing, see `acquireExternally` option. 44 | */ 45 | identifier?: string 46 | 47 | /** 48 | * If `identifier` provided and `acquiredExternally` is `true` then `_refresh` will be used instead of `_acquire` in `.tryAcquire()`/`.acquire()`. 49 | * 50 | * Useful for lock sharing between processes: acquire in scheduler, refresh and release in handler. 51 | */ 52 | acquiredExternally?: true 53 | 54 | onLockLost?: LockLostCallback 55 | } 56 | 57 | export interface AcquireOptions { 58 | identifier: string 59 | lockTimeout: number 60 | acquireTimeout: number 61 | acquireAttemptsLimit: number 62 | retryInterval: number 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/createEval.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto' 2 | import createDebug from 'debug' 3 | import { RedisClient } from '../types' 4 | import { getConnectionName } from './index' 5 | 6 | const debug = createDebug('redis-semaphore:eval') 7 | 8 | function createSHA1(script: string): string { 9 | return createHash('sha1').update(script, 'utf8').digest('hex') 10 | } 11 | 12 | function isNoScriptError(err: Error): boolean { 13 | return err.toString().indexOf('NOSCRIPT') !== -1 14 | } 15 | 16 | export default function createEval, Result>( 17 | script: string, 18 | keysCount: number 19 | ): (client: RedisClient, args: Args) => Promise { 20 | const sha1 = createSHA1(script) 21 | debug('creating script:', script, 'sha1:', sha1) 22 | return async function optimizedEval( 23 | client: RedisClient, 24 | args: Args 25 | ): Promise { 26 | const connectionName = getConnectionName(client) 27 | const evalSHAArgs = [sha1, keysCount, ...args] 28 | debug(connectionName, sha1, 'attempt, args:', evalSHAArgs) 29 | try { 30 | return (await client.evalsha(sha1, keysCount, ...args)) as Promise 31 | } catch (err) { 32 | if (err instanceof Error && isNoScriptError(err)) { 33 | const evalArgs = [script, keysCount, ...args] 34 | debug(connectionName, sha1, 'fallback to eval, args:', evalArgs) 35 | return (await client.eval( 36 | script, 37 | keysCount, 38 | ...args 39 | )) as Promise 40 | } else { 41 | throw err 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from '../types' 2 | import createEval from './createEval' 3 | 4 | export { createEval } 5 | 6 | export async function delay(ms: number): Promise { 7 | return await new Promise(resolve => setTimeout(resolve, ms)) 8 | } 9 | 10 | export function getConnectionName(client: RedisClient): string { 11 | const connectionName = client.options?.connectionName 12 | return connectionName ? `<${connectionName}>` : '' 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/redlock.ts: -------------------------------------------------------------------------------- 1 | export function getQuorum(clientCount: number): number { 2 | return Math.round((clientCount + 1) / 2) 3 | } 4 | 5 | export function smartSum(count: number, zeroOrOne: number): number { 6 | return count + zeroOrOne 7 | } 8 | -------------------------------------------------------------------------------- /test/init.test.ts: -------------------------------------------------------------------------------- 1 | import { init, removeAllListeners } from './unhandledRejection' 2 | 3 | before(() => { 4 | init() 5 | }) 6 | 7 | after(() => { 8 | removeAllListeners() 9 | }) 10 | -------------------------------------------------------------------------------- /test/redisClient.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import RedisMock from 'ioredis-mock' 3 | import { once } from 'stream' 4 | 5 | function createClient(num: number) { 6 | const serverURL = 7 | process.env[`REDIS_URI${num}`] || `redis://127.0.0.1:${6000 + num}` 8 | const client = new Redis(serverURL, { 9 | connectionName: `client${num}`, 10 | lazyConnect: true, 11 | enableOfflineQueue: false, 12 | autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) 13 | maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20) 14 | 15 | // https://github.com/luin/ioredis#auto-reconnect 16 | // retryStrategy is a function that will be called when the connection is lost. 17 | // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect. 18 | retryStrategy() { 19 | return 100 // for tests we disable increasing timeout 20 | } 21 | }) 22 | client.on('error', err => { 23 | console.log('Redis client error:', err.message) 24 | }) 25 | return client 26 | } 27 | 28 | function createClientMock(num: number) { 29 | return new RedisMock(`redis://mock:${4200 + num}`, { 30 | connectionName: `client-mock${num}`, 31 | lazyConnect: true, 32 | enableOfflineQueue: false, 33 | autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) 34 | maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20) 35 | 36 | // https://github.com/luin/ioredis#auto-reconnect 37 | // retryStrategy is a function that will be called when the connection is lost. 38 | // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect. 39 | retryStrategy() { 40 | return 100 // for tests we disable increasing timeout 41 | } 42 | }) 43 | } 44 | 45 | export const client1 = createClient(1) 46 | export const client2 = createClient(2) 47 | export const client3 = createClient(3) 48 | 49 | export const allClients = [client1, client2, client3] 50 | 51 | export const clientMock1 = createClientMock(1) 52 | export const clientMock2 = createClientMock(2) 53 | export const clientMock3 = createClientMock(3) 54 | 55 | export const allClientMocks = [clientMock1, clientMock2, clientMock3] 56 | 57 | before(async () => { 58 | await Promise.all(allClients.map(c => c.connect())) 59 | await Promise.all(allClientMocks.map(c => c.connect())) 60 | }) 61 | 62 | beforeEach(async () => { 63 | await Promise.all( 64 | allClients.map(c => { 65 | if (c.status !== 'ready') { 66 | console.warn( 67 | `client ${c.options.connectionName} status = ${c.status}. Wait for ready.` 68 | ) 69 | return once(c, 'ready') 70 | } 71 | return null 72 | }) 73 | ) 74 | await Promise.all(allClients.map(c => c.flushdb())) 75 | await Promise.all(allClientMocks.map(c => c.flushdb())) 76 | }) 77 | 78 | after(async () => { 79 | await Promise.all(allClients.map(c => c.quit())) 80 | await Promise.all(allClientMocks.map(c => c.quit())) 81 | // allClients.forEach(c => c.disconnect()) 82 | }) 83 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import chaiAsPromised from 'chai-as-promised' 3 | import sinonChai from 'sinon-chai' 4 | 5 | chai.use(chaiAsPromised) 6 | chai.use(sinonChai) 7 | -------------------------------------------------------------------------------- /test/shell.test.ts: -------------------------------------------------------------------------------- 1 | import { downRedisServer, upRedisServer } from './shell' 2 | 3 | describe('TEST UTILS', () => { 4 | describe('shell', () => { 5 | it('should up redis server', async function () { 6 | this.timeout(30000) 7 | await upRedisServer(1) 8 | }) 9 | it('should down and up redis servers', async function () { 10 | this.timeout(30000) 11 | await downRedisServer(1) 12 | await upRedisServer(1) 13 | }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/shell.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | 3 | import { delay } from '../src/utils/index' 4 | 5 | const LOGGING = !!process.env.LOGSHELL 6 | 7 | function sh(cmd: string) { 8 | return new Promise((resolve, reject) => { 9 | const cp = exec(cmd, (err, stdout, stderr) => { 10 | if (stdout && LOGGING) { 11 | console.log(`[${cp.pid}] stdout:`) 12 | console.log(stdout) 13 | } 14 | if (stderr && LOGGING) { 15 | console.log(`[${cp.pid}] stderr:`) 16 | console.log(stderr) 17 | } 18 | if (err) { 19 | reject(err) 20 | } else { 21 | resolve() 22 | } 23 | }) 24 | console.log(`[${cp.pid}] ${cmd}`) 25 | }) 26 | } 27 | 28 | export async function upRedisServer(num: number) { 29 | const port = 6000 + num 30 | await sh( 31 | `docker compose up -d redis${num} && yarn wait-for --redis redis://127.0.0.1:${port}` 32 | ) 33 | } 34 | 35 | export async function downRedisServer(num: number) { 36 | const port = 6000 + num 37 | await sh(`docker compose stop redis${num}`) 38 | let tries = 0 39 | while (true) { 40 | try { 41 | console.log(`wait server${num} shut down... ${++tries}`) 42 | await sh(`yarn wait-for --redis redis://127.0.0.1:${port} -c 1`) 43 | await delay(100) 44 | } catch { 45 | break 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/src/Lock.test.ts: -------------------------------------------------------------------------------- 1 | import { LockOptions } from '../../src' 2 | import { Lock } from '../../src/Lock' 3 | import { delay } from '../../src/utils' 4 | 5 | describe('Lock', () => { 6 | describe('refresh and release race condition', () => { 7 | class TestLock extends Lock { 8 | protected _kind = 'test-lock' 9 | protected _key: string 10 | constructor(key: string, options: LockOptions) { 11 | super(options) 12 | this._key = key 13 | } 14 | protected async _refresh(): Promise { 15 | await delay(200) 16 | return false 17 | } 18 | protected async _acquire(): Promise { 19 | return true 20 | } 21 | protected async _release(): Promise {} 22 | } 23 | it('should not throw LostLock error when refresh started but not finished before release happened', async function () { 24 | const lock = new TestLock('key', { 25 | lockTimeout: 1000, 26 | acquireTimeout: 1000, 27 | refreshInterval: 50 28 | }) 29 | try { 30 | await lock.acquire() 31 | await delay(100) 32 | } finally { 33 | await lock.release() 34 | } 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/src/RedisMultiSemaphore.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import MultiSemaphore from '../../src/RedisMultiSemaphore' 6 | import Semaphore from '../../src/RedisSemaphore' 7 | import { TimeoutOptions } from '../../src/types' 8 | import { delay } from '../../src/utils/index' 9 | import { client1 as client, clientMock1 as clientMock } from '../redisClient' 10 | import { downRedisServer, upRedisServer } from '../shell' 11 | import { 12 | catchUnhandledRejection, 13 | throwUnhandledRejection, 14 | unhandledRejectionSpy 15 | } from '../unhandledRejection' 16 | 17 | const timeoutOptions: TimeoutOptions = { 18 | lockTimeout: 300, 19 | acquireTimeout: 100, 20 | refreshInterval: 80, 21 | retryInterval: 10 22 | } 23 | 24 | describe('MultiSemaphore', () => { 25 | it('should fail on invalid arguments', () => { 26 | expect( 27 | () => new MultiSemaphore(null as unknown as Redis, 'key', 5, 2) 28 | ).to.throw('"client" is required') 29 | expect(() => new MultiSemaphore(client, '', 5, 2)).to.throw( 30 | '"key" is required' 31 | ) 32 | expect( 33 | () => new MultiSemaphore(client, 1 as unknown as string, 5, 2) 34 | ).to.throw('"key" must be a string') 35 | expect(() => new MultiSemaphore(client, 'key', 0, 2)).to.throw( 36 | '"limit" is required' 37 | ) 38 | expect( 39 | () => new MultiSemaphore(client, 'key', '10' as unknown as number, 2) 40 | ).to.throw('"limit" must be a number') 41 | expect(() => new MultiSemaphore(client, 'key', 5, 0)).to.throw( 42 | '"permits" is required' 43 | ) 44 | expect( 45 | () => new MultiSemaphore(client, 'key', 5, '2' as unknown as number) 46 | ).to.throw('"permits" must be a number') 47 | }) 48 | it('should acquire and release semaphore', async () => { 49 | const semaphore1 = new MultiSemaphore(client, 'key', 3, 2) 50 | const semaphore2 = new MultiSemaphore(client, 'key', 3, 1) 51 | expect(semaphore1.isAcquired).to.be.false 52 | expect(semaphore2.isAcquired).to.be.false 53 | 54 | await semaphore1.acquire() 55 | expect(semaphore1.isAcquired).to.be.true 56 | await semaphore2.acquire() 57 | expect(semaphore2.isAcquired).to.be.true 58 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 59 | semaphore1.identifier + '_0', 60 | semaphore1.identifier + '_1', 61 | semaphore2.identifier + '_0' 62 | ]) 63 | 64 | await semaphore1.release() 65 | expect(semaphore1.isAcquired).to.be.false 66 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 67 | semaphore2.identifier + '_0' 68 | ]) 69 | await semaphore2.release() 70 | expect(semaphore2.isAcquired).to.be.false 71 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 72 | }) 73 | it('should reject after timeout', async () => { 74 | const semaphore1 = new MultiSemaphore(client, 'key', 3, 3, timeoutOptions) 75 | const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) 76 | await semaphore1.acquire() 77 | await expect(semaphore2.acquire()).to.be.rejectedWith( 78 | 'Acquire multi-semaphore semaphore:key timeout' 79 | ) 80 | await semaphore1.release() 81 | expect(await client.get('semaphore:key')).to.be.eql(null) 82 | }) 83 | it('should refresh lock every refreshInterval ms until release', async () => { 84 | const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) 85 | const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) 86 | await semaphore1.acquire() 87 | await semaphore2.acquire() 88 | await delay(400) 89 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 90 | semaphore1.identifier + '_0', 91 | semaphore1.identifier + '_1', 92 | semaphore2.identifier + '_0' 93 | ]) 94 | await semaphore1.release() 95 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 96 | semaphore2.identifier + '_0' 97 | ]) 98 | await semaphore2.release() 99 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 100 | }) 101 | it('should stop refreshing lock if stopped', async () => { 102 | const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) 103 | const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) 104 | await semaphore1.acquire() 105 | await semaphore2.acquire() 106 | await semaphore1.stopRefresh() 107 | await delay(400) 108 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 109 | semaphore2.identifier + '_0' 110 | ]) 111 | await semaphore2.stopRefresh() 112 | await delay(400) 113 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 114 | }) 115 | it('should acquire maximum LIMIT semaphores', async () => { 116 | const s = () => 117 | new MultiSemaphore(client, 'key', 3, 1, { 118 | acquireTimeout: 1000, 119 | lockTimeout: 50, 120 | retryInterval: 10, 121 | refreshInterval: 0 // disable refresh 122 | }) 123 | const pr1 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) 124 | await delay(5) 125 | const pr2 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) 126 | await pr1 127 | const ids1 = await client.zrange('semaphore:key', 0, -1) 128 | expect(ids1.length).to.be.eql(3) 129 | await pr2 130 | const ids2 = await client.zrange('semaphore:key', 0, -1) 131 | expect(ids2.length).to.be.eql(3) 132 | expect(ids2) 133 | .to.not.include(ids1[0]) 134 | .and.not.include(ids1[1]) 135 | .and.not.include(ids1[2]) 136 | }) 137 | it('should support externally acquired semaphore (deprecated interface)', async () => { 138 | const externalSemaphore = new MultiSemaphore(client, 'key', 3, 2, { 139 | ...timeoutOptions, 140 | refreshInterval: 0 141 | }) 142 | const localSemaphore = new MultiSemaphore(client, 'key', 3, 2, { 143 | ...timeoutOptions, 144 | externallyAcquiredIdentifier: externalSemaphore.identifier 145 | }) 146 | await externalSemaphore.acquire() 147 | await localSemaphore.acquire() 148 | await delay(400) 149 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 150 | localSemaphore.identifier + '_0', 151 | localSemaphore.identifier + '_1' 152 | ]) 153 | await localSemaphore.release() 154 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 155 | }) 156 | it('should support externally acquired semaphore', async () => { 157 | const externalSemaphore = new MultiSemaphore(client, 'key', 3, 2, { 158 | ...timeoutOptions, 159 | refreshInterval: 0 160 | }) 161 | const localSemaphore = new MultiSemaphore(client, 'key', 3, 2, { 162 | ...timeoutOptions, 163 | identifier: externalSemaphore.identifier, 164 | acquiredExternally: true 165 | }) 166 | await externalSemaphore.acquire() 167 | await localSemaphore.acquire() 168 | await delay(400) 169 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 170 | localSemaphore.identifier + '_0', 171 | localSemaphore.identifier + '_1' 172 | ]) 173 | await localSemaphore.release() 174 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 175 | }) 176 | describe('lost lock case', () => { 177 | beforeEach(() => { 178 | catchUnhandledRejection() 179 | }) 180 | afterEach(() => { 181 | throwUnhandledRejection() 182 | }) 183 | it('should throw unhandled error if lock is lost between refreshes', async () => { 184 | const semaphore = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) 185 | await semaphore.acquire() 186 | await client.del('semaphore:key') 187 | await client.zadd( 188 | 'semaphore:key', 189 | Date.now(), 190 | 'aaa', 191 | Date.now(), 192 | 'bbb', 193 | Date.now(), 194 | 'ccc' 195 | ) 196 | await delay(200) 197 | expect(unhandledRejectionSpy).to.be.called 198 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 199 | .to.be.true 200 | }) 201 | it('should call onLockLost callback if provided', async () => { 202 | const onLockLostCallback = sinon.spy(function (this: MultiSemaphore) { 203 | expect(this.isAcquired).to.be.false 204 | }) 205 | const semaphore = new MultiSemaphore(client, 'key', 3, 2, { 206 | ...timeoutOptions, 207 | onLockLost: onLockLostCallback 208 | }) 209 | await semaphore.acquire() 210 | expect(semaphore.isAcquired).to.be.true 211 | await client.del('semaphore:key') 212 | await client.zadd( 213 | 'semaphore:key', 214 | Date.now(), 215 | 'aaa', 216 | Date.now(), 217 | 'bbb', 218 | Date.now(), 219 | 'ccc' 220 | ) 221 | await delay(200) 222 | expect(semaphore.isAcquired).to.be.false 223 | expect(unhandledRejectionSpy).to.not.called 224 | expect(onLockLostCallback).to.be.called 225 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 226 | .be.true 227 | }) 228 | }) 229 | describe('reusable', () => { 230 | it('autorefresh enabled', async function () { 231 | this.timeout(10000) 232 | const semaphore1 = new MultiSemaphore(client, 'key', 4, 2, timeoutOptions) 233 | const semaphore2 = new MultiSemaphore(client, 'key', 4, 2, timeoutOptions) 234 | 235 | await semaphore1.acquire() 236 | await semaphore2.acquire() 237 | await delay(300) 238 | await semaphore1.release() 239 | await semaphore2.release() 240 | 241 | await delay(300) 242 | 243 | await semaphore1.acquire() 244 | await semaphore2.acquire() 245 | await delay(300) 246 | await semaphore1.release() 247 | await semaphore2.release() 248 | 249 | await delay(300) 250 | 251 | await semaphore1.acquire() 252 | await semaphore2.acquire() 253 | await delay(300) 254 | await semaphore1.release() 255 | await semaphore2.release() 256 | }) 257 | 258 | it('autorefresh disabled', async () => { 259 | const noRefreshOptions = { 260 | ...timeoutOptions, 261 | refreshInterval: 0, 262 | acquireTimeout: 10 263 | } 264 | const semaphore1 = new MultiSemaphore( 265 | client, 266 | 'key', 267 | 4, 268 | 2, 269 | noRefreshOptions 270 | ) 271 | const semaphore2 = new MultiSemaphore( 272 | client, 273 | 'key', 274 | 4, 275 | 2, 276 | noRefreshOptions 277 | ) 278 | const semaphore3 = new MultiSemaphore( 279 | client, 280 | 'key', 281 | 4, 282 | 2, 283 | noRefreshOptions 284 | ) 285 | 286 | await semaphore1.acquire() 287 | await semaphore2.acquire() 288 | await delay(300) 289 | await semaphore1.release() 290 | await semaphore2.release() 291 | 292 | await delay(300) 293 | 294 | // [0/2] 295 | await semaphore1.acquire() 296 | // [1/2] 297 | await delay(80) 298 | await semaphore2.acquire() 299 | // [2/2] 300 | await expect(semaphore3.acquire()).to.be.rejectedWith( 301 | 'Acquire multi-semaphore semaphore:key timeout' 302 | ) // rejectes after 10ms 303 | 304 | // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) 305 | // semaphore1 will expire after 300 - 90 = 210ms 306 | await delay(210) 307 | 308 | // [1/2] 309 | await semaphore3.acquire() 310 | }) 311 | }) 312 | describe('Compatibility with Semaphore', () => { 313 | it('should work with Semaphore', async () => { 314 | const multiSemaphore1 = new MultiSemaphore( 315 | client, 316 | 'key', 317 | 3, 318 | 2, 319 | timeoutOptions 320 | ) 321 | const multiSemaphore2 = new MultiSemaphore( 322 | client, 323 | 'key', 324 | 3, 325 | 2, 326 | timeoutOptions 327 | ) 328 | const semaphore1 = new Semaphore(client, 'key', 3, timeoutOptions) 329 | const semaphore2 = new Semaphore(client, 'key', 3, timeoutOptions) 330 | await multiSemaphore1.acquire() 331 | await semaphore1.acquire() 332 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 333 | multiSemaphore1.identifier + '_0', 334 | multiSemaphore1.identifier + '_1', 335 | semaphore1.identifier 336 | ]) 337 | await expect(multiSemaphore2.acquire()).to.be.rejectedWith( 338 | 'Acquire multi-semaphore semaphore:key timeout' 339 | ) 340 | await expect(semaphore2.acquire()).to.be.rejectedWith( 341 | 'Acquire semaphore semaphore:key timeout' 342 | ) 343 | await multiSemaphore1.release() 344 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 345 | semaphore1.identifier 346 | ]) 347 | await semaphore1.release() 348 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 349 | }) 350 | }) 351 | describe('[Node shutdown]', () => { 352 | beforeEach(() => { 353 | catchUnhandledRejection() 354 | }) 355 | afterEach(async () => { 356 | throwUnhandledRejection() 357 | await upRedisServer(1) 358 | }) 359 | it('should lost lock when node become alive', async function () { 360 | this.timeout(60000) 361 | const onLockLostCallback = sinon.spy(function (this: Semaphore) { 362 | expect(this.isAcquired).to.be.false 363 | }) 364 | const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, { 365 | ...timeoutOptions, 366 | onLockLost: onLockLostCallback 367 | }) 368 | await semaphore1.acquire() 369 | 370 | await downRedisServer(1) 371 | console.log('SHUT DOWN') 372 | 373 | await delay(1000) 374 | 375 | await upRedisServer(1) 376 | console.log('ONLINE') 377 | 378 | // semaphore was expired, key was deleted in redis 379 | // give refresh mechanism time to detect lock lost 380 | // (includes reconnection time) 381 | await delay(1000) 382 | 383 | const data1 = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') 384 | // console.log(data) 385 | expect(data1).to.be.eql([]) 386 | 387 | // lock was not refreshed by semaphore1, so semaphore2 can acquire the lock 388 | 389 | const semaphore2 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) 390 | await semaphore2.acquire() 391 | const data2 = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') 392 | expect(data2).to.include(semaphore2.identifier + '_0') 393 | expect(data2).to.include(semaphore2.identifier + '_1') 394 | 395 | await Promise.all([semaphore1.release(), semaphore2.release()]) 396 | }) 397 | }) 398 | describe('ioredis-mock support', () => { 399 | it('should acquire and release semaphore', async () => { 400 | const semaphore1 = new MultiSemaphore(clientMock, 'key', 3, 2) 401 | const semaphore2 = new MultiSemaphore(clientMock, 'key', 3, 1) 402 | expect(semaphore1.isAcquired).to.be.false 403 | expect(semaphore2.isAcquired).to.be.false 404 | 405 | await semaphore1.acquire() 406 | expect(semaphore1.isAcquired).to.be.true 407 | await semaphore2.acquire() 408 | expect(semaphore2.isAcquired).to.be.true 409 | expect(await clientMock.zrange('semaphore:key', 0, -1)).to.have.members([ 410 | semaphore1.identifier + '_0', 411 | semaphore1.identifier + '_1', 412 | semaphore2.identifier + '_0' 413 | ]) 414 | 415 | await semaphore1.release() 416 | expect(semaphore1.isAcquired).to.be.false 417 | expect(await clientMock.zrange('semaphore:key', 0, -1)).to.be.eql([ 418 | semaphore2.identifier + '_0' 419 | ]) 420 | await semaphore2.release() 421 | expect(semaphore2.isAcquired).to.be.false 422 | expect(await clientMock.zcard('semaphore:key')).to.be.eql(0) 423 | }) 424 | }) 425 | }) 426 | -------------------------------------------------------------------------------- /test/src/RedisMutex.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import Mutex from '../../src/RedisMutex' 6 | import { TimeoutOptions } from '../../src/types' 7 | import { delay } from '../../src/utils/index' 8 | import { client1 as client, clientMock1 as clientMock } from '../redisClient' 9 | import { downRedisServer, upRedisServer } from '../shell' 10 | import { 11 | catchUnhandledRejection, 12 | throwUnhandledRejection, 13 | unhandledRejectionSpy 14 | } from '../unhandledRejection' 15 | 16 | const timeoutOptions: TimeoutOptions = { 17 | lockTimeout: 300, 18 | acquireTimeout: 100, 19 | refreshInterval: 80, 20 | retryInterval: 10 21 | } 22 | 23 | describe('Mutex', () => { 24 | it('should fail on invalid arguments', () => { 25 | expect(() => new Mutex(null as unknown as Redis, 'key')).to.throw( 26 | '"client" is required' 27 | ) 28 | expect(() => new Mutex(client, '')).to.throw('"key" is required') 29 | expect(() => new Mutex(client, 1 as unknown as string)).to.throw( 30 | '"key" must be a string' 31 | ) 32 | expect(() => new Mutex(client, 'key', { identifier: '' })).to.throw( 33 | 'identifier must be not empty random string' 34 | ) 35 | expect( 36 | () => new Mutex(client, 'key', { acquiredExternally: true }) 37 | ).to.throw('acquiredExternally=true meanless without custom identifier') 38 | expect( 39 | () => 40 | new Mutex(client, 'key', { 41 | externallyAcquiredIdentifier: '123', 42 | identifier: '123' 43 | }) 44 | ).to.throw( 45 | 'Invalid usage. Use custom identifier and acquiredExternally: true' 46 | ) 47 | expect( 48 | () => 49 | new Mutex(client, 'key', { 50 | externallyAcquiredIdentifier: '123', 51 | acquiredExternally: true, 52 | identifier: '123' 53 | }) 54 | ).to.throw( 55 | 'Invalid usage. Use custom identifier and acquiredExternally: true' 56 | ) 57 | }) 58 | it('should set default options', () => { 59 | expect(new Mutex(client, 'key', {})).to.be.ok 60 | expect(new Mutex(client, 'key')).to.be.ok 61 | }) 62 | it('should set random UUID as identifier', () => { 63 | expect(new Mutex(client, 'key').identifier).to.match( 64 | /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/ 65 | ) 66 | }) 67 | it('should add identifier suffix', () => { 68 | expect( 69 | new Mutex(client, 'key', { identifierSuffix: 'abc' }).identifier 70 | ).to.match( 71 | /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}-abc$/ 72 | ) 73 | }) 74 | it('should use custom identifier if provided', () => { 75 | expect( 76 | new Mutex(client, 'key', { identifier: 'abc' }).identifier 77 | ).to.be.eql('abc') 78 | }) 79 | it('should acquire and release lock', async () => { 80 | const mutex = new Mutex(client, 'key') 81 | expect(mutex.isAcquired).to.be.false 82 | await mutex.acquire() 83 | expect(mutex.isAcquired).to.be.true 84 | expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) 85 | await mutex.release() 86 | expect(mutex.isAcquired).to.be.false 87 | expect(await client.get('mutex:key')).to.be.eql(null) 88 | }) 89 | it('should reject after timeout', async () => { 90 | const mutex1 = new Mutex(client, 'key', timeoutOptions) 91 | const mutex2 = new Mutex(client, 'key', timeoutOptions) 92 | await mutex1.acquire() 93 | await expect(mutex2.acquire()).to.be.rejectedWith( 94 | 'Acquire mutex mutex:key timeout' 95 | ) 96 | await mutex1.release() 97 | expect(await client.get('mutex:key')).to.be.eql(null) 98 | }) 99 | it('should return false for tryAcquire after timeout', async () => { 100 | const mutex1 = new Mutex(client, 'key', timeoutOptions) 101 | const mutex2 = new Mutex(client, 'key', timeoutOptions) 102 | await mutex1.acquire() 103 | const result = await mutex2.tryAcquire() 104 | expect(result).to.be.false 105 | await mutex1.release() 106 | expect(await client.get('mutex:key')).to.be.eql(null) 107 | }) 108 | it('should return true for successful tryAcquire', async () => { 109 | const mutex = new Mutex(client, 'key', timeoutOptions) 110 | const result = await mutex.tryAcquire() 111 | expect(result).to.be.true 112 | await mutex.release() 113 | expect(await client.get('mutex:key')).to.be.eql(null) 114 | }) 115 | it('should refresh lock every refreshInterval ms until release', async () => { 116 | const mutex = new Mutex(client, 'key', timeoutOptions) 117 | await mutex.acquire() 118 | await delay(400) 119 | expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) 120 | await mutex.release() 121 | expect(await client.get('mutex:key')).to.be.eql(null) 122 | }) 123 | it('should stop refreshing lock every refreshInterval ms if stopped', async () => { 124 | const mutex = new Mutex(client, 'key', timeoutOptions) 125 | await mutex.acquire() 126 | mutex.stopRefresh() 127 | await delay(400) 128 | expect(await client.get('mutex:key')).to.be.eql(null) 129 | }) 130 | it('should not call _refresh if already refreshing', async () => { 131 | const mutex = new Mutex(client, 'key', timeoutOptions) 132 | let callCount = 0 133 | Object.assign(mutex, { 134 | _refresh: () => 135 | delay(100).then(() => { 136 | callCount++ 137 | return true 138 | }) 139 | }) 140 | await mutex.acquire() 141 | await delay(400) 142 | expect(callCount).to.be.eql(2) // not floor(400/80) = 9 143 | }) 144 | 145 | it('should support externally acquired mutex (deprecated interface)', async () => { 146 | const externalMutex = new Mutex(client, 'key', { 147 | ...timeoutOptions, 148 | refreshInterval: 0 149 | }) 150 | const localMutex = new Mutex(client, 'key', { 151 | ...timeoutOptions, 152 | externallyAcquiredIdentifier: externalMutex.identifier 153 | }) 154 | await externalMutex.acquire() 155 | await localMutex.acquire() 156 | await delay(400) 157 | expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier) 158 | await localMutex.release() 159 | expect(await client.get('mutex:key')).to.be.eql(null) 160 | }) 161 | it('should support externally acquired mutex', async () => { 162 | const externalMutex = new Mutex(client, 'key', { 163 | ...timeoutOptions, 164 | refreshInterval: 0 165 | }) 166 | const localMutex = new Mutex(client, 'key', { 167 | ...timeoutOptions, 168 | identifier: externalMutex.identifier, 169 | acquiredExternally: true 170 | }) 171 | await externalMutex.acquire() 172 | await localMutex.acquire() 173 | await delay(400) 174 | expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier) 175 | await localMutex.release() 176 | expect(await client.get('mutex:key')).to.be.eql(null) 177 | }) 178 | describe('lost lock case', () => { 179 | beforeEach(() => { 180 | catchUnhandledRejection() 181 | }) 182 | afterEach(() => { 183 | throwUnhandledRejection() 184 | }) 185 | it('should throw unhandled error if lock was lost between refreshes (another instance acquired)', async () => { 186 | const mutex = new Mutex(client, 'key', timeoutOptions) 187 | await mutex.acquire() 188 | expect(mutex.isAcquired).to.be.true 189 | await client.set('mutex:key', '222') // another instance 190 | await delay(200) 191 | expect(mutex.isAcquired).to.be.false 192 | expect(unhandledRejectionSpy).to.be.called 193 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 194 | .to.be.true 195 | }) 196 | it('should throw unhandled error if lock was lost between refreshes (lock expired)', async () => { 197 | const mutex = new Mutex(client, 'key', timeoutOptions) 198 | await mutex.acquire() 199 | expect(mutex.isAcquired).to.be.true 200 | await client.del('mutex:key') // expired 201 | await delay(200) 202 | expect(mutex.isAcquired).to.be.false 203 | expect(unhandledRejectionSpy).to.be.called 204 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 205 | .to.be.true 206 | }) 207 | it('should call onLockLost callback if provided (another instance acquired)', async () => { 208 | const onLockLostCallback = sinon.spy(function (this: Mutex) { 209 | expect(this.isAcquired).to.be.false 210 | }) 211 | const mutex = new Mutex(client, 'key', { 212 | ...timeoutOptions, 213 | onLockLost: onLockLostCallback 214 | }) 215 | await mutex.acquire() 216 | expect(mutex.isAcquired).to.be.true 217 | await client.set('mutex:key', '222') // another instance 218 | await delay(200) 219 | expect(mutex.isAcquired).to.be.false 220 | expect(unhandledRejectionSpy).to.not.called 221 | expect(onLockLostCallback).to.be.called 222 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 223 | .be.true 224 | }) 225 | it('should call onLockLost callback if provided (lock expired)', async () => { 226 | const onLockLostCallback = sinon.spy(function (this: Mutex) { 227 | expect(this.isAcquired).to.be.false 228 | }) 229 | const mutex = new Mutex(client, 'key', { 230 | ...timeoutOptions, 231 | onLockLost: onLockLostCallback 232 | }) 233 | await mutex.acquire() 234 | expect(mutex.isAcquired).to.be.true 235 | await client.del('mutex:key') // expired 236 | await delay(200) 237 | expect(mutex.isAcquired).to.be.false 238 | expect(unhandledRejectionSpy).to.not.called 239 | expect(onLockLostCallback).to.be.called 240 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 241 | .be.true 242 | }) 243 | }) 244 | it('should be reusable', async function () { 245 | this.timeout(10000) 246 | const mutex = new Mutex(client, 'key', timeoutOptions) 247 | 248 | /* Lifecycle 1 */ 249 | await mutex.acquire() 250 | await delay(300) 251 | expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) 252 | await mutex.release() 253 | expect(await client.get('mutex:key')).to.be.eql(null) 254 | await delay(300) 255 | expect(await client.get('mutex:key')).to.be.eql(null) 256 | 257 | await delay(300) 258 | 259 | /* Lifecycle 2 */ 260 | await mutex.acquire() 261 | await delay(300) 262 | expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) 263 | await mutex.release() 264 | expect(await client.get('mutex:key')).to.be.eql(null) 265 | await delay(300) 266 | expect(await client.get('mutex:key')).to.be.eql(null) 267 | 268 | await delay(300) 269 | 270 | /* Lifecycle 3 */ 271 | await mutex.acquire() 272 | await delay(300) 273 | expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) 274 | await mutex.release() 275 | expect(await client.get('mutex:key')).to.be.eql(null) 276 | await delay(300) 277 | expect(await client.get('mutex:key')).to.be.eql(null) 278 | }) 279 | describe('[Node shutdown]', () => { 280 | beforeEach(() => { 281 | catchUnhandledRejection() 282 | }) 283 | afterEach(async () => { 284 | throwUnhandledRejection() 285 | await upRedisServer(1) 286 | }) 287 | it('should lost lock when node become alive', async function () { 288 | this.timeout(60000) 289 | const onLockLostCallback = sinon.spy(function (this: Mutex) { 290 | expect(this.isAcquired).to.be.false 291 | }) 292 | const mutex1 = new Mutex(client, 'key', { 293 | ...timeoutOptions, 294 | onLockLost: onLockLostCallback 295 | }) 296 | await mutex1.acquire() 297 | await downRedisServer(1) 298 | 299 | await delay(1000) 300 | // lock expired now 301 | 302 | await upRedisServer(1) 303 | // mutex was expired, key was deleted in redis 304 | // give refresh mechanism time to detect lock lost 305 | // (includes client reconnection time) 306 | await delay(1000) 307 | 308 | expect(await client.get('mutex:key')).to.be.eql(null) 309 | expect(onLockLostCallback).to.be.called 310 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 311 | .be.true 312 | 313 | // lock was not reacquired by mutex1, so mutex2 can acquire the lock 314 | 315 | const mutex2 = new Mutex(client, 'key', timeoutOptions) 316 | await mutex2.acquire() 317 | expect(await client.get('mutex:key')).to.be.eql(mutex2.identifier) 318 | 319 | await Promise.all([mutex1.release(), mutex2.release()]) 320 | }) 321 | }) 322 | describe('ioredis-mock support', async () => { 323 | it('should acquire and release lock', async () => { 324 | const mutex = new Mutex(clientMock, 'key') 325 | expect(mutex.isAcquired).to.be.false 326 | await mutex.acquire() 327 | expect(mutex.isAcquired).to.be.true 328 | expect(await clientMock.get('mutex:key')).to.be.eql(mutex.identifier) 329 | await mutex.release() 330 | expect(mutex.isAcquired).to.be.false 331 | expect(await clientMock.get('mutex:key')).to.be.eql(null) 332 | }) 333 | }) 334 | }) 335 | -------------------------------------------------------------------------------- /test/src/RedisSemaphore.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import Semaphore from '../../src/RedisSemaphore' 6 | import { TimeoutOptions } from '../../src/types' 7 | import { delay } from '../../src/utils/index' 8 | import { client1 as client, clientMock1 as clientMock } from '../redisClient' 9 | import { downRedisServer, upRedisServer } from '../shell' 10 | import { 11 | catchUnhandledRejection, 12 | throwUnhandledRejection, 13 | unhandledRejectionSpy 14 | } from '../unhandledRejection' 15 | 16 | const timeoutOptions: TimeoutOptions = { 17 | lockTimeout: 300, 18 | acquireTimeout: 100, 19 | refreshInterval: 80, 20 | retryInterval: 10 21 | } 22 | 23 | describe('Semaphore', () => { 24 | it('should fail on invalid arguments', () => { 25 | expect(() => new Semaphore(null as unknown as Redis, 'key', 5)).to.throw( 26 | '"client" is required' 27 | ) 28 | expect(() => new Semaphore(client, '', 5)).to.throw('"key" is required') 29 | expect(() => new Semaphore(client, 1 as unknown as string, 5)).to.throw( 30 | '"key" must be a string' 31 | ) 32 | expect(() => new Semaphore(client, 'key', 0)).to.throw( 33 | '"limit" is required' 34 | ) 35 | expect( 36 | () => new Semaphore(client, 'key', '10' as unknown as number) 37 | ).to.throw('"limit" must be a number') 38 | }) 39 | it('should acquire and release semaphore', async () => { 40 | const semaphore1 = new Semaphore(client, 'key', 2) 41 | const semaphore2 = new Semaphore(client, 'key', 2) 42 | expect(semaphore1.isAcquired).to.be.false 43 | expect(semaphore2.isAcquired).to.be.false 44 | 45 | await semaphore1.acquire() 46 | expect(semaphore1.isAcquired).to.be.true 47 | await semaphore2.acquire() 48 | expect(semaphore2.isAcquired).to.be.true 49 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 50 | semaphore1.identifier, 51 | semaphore2.identifier 52 | ]) 53 | 54 | await semaphore1.release() 55 | expect(semaphore1.isAcquired).to.be.false 56 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 57 | semaphore2.identifier 58 | ]) 59 | await semaphore2.release() 60 | expect(semaphore2.isAcquired).to.be.false 61 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 62 | }) 63 | it('should reject after timeout', async () => { 64 | const semaphore1 = new Semaphore(client, 'key', 1, timeoutOptions) 65 | const semaphore2 = new Semaphore(client, 'key', 1, timeoutOptions) 66 | await semaphore1.acquire() 67 | await expect(semaphore2.acquire()).to.be.rejectedWith( 68 | 'Acquire semaphore semaphore:key timeout' 69 | ) 70 | await semaphore1.release() 71 | expect(await client.get('semaphore:key')).to.be.eql(null) 72 | }) 73 | it('should refresh lock every refreshInterval ms until release', async () => { 74 | const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) 75 | const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) 76 | await semaphore1.acquire() 77 | await semaphore2.acquire() 78 | await delay(400) 79 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 80 | semaphore1.identifier, 81 | semaphore2.identifier 82 | ]) 83 | await semaphore1.release() 84 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 85 | semaphore2.identifier 86 | ]) 87 | await semaphore2.release() 88 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 89 | }) 90 | it('should stop refreshing lock if stopped', async () => { 91 | const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) 92 | const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) 93 | await semaphore1.acquire() 94 | await semaphore2.acquire() 95 | await semaphore1.stopRefresh() 96 | await delay(400) 97 | expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ 98 | semaphore2.identifier 99 | ]) 100 | await semaphore2.stopRefresh() 101 | await delay(400) 102 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 103 | }) 104 | it('should acquire maximum LIMIT semaphores', async () => { 105 | const s = () => 106 | new Semaphore(client, 'key', 3, { 107 | acquireTimeout: 1000, 108 | lockTimeout: 50, 109 | retryInterval: 10, 110 | refreshInterval: 0 // disable refresh 111 | }) 112 | const pr1 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) 113 | await delay(5) 114 | const pr2 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) 115 | await pr1 116 | const ids1 = await client.zrange('semaphore:key', 0, -1) 117 | expect(ids1.length).to.be.eql(3) 118 | await pr2 119 | const ids2 = await client.zrange('semaphore:key', 0, -1) 120 | expect(ids2.length).to.be.eql(3) 121 | expect(ids2) 122 | .to.not.include(ids1[0]) 123 | .and.not.include(ids1[1]) 124 | .and.not.include(ids1[2]) 125 | }) 126 | it('should support externally acquired semaphore (deprecated interface)', async () => { 127 | const externalSemaphore = new Semaphore(client, 'key', 3, { 128 | ...timeoutOptions, 129 | refreshInterval: 0 130 | }) 131 | const localSemaphore = new Semaphore(client, 'key', 3, { 132 | ...timeoutOptions, 133 | externallyAcquiredIdentifier: externalSemaphore.identifier 134 | }) 135 | await externalSemaphore.acquire() 136 | await localSemaphore.acquire() 137 | await delay(400) 138 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 139 | localSemaphore.identifier 140 | ]) 141 | await localSemaphore.release() 142 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 143 | }) 144 | it('should support externally acquired semaphore', async () => { 145 | const externalSemaphore = new Semaphore(client, 'key', 3, { 146 | ...timeoutOptions, 147 | refreshInterval: 0 148 | }) 149 | const localSemaphore = new Semaphore(client, 'key', 3, { 150 | ...timeoutOptions, 151 | identifier: externalSemaphore.identifier, 152 | acquiredExternally: true 153 | }) 154 | await externalSemaphore.acquire() 155 | await localSemaphore.acquire() 156 | await delay(400) 157 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 158 | localSemaphore.identifier 159 | ]) 160 | await localSemaphore.release() 161 | expect(await client.zcard('semaphore:key')).to.be.eql(0) 162 | }) 163 | describe('lost lock case', () => { 164 | beforeEach(() => { 165 | catchUnhandledRejection() 166 | }) 167 | afterEach(() => { 168 | throwUnhandledRejection() 169 | }) 170 | it('should throw unhandled error if lock is lost between refreshes', async () => { 171 | const semaphore = new Semaphore(client, 'key', 3, timeoutOptions) 172 | await semaphore.acquire() 173 | await client.del('semaphore:key') 174 | await client.zadd( 175 | 'semaphore:key', 176 | Date.now(), 177 | 'aaa', 178 | Date.now(), 179 | 'bbb', 180 | Date.now(), 181 | 'ccc' 182 | ) 183 | await delay(200) 184 | expect(unhandledRejectionSpy).to.be.called 185 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 186 | .to.be.true 187 | }) 188 | it('should call onLockLost callback if provided', async () => { 189 | const onLockLostCallback = sinon.spy(function (this: Semaphore) { 190 | expect(this.isAcquired).to.be.false 191 | }) 192 | const semaphore = new Semaphore(client, 'key', 3, { 193 | ...timeoutOptions, 194 | onLockLost: onLockLostCallback 195 | }) 196 | await semaphore.acquire() 197 | expect(semaphore.isAcquired).to.be.true 198 | await client.del('semaphore:key') 199 | await client.zadd( 200 | 'semaphore:key', 201 | Date.now(), 202 | 'aaa', 203 | Date.now(), 204 | 'bbb', 205 | Date.now(), 206 | 'ccc' 207 | ) 208 | await delay(200) 209 | expect(semaphore.isAcquired).to.be.false 210 | expect(unhandledRejectionSpy).to.not.called 211 | expect(onLockLostCallback).to.be.called 212 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 213 | .be.true 214 | }) 215 | }) 216 | describe('reusable', () => { 217 | it('autorefresh enabled', async () => { 218 | const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) 219 | const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) 220 | 221 | await semaphore1.acquire() 222 | await semaphore2.acquire() 223 | await delay(300) 224 | await semaphore1.release() 225 | await semaphore2.release() 226 | 227 | await delay(300) 228 | 229 | await semaphore1.acquire() 230 | await semaphore2.acquire() 231 | await delay(300) 232 | await semaphore1.release() 233 | await semaphore2.release() 234 | 235 | await delay(300) 236 | 237 | await semaphore1.acquire() 238 | await semaphore2.acquire() 239 | await delay(300) 240 | await semaphore1.release() 241 | await semaphore2.release() 242 | }) 243 | 244 | it('autorefresh disabled', async () => { 245 | const noRefreshOptions = { 246 | ...timeoutOptions, 247 | refreshInterval: 0, 248 | acquireTimeout: 10 249 | } 250 | const semaphore1 = new Semaphore(client, 'key', 2, noRefreshOptions) 251 | const semaphore2 = new Semaphore(client, 'key', 2, noRefreshOptions) 252 | const semaphore3 = new Semaphore(client, 'key', 2, noRefreshOptions) 253 | 254 | await semaphore1.acquire() 255 | await semaphore2.acquire() 256 | await delay(300) 257 | await semaphore1.release() 258 | await semaphore2.release() 259 | 260 | await delay(300) 261 | 262 | // [0/2] 263 | await semaphore1.acquire() 264 | // [1/2] 265 | await delay(80) 266 | await semaphore2.acquire() 267 | // [2/2] 268 | await expect(semaphore3.acquire()).to.be.rejectedWith( 269 | 'Acquire semaphore semaphore:key timeout' 270 | ) // rejectes after 10ms 271 | 272 | // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) 273 | // semaphore1 will expire after 300 - 90 = 210ms 274 | await delay(210) 275 | 276 | // [1/2] 277 | await semaphore3.acquire() 278 | }) 279 | }) 280 | describe('[Node shutdown]', () => { 281 | beforeEach(() => { 282 | catchUnhandledRejection() 283 | }) 284 | afterEach(async () => { 285 | throwUnhandledRejection() 286 | await upRedisServer(1) 287 | }) 288 | it('should lost lock when node become alive', async function () { 289 | this.timeout(60000) 290 | const onLockLostCallbacks = [1, 2, 3].map(() => 291 | sinon.spy(function (this: Semaphore) { 292 | expect(this.isAcquired).to.be.false 293 | }) 294 | ) 295 | const semaphores1 = [1, 2, 3].map( 296 | (n, i) => 297 | new Semaphore(client, 'key', 3, { 298 | ...timeoutOptions, 299 | onLockLost: onLockLostCallbacks[i] 300 | }) 301 | ) 302 | 303 | await Promise.all(semaphores1.map(s => s.acquire())) 304 | 305 | await downRedisServer(1) 306 | console.log('SHUT DOWN') 307 | 308 | await delay(1000) 309 | 310 | await upRedisServer(1) 311 | console.log('ONLINE') 312 | 313 | // semaphore was expired, key was deleted in redis 314 | // give refresh mechanism time to detect lock lost 315 | // (includes reconnection time) 316 | await delay(1000) 317 | 318 | const data = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') 319 | expect(data).to.be.eql([]) 320 | 321 | // console.log(data) 322 | // expect(data).to.include(semaphore11.identifier) 323 | // expect(data).to.include(semaphore12.identifier) 324 | // expect(data).to.include(semaphore13.identifier) 325 | 326 | // lock was not reacquired by semaphore1[1-3], so semaphore2 can acquire the lock 327 | 328 | const semaphore2 = new Semaphore(client, 'key', 3, timeoutOptions) 329 | await semaphore2.acquire() 330 | 331 | expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ 332 | semaphore2.identifier 333 | ]) 334 | 335 | await Promise.all([ 336 | ...semaphores1.map(s => s.release()), 337 | semaphore2.release() 338 | ]) 339 | }) 340 | }) 341 | describe('ioredis-mock support', async () => { 342 | it('should acquire and release semaphore', async () => { 343 | const semaphore1 = new Semaphore(clientMock, 'key', 2) 344 | const semaphore2 = new Semaphore(clientMock, 'key', 2) 345 | expect(semaphore1.isAcquired).to.be.false 346 | expect(semaphore2.isAcquired).to.be.false 347 | 348 | await semaphore1.acquire() 349 | expect(semaphore1.isAcquired).to.be.true 350 | await semaphore2.acquire() 351 | expect(semaphore2.isAcquired).to.be.true 352 | expect(await clientMock.zrange('semaphore:key', 0, -1)).to.have.members([ 353 | semaphore1.identifier, 354 | semaphore2.identifier 355 | ]) 356 | 357 | await semaphore1.release() 358 | expect(semaphore1.isAcquired).to.be.false 359 | expect(await clientMock.zrange('semaphore:key', 0, -1)).to.be.eql([ 360 | semaphore2.identifier 361 | ]) 362 | await semaphore2.release() 363 | expect(semaphore2.isAcquired).to.be.false 364 | expect(await clientMock.zcard('semaphore:key')).to.be.eql(0) 365 | }) 366 | }) 367 | }) 368 | -------------------------------------------------------------------------------- /test/src/RedlockMultiSemaphore.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import RedlockMultiSemaphore from '../../src/RedlockMultiSemaphore' 6 | import RedlockSemaphore from '../../src/RedlockSemaphore' 7 | import { TimeoutOptions } from '../../src/types' 8 | import { delay } from '../../src/utils/index' 9 | import { 10 | allClientMocks, 11 | allClients, 12 | client1, 13 | client2, 14 | client3 15 | } from '../redisClient' 16 | import { downRedisServer, upRedisServer } from '../shell' 17 | import { 18 | catchUnhandledRejection, 19 | throwUnhandledRejection, 20 | unhandledRejectionSpy 21 | } from '../unhandledRejection' 22 | 23 | const timeoutOptions: TimeoutOptions = { 24 | lockTimeout: 300, 25 | acquireTimeout: 100, 26 | refreshInterval: 80, 27 | retryInterval: 10 28 | } 29 | 30 | async function expectZRangeAllEql(key: string, values: string[]) { 31 | const results = await Promise.all([ 32 | client1.zrange(key, 0, -1), 33 | client2.zrange(key, 0, -1), 34 | client3.zrange(key, 0, -1) 35 | ]) 36 | expect(results).to.be.eql([values, values, values]) 37 | } 38 | 39 | async function expectZRangeAllHaveMembers(key: string, values: string[]) { 40 | const results = await Promise.all([ 41 | client1.zrange(key, 0, -1), 42 | client2.zrange(key, 0, -1), 43 | client3.zrange(key, 0, -1) 44 | ]) 45 | for (const result of results) { 46 | expect(result).to.have.members(values) 47 | } 48 | } 49 | 50 | async function expectZCardAllEql(key: string, count: number) { 51 | const results = await Promise.all([ 52 | client1.zcard(key), 53 | client2.zcard(key), 54 | client3.zcard(key) 55 | ]) 56 | expect(results).to.be.eql([count, count, count]) 57 | } 58 | 59 | describe('RedlockMultiSemaphore', () => { 60 | it('should fail on invalid arguments', () => { 61 | expect( 62 | () => new RedlockMultiSemaphore(null as unknown as Redis[], 'key', 5, 2) 63 | ).to.throw('"clients" array is required') 64 | expect(() => new RedlockMultiSemaphore(allClients, '', 5, 2)).to.throw( 65 | '"key" is required' 66 | ) 67 | expect( 68 | () => new RedlockMultiSemaphore(allClients, 1 as unknown as string, 5, 2) 69 | ).to.throw('"key" must be a string') 70 | expect(() => new RedlockMultiSemaphore(allClients, 'key', 0, 2)).to.throw( 71 | '"limit" is required' 72 | ) 73 | expect( 74 | () => 75 | new RedlockMultiSemaphore( 76 | allClients, 77 | 'key', 78 | '10' as unknown as number, 79 | 2 80 | ) 81 | ).to.throw('"limit" must be a number') 82 | expect(() => new RedlockMultiSemaphore(allClients, 'key', 5, 0)).to.throw( 83 | '"permits" is required' 84 | ) 85 | expect( 86 | () => 87 | new RedlockMultiSemaphore( 88 | allClients, 89 | 'key', 90 | 5, 91 | '2' as unknown as number 92 | ) 93 | ).to.throw('"permits" must be a number') 94 | }) 95 | it('should acquire and release semaphore', async () => { 96 | const semaphore1 = new RedlockMultiSemaphore(allClients, 'key', 3, 2) 97 | const semaphore2 = new RedlockMultiSemaphore(allClients, 'key', 3, 1) 98 | expect(semaphore1.isAcquired).to.be.false 99 | expect(semaphore2.isAcquired).to.be.false 100 | 101 | await semaphore1.acquire() 102 | expect(semaphore1.isAcquired).to.be.true 103 | await semaphore2.acquire() 104 | expect(semaphore2.isAcquired).to.be.true 105 | await expectZRangeAllHaveMembers('semaphore:key', [ 106 | semaphore1.identifier + '_0', 107 | semaphore1.identifier + '_1', 108 | semaphore2.identifier + '_0' 109 | ]) 110 | 111 | await semaphore1.release() 112 | expect(semaphore1.isAcquired).to.be.false 113 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) 114 | await semaphore2.release() 115 | expect(semaphore2.isAcquired).to.be.false 116 | await expectZCardAllEql('semaphore:key', 0) 117 | }) 118 | it('should reject after timeout', async () => { 119 | const semaphore1 = new RedlockMultiSemaphore( 120 | allClients, 121 | 'key', 122 | 3, 123 | 3, 124 | timeoutOptions 125 | ) 126 | const semaphore2 = new RedlockMultiSemaphore( 127 | allClients, 128 | 'key', 129 | 3, 130 | 1, 131 | timeoutOptions 132 | ) 133 | await semaphore1.acquire() 134 | await expect(semaphore2.acquire()).to.be.rejectedWith( 135 | 'Acquire redlock-multi-semaphore semaphore:key timeout' 136 | ) 137 | await semaphore1.release() 138 | await expectZCardAllEql('semaphore:key', 0) 139 | }) 140 | it('should refresh lock every refreshInterval ms until release', async () => { 141 | const semaphore1 = new RedlockMultiSemaphore( 142 | allClients, 143 | 'key', 144 | 3, 145 | 2, 146 | timeoutOptions 147 | ) 148 | const semaphore2 = new RedlockMultiSemaphore( 149 | allClients, 150 | 'key', 151 | 3, 152 | 1, 153 | timeoutOptions 154 | ) 155 | await semaphore1.acquire() 156 | await semaphore2.acquire() 157 | await delay(400) 158 | await expectZRangeAllHaveMembers('semaphore:key', [ 159 | semaphore1.identifier + '_0', 160 | semaphore1.identifier + '_1', 161 | semaphore2.identifier + '_0' 162 | ]) 163 | await semaphore1.release() 164 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) 165 | await semaphore2.release() 166 | await expectZCardAllEql('semaphore:key', 0) 167 | }) 168 | it('should stop refreshing lock if stopped', async () => { 169 | const semaphore1 = new RedlockMultiSemaphore( 170 | allClients, 171 | 'key', 172 | 3, 173 | 2, 174 | timeoutOptions 175 | ) 176 | const semaphore2 = new RedlockMultiSemaphore( 177 | allClients, 178 | 'key', 179 | 3, 180 | 1, 181 | timeoutOptions 182 | ) 183 | await semaphore1.acquire() 184 | await semaphore2.acquire() 185 | semaphore1.stopRefresh() 186 | await delay(400) 187 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) 188 | semaphore2.stopRefresh() 189 | await delay(400) 190 | await expectZCardAllEql('semaphore:key', 0) 191 | }) 192 | it('should acquire maximum LIMIT semaphores', async () => { 193 | const s = () => 194 | new RedlockMultiSemaphore(allClients, 'key', 3, 1, { 195 | acquireTimeout: 1000, 196 | lockTimeout: 50, 197 | retryInterval: 10, 198 | refreshInterval: 0 // disable refresh 199 | }) 200 | const set1 = [s(), s(), s()] 201 | const pr1 = Promise.all(set1.map(sem => sem.acquire())) 202 | await delay(5) 203 | const set2 = [s(), s(), s()] 204 | const pr2 = Promise.all(set2.map(sem => sem.acquire())) 205 | await pr1 206 | await expectZRangeAllHaveMembers('semaphore:key', [ 207 | set1[0].identifier + '_0', 208 | set1[1].identifier + '_0', 209 | set1[2].identifier + '_0' 210 | ]) 211 | await expectZCardAllEql('semaphore:key', 3) 212 | await pr2 213 | await expectZRangeAllHaveMembers('semaphore:key', [ 214 | set2[0].identifier + '_0', 215 | set2[1].identifier + '_0', 216 | set2[2].identifier + '_0' 217 | ]) 218 | await expectZCardAllEql('semaphore:key', 3) 219 | }) 220 | it('should support externally acquired semaphore (deprecated interface)', async () => { 221 | const externalSemaphore = new RedlockMultiSemaphore( 222 | allClients, 223 | 'key', 224 | 3, 225 | 2, 226 | { 227 | ...timeoutOptions, 228 | refreshInterval: 0 229 | } 230 | ) 231 | const localSemaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { 232 | ...timeoutOptions, 233 | externallyAcquiredIdentifier: externalSemaphore.identifier 234 | }) 235 | await externalSemaphore.acquire() 236 | await localSemaphore.acquire() 237 | await delay(400) 238 | await expectZRangeAllHaveMembers('semaphore:key', [ 239 | localSemaphore.identifier + '_0', 240 | localSemaphore.identifier + '_1' 241 | ]) 242 | await localSemaphore.release() 243 | await expectZCardAllEql('semaphore:key', 0) 244 | }) 245 | it('should support externally acquired semaphore', async () => { 246 | const externalSemaphore = new RedlockMultiSemaphore( 247 | allClients, 248 | 'key', 249 | 3, 250 | 2, 251 | { 252 | ...timeoutOptions, 253 | refreshInterval: 0 254 | } 255 | ) 256 | const localSemaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { 257 | ...timeoutOptions, 258 | identifier: externalSemaphore.identifier, 259 | acquiredExternally: true 260 | }) 261 | await externalSemaphore.acquire() 262 | await localSemaphore.acquire() 263 | await delay(400) 264 | await expectZRangeAllHaveMembers('semaphore:key', [ 265 | localSemaphore.identifier + '_0', 266 | localSemaphore.identifier + '_1' 267 | ]) 268 | await localSemaphore.release() 269 | await expectZCardAllEql('semaphore:key', 0) 270 | }) 271 | describe('lost lock case', () => { 272 | beforeEach(() => { 273 | catchUnhandledRejection() 274 | }) 275 | afterEach(() => { 276 | throwUnhandledRejection() 277 | }) 278 | it('should throw unhandled error if lock is lost between refreshes', async () => { 279 | const semaphore = new RedlockMultiSemaphore( 280 | allClients, 281 | 'key', 282 | 3, 283 | 2, 284 | timeoutOptions 285 | ) 286 | await semaphore.acquire() 287 | await Promise.all(allClients.map(client => client.del('semaphore:key'))) 288 | await Promise.all( 289 | allClients.map(client => 290 | client.zadd( 291 | 'semaphore:key', 292 | Date.now(), 293 | 'aaa', 294 | Date.now(), 295 | 'bbb', 296 | Date.now(), 297 | 'ccc' 298 | ) 299 | ) 300 | ) 301 | await delay(200) 302 | expect(unhandledRejectionSpy).to.be.called 303 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 304 | .to.be.true 305 | }) 306 | it('should call onLockLost callback if provided', async () => { 307 | const onLockLostCallback = sinon.spy(function ( 308 | this: RedlockMultiSemaphore 309 | ) { 310 | expect(this.isAcquired).to.be.false 311 | }) 312 | const semaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { 313 | ...timeoutOptions, 314 | onLockLost: onLockLostCallback 315 | }) 316 | await semaphore.acquire() 317 | expect(semaphore.isAcquired).to.be.true 318 | await Promise.all(allClients.map(client => client.del('semaphore:key'))) 319 | await Promise.all( 320 | allClients.map(client => 321 | client.zadd( 322 | 'semaphore:key', 323 | Date.now(), 324 | 'aaa', 325 | Date.now(), 326 | 'bbb', 327 | Date.now(), 328 | 'ccc' 329 | ) 330 | ) 331 | ) 332 | await delay(200) 333 | expect(semaphore.isAcquired).to.be.false 334 | expect(unhandledRejectionSpy).to.not.called 335 | expect(onLockLostCallback).to.be.called 336 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 337 | .be.true 338 | }) 339 | }) 340 | describe('reusable', () => { 341 | it('autorefresh enabled', async function () { 342 | this.timeout(10000) 343 | const semaphore1 = new RedlockMultiSemaphore( 344 | allClients, 345 | 'key', 346 | 4, 347 | 2, 348 | timeoutOptions 349 | ) 350 | const semaphore2 = new RedlockMultiSemaphore( 351 | allClients, 352 | 'key', 353 | 4, 354 | 2, 355 | timeoutOptions 356 | ) 357 | 358 | await semaphore1.acquire() 359 | await semaphore2.acquire() 360 | await delay(300) 361 | await semaphore1.release() 362 | await semaphore2.release() 363 | 364 | await delay(300) 365 | 366 | await semaphore1.acquire() 367 | await semaphore2.acquire() 368 | await delay(300) 369 | await semaphore1.release() 370 | await semaphore2.release() 371 | 372 | await delay(300) 373 | 374 | await semaphore1.acquire() 375 | await semaphore2.acquire() 376 | await delay(300) 377 | await semaphore1.release() 378 | await semaphore2.release() 379 | }) 380 | 381 | it('autorefresh disabled', async () => { 382 | const noRefreshOptions = { 383 | ...timeoutOptions, 384 | refreshInterval: 0, 385 | acquireTimeout: 10 386 | } 387 | const semaphore1 = new RedlockMultiSemaphore( 388 | allClients, 389 | 'key', 390 | 4, 391 | 2, 392 | noRefreshOptions 393 | ) 394 | const semaphore2 = new RedlockMultiSemaphore( 395 | allClients, 396 | 'key', 397 | 4, 398 | 2, 399 | noRefreshOptions 400 | ) 401 | const semaphore3 = new RedlockMultiSemaphore( 402 | allClients, 403 | 'key', 404 | 4, 405 | 2, 406 | noRefreshOptions 407 | ) 408 | 409 | await semaphore1.acquire() 410 | await semaphore2.acquire() 411 | await delay(300) 412 | await semaphore1.release() 413 | await semaphore2.release() 414 | 415 | await delay(300) 416 | 417 | // [0/2] 418 | await semaphore1.acquire() 419 | // [1/2] 420 | await delay(80) 421 | await semaphore2.acquire() 422 | // [2/2] 423 | await expect(semaphore3.acquire()).to.be.rejectedWith( 424 | 'Acquire redlock-multi-semaphore semaphore:key timeout' 425 | ) // rejectes after 10ms 426 | 427 | // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) 428 | // semaphore1 will expire after 300 - 90 = 210ms 429 | await delay(210) 430 | 431 | // [1/2] 432 | await semaphore3.acquire() 433 | }) 434 | }) 435 | describe('Compatibility with Semaphore', () => { 436 | it('should work with Semaphore', async () => { 437 | const multiSemaphore1 = new RedlockMultiSemaphore( 438 | allClients, 439 | 'key', 440 | 3, 441 | 2, 442 | timeoutOptions 443 | ) 444 | const multiSemaphore2 = new RedlockMultiSemaphore( 445 | allClients, 446 | 'key', 447 | 3, 448 | 2, 449 | timeoutOptions 450 | ) 451 | const semaphore1 = new RedlockSemaphore( 452 | allClients, 453 | 'key', 454 | 3, 455 | timeoutOptions 456 | ) 457 | const semaphore2 = new RedlockSemaphore( 458 | allClients, 459 | 'key', 460 | 3, 461 | timeoutOptions 462 | ) 463 | await multiSemaphore1.acquire() 464 | await semaphore1.acquire() 465 | await expectZRangeAllHaveMembers('semaphore:key', [ 466 | multiSemaphore1.identifier + '_0', 467 | multiSemaphore1.identifier + '_1', 468 | semaphore1.identifier 469 | ]) 470 | await expect(multiSemaphore2.acquire()).to.be.rejectedWith( 471 | 'Acquire redlock-multi-semaphore semaphore:key timeout' 472 | ) 473 | await expect(semaphore2.acquire()).to.be.rejectedWith( 474 | 'Acquire redlock-semaphore semaphore:key timeout' 475 | ) 476 | await multiSemaphore1.release() 477 | await expectZRangeAllEql('semaphore:key', [semaphore1.identifier]) 478 | await semaphore1.release() 479 | await expectZCardAllEql('semaphore:key', 0) 480 | }) 481 | }) 482 | describe('[Node shutdown]', () => { 483 | afterEach(async () => { 484 | await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) 485 | }) 486 | it('should handle server shutdown if quorum is alive', async function () { 487 | this.timeout(60000) 488 | const semaphore11 = new RedlockMultiSemaphore( 489 | allClients, 490 | 'key', 491 | 3, 492 | 2, 493 | timeoutOptions 494 | ) 495 | const semaphore12 = new RedlockMultiSemaphore( 496 | allClients, 497 | 'key', 498 | 3, 499 | 1, 500 | timeoutOptions 501 | ) 502 | await Promise.all([semaphore11.acquire(), semaphore12.acquire()]) 503 | 504 | // 505 | await downRedisServer(1) 506 | console.log('SHUT DOWN 1') 507 | 508 | await delay(1000) 509 | 510 | // lock survive in server2 and server3 511 | // semaphore2 will NOT be able to acquire the lock 512 | 513 | const semaphore2 = new RedlockSemaphore( 514 | allClients, 515 | 'key', 516 | 3, 517 | timeoutOptions 518 | ) 519 | await expect(semaphore2.acquire()).to.be.rejectedWith( 520 | 'Acquire redlock-semaphore semaphore:key timeout' 521 | ) 522 | 523 | // key in server1 has expired now 524 | 525 | await upRedisServer(1) 526 | console.log('ONLINE 1') 527 | 528 | // let semaphore1[1-3] to refresh lock on server1 529 | await delay(1000) 530 | expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ 531 | semaphore11.identifier + '_0', 532 | semaphore11.identifier + '_1', 533 | semaphore12.identifier + '_0' 534 | ]) 535 | // 536 | 537 | // 538 | await downRedisServer(2) 539 | console.log('SHUT DOWN 2') 540 | 541 | await delay(1000) 542 | 543 | // lock survive in server1 and server3 544 | // semaphore3 will NOT be able to acquire the lock 545 | 546 | const semaphore3 = new RedlockSemaphore( 547 | allClients, 548 | 'key', 549 | 3, 550 | timeoutOptions 551 | ) 552 | await expect(semaphore3.acquire()).to.be.rejectedWith( 553 | 'Acquire redlock-semaphore semaphore:key timeout' 554 | ) 555 | 556 | // key in server2 has expired now 557 | 558 | await upRedisServer(2) 559 | console.log('ONLINE 2') 560 | 561 | // let semaphore1[1-3] to refresh lock on server1 562 | await delay(1000) 563 | expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ 564 | semaphore11.identifier + '_0', 565 | semaphore11.identifier + '_1', 566 | semaphore12.identifier + '_0' 567 | ]) 568 | // 569 | 570 | // 571 | await downRedisServer(3) 572 | console.log('SHUT DOWN 3') 573 | 574 | await delay(1000) 575 | 576 | // lock survive in server1 and server2 577 | // semaphore4 will NOT be able to acquire the lock 578 | 579 | const semaphore4 = new RedlockSemaphore( 580 | allClients, 581 | 'key', 582 | 3, 583 | timeoutOptions 584 | ) 585 | await expect(semaphore4.acquire()).to.be.rejectedWith( 586 | 'Acquire redlock-semaphore semaphore:key timeout' 587 | ) 588 | 589 | // key in server1 has expired now 590 | 591 | await upRedisServer(3) 592 | console.log('ONLINE 3') 593 | 594 | // let semaphore1[1-3] to refresh lock on server1 595 | await delay(1000) 596 | expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ 597 | semaphore11.identifier + '_0', 598 | semaphore11.identifier + '_1', 599 | semaphore12.identifier + '_0' 600 | ]) 601 | // 602 | 603 | await Promise.all([semaphore11.release(), semaphore12.release()]) 604 | }) 605 | it('should fail and release if quorum become dead', async function () { 606 | this.timeout(60000) 607 | const onLockLostCallbacks = [1, 2].map(() => 608 | sinon.spy(function (this: RedlockMultiSemaphore) { 609 | expect(this.isAcquired).to.be.false 610 | }) 611 | ) 612 | 613 | const semaphore11 = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { 614 | ...timeoutOptions, 615 | onLockLost: onLockLostCallbacks[0] 616 | }) 617 | const semaphore12 = new RedlockMultiSemaphore(allClients, 'key', 3, 1, { 618 | ...timeoutOptions, 619 | onLockLost: onLockLostCallbacks[1] 620 | }) 621 | await Promise.all([semaphore11.acquire(), semaphore12.acquire()]) 622 | 623 | await downRedisServer(1) 624 | console.log('SHUT DOWN 1') 625 | 626 | await downRedisServer(2) 627 | console.log('SHUT DOWN 2') 628 | 629 | await delay(1000) 630 | 631 | for (const lostCb of onLockLostCallbacks) { 632 | expect(lostCb).to.be.called 633 | expect(lostCb.firstCall.firstArg instanceof LostLockError).to.be.true 634 | } 635 | 636 | // released lock on server3 637 | expect(await client3.zrange('semaphore:key', 0, -1)).to.be.eql([]) 638 | 639 | // semaphore2 will NOT be able to acquire the lock 640 | 641 | const semaphore2 = new RedlockMultiSemaphore( 642 | allClients, 643 | 'key', 644 | 3, 645 | 1, 646 | timeoutOptions 647 | ) 648 | await expect(semaphore2.acquire()).to.be.rejectedWith( 649 | 'Acquire redlock-multi-semaphore semaphore:key timeout' 650 | ) 651 | }) 652 | }) 653 | describe('ioredis-mock support', () => { 654 | it('should acquire and release semaphore', async () => { 655 | const semaphore1 = new RedlockMultiSemaphore(allClientMocks, 'key', 3, 2) 656 | const semaphore2 = new RedlockMultiSemaphore(allClientMocks, 'key', 3, 1) 657 | expect(semaphore1.isAcquired).to.be.false 658 | expect(semaphore2.isAcquired).to.be.false 659 | 660 | await semaphore1.acquire() 661 | expect(semaphore1.isAcquired).to.be.true 662 | await semaphore2.acquire() 663 | expect(semaphore2.isAcquired).to.be.true 664 | 665 | await semaphore1.release() 666 | expect(semaphore1.isAcquired).to.be.false 667 | await semaphore2.release() 668 | expect(semaphore2.isAcquired).to.be.false 669 | }) 670 | }) 671 | }) 672 | -------------------------------------------------------------------------------- /test/src/RedlockMutex.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import RedlockMutex from '../../src/RedlockMutex' 6 | import { TimeoutOptions } from '../../src/types' 7 | import { delay } from '../../src/utils/index' 8 | import { 9 | allClientMocks, 10 | allClients, 11 | client1, 12 | client2, 13 | client3 14 | } from '../redisClient' 15 | import { downRedisServer, upRedisServer } from '../shell' 16 | import { 17 | catchUnhandledRejection, 18 | throwUnhandledRejection, 19 | unhandledRejectionSpy 20 | } from '../unhandledRejection' 21 | 22 | const timeoutOptions: TimeoutOptions = { 23 | lockTimeout: 300, 24 | acquireTimeout: 100, 25 | refreshInterval: 80, 26 | retryInterval: 10 27 | } 28 | 29 | async function expectGetAll( 30 | key: string, 31 | value: string | null, 32 | clients = allClients 33 | ) { 34 | await expect( 35 | Promise.all([clients[0].get(key), clients[1].get(key), clients[2].get(key)]) 36 | ).to.become([value, value, value]) 37 | } 38 | 39 | describe('RedlockMutex', () => { 40 | it('should fail on invalid arguments', () => { 41 | expect(() => new RedlockMutex(null as unknown as Redis[], 'key')).to.throw( 42 | '"clients" array is required' 43 | ) 44 | expect(() => new RedlockMutex(allClients, '')).to.throw('"key" is required') 45 | expect(() => new RedlockMutex(allClients, 1 as unknown as string)).to.throw( 46 | '"key" must be a string' 47 | ) 48 | }) 49 | it('should acquire and release lock', async () => { 50 | const mutex = new RedlockMutex(allClients, 'key') 51 | expect(mutex.isAcquired).to.be.false 52 | 53 | await mutex.acquire() 54 | expect(mutex.isAcquired).to.be.true 55 | await expectGetAll('mutex:key', mutex.identifier) 56 | 57 | await mutex.release() 58 | expect(mutex.isAcquired).to.be.false 59 | await expectGetAll('mutex:key', null) 60 | }) 61 | it('should reject after timeout', async () => { 62 | const mutex1 = new RedlockMutex(allClients, 'key', timeoutOptions) 63 | const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) 64 | await mutex1.acquire() 65 | await expect(mutex2.acquire()).to.be.rejectedWith( 66 | 'Acquire redlock-mutex mutex:key timeout' 67 | ) 68 | await mutex1.release() 69 | await expectGetAll('mutex:key', null) 70 | }) 71 | it('should refresh lock every refreshInterval ms until release', async () => { 72 | const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) 73 | await mutex.acquire() 74 | await delay(400) 75 | await expectGetAll('mutex:key', mutex.identifier) 76 | await mutex.release() 77 | await expectGetAll('mutex:key', null) 78 | }) 79 | it('should stop refreshing if stopped', async () => { 80 | const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) 81 | await mutex.acquire() 82 | mutex.stopRefresh() 83 | await delay(400) 84 | await expectGetAll('mutex:key', null) 85 | }) 86 | it('should support externally acquired mutex (deprecated interface)', async () => { 87 | const externalMutex = new RedlockMutex(allClients, 'key', { 88 | ...timeoutOptions, 89 | refreshInterval: 0 90 | }) 91 | const localMutex = new RedlockMutex(allClients, 'key', { 92 | ...timeoutOptions, 93 | externallyAcquiredIdentifier: externalMutex.identifier 94 | }) 95 | await externalMutex.acquire() 96 | await localMutex.acquire() 97 | await delay(400) 98 | await expectGetAll('mutex:key', localMutex.identifier) 99 | await localMutex.release() 100 | await expectGetAll('mutex:key', null) 101 | }) 102 | it('should support externally acquired mutex', async () => { 103 | const externalMutex = new RedlockMutex(allClients, 'key', { 104 | ...timeoutOptions, 105 | refreshInterval: 0 106 | }) 107 | const localMutex = new RedlockMutex(allClients, 'key', { 108 | ...timeoutOptions, 109 | identifier: externalMutex.identifier, 110 | acquiredExternally: true 111 | }) 112 | await externalMutex.acquire() 113 | await localMutex.acquire() 114 | await delay(400) 115 | await expectGetAll('mutex:key', localMutex.identifier) 116 | await localMutex.release() 117 | await expectGetAll('mutex:key', null) 118 | }) 119 | describe('lost lock case', () => { 120 | beforeEach(() => { 121 | catchUnhandledRejection() 122 | }) 123 | afterEach(() => { 124 | throwUnhandledRejection() 125 | }) 126 | it('should throw unhandled error if lock is lost between refreshes', async () => { 127 | const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) 128 | await mutex.acquire() 129 | await Promise.all([ 130 | client1.set('mutex:key', '222'), // another instance 131 | client2.set('mutex:key', '222'), // another instance 132 | client3.set('mutex:key', '222') // another instance 133 | ]) 134 | await delay(200) 135 | expect(unhandledRejectionSpy).to.be.called 136 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 137 | .to.be.true 138 | }) 139 | it('should call onLockLost callback if provided', async () => { 140 | const onLockLostCallback = sinon.spy(function (this: RedlockMutex) { 141 | expect(this.isAcquired).to.be.false 142 | }) 143 | const mutex = new RedlockMutex(allClients, 'key', { 144 | ...timeoutOptions, 145 | onLockLost: onLockLostCallback 146 | }) 147 | await mutex.acquire() 148 | expect(mutex.isAcquired).to.be.true 149 | await Promise.all([ 150 | client1.set('mutex:key', '222'), // another instance 151 | client2.set('mutex:key', '222'), // another instance 152 | client3.set('mutex:key', '222') // another instance 153 | ]) 154 | await delay(200) 155 | expect(mutex.isAcquired).to.be.false 156 | expect(unhandledRejectionSpy).to.not.called 157 | expect(onLockLostCallback).to.be.called 158 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 159 | .be.true 160 | }) 161 | }) 162 | it('should be reusable', async () => { 163 | const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) 164 | 165 | /* Lifecycle 1 */ 166 | await mutex.acquire() 167 | await delay(100) 168 | await expectGetAll('mutex:key', mutex.identifier) 169 | await mutex.release() 170 | await expectGetAll('mutex:key', null) 171 | await delay(100) 172 | await expectGetAll('mutex:key', null) 173 | 174 | await delay(100) 175 | 176 | /* Lifecycle 2 */ 177 | await mutex.acquire() 178 | await delay(100) 179 | await expectGetAll('mutex:key', mutex.identifier) 180 | await mutex.release() 181 | await expectGetAll('mutex:key', null) 182 | await delay(100) 183 | await expectGetAll('mutex:key', null) 184 | 185 | await delay(100) 186 | 187 | /* Lifecycle 3 */ 188 | await mutex.acquire() 189 | await delay(100) 190 | await expectGetAll('mutex:key', mutex.identifier) 191 | await mutex.release() 192 | await expectGetAll('mutex:key', null) 193 | await delay(100) 194 | await expectGetAll('mutex:key', null) 195 | }) 196 | describe('[Node shutdown]', () => { 197 | afterEach(async () => { 198 | await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) 199 | }) 200 | it('should handle server shutdown if quorum is alive', async function () { 201 | this.timeout(60000) 202 | const mutex1 = new RedlockMutex(allClients, 'key', timeoutOptions) 203 | await mutex1.acquire() 204 | 205 | // 206 | await downRedisServer(1) 207 | console.log('SHUT DOWN 1') 208 | 209 | await delay(1000) 210 | 211 | // lock survive in server2 and server3 212 | // mutex2 will NOT be able to acquire the lock 213 | 214 | const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) 215 | await expect(mutex2.acquire()).to.be.rejectedWith( 216 | 'Acquire redlock-mutex mutex:key timeout' 217 | ) 218 | 219 | // key in server1 has expired now 220 | 221 | await upRedisServer(1) 222 | console.log('ONLINE 1') 223 | 224 | // let mutex1 to refresh lock on server1 225 | await delay(1000) 226 | expect(await client1.get('mutex:key')).to.be.eql(mutex1.identifier) 227 | // 228 | 229 | // 230 | await downRedisServer(2) 231 | console.log('SHUT DOWN 2') 232 | 233 | await delay(1000) 234 | 235 | // lock survive in server1 and server3 236 | // mutex3 will NOT be able to acquire the lock 237 | 238 | const mutex3 = new RedlockMutex(allClients, 'key', timeoutOptions) 239 | await expect(mutex3.acquire()).to.be.rejectedWith( 240 | 'Acquire redlock-mutex mutex:key timeout' 241 | ) 242 | 243 | // key in server2 has expired now 244 | 245 | await upRedisServer(2) 246 | console.log('ONLINE 2') 247 | 248 | // let mutex1 to refresh lock on server2 249 | await delay(1000) 250 | expect(await client2.get('mutex:key')).to.be.eql(mutex1.identifier) 251 | // 252 | 253 | // 254 | await downRedisServer(3) 255 | console.log('SHUT DOWN 3') 256 | 257 | await delay(1000) 258 | 259 | // lock survive in server1 and server2 260 | // mutex4 will NOT be able to acquire the lock 261 | 262 | const mutex4 = new RedlockMutex(allClients, 'key', timeoutOptions) 263 | await expect(mutex4.acquire()).to.be.rejectedWith( 264 | 'Acquire redlock-mutex mutex:key timeout' 265 | ) 266 | 267 | // key in server3 has expired now 268 | 269 | await upRedisServer(3) 270 | console.log('ONLINE 3') 271 | 272 | // let mutex1 to refresh lock on server3 273 | await delay(1000) 274 | expect(await client3.get('mutex:key')).to.be.eql(mutex1.identifier) 275 | // 276 | 277 | await mutex1.release() 278 | }) 279 | it('should fail and release when quorum is become dead', async function () { 280 | this.timeout(60000) 281 | const onLockLostCallback = sinon.spy(function (this: RedlockMutex) { 282 | expect(this.isAcquired).to.be.false 283 | }) 284 | const mutex1 = new RedlockMutex(allClients, 'key', { 285 | ...timeoutOptions, 286 | onLockLost: onLockLostCallback 287 | }) 288 | await mutex1.acquire() 289 | 290 | await downRedisServer(1) 291 | console.log('SHUT DOWN 1') 292 | 293 | await downRedisServer(2) 294 | console.log('SHUT DOWN 2') 295 | 296 | await delay(1000) 297 | 298 | expect(onLockLostCallback).to.be.called 299 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 300 | .be.true 301 | 302 | // released lock on server3 303 | expect(await client3.get('mutex:key')).to.be.eql(null) 304 | 305 | // mutex2 will NOT be able to acquire the lock cause quorum is dead 306 | 307 | const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) 308 | await expect(mutex2.acquire()).to.be.rejectedWith( 309 | 'Acquire redlock-mutex mutex:key timeout' 310 | ) 311 | }) 312 | }) 313 | describe('ioredis-mock support', () => { 314 | it('should acquire and release lock', async () => { 315 | const mutex = new RedlockMutex(allClientMocks, 'key') 316 | expect(mutex.isAcquired).to.be.false 317 | 318 | await mutex.acquire() 319 | console.log('acquired!') 320 | expect(mutex.isAcquired).to.be.true 321 | await expectGetAll('mutex:key', mutex.identifier, allClientMocks) 322 | 323 | await mutex.release() 324 | expect(mutex.isAcquired).to.be.false 325 | await expectGetAll('mutex:key', null, allClientMocks) 326 | }) 327 | }) 328 | }) 329 | -------------------------------------------------------------------------------- /test/src/RedlockSemaphore.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Redis } from 'ioredis' 3 | import sinon from 'sinon' 4 | import LostLockError from '../../src/errors/LostLockError' 5 | import RedlockSemaphore from '../../src/RedlockSemaphore' 6 | import { TimeoutOptions } from '../../src/types' 7 | import { delay } from '../../src/utils/index' 8 | import { 9 | allClientMocks, 10 | allClients, 11 | client1, 12 | client2, 13 | client3 14 | } from '../redisClient' 15 | import { downRedisServer, upRedisServer } from '../shell' 16 | import { 17 | catchUnhandledRejection, 18 | throwUnhandledRejection, 19 | unhandledRejectionSpy 20 | } from '../unhandledRejection' 21 | 22 | const timeoutOptions: TimeoutOptions = { 23 | lockTimeout: 300, 24 | acquireTimeout: 100, 25 | refreshInterval: 80, 26 | retryInterval: 10 27 | } 28 | 29 | async function expectZRangeAllEql(key: string, values: string[]) { 30 | const results = await Promise.all([ 31 | client1.zrange(key, 0, -1), 32 | client2.zrange(key, 0, -1), 33 | client3.zrange(key, 0, -1) 34 | ]) 35 | expect(results).to.be.eql([values, values, values]) 36 | } 37 | 38 | async function expectZRangeAllHaveMembers(key: string, values: string[]) { 39 | const results = await Promise.all([ 40 | client1.zrange(key, 0, -1), 41 | client2.zrange(key, 0, -1), 42 | client3.zrange(key, 0, -1) 43 | ]) 44 | for (const result of results) { 45 | expect(result).to.have.members(values) 46 | } 47 | } 48 | 49 | async function expectZCardAllEql(key: string, count: number) { 50 | const results = await Promise.all([ 51 | client1.zcard(key), 52 | client2.zcard(key), 53 | client3.zcard(key) 54 | ]) 55 | expect(results).to.be.eql([count, count, count]) 56 | } 57 | 58 | describe('RedlockSemaphore', () => { 59 | it('should fail on invalid arguments', () => { 60 | expect( 61 | () => new RedlockSemaphore(null as unknown as Redis[], 'key', 5) 62 | ).to.throw('"clients" array is required') 63 | expect(() => new RedlockSemaphore(allClients, '', 5)).to.throw( 64 | '"key" is required' 65 | ) 66 | expect( 67 | () => new RedlockSemaphore(allClients, 1 as unknown as string, 5) 68 | ).to.throw('"key" must be a string') 69 | expect(() => new RedlockSemaphore(allClients, 'key', 0)).to.throw( 70 | '"limit" is required' 71 | ) 72 | expect( 73 | () => new RedlockSemaphore(allClients, 'key', '10' as unknown as number) 74 | ).to.throw('"limit" must be a number') 75 | }) 76 | it('should acquire and release semaphore', async () => { 77 | const semaphore1 = new RedlockSemaphore(allClients, 'key', 2) 78 | const semaphore2 = new RedlockSemaphore(allClients, 'key', 2) 79 | await semaphore1.acquire() 80 | await semaphore2.acquire() 81 | await expectZRangeAllHaveMembers('semaphore:key', [ 82 | semaphore1.identifier, 83 | semaphore2.identifier 84 | ]) 85 | await semaphore1.release() 86 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) 87 | await semaphore2.release() 88 | await expectZCardAllEql('semaphore:key', 0) 89 | }) 90 | it('should reject after timeout', async () => { 91 | const semaphore1 = new RedlockSemaphore( 92 | allClients, 93 | 'key', 94 | 1, 95 | timeoutOptions 96 | ) 97 | const semaphore2 = new RedlockSemaphore( 98 | allClients, 99 | 'key', 100 | 1, 101 | timeoutOptions 102 | ) 103 | await semaphore1.acquire() 104 | await expect(semaphore2.acquire()).to.be.rejectedWith( 105 | 'Acquire redlock-semaphore semaphore:key timeout' 106 | ) 107 | await semaphore1.release() 108 | await expectZCardAllEql('semaphore:key', 0) 109 | }) 110 | it('should refresh lock every refreshInterval ms until release', async () => { 111 | const semaphore1 = new RedlockSemaphore( 112 | allClients, 113 | 'key', 114 | 2, 115 | timeoutOptions 116 | ) 117 | const semaphore2 = new RedlockSemaphore( 118 | allClients, 119 | 'key', 120 | 2, 121 | timeoutOptions 122 | ) 123 | await semaphore1.acquire() 124 | await semaphore2.acquire() 125 | await delay(400) 126 | await expectZRangeAllHaveMembers('semaphore:key', [ 127 | semaphore1.identifier, 128 | semaphore2.identifier 129 | ]) 130 | await semaphore1.release() 131 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) 132 | await semaphore2.release() 133 | await expectZCardAllEql('semaphore:key', 0) 134 | }) 135 | it('should stop refreshing lock if stopped', async () => { 136 | const semaphore1 = new RedlockSemaphore( 137 | allClients, 138 | 'key', 139 | 2, 140 | timeoutOptions 141 | ) 142 | const semaphore2 = new RedlockSemaphore( 143 | allClients, 144 | 'key', 145 | 2, 146 | timeoutOptions 147 | ) 148 | await semaphore1.acquire() 149 | await semaphore2.acquire() 150 | semaphore1.stopRefresh() 151 | await delay(400) 152 | await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) 153 | semaphore2.stopRefresh() 154 | await delay(400) 155 | await expectZCardAllEql('semaphore:key', 0) 156 | }) 157 | it('should acquire maximum LIMIT semaphores', async () => { 158 | const s = () => 159 | new RedlockSemaphore(allClients, 'key', 3, { 160 | acquireTimeout: 1000, 161 | lockTimeout: 50, 162 | retryInterval: 10, 163 | refreshInterval: 0 // disable refresh 164 | }) 165 | const set1 = [s(), s(), s()] 166 | const pr1 = Promise.all(set1.map(sem => sem.acquire())) 167 | await delay(5) 168 | const set2 = [s(), s(), s()] 169 | const pr2 = Promise.all(set2.map(sem => sem.acquire())) 170 | await pr1 171 | await expectZRangeAllHaveMembers('semaphore:key', [ 172 | set1[0].identifier, 173 | set1[1].identifier, 174 | set1[2].identifier 175 | ]) 176 | await expectZCardAllEql('semaphore:key', 3) 177 | await pr2 178 | await expectZRangeAllHaveMembers('semaphore:key', [ 179 | set2[0].identifier, 180 | set2[1].identifier, 181 | set2[2].identifier 182 | ]) 183 | await expectZCardAllEql('semaphore:key', 3) 184 | }) 185 | it('should support externally acquired semaphore (deprecated interface)', async () => { 186 | const externalSemaphore = new RedlockSemaphore(allClients, 'key', 3, { 187 | ...timeoutOptions, 188 | refreshInterval: 0 189 | }) 190 | const localSemaphore = new RedlockSemaphore(allClients, 'key', 3, { 191 | ...timeoutOptions, 192 | externallyAcquiredIdentifier: externalSemaphore.identifier 193 | }) 194 | await externalSemaphore.acquire() 195 | await localSemaphore.acquire() 196 | await delay(400) 197 | await expectZRangeAllEql('semaphore:key', [localSemaphore.identifier]) 198 | await localSemaphore.release() 199 | await expectZCardAllEql('semaphore:key', 0) 200 | }) 201 | it('should support externally acquired semaphore', async () => { 202 | const externalSemaphore = new RedlockSemaphore(allClients, 'key', 3, { 203 | ...timeoutOptions, 204 | refreshInterval: 0 205 | }) 206 | const localSemaphore = new RedlockSemaphore(allClients, 'key', 3, { 207 | ...timeoutOptions, 208 | identifier: externalSemaphore.identifier, 209 | acquiredExternally: true 210 | }) 211 | await externalSemaphore.acquire() 212 | await localSemaphore.acquire() 213 | await delay(400) 214 | await expectZRangeAllEql('semaphore:key', [localSemaphore.identifier]) 215 | await localSemaphore.release() 216 | await expectZCardAllEql('semaphore:key', 0) 217 | }) 218 | describe('lost lock case', () => { 219 | beforeEach(() => { 220 | catchUnhandledRejection() 221 | }) 222 | afterEach(() => { 223 | throwUnhandledRejection() 224 | }) 225 | it('should throw unhandled error if lock is lost between refreshes', async () => { 226 | const semaphore = new RedlockSemaphore( 227 | allClients, 228 | 'key', 229 | 3, 230 | timeoutOptions 231 | ) 232 | await semaphore.acquire() 233 | await Promise.all(allClients.map(client => client.del('semaphore:key'))) 234 | await Promise.all( 235 | allClients.map(client => 236 | client.zadd( 237 | 'semaphore:key', 238 | Date.now(), 239 | 'aaa', 240 | Date.now(), 241 | 'bbb', 242 | Date.now(), 243 | 'ccc' 244 | ) 245 | ) 246 | ) 247 | await delay(200) 248 | expect(unhandledRejectionSpy).to.be.called 249 | expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) 250 | .to.be.true 251 | }) 252 | it('should call onLockLost callback if provided', async () => { 253 | const onLockLostCallback = sinon.spy(function (this: RedlockSemaphore) { 254 | expect(this.isAcquired).to.be.false 255 | }) 256 | const semaphore = new RedlockSemaphore(allClients, 'key', 3, { 257 | ...timeoutOptions, 258 | onLockLost: onLockLostCallback 259 | }) 260 | await semaphore.acquire() 261 | expect(semaphore.isAcquired).to.be.true 262 | await Promise.all(allClients.map(client => client.del('semaphore:key'))) 263 | await Promise.all( 264 | allClients.map(client => 265 | client.zadd( 266 | 'semaphore:key', 267 | Date.now(), 268 | 'aaa', 269 | Date.now(), 270 | 'bbb', 271 | Date.now(), 272 | 'ccc' 273 | ) 274 | ) 275 | ) 276 | await delay(200) 277 | expect(semaphore.isAcquired).to.be.false 278 | expect(unhandledRejectionSpy).to.not.called 279 | expect(onLockLostCallback).to.be.called 280 | expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to 281 | .be.true 282 | }) 283 | }) 284 | describe('reusable', () => { 285 | it('autorefresh enabled', async () => { 286 | const semaphore1 = new RedlockSemaphore( 287 | allClients, 288 | 'key', 289 | 2, 290 | timeoutOptions 291 | ) 292 | const semaphore2 = new RedlockSemaphore( 293 | allClients, 294 | 'key', 295 | 2, 296 | timeoutOptions 297 | ) 298 | 299 | await semaphore1.acquire() 300 | await semaphore2.acquire() 301 | await delay(100) 302 | await semaphore1.release() 303 | await semaphore2.release() 304 | 305 | await delay(100) 306 | 307 | await semaphore1.acquire() 308 | await semaphore2.acquire() 309 | await delay(100) 310 | await semaphore1.release() 311 | await semaphore2.release() 312 | 313 | await delay(100) 314 | 315 | await semaphore1.acquire() 316 | await semaphore2.acquire() 317 | await delay(100) 318 | await semaphore1.release() 319 | await semaphore2.release() 320 | }) 321 | 322 | it('autorefresh disabled', async () => { 323 | const noRefreshOptions = { 324 | ...timeoutOptions, 325 | refreshInterval: 0, 326 | acquireTimeout: 10 327 | } 328 | const semaphore1 = new RedlockSemaphore( 329 | allClients, 330 | 'key', 331 | 2, 332 | noRefreshOptions 333 | ) 334 | const semaphore2 = new RedlockSemaphore( 335 | allClients, 336 | 'key', 337 | 2, 338 | noRefreshOptions 339 | ) 340 | const semaphore3 = new RedlockSemaphore( 341 | allClients, 342 | 'key', 343 | 2, 344 | noRefreshOptions 345 | ) 346 | 347 | await semaphore1.acquire() 348 | await semaphore2.acquire() 349 | await delay(100) 350 | await semaphore1.release() 351 | await semaphore2.release() 352 | 353 | await delay(100) 354 | 355 | // [0/2] 356 | await semaphore1.acquire() 357 | // [1/2] 358 | await delay(80) 359 | await semaphore2.acquire() 360 | // [2/2] 361 | await expect(semaphore3.acquire()).to.be.rejectedWith( 362 | 'Acquire redlock-semaphore semaphore:key timeout' 363 | ) // rejectes after 10ms 364 | 365 | // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) 366 | // semaphore1 will expire after 300 - 90 = 210ms 367 | await delay(210) 368 | 369 | // [1/2] 370 | await semaphore3.acquire() 371 | }) 372 | }) 373 | describe('[Node shutdown]', () => { 374 | afterEach(async () => { 375 | await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) 376 | }) 377 | it('should handle server shutdown if quorum is alive', async function () { 378 | this.timeout(60000) 379 | const semaphore11 = new RedlockSemaphore( 380 | allClients, 381 | 'key', 382 | 3, 383 | timeoutOptions 384 | ) 385 | const semaphore12 = new RedlockSemaphore( 386 | allClients, 387 | 'key', 388 | 3, 389 | timeoutOptions 390 | ) 391 | const semaphore13 = new RedlockSemaphore( 392 | allClients, 393 | 'key', 394 | 3, 395 | timeoutOptions 396 | ) 397 | await Promise.all([ 398 | semaphore11.acquire(), 399 | semaphore12.acquire(), 400 | semaphore13.acquire() 401 | ]) 402 | 403 | // 404 | await downRedisServer(1) 405 | console.log('SHUT DOWN 1') 406 | 407 | await delay(1000) 408 | 409 | // lock survive in server2 and server3 410 | // semaphore2 will NOT be able to acquire the lock 411 | 412 | const semaphore2 = new RedlockSemaphore( 413 | allClients, 414 | 'key', 415 | 3, 416 | timeoutOptions 417 | ) 418 | await expect(semaphore2.acquire()).to.be.rejectedWith( 419 | 'Acquire redlock-semaphore semaphore:key timeout' 420 | ) 421 | 422 | // key in server1 has expired now 423 | 424 | await upRedisServer(1) 425 | console.log('ONLINE 1') 426 | 427 | // let semaphore1[1-3] to refresh lock on server1 428 | await delay(1000) 429 | expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ 430 | semaphore11.identifier, 431 | semaphore12.identifier, 432 | semaphore13.identifier 433 | ]) 434 | // 435 | 436 | // 437 | await downRedisServer(2) 438 | console.log('SHUT DOWN 2') 439 | 440 | await delay(1000) 441 | 442 | // lock survive in server1 and server3 443 | // semaphore3 will NOT be able to acquire the lock 444 | 445 | const semaphore3 = new RedlockSemaphore( 446 | allClients, 447 | 'key', 448 | 3, 449 | timeoutOptions 450 | ) 451 | await expect(semaphore3.acquire()).to.be.rejectedWith( 452 | 'Acquire redlock-semaphore semaphore:key timeout' 453 | ) 454 | 455 | // key in server2 has expired now 456 | 457 | await upRedisServer(2) 458 | console.log('ONLINE 2') 459 | 460 | // let semaphore1[1-3] to refresh lock on server1 461 | await delay(1000) 462 | expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ 463 | semaphore11.identifier, 464 | semaphore12.identifier, 465 | semaphore13.identifier 466 | ]) 467 | // 468 | 469 | // 470 | await downRedisServer(3) 471 | console.log('SHUT DOWN 3') 472 | 473 | await delay(1000) 474 | 475 | // lock survive in server1 and server2 476 | // semaphore4 will NOT be able to acquire the lock 477 | 478 | const semaphore4 = new RedlockSemaphore( 479 | allClients, 480 | 'key', 481 | 3, 482 | timeoutOptions 483 | ) 484 | await expect(semaphore4.acquire()).to.be.rejectedWith( 485 | 'Acquire redlock-semaphore semaphore:key timeout' 486 | ) 487 | 488 | // key in server1 has expired now 489 | 490 | await upRedisServer(3) 491 | console.log('ONLINE 3') 492 | 493 | // let semaphore1[1-3] to refresh lock on server1 494 | await delay(1000) 495 | expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ 496 | semaphore11.identifier, 497 | semaphore12.identifier, 498 | semaphore13.identifier 499 | ]) 500 | // 501 | 502 | await Promise.all([ 503 | semaphore11.release(), 504 | semaphore12.release(), 505 | semaphore13.release() 506 | ]) 507 | }) 508 | it('should fail and release when quorum become dead', async function () { 509 | this.timeout(60000) 510 | const onLockLostCallbacks = [1, 2, 3].map(() => 511 | sinon.spy(function (this: RedlockSemaphore) { 512 | expect(this.isAcquired).to.be.false 513 | }) 514 | ) 515 | const semaphores1 = [1, 2, 3].map( 516 | (n, i) => 517 | new RedlockSemaphore(allClients, 'key', 3, { 518 | ...timeoutOptions, 519 | onLockLost: onLockLostCallbacks[i] 520 | }) 521 | ) 522 | await Promise.all(semaphores1.map(s => s.acquire())) 523 | 524 | expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ 525 | semaphores1[0].identifier, 526 | semaphores1[1].identifier, 527 | semaphores1[2].identifier 528 | ]) 529 | expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ 530 | semaphores1[0].identifier, 531 | semaphores1[1].identifier, 532 | semaphores1[2].identifier 533 | ]) 534 | expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ 535 | semaphores1[0].identifier, 536 | semaphores1[1].identifier, 537 | semaphores1[2].identifier 538 | ]) 539 | 540 | await downRedisServer(1) 541 | console.log('SHUT DOWN 1') 542 | 543 | await downRedisServer(2) 544 | console.log('SHUT DOWN 2') 545 | 546 | await delay(1000) 547 | 548 | for (const lostCb of onLockLostCallbacks) { 549 | expect(lostCb).to.be.called 550 | expect(lostCb.firstCall.firstArg instanceof LostLockError).to.be.true 551 | } 552 | 553 | // released lock on server3 554 | expect(await client3.zrange('semaphore:key', 0, -1)).to.be.eql([]) 555 | 556 | // semaphore2 will NOT be able to acquire the lock 557 | 558 | const semaphore2 = new RedlockSemaphore( 559 | allClients, 560 | 'key', 561 | 3, 562 | timeoutOptions 563 | ) 564 | await expect(semaphore2.acquire()).to.be.rejectedWith( 565 | 'Acquire redlock-semaphore semaphore:key timeout' 566 | ) 567 | }) 568 | }) 569 | describe('ioredis-mock support', () => { 570 | it('should acquire and release semaphore', async () => { 571 | const semaphore1 = new RedlockSemaphore(allClientMocks, 'key', 2) 572 | const semaphore2 = new RedlockSemaphore(allClientMocks, 'key', 2) 573 | await semaphore1.acquire() 574 | await semaphore2.acquire() 575 | await semaphore1.release() 576 | await semaphore2.release() 577 | }) 578 | }) 579 | }) 580 | -------------------------------------------------------------------------------- /test/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | defaultTimeoutOptions, 4 | MultiSemaphore, 5 | Mutex, 6 | RedlockMultiSemaphore, 7 | RedlockMutex, 8 | RedlockSemaphore, 9 | Semaphore 10 | } from '../../src/index' 11 | 12 | describe('index', () => { 13 | it('should export public API', () => { 14 | expect(Mutex).to.be.ok 15 | expect(Semaphore).to.be.ok 16 | expect(MultiSemaphore).to.be.ok 17 | expect(RedlockMutex).to.be.ok 18 | expect(RedlockSemaphore).to.be.ok 19 | expect(RedlockMultiSemaphore).to.be.ok 20 | expect(defaultTimeoutOptions).to.be.ok 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/src/multiSemaphore/acquire/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { 4 | acquireSemaphore as acquire, 5 | Options 6 | } from '../../../../src/multiSemaphore/acquire/index' 7 | import { client1 as client } from '../../../redisClient' 8 | 9 | const opts = (id: string, overrides?: Partial): Options => ({ 10 | identifier: id, 11 | acquireTimeout: 50, 12 | acquireAttemptsLimit: Number.POSITIVE_INFINITY, 13 | lockTimeout: 100, 14 | retryInterval: 10, 15 | ...overrides 16 | }) 17 | 18 | describe('multiSemaphore acquire', () => { 19 | it('should return true for success acquire', async () => { 20 | const result = await acquire(client, 'key', 1, 1, opts('111')) 21 | expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) 22 | expect(result).to.be.true 23 | }) 24 | it('should return false when timeout', async () => { 25 | const result1 = await acquire(client, 'key', 2, 1, opts('111')) // expire after 100ms 26 | const result2 = await acquire(client, 'key', 2, 1, opts('112')) // expire after 100ms 27 | const result3 = await acquire(client, 'key', 2, 1, opts('113')) // timeout after 50ms 28 | 29 | expect(result1).to.be.true 30 | expect(result2).to.be.true 31 | expect(result3).to.be.false 32 | }) 33 | it('should return false after acquireAttemptsLimit', async () => { 34 | const result1 = await acquire(client, 'key', 2, 1, opts('111')) // expire after 100ms 35 | const result2 = await acquire(client, 'key', 2, 1, opts('112')) // expire after 100ms 36 | const result3 = await acquire( 37 | client, 38 | 'key', 39 | 2, 40 | 1, 41 | opts('113', { 42 | acquireAttemptsLimit: 1, 43 | acquireTimeout: Number.POSITIVE_INFINITY 44 | }) 45 | ) // no timeout, attempt limit = 1 46 | 47 | expect(result1).to.be.true 48 | expect(result2).to.be.true 49 | expect(result3).to.be.false 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /test/src/multiSemaphore/acquire/internal.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { acquireLua } from '../../../../src/multiSemaphore/acquire/lua' 4 | import { client1 as client } from '../../../redisClient' 5 | 6 | interface Options { 7 | identifier: string 8 | lockTimeout: number 9 | now: number 10 | } 11 | 12 | const opts = (id: string, nowOffset = 0): Options => ({ 13 | identifier: id, 14 | lockTimeout: 500, 15 | now: new Date().getTime() + nowOffset 16 | }) 17 | 18 | async function acquire(options: Options) { 19 | const { identifier, lockTimeout, now } = options 20 | return await acquireLua(client, ['key', 1, 1, identifier, lockTimeout, now]) 21 | } 22 | 23 | describe('multiSemaphore acquire internal', () => { 24 | it('should return 1 for success acquire', async () => { 25 | const result = await acquire(opts('111')) 26 | expect(result).to.be.eql(1) 27 | expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) 28 | }) 29 | it('should return 0 for failure acquire', async () => { 30 | const result1 = await acquire(opts('111')) 31 | const result2 = await acquire(opts('112')) 32 | expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) 33 | expect(result1).to.be.eql(1) 34 | expect(result2).to.be.eql(0) 35 | }) 36 | describe('TIME SHIFT case', () => { 37 | it('should handle time difference less than lockTimeout (nodeA has faster clocks)', async () => { 38 | // lockTimeout = 500ms 39 | // nodeA is for 450ms faster than nodeB 40 | const resultA = await acquire(opts('111', 450)) 41 | const resultB = await acquire(opts('112', 0)) 42 | expect(resultA).to.be.eql(1) 43 | expect(resultB).to.be.eql(0) 44 | }) 45 | it('should handle time difference less than lockTimeout (nodeA has slower clocks)', async () => { 46 | // lockTimeout = 500ms 47 | // nodeB is for 450ms faster than nodeA 48 | const resultA = await acquire(opts('111', 0)) 49 | const resultB = await acquire(opts('112', 450)) 50 | expect(resultA).to.be.eql(1) 51 | expect(resultB).to.be.eql(0) 52 | }) 53 | it('cant handle time difference greater than lockTimeout (nodeA has slower clocks)', async () => { 54 | // lockTimeout = 500ms 55 | // nodeB is for 550ms faster than nodeA 56 | const resultA = await acquire(opts('111', 0)) 57 | const resultB = await acquire(opts('112', 550)) 58 | expect(resultA).to.be.eql(1) 59 | expect(resultB).to.be.eql(1) // Semaphore stealed... 60 | 61 | // This happens due removing "expired" nodeA lock (at nodeB "now" nodeA lock has been expired 50ms ago) 62 | // Unfortunatelly "fair" semaphore described here 63 | // https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/6-3-1-building-a-basic-counting-semaphore/ 64 | // also has the same problem 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/src/multiSemaphore/refresh/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { 4 | Options, 5 | refreshSemaphore as refresh 6 | } from '../../../../src/multiSemaphore/refresh/index' 7 | import { client1 as client } from '../../../redisClient' 8 | 9 | const opts = (id: string): Options => ({ 10 | identifier: id, 11 | lockTimeout: 100 12 | }) 13 | 14 | describe('multiSemaphore refresh', () => { 15 | it('should return false if resource is already acquired', async () => { 16 | const now = '' + (Date.now() - 10) 17 | await client.zadd('key', now, '222', now, '333', now, '444') 18 | const result = await refresh(client, 'key', 3, 2, opts('111')) 19 | expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) 20 | expect(result).to.be.false 21 | }) 22 | it('should return false if resource is already acquired, but some expired', async () => { 23 | const now = '' + (Date.now() - 10) 24 | const oldNow = '' + (Date.now() - 10000) 25 | await client.zadd('key', oldNow, '222', oldNow, '333', now, '444') 26 | expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) 27 | const result = await refresh(client, 'key', 3, 2, opts('111')) 28 | expect(await client.zrange('key', 0, -1)).to.be.eql(['444']) 29 | expect(result).to.be.false 30 | }) 31 | it('should return false if resource is not acquired', async () => { 32 | const result = await refresh(client, 'key', 3, 2, opts('111')) 33 | expect(await client.zrange('key', 0, -1)).to.be.eql([]) 34 | expect(result).to.be.false 35 | }) 36 | it('should return true for success refresh', async () => { 37 | const now = '' + (Date.now() - 10) 38 | await client.zadd('key', now, '111_0', now, '111_1', now, '333') 39 | expect(await client.zrange('key', 0, -1)).to.be.eql([ 40 | '111_0', 41 | '111_1', 42 | '333' 43 | ]) 44 | const result = await refresh(client, 'key', 3, 2, opts('111')) 45 | expect(await client.zrange('key', 0, -1)).to.be.eql([ 46 | '333', 47 | '111_0', 48 | '111_1' 49 | ]) 50 | expect(result).to.be.true 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/src/multiSemaphore/release/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { releaseSemaphore as release } from '../../../../src/multiSemaphore/release/index' 4 | import { client1 as client } from '../../../redisClient' 5 | 6 | describe('multiSemaphore release', () => { 7 | it('should remove key after success release', async () => { 8 | await client.zadd('key', '' + Date.now(), '111_0') 9 | expect(await client.zcard('key')).to.be.eql(1) 10 | await release(client, 'key', 1, '111') 11 | expect(await client.zcard('key')).to.be.eql(0) 12 | }) 13 | it('should do nothing if resource is not locked', async () => { 14 | expect(await client.zcard('key')).to.be.eql(0) 15 | await release(client, 'key', 1, '111') 16 | expect(await client.zcard('key')).to.be.eql(0) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /test/src/mutex/acquire.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { acquireMutex as acquire, Options } from '../../../src/mutex/acquire' 4 | import { client1 as client } from '../../redisClient' 5 | 6 | const opts = (id: string, overrides?: Partial): Options => ({ 7 | identifier: id, 8 | acquireTimeout: 50, 9 | acquireAttemptsLimit: Number.POSITIVE_INFINITY, 10 | lockTimeout: 100, 11 | retryInterval: 10, 12 | ...overrides 13 | }) 14 | 15 | describe('mutex acquire', () => { 16 | it('should return true for success lock', async () => { 17 | const result = await acquire(client, 'key', opts('111')) 18 | expect(result).to.be.true 19 | }) 20 | it('should return false when timeout', async () => { 21 | const result1 = await acquire(client, 'key', opts('111')) 22 | const result2 = await acquire(client, 'key', opts('222')) 23 | expect(result1).to.be.true 24 | expect(result2).to.be.false 25 | }) 26 | it('should return false after acquireAttemptsLimit', async () => { 27 | const result1 = await acquire(client, 'key', opts('111')) 28 | const result2 = await acquire( 29 | client, 30 | 'key', 31 | opts('222', { 32 | acquireAttemptsLimit: 1, 33 | acquireTimeout: Number.POSITIVE_INFINITY 34 | }) 35 | ) 36 | expect(result1).to.be.true 37 | expect(result2).to.be.false 38 | }) 39 | it('should set identifier for key', async () => { 40 | await acquire(client, 'key1', opts('111')) 41 | const value = await client.get('key1') 42 | expect(value).to.be.eql('111') 43 | }) 44 | it('should set TTL for key', async () => { 45 | await acquire(client, 'key2', opts('111')) 46 | const ttl = await client.pttl('key2') 47 | expect(ttl).to.be.gte(90) 48 | expect(ttl).to.be.lte(100) 49 | }) 50 | it('should wait for auto-release', async () => { 51 | const start1 = Date.now() 52 | await acquire(client, 'key', opts('111')) 53 | const start2 = Date.now() 54 | await acquire(client, 'key', opts('222')) 55 | const now = Date.now() 56 | expect(start2 - start1).to.be.gte(0) 57 | expect(start2 - start1).to.be.lt(10) 58 | expect(now - start1).to.be.gte(50) 59 | expect(now - start2).to.be.gte(50) 60 | }) 61 | it('should wait per key', async () => { 62 | const start1 = Date.now() 63 | await Promise.all([ 64 | acquire(client, 'key1', opts('a1')), 65 | acquire(client, 'key2', opts('a2')) 66 | ]) 67 | const start2 = Date.now() 68 | await Promise.all([ 69 | acquire(client, 'key1', opts('b1')), 70 | acquire(client, 'key2', opts('b2')) 71 | ]) 72 | const now = Date.now() 73 | expect(start2 - start1).to.be.gte(0) 74 | expect(start2 - start1).to.be.lt(10) 75 | expect(now - start1).to.be.gte(50) 76 | expect(now - start2).to.be.gte(50) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /test/src/mutex/refresh.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { refreshMutex as refresh } from '../../../src/mutex/refresh' 4 | import { client1 as client } from '../../redisClient' 5 | 6 | describe('mutex refresh', () => { 7 | it('should return false if resource is already acquired by different instance', async () => { 8 | await client.set('key', '222') 9 | const result = await refresh(client, 'key', '111', 10000) 10 | expect(result).to.be.false 11 | }) 12 | it('should return false if resource is not acquired', async () => { 13 | const result = await refresh(client, 'key', '111', 10000) 14 | expect(result).to.be.false 15 | }) 16 | it('should return true for success refresh', async () => { 17 | await client.set('key', '111') 18 | const result = await refresh(client, 'key', '111', 20000) 19 | expect(result).to.be.true 20 | expect(await client.pttl('key')).to.be.gte(10000) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/src/mutex/release.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { releaseMutex as release } from '../../../src/mutex/release' 4 | import { client1 as client } from '../../redisClient' 5 | 6 | describe('Mutex release', () => { 7 | it('should remove key after release', async () => { 8 | await client.set('key', '111') 9 | await release(client, 'key', '111') 10 | expect(await client.get('key')).to.be.eql(null) 11 | }) 12 | it('should do nothing if resource is not locked', async () => { 13 | expect(await client.get('key')).to.be.eql(null) 14 | await release(client, 'key', '111') 15 | expect(await client.get('key')).to.be.eql(null) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/src/redlockMutex/acquire.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { 4 | acquireRedlockMutex as acquire, 5 | Options 6 | } from '../../../src/redlockMutex/acquire' 7 | import { allClients } from '../../redisClient' 8 | 9 | const opts = (id: string, overrides?: Partial): Options => ({ 10 | identifier: id, 11 | acquireTimeout: 50, 12 | acquireAttemptsLimit: Number.POSITIVE_INFINITY, 13 | lockTimeout: 100, 14 | retryInterval: 10, 15 | ...overrides 16 | }) 17 | 18 | describe('redlockMutex acquire', () => { 19 | it('should return true for success lock', async () => { 20 | const result = await acquire(allClients, 'key', opts('111')) 21 | expect(result).to.be.true 22 | }) 23 | it('should return false when timeout', async () => { 24 | const result1 = await acquire(allClients, 'key', opts('111')) 25 | const result2 = await acquire(allClients, 'key', opts('222')) 26 | expect(result1).to.be.true 27 | expect(result2).to.be.false 28 | }) 29 | it('should return false after acquireAttemptsLimit', async () => { 30 | const result1 = await acquire(allClients, 'key', opts('111')) 31 | const result2 = await acquire( 32 | allClients, 33 | 'key', 34 | opts('222', { 35 | acquireAttemptsLimit: 1, 36 | acquireTimeout: Number.POSITIVE_INFINITY 37 | }) 38 | ) 39 | expect(result1).to.be.true 40 | expect(result2).to.be.false 41 | }) 42 | it('should set identifier for key', async () => { 43 | await acquire(allClients, 'key1', opts('111')) 44 | const values = await Promise.all( 45 | allClients.map(client => client.get('key1')) 46 | ) 47 | expect(values).to.be.eql(['111', '111', '111']) 48 | }) 49 | it('should set TTL for key', async () => { 50 | await acquire(allClients, 'key2', opts('111')) 51 | const ttls = await Promise.all( 52 | allClients.map(client => client.pttl('key2')) 53 | ) 54 | for (const ttl of ttls) { 55 | if (ttl === -2) { 56 | continue 57 | } 58 | expect(ttl).to.be.gte(90) 59 | expect(ttl).to.be.lte(100) 60 | } 61 | }) 62 | it('should wait for auto-release', async () => { 63 | const start1 = Date.now() 64 | await acquire(allClients, 'key', opts('111')) 65 | const start2 = Date.now() 66 | await acquire(allClients, 'key', opts('222')) 67 | const now = Date.now() 68 | expect(start2 - start1).to.be.gte(0) 69 | expect(start2 - start1).to.be.lt(10) 70 | expect(now - start1).to.be.gte(50) 71 | expect(now - start2).to.be.gte(50) 72 | }) 73 | it('should wait per key', async () => { 74 | const start1 = Date.now() 75 | await Promise.all([ 76 | acquire(allClients, 'key1', opts('a1')), 77 | acquire(allClients, 'key2', opts('a2')) 78 | ]) 79 | const start2 = Date.now() 80 | await Promise.all([ 81 | acquire(allClients, 'key1', opts('b1')), 82 | acquire(allClients, 'key2', opts('b2')) 83 | ]) 84 | const now = Date.now() 85 | expect(start2 - start1).to.be.gte(0) 86 | expect(start2 - start1).to.be.lt(10) 87 | expect(now - start1).to.be.gte(50) 88 | expect(now - start2).to.be.gte(50) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/src/redlockMutex/refresh.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { refreshRedlockMutex as refresh } from '../../../src/redlockMutex/refresh' 4 | import { allClients, client1, client2, client3 } from '../../redisClient' 5 | 6 | describe('redlockMutex refresh', () => { 7 | it('should return false if resource is acquired by different instance on quorum', async () => { 8 | await client1.set('key', '111') 9 | await client2.set('key', '222') 10 | await client3.set('key', '222') 11 | const result = await refresh(allClients, 'key', '111', 10000) 12 | expect(result).to.be.false 13 | }) 14 | it('should return true if resource is acquired on quorum', async () => { 15 | await client1.set('key', '111') 16 | await client2.set('key', '111') 17 | const result = await refresh(allClients, 'key', '111', 20000) 18 | expect(result).to.be.true 19 | expect(await client1.pttl('key')).to.be.gte(10000) 20 | expect(await client2.pttl('key')).to.be.gte(10000) 21 | }) 22 | it('should return false if resource is not acquired on quorum', async () => { 23 | await client1.set('key', '111') 24 | const result = await refresh(allClients, 'key', '111', 10000) 25 | expect(result).to.be.false 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/src/redlockMutex/release.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { releaseRedlockMutex as release } from '../../../src/redlockMutex/release' 4 | import { allClients, client1 } from '../../redisClient' 5 | 6 | describe('redlockMutex release', () => { 7 | it('should remove key after release', async () => { 8 | await client1.set('key', '111') 9 | await release(allClients, 'key', '111') 10 | expect(await client1.get('key')).to.be.eql(null) 11 | }) 12 | it('should do nothing if resource is not locked', async () => { 13 | expect(await client1.get('key')).to.be.eql(null) 14 | await release(allClients, 'key', '111') 15 | expect(await client1.get('key')).to.be.eql(null) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/src/semaphore/acquire/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { 4 | acquireSemaphore as acquire, 5 | Options 6 | } from '../../../../src/semaphore/acquire/index' 7 | import { client1 as client } from '../../../redisClient' 8 | 9 | const opts = (id: string, overrides?: Partial): Options => ({ 10 | identifier: id, 11 | acquireTimeout: 50, 12 | acquireAttemptsLimit: Number.POSITIVE_INFINITY, 13 | lockTimeout: 100, 14 | retryInterval: 10, 15 | ...overrides 16 | }) 17 | 18 | describe('semaphore acquire', () => { 19 | it('should return true for success acquire', async () => { 20 | const result = await acquire(client, 'key', 1, opts('111')) 21 | expect(result).to.be.true 22 | }) 23 | it('should return false when timeout', async () => { 24 | const result1 = await acquire(client, 'key', 2, opts('111')) // expire after 100ms 25 | const result2 = await acquire(client, 'key', 2, opts('112')) // expire after 100ms 26 | const result3 = await acquire(client, 'key', 2, opts('113')) // timeout after 50ms 27 | 28 | expect(result1).to.be.true 29 | expect(result2).to.be.true 30 | expect(result3).to.be.false 31 | }) 32 | it('should return false after acquireAttemptsLimit', async () => { 33 | const result1 = await acquire(client, 'key', 2, opts('111')) // expire after 100ms 34 | const result2 = await acquire(client, 'key', 2, opts('112')) // expire after 100ms 35 | const result3 = await acquire( 36 | client, 37 | 'key', 38 | 2, 39 | opts('113', { 40 | acquireAttemptsLimit: 1, 41 | acquireTimeout: Number.POSITIVE_INFINITY 42 | }) 43 | ) // no timeout, acquire limit = 1 44 | 45 | expect(result1).to.be.true 46 | expect(result2).to.be.true 47 | expect(result3).to.be.false 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/src/semaphore/acquire/internal.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { acquireLua } from '../../../../src/semaphore/acquire/lua' 4 | import { client1 as client } from '../../../redisClient' 5 | 6 | interface Options { 7 | identifier: string 8 | lockTimeout: number 9 | now: number 10 | } 11 | 12 | const opts = (id: string, nowOffset = 0): Options => ({ 13 | identifier: id, 14 | lockTimeout: 500, 15 | now: new Date().getTime() + nowOffset 16 | }) 17 | 18 | async function acquire(options: Options) { 19 | const { identifier, lockTimeout, now } = options 20 | return await acquireLua(client, ['key', 1, identifier, lockTimeout, now]) 21 | } 22 | 23 | describe('semaphore acquire internal', () => { 24 | it('should return 1 for success acquire', async () => { 25 | const result = await acquire(opts('111')) 26 | expect(result).to.be.eql(1) 27 | }) 28 | it('should return 0 for failure acquire', async () => { 29 | const result1 = await acquire(opts('111')) 30 | const result2 = await acquire(opts('112')) 31 | expect(result1).to.be.eql(1) 32 | expect(result2).to.be.eql(0) 33 | }) 34 | describe('TIME SHIFT case', () => { 35 | it('should handle time difference less than lockTimeout (nodeA has faster clocks)', async () => { 36 | // lockTimeout = 500ms 37 | // nodeA is for 450ms faster than nodeB 38 | const resultA = await acquire(opts('111', 450)) 39 | const resultB = await acquire(opts('112', 0)) 40 | expect(resultA).to.be.eql(1) 41 | expect(resultB).to.be.eql(0) 42 | }) 43 | it('should handle time difference less than lockTimeout (nodeA has slower clocks)', async () => { 44 | // lockTimeout = 500ms 45 | // nodeB is for 450ms faster than nodeA 46 | const resultA = await acquire(opts('111', 0)) 47 | const resultB = await acquire(opts('112', 450)) 48 | expect(resultA).to.be.eql(1) 49 | expect(resultB).to.be.eql(0) 50 | }) 51 | it('cant handle time difference greater than lockTimeout (nodeA has slower clocks)', async () => { 52 | // lockTimeout = 500ms 53 | // nodeB is for 550ms faster than nodeA 54 | const resultA = await acquire(opts('111', 0)) 55 | const resultB = await acquire(opts('112', 550)) 56 | expect(resultA).to.be.eql(1) 57 | expect(resultB).to.be.eql(1) // Semaphore stealed... 58 | 59 | // This happens due removing "expired" nodeA lock (at nodeB "now" nodeA lock has been expired 50ms ago) 60 | // Unfortunatelly "fair" semaphore described here 61 | // https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/6-3-1-building-a-basic-counting-semaphore/ 62 | // also has the same problem 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/src/semaphore/refresh/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { 4 | Options, 5 | refreshSemaphore as refresh 6 | } from '../../../../src/semaphore/refresh/index' 7 | import { client1 as client } from '../../../redisClient' 8 | 9 | const opts = (id: string): Options => ({ 10 | identifier: id, 11 | lockTimeout: 100 12 | }) 13 | 14 | describe('semaphore refresh', () => { 15 | it('should return false if resource is already acquired', async () => { 16 | const now = '' + (Date.now() - 10) 17 | await client.zadd('key', now, '222', now, '333', now, '444') 18 | const result = await refresh(client, 'key', 3, opts('111')) 19 | expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) 20 | expect(result).to.be.false 21 | }) 22 | it('should return false if resource is already acquired, but some expired', async () => { 23 | const now = '' + (Date.now() - 10) 24 | const oldNow = '' + (Date.now() - 10000) 25 | await client.zadd('key', oldNow, '222', now, '333', now, '444') 26 | expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) 27 | const result = await refresh(client, 'key', 3, opts('111')) 28 | expect(await client.zrange('key', 0, -1)).to.be.eql(['333', '444']) 29 | expect(result).to.be.false 30 | }) 31 | it('should return false if resource is not acquired', async () => { 32 | const result = await refresh(client, 'key', 3, opts('111')) 33 | expect(await client.zrange('key', 0, -1)).to.be.eql([]) 34 | expect(result).to.be.false 35 | }) 36 | it('should return true for success refresh', async () => { 37 | const now = '' + (Date.now() - 10) 38 | await client.zadd('key', now, '111', now, '222', now, '333') 39 | expect(await client.zrange('key', 0, -1)).to.be.eql(['111', '222', '333']) 40 | const result = await refresh(client, 'key', 3, opts('111')) 41 | expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '111']) 42 | expect(result).to.be.true 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/src/semaphore/release.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { releaseSemaphore as release } from '../../../src/semaphore/release' 4 | import { client1 as client } from '../../redisClient' 5 | 6 | describe('semaphore release', () => { 7 | it('should remove key after success release', async () => { 8 | await client.zadd('key', '' + Date.now(), '111') 9 | expect(await client.zcard('key')).to.be.eql(1) 10 | await release(client, 'key', '111') 11 | expect(await client.zcard('key')).to.be.eql(0) 12 | }) 13 | it('should do nothing if resource is not locked', async () => { 14 | expect(await client.zcard('key')).to.be.eql(0) 15 | await release(client, 'key', '111') 16 | expect(await client.zcard('key')).to.be.eql(0) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /test/src/utils/eval.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { createEval } from '../../../src/utils/index' 4 | import { client1 as client } from '../../redisClient' 5 | 6 | describe('utils createEval', () => { 7 | it('should return function', async () => { 8 | expect(createEval('return 5', 0)).to.be.a('function') 9 | }) 10 | it('should call evalsha or fallback to eval', async () => { 11 | const now = Date.now() 12 | const SCRIPT = `return ${now}` 13 | const execScript = createEval(SCRIPT, 0) 14 | const result = await execScript(client, []) 15 | expect(result).to.be.eql(now) 16 | expect(Date.now() - now).to.be.lt(50) 17 | }) 18 | it('should handle eval errors', async () => { 19 | const execScript = createEval('return asdfkasjdf', 0) 20 | await expect(execScript(client, [])).to.be.rejected 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/src/utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import Redis from 'ioredis' 3 | import { getConnectionName } from '../../../src/utils/index' 4 | import { client1 } from '../../redisClient' 5 | 6 | describe('utils getConnectionName', () => { 7 | it('should return connection name', async () => { 8 | expect(getConnectionName(client1)).to.be.eql('') 9 | }) 10 | it('should return unknown if connection name not configured', () => { 11 | const client = new Redis('redis://127.0.0.1:6000', { 12 | lazyConnect: true, 13 | enableOfflineQueue: false, 14 | autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) 15 | maxRetriesPerRequest: 0 // dont retry, fail faster (default is 20) 16 | }) 17 | expect(getConnectionName(client)).to.be.eql('') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/src/utils/redlock.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { getQuorum } from '../../../src/utils/redlock' 4 | 5 | describe('redlockMutex utils', () => { 6 | describe('getQuorum', () => { 7 | function makeTest(count: number, expectedResult: number) { 8 | it(`should return valid majority for ${count} nodes`, () => { 9 | expect(getQuorum(count)).to.be.eql(expectedResult) 10 | expect(getQuorum(2)).to.be.eql(2) 11 | expect(getQuorum(3)).to.be.eql(2) 12 | expect(getQuorum(4)).to.be.eql(3) 13 | expect(getQuorum(5)).to.be.eql(3) 14 | expect(getQuorum(6)).to.be.eql(4) 15 | expect(getQuorum(7)).to.be.eql(4) 16 | expect(getQuorum(8)).to.be.eql(5) 17 | expect(getQuorum(9)).to.be.eql(5) 18 | }) 19 | } 20 | // makeTest(0, 1) 21 | makeTest(1, 1) 22 | makeTest(2, 2) 23 | makeTest(3, 2) 24 | makeTest(4, 3) 25 | makeTest(5, 3) 26 | makeTest(6, 4) 27 | makeTest(7, 4) 28 | makeTest(8, 5) 29 | makeTest(9, 5) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/unhandledRejection.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | 3 | function throwReason(reason: any) { 4 | console.log('unhandled rejection:', reason) 5 | throw reason 6 | } 7 | 8 | export const unhandledRejectionSpy = sinon.spy() 9 | 10 | export function catchUnhandledRejection() { 11 | unhandledRejectionSpy.resetHistory() 12 | process.removeListener('unhandledRejection', throwReason) 13 | process.on('unhandledRejection', unhandledRejectionSpy) 14 | } 15 | 16 | export function throwUnhandledRejection() { 17 | process.removeListener('unhandledRejection', unhandledRejectionSpy) 18 | process.on('unhandledRejection', throwReason) 19 | } 20 | 21 | export function init() { 22 | process.on('unhandledRejection', throwReason) 23 | } 24 | 25 | export function removeAllListeners() { 26 | process.removeListener('unhandledRejection', unhandledRejectionSpy) 27 | process.removeListener('unhandledRejection', throwReason) 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.build-commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["node_modules", "*.test.ts"], 5 | "compilerOptions": { 6 | "module": "CommonJS", 7 | "outDir": "lib" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build-es.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["node_modules", "*.test.ts"], 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "outDir": "es" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES6"], 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "Node", 8 | "sourceMap": true, 9 | "strict": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------