Repository: swarthy/redis-semaphore Branch: master Commit: 63c2d013a318 Files: 90 Total size: 183.5 KB Directory structure: gitextract_gpfdol5s/ ├── .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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codeclimate.yml ================================================ version: '2' plugins: eslint: enabled: true channel: 'eslint-6' ================================================ FILE: .fossa.yml ================================================ # Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) # Visit https://fossa.com to learn more version: 2 cli: server: https://app.fossa.com fetcher: custom project: git@github.com:swarthy/redis-semaphore.git analyze: modules: - name: . type: npm target: . path: . ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: interval: 'monthly' ================================================ FILE: .github/workflows/branches.yml ================================================ name: CI (push) on: push: branches: - master workflow_dispatch: jobs: integration-test: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, 22.x, 24.x] env: COVERALLS_REPO_TOKEN: '${{ secrets.COVERALLS_REPO_TOKEN }}' COVERALLS_GIT_BRANCH: '${{ github.ref }}' steps: - uses: actions/checkout@v4 - name: Enable Corepack run: corepack enable - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'yarn' - run: yarn install --immutable - run: docker compose up -d redis1 redis2 redis3 - run: docker compose run waiter - run: yarn build - run: yarn lint - run: yarn test-ci-with-coverage ================================================ FILE: .github/workflows/pull-requests.yml ================================================ name: CI (PR) on: pull_request: branches: - master workflow_dispatch: jobs: integration-test: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, 22.x, 24.x] steps: - uses: actions/checkout@v4 - name: Enable Corepack run: corepack enable - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'yarn' - run: yarn install --immutable - run: docker compose up -d redis1 redis2 redis3 - run: docker compose run waiter - run: yarn build - run: yarn lint - run: yarn test ================================================ FILE: .gitignore ================================================ # Logs logs *.log # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # Yarn .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/sdks !.yarn/versions # dotenv environment variables file .env es lib /.idea/ ================================================ FILE: .mocharc.yaml ================================================ extension: ts recursive: true timeout: 5s require: - '@swc-node/register' - test/setup.ts ================================================ FILE: .prettierrc ================================================ { "semi": false, "singleQuote": true, "trailingComma": "none", "arrowParens": "avoid" } ================================================ FILE: .snyk ================================================ # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. version: v1.13.1 ignore: {} patch: {} ================================================ FILE: .yarnrc.yml ================================================ nodeLinker: node-modules defaultSemverRangePrefix: '' ================================================ FILE: CHANGELOG.md ================================================ ### redis-semaphore@5.7.0 - Added `AbortSignal` to acquire ### redis-semaphore@5.6.2 - Fixed implicit import from `src` - Removed `src` folder from NPM package ### redis-semaphore@5.6.1 - Removed `module` field from `package.json` ### redis-semaphore@5.6.0 - Added interface compatible client support (ex. `ioredis-mock`) - Removed `instanceof Redis` validation in constructor - `ioredis` marked as optional peerDependency, explicit `ioredis` install is required now ### redis-semaphore@5.5.1 - Fix race condition for refresh started before release and finished after release ### redis-semaphore@5.5.0 - Added `identifier` constructor option. - Added `acquiredExternally` constructor option. - Option `externallyAcquiredIdentifier` **DEPRECATED**. - Option `identifierSuffix` **DEPRECATED**. ### redis-semaphore@5.4.0 - Added `identifierSuffix` option, usefull for tracing app instance which locked resource ### redis-semaphore@5.3.1 - Fixed reacquire expired resource in refresh ### redis-semaphore@5.3.0 - Added `stopRefresh` method - Added `externallyAcquiredIdentifier` optional constructor option - Removed `uuid` dependency ### redis-semaphore@5.2.0 - Added `acquireAttemptsLimit` method ### redis-semaphore@5.1.0 - Added `tryAcquire` ### redis-semaphore@5.0.0 - **Breadking change:** Drop Node.js v10.x, v12.x support - Added `ioredis@5` support ### redis-semaphore@4.1.0 - Added `.isAcquired` property on all locks - Added `onLostLock` constructor option. By default throws unhandled error. ### redis-semaphore@4.0.0 - **Breaking change:** `Mutex`, `Semaphore`, `MultiSemaphore` not longer support `Cluster`. For multi-node case use `Redlock*` instead. - Added `RedlockMutex`, `RedlockSemaphore`, `RedlockMultiSemaphore` - Internals refactored ### redis-semaphore@3.2.0 - Added `MultiSemaphore` ### redis-semaphore@3.0.0 - **Breaking change:** `FairSemaphore` has been removed. Use `Semaphore` instead (has the same "fairness") - 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. - Internal code has been cleaned up - Added more test, include synthetic node unsynchroned clocks ================================================ FILE: Dockerfile ================================================ FROM node:alpine RUN npm i -g @swarthy/wait-for@2.0.2 VOLUME /app WORKDIR /app USER node ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Alexander Mochalin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # redis-semaphore [![NPM version][npm-image]][npm-url] [![Build status][ci-image]][ci-url] ![FOSSA Status][typescript-image] [![Coverage Status][coverage-image]][coverage-url] [![Maintainability][codeclimate-image]][codeclimate-url] [![Known Vulnerabilities][snyk-image]][snyk-url] [![FOSSA Status][fossa-badge-image]][fossa-badge-url] [Mutex]() and [Semaphore]() implementations based on [Redis](https://redis.io/) ready for distributed systems ## Features - Fail-safe (all actions performed by LUA scripts (atomic)) ## Usage ### Installation ```bash npm install --save redis-semaphore ioredis # or yarn add redis-semaphore ioredis ``` ioredis is the officially supported Redis client. This library's test code runs on it. Users of other Redis clients should ensure ioredis-compatible API (see src/types.ts) when creating lock objects. ### Mutex > 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/) ##### new Mutex(redisClient, key [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8, identifier = crypto.randomUUID() }]) - `redisClient` - **required**, configured `redis` client - `key` - **required**, key for locking resource (final key in redis: `mutex:`) - `options` - _optional_ - `lockTimeout` - _optional_ ms, time after mutex will be auto released (expired) - `acquireTimeout` - _optional_ ms, max timeout for `.acquire()` call - `acquireAttemptsLimit` - _optional_ max number of attempts to be made in `.acquire()` call - `retryInterval` - _optional_ ms, time between acquire attempts if resource locked - `refreshInterval` - _optional_ ms, auto-refresh interval; to disable auto-refresh behaviour set `0` - `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). - `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. - `onLockLost` - _optional_ function, called when lock loss is detected due refresh cycle; default onLockLost throws unhandled LostLockError #### Example ```javascript const Mutex = require('redis-semaphore').Mutex const Redis = require('ioredis') // TypeScript // import { Mutex } from 'redis-semaphore' // import Redis from 'ioredis' const redisClient = new Redis() async function doSomething() { const mutex = new Mutex(redisClient, 'lockingResource') await mutex.acquire() try { // critical code } finally { await mutex.release() } } ``` #### Example with lost lock handling ```javascript async function doSomething() { const mutex = new Mutex(redisClient, 'lockingResource', { // By default onLockLost throws unhandled LostLockError onLockLost(err) { console.error(err) } }) await mutex.acquire() try { while (mutex.isAcquired) { // critical cycle iteration } } finally { // It's safe to always call release, because if lock is no longer belongs to this mutex, .release() will have no effect await mutex.release() } } ``` #### Example with optional lock ```javascript async function doSomething() { const mutex = new Mutex(redisClient, 'lockingResource', { acquireAttemptsLimit: 1 }) const lockAcquired = await mutex.tryAcquire() if (!lockAcquired) { return } try { while (mutex.isAcquired) { // critical cycle iteration } } finally { // It's safe to always call release, because if lock is no longer belongs to this mutex, .release() will have no effect await mutex.release() } } ``` #### Example with temporary refresh ```javascript async function doSomething() { const mutex = new Mutex(redisClient, 'lockingResource', { lockTimeout: 120000, refreshInterval: 15000 }) const lockAcquired = await mutex.tryAcquire() if (!lockAcquired) { return } try { // critical cycle iteration } finally { // We want to let lock expire over time after operation is finished await mutex.stopRefresh() } } ``` #### Example with dynamically adjusting existing lock ```javascript const Mutex = require('redis-semaphore').Mutex const Redis = require('ioredis') // TypeScript // import { Mutex } from 'redis-semaphore' // import Redis from 'ioredis' const redisClient = new Redis() // This creates an original lock const preMutex = new Mutex(redisClient, 'lockingResource', { lockTimeout: 10 * 1e3, // lock for 10s refreshInterval: 0 }); // This modifies lock with a new TTL and starts refresh const mutex = new Mutex(redisClient, 'lockingResource', { identifier: preMutex.identifier, acquiredExternally: true, // required in this case lockTimeout: 30 * 60 * 1e3, // lock for 30min refreshInterval: 60 * 1e3 }); ``` #### Example with shared lock between scheduler and handler apps ```javascript const Mutex = require('redis-semaphore').Mutex const Redis = require('ioredis') // TypeScript // import { Mutex } from 'redis-semaphore' // import Redis from 'ioredis' const redisClient = new Redis() // scheduler app code async function every10MinutesCronScheduler() { const mutex = new Mutex(redisClient, 'lockingResource', { lockTimeout: 30 * 60 * 1e3, // lock for 30min refreshInterval: 0 }) if (await mutex.tryAcquire()) { someQueue.publish({ mutexIdentifier: mutex.identifier }) } else { logger.info('Job already scheduled. Do nothing in current cron cycle') } } // handler app code async function queueHandler(queueMessageData) { const { mutexIdentifier } = queueMessageData const mutex = new Mutex(redisClient, 'lockingResource', { lockTimeout: 10 * 1e3, // 10sec identifier: mutexIdentifier, acquiredExternally: true // required in this case }) // actually will do `refresh` with new lockTimeout instead of acquire // if mutex was locked by another process or lock was expired - exception will be thrown (default refresh behavior) await mutex.acquire() try { // critical code } finally { await mutex.release() } } ``` ### Semaphore > 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/) This implementation is slightly different from the algorithm described in the book, but the main idea has not changed. `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). 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`). 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`) ##### new Semaphore(redisClient, key, maxCount [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) - `redisClient` - **required**, configured `redis` client - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) - `maxCount` - **required**, maximum simultaneously resource usage count - `options` _optional_ See `Mutex` options #### Example ```javascript const Semaphore = require('redis-semaphore').Semaphore const Redis = require('ioredis') // TypeScript // import { Semaphore } from 'redis-semaphore' // import Redis from 'ioredis' const redisClient = new Redis() async function doSomething() { const semaphore = new Semaphore(redisClient, 'lockingResource', 5) await semaphore.acquire() try { // maximum 5 simultaneously executions } finally { await semaphore.release() } } ``` ### MultiSemaphore Same as `Semaphore` with one difference - MultiSemaphore will try to acquire multiple permits instead of one. `MultiSemaphore` and `Semaphore` shares same key namespace and can be used together (see test/src/RedisMultiSemaphore.test.ts). ##### new MultiSemaphore(redisClient, key, maxCount, permits [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) - `redisClient` - **required**, configured `redis` client - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) - `maxCount` - **required**, maximum simultaneously resource usage count - `permits` - **required**, number of acquiring permits - `options` _optional_ See `Mutex` options #### Example ```javascript const MultiSemaphore = require('redis-semaphore').MultiSemaphore const Redis = require('ioredis') // TypeScript // import { MultiSemaphore } from 'redis-semaphore' // import Redis from 'ioredis' const redisClient = new Redis() async function doSomething() { const semaphore = new MultiSemaphore(redisClient, 'lockingResource', 5, 2) await semaphore.acquire() try { // make 2 parallel calls to remote service which allow only 5 simultaneously calls } finally { await semaphore.release() } } ``` ### RedlockMutex Distributed `Mutex` version > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) ##### new RedlockMutex(redisClients, key [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) - `redisClients` - **required**, array of configured `redis` client connected to independent nodes - `key` - **required**, key for locking resource (final key in redis: `mutex:`) - `options` _optional_ See `Mutex` options #### Example ```javascript const RedlockMutex = require('redis-semaphore').RedlockMutex const Redis = require('ioredis') // TypeScript // import { RedlockMutex } from 'redis-semaphore' // import Redis from 'ioredis' const redisClients = [ new Redis('127.0.0.1:6377'), new Redis('127.0.0.1:6378'), new Redis('127.0.0.1:6379') ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." async function doSomething() { const mutex = new RedlockMutex(redisClients, 'lockingResource') await mutex.acquire() try { // critical code } finally { await mutex.release() } } ``` ### RedlockSemaphore Distributed `Semaphore` version > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) ##### new RedlockSemaphore(redisClients, key, maxCount [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) - `redisClients` - **required**, array of configured `redis` client connected to independent nodes - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) - `maxCount` - **required**, maximum simultaneously resource usage count - `options` _optional_ See `Mutex` options #### Example ```javascript const RedlockSemaphore = require('redis-semaphore').RedlockSemaphore const Redis = require('ioredis') // TypeScript // import { RedlockSemaphore } from 'redis-semaphore' // import Redis from 'ioredis' const redisClients = [ new Redis('127.0.0.1:6377'), new Redis('127.0.0.1:6378'), new Redis('127.0.0.1:6379') ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." async function doSomething() { const semaphore = new Semaphore(redisClients, 'lockingResource', 5) await semaphore.acquire() try { // maximum 5 simultaneously executions } finally { await semaphore.release() } } ``` ### RedlockMultiSemaphore Distributed `MultiSemaphore` version > See [The Redlock algorithm](https://redis.io/topics/distlock#the-redlock-algorithm) ##### new RedlockMultiSemaphore(redisClients, key, maxCount, permits [, { lockTimeout = 10000, acquireTimeout = 10000, acquireAttemptsLimit = Number.POSITIVE_INFINITY, retryInterval = 10, refreshInterval = lockTimeout * 0.8 }]) - `redisClients` - **required**, array of configured `redis` client connected to independent nodes - `key` - **required**, key for locking resource (final key in redis: `semaphore:`) - `maxCount` - **required**, maximum simultaneously resource usage count - `permits` - **required**, number of acquiring permits - `options` _optional_ See `Mutex` options #### Example ```javascript const RedlockMultiSemaphore = require('redis-semaphore').RedlockMultiSemaphore const Redis = require('ioredis') // TypeScript // import { RedlockMultiSemaphore } from 'redis-semaphore' // import Redis from 'ioredis' const redisClients = [ new Redis('127.0.0.1:6377'), new Redis('127.0.0.1:6378'), new Redis('127.0.0.1:6379') ] // "Those nodes are totally independent, so we don’t use replication or any other implicit coordination system." async function doSomething() { const semaphore = new RedlockMultiSemaphore( redisClients, 'lockingResource', 5, 2 ) await semaphore.acquire() try { // make 2 parallel calls to remote service which allow only 5 simultaneously calls } finally { await semaphore.release() } } ``` ## Development ```shell yarn --immutable ./setup-redis-servers.sh yarn dev ``` ## License MIT [![FOSSA Status][fossa-large-image]][fossa-large-url] [npm-image]: https://img.shields.io/npm/v/redis-semaphore.svg?style=flat-square [npm-url]: https://npmjs.org/package/redis-semaphore [ci-image]: https://github.com/swarthy/redis-semaphore/actions/workflows/branches.yml/badge.svg [ci-url]: https://github.com/swarthy/redis-semaphore/actions/workflows/branches.yml [codeclimate-image]: https://api.codeclimate.com/v1/badges/02778c96bb5983eb150c/maintainability [codeclimate-url]: https://codeclimate.com/github/swarthy/redis-semaphore/maintainability [snyk-image]: https://snyk.io/test/npm/redis-semaphore/badge.svg [snyk-url]: https://snyk.io/test/npm/redis-semaphore [coverage-image]: https://coveralls.io/repos/github/swarthy/redis-semaphore/badge.svg?branch=master [coverage-url]: https://coveralls.io/r/swarthy/redis-semaphore?branch=master [fossa-badge-image]: https://app.fossa.com/api/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git.svg?type=shield [fossa-badge-url]: https://app.fossa.com/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git?ref=badge_shield [fossa-large-image]: https://app.fossa.com/api/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git.svg?type=large [fossa-large-url]: https://app.fossa.com/projects/custom%2B10538%2Fgit%40github.com%3Aswarthy%2Fredis-semaphore.git?ref=badge_large [typescript-image]: https://badgen.net/npm/types/tslib ================================================ FILE: docker-compose.yml ================================================ version: '3.7' services: waiter: image: node:24-alpine volumes: - ./:/app working_dir: /app command: > sh -c " corepack --version && corepack enable && echo 'Wait for all redis servers...' && yarn wait-for --redis redis://redis1 && yarn wait-for --redis redis://redis2 && yarn wait-for --redis redis://redis3 && echo 'All redis instances ready!' " redis1: image: redis:alpine ports: - 6001:6379 redis2: image: redis:alpine ports: - 6002:6379 redis3: image: redis:alpine ports: - 6003:6379 ================================================ FILE: eslint.config.mjs ================================================ import typescript from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' import nodePlugin from 'eslint-plugin-node' export default [ { ignores: ['lib/**', 'es/**', 'coverage/**', '.nyc_output/**'] }, { files: ['**/*.ts'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 2024, sourceType: 'module', project: './tsconfig.json', ecmaFeatures: { jsx: false } }, globals: { console: true, process: true, setTimeout: true, clearTimeout: true, setInterval: true, clearInterval: true } }, plugins: { '@typescript-eslint': typescript }, rules: { ...typescript.configs['recommended'].rules, ...typescript.configs['recommended-requiring-type-checking'].rules, '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' } ], '@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/require-await': 'off', 'no-unused-expressions': 'off' } }, { files: ['test/**/*.ts'], languageOptions: { globals: { describe: true, it: true, before: true, after: true, beforeEach: true, afterEach: true, mocha: true } }, rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/await-thenable': 'off', '@typescript-eslint/explicit-function-return-type': 'off', 'no-unused-vars': 'off' } } ] ================================================ FILE: package.json ================================================ { "name": "redis-semaphore", "version": "5.7.0", "description": "Distributed mutex and semaphore based on Redis", "main": "lib/index.js", "scripts": { "lint": "eslint --ext .js,.ts .", "test": "mocha", "test-ci-with-coverage": "nyc mocha && nyc report --reporter=text-lcov | coveralls", "coverage-html": "nyc mocha && nyc report --reporter=html", "converalls": "nyc mocha && nyc report --reporter=text-lcov | coveralls", "dev": "mocha -w", "build": "yarn build-commonjs", "build-commonjs": "rm -rf lib && yarn tsc -b tsconfig.build-commonjs.json", "build-es": "rm -rf es && yarn tsc -b tsconfig.build-es.json", "preversion": "yarn lint && yarn test && yarn build" }, "repository": { "type": "git", "url": "git@github.com:swarthy/redis-semaphore.git" }, "keywords": [ "redis", "redlock", "mutex", "semaphore" ], "author": "Alexander Mochalin (horroshow@mail.ru)", "license": "MIT", "devDependencies": { "@swarthy/wait-for": "^2.1.1", "@swc-node/register": "1.10.10", "@swc/core": "1.11.11", "@types/chai": "^4.3.20", "@types/chai-as-promised": "^7.1.8", "@types/debug": "^4.1.12", "@types/ioredis-mock": "^8.2.5", "@types/mocha": "^10.0.10", "@types/node": "22.13.11", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^3.2.12", "@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/parser": "8.27.0", "benchmark": "^2.1.4", "chai": "4.5.0", "chai-as-promised": "7.1.2", "coveralls": "^3.1.1", "eslint": "9.23.0", "eslint-plugin-node": "11.1.0", "ioredis": "5.6.0", "ioredis-mock": "8.9.0", "mocha": "11.1.0", "mocha-lcov-reporter": "^1.3.0", "nyc": "^17.1.0", "sinon": "19.0.4", "sinon-chai": "3.7.0", "snyk": "1.1296.0", "ts-node": "^10.9.2", "typescript": "^5.8.2" }, "engines": { "node": ">= 14.17.0" }, "peerDependencies": { "ioredis": "^4.1.0 || ^5" }, "peerDependenciesMeta": { "ioredis": { "optional": true } }, "dependencies": { "debug": "^4.4.0" }, "packageManager": "yarn@4.1.0+sha256.81a00df816059803e6b5148acf03ce313cad36b7f6e5af6efa040a15981a6ffb", "files": [ "lib/" ] } ================================================ FILE: setup-redis-servers.sh ================================================ #!/bin/sh docker compose up -d redis1 redis2 redis3 ================================================ FILE: src/Lock.ts ================================================ import createDebug from 'debug' import * as crypto from 'node:crypto' import LostLockError from './errors/LostLockError' import TimeoutError from './errors/TimeoutError' import { defaultOnLockLost, defaultTimeoutOptions } from './misc' import { AcquireOptions, LockLostCallback, LockOptions } from './types' const REFRESH_INTERVAL_COEF = 0.8 const debug = createDebug('redis-semaphore:instance') export abstract class Lock { protected abstract _kind: string protected abstract _key: string protected _identifier: string protected _acquireOptions: AcquireOptions protected _refreshTimeInterval: number protected _refreshInterval?: ReturnType protected _refreshing = false protected _acquired = false protected _acquiredExternally = false protected _onLockLost: LockLostCallback protected abstract _refresh(): Promise protected abstract _acquire(abortSignal?: AbortSignal): Promise protected abstract _release(): Promise constructor({ lockTimeout = defaultTimeoutOptions.lockTimeout, acquireTimeout = defaultTimeoutOptions.acquireTimeout, acquireAttemptsLimit = defaultTimeoutOptions.acquireAttemptsLimit, retryInterval = defaultTimeoutOptions.retryInterval, refreshInterval = Math.round(lockTimeout * REFRESH_INTERVAL_COEF), onLockLost = defaultOnLockLost, externallyAcquiredIdentifier, identifierSuffix, identifier, acquiredExternally }: LockOptions = defaultTimeoutOptions) { if ( identifier !== undefined && (!identifier || typeof identifier !== 'string') ) { throw new Error('identifier must be not empty random string') } if (acquiredExternally && !identifier) { throw new Error( 'acquiredExternally=true meanless without custom identifier' ) } if (externallyAcquiredIdentifier && (identifier || acquiredExternally)) { throw new Error( 'Invalid usage. Use custom identifier and acquiredExternally: true' ) } this._identifier = identifier || externallyAcquiredIdentifier || this.getIdentifier(identifierSuffix) this._acquiredExternally = !!acquiredExternally || !!externallyAcquiredIdentifier this._acquireOptions = { lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval, identifier: this._identifier } this._refreshTimeInterval = refreshInterval this._onLockLost = onLockLost } get identifier(): string { return this._identifier } get isAcquired(): boolean { return this._acquired } private getIdentifier(identifierSuffix: string | undefined): string { const uuid = crypto.randomUUID() return identifierSuffix ? `${uuid}-${identifierSuffix}` : uuid } private _startRefresh(): void { this._refreshInterval = setInterval( this._processRefresh, this._refreshTimeInterval ) this._refreshInterval.unref() } stopRefresh(): void { if (this._refreshInterval) { debug( `clear refresh interval ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` ) clearInterval(this._refreshInterval) } } private _processRefresh = async (): Promise => { if (this._refreshing) { debug( `already refreshing ${this._kind} (key: ${this._key}, identifier: ${this._identifier}) (skip)` ) return } this._refreshing = true try { debug( `refresh ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` ) const refreshed = await this._refresh() if (!refreshed) { if (!this._acquired) { debug( `refresh ${this._kind} (key: ${this._key}, identifier: ${this._identifier} failed, but lock was purposefully released` ) return } this._acquired = false this.stopRefresh() const lockLostError = new LostLockError( `Lost ${this._kind} for key ${this._key}` ) this._onLockLost(lockLostError) } } finally { this._refreshing = false } } async acquire(abortSignal?: AbortSignal): Promise { debug(`acquire ${this._kind} (key: ${this._key})`) const acquired = await this.tryAcquire(abortSignal) if (!acquired) { throw new TimeoutError(`Acquire ${this._kind} ${this._key} timeout`) } } async tryAcquire(abortSignal?: AbortSignal): Promise { debug(`tryAcquire ${this._kind} (key: ${this._key})`) const acquired = this._acquiredExternally ? await this._refresh() : await this._acquire(abortSignal) if (!acquired) { return false } this._acquired = true this._acquiredExternally = false if (this._refreshTimeInterval > 0) { this._startRefresh() } return true } async release(): Promise { debug( `release ${this._kind} (key: ${this._key}, identifier: ${this._identifier})` ) if (this._refreshTimeInterval > 0) { this.stopRefresh() } if (this._acquired || this._acquiredExternally) { await this._release() } this._acquired = false this._acquiredExternally = false } } ================================================ FILE: src/RedisMultiSemaphore.ts ================================================ import { acquireSemaphore } from './multiSemaphore/acquire' import { refreshSemaphore } from './multiSemaphore/refresh' import { releaseSemaphore } from './multiSemaphore/release' import RedisSemaphore from './RedisSemaphore' import { LockOptions, RedisClient } from './types' export default class RedisMultiSemaphore extends RedisSemaphore { protected _kind = 'multi-semaphore' protected _permits: number constructor( client: RedisClient, key: string, limit: number, permits: number, options?: LockOptions ) { super(client, key, limit, options) if (!permits) { throw new Error('"permits" is required') } if (typeof permits !== 'number') { throw new Error('"permits" must be a number') } this._permits = permits } protected async _refresh(): Promise { return await refreshSemaphore( this._client, this._key, this._limit, this._permits, this._acquireOptions ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireSemaphore( this._client, this._key, this._limit, this._permits, this._acquireOptions, abortSignal ) } protected async _release(): Promise { await releaseSemaphore( this._client, this._key, this._permits, this._identifier ) } } ================================================ FILE: src/RedisMutex.ts ================================================ import type { RedisClient } from './types' import { Lock } from './Lock' import { acquireMutex } from './mutex/acquire' import { refreshMutex } from './mutex/refresh' import { releaseMutex } from './mutex/release' import { LockOptions } from './types' export default class RedisMutex extends Lock { protected _kind = 'mutex' protected _key: string protected _client: RedisClient constructor(client: RedisClient, key: string, options?: LockOptions) { super(options) if (!client) { throw new Error('"client" is required') } if (!key) { throw new Error('"key" is required') } if (typeof key !== 'string') { throw new Error('"key" must be a string') } this._client = client this._key = `mutex:${key}` } protected async _refresh(): Promise { return await refreshMutex( this._client, this._key, this._identifier, this._acquireOptions.lockTimeout ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireMutex(this._client, this._key, this._acquireOptions, abortSignal) } protected async _release(): Promise { await releaseMutex(this._client, this._key, this._identifier) } } ================================================ FILE: src/RedisSemaphore.ts ================================================ import RedisMutex from './RedisMutex' import { acquireSemaphore } from './semaphore/acquire' import { refreshSemaphore } from './semaphore/refresh' import { releaseSemaphore } from './semaphore/release' import { LockOptions, RedisClient } from './types' export default class RedisSemaphore extends RedisMutex { protected _kind = 'semaphore' protected _limit: number constructor( client: RedisClient, key: string, limit: number, options?: LockOptions ) { super(client, key, options) if (!limit) { throw new Error('"limit" is required') } if (typeof limit !== 'number') { throw new Error('"limit" must be a number') } this._key = `semaphore:${key}` this._limit = limit } protected async _refresh(): Promise { return await refreshSemaphore( this._client, this._key, this._limit, this._acquireOptions ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireSemaphore( this._client, this._key, this._limit, this._acquireOptions, abortSignal ) } protected async _release(): Promise { await releaseSemaphore(this._client, this._key, this._identifier) } } ================================================ FILE: src/RedlockMultiSemaphore.ts ================================================ import { acquireRedlockMultiSemaphore } from './redlockMultiSemaphore/acquire' import { refreshRedlockMultiSemaphore } from './redlockMultiSemaphore/refresh' import { releaseRedlockMultiSemaphore } from './redlockMultiSemaphore/release' import RedlockSemaphore from './RedlockSemaphore' import { LockOptions, RedisClient } from './types' export default class RedlockMultiSemaphore extends RedlockSemaphore { protected _kind = 'redlock-multi-semaphore' protected _permits: number constructor( clients: RedisClient[], key: string, limit: number, permits: number, options?: LockOptions ) { super(clients, key, limit, options) if (!permits) { throw new Error('"permits" is required') } if (typeof permits !== 'number') { throw new Error('"permits" must be a number') } this._permits = permits } protected async _refresh(): Promise { return await refreshRedlockMultiSemaphore( this._clients, this._key, this._limit, this._permits, this._acquireOptions ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireRedlockMultiSemaphore( this._clients, this._key, this._limit, this._permits, this._acquireOptions, abortSignal ) } protected async _release(): Promise { await releaseRedlockMultiSemaphore( this._clients, this._key, this._permits, this._identifier ) } } ================================================ FILE: src/RedlockMutex.ts ================================================ import { Lock } from './Lock' import { defaultTimeoutOptions } from './misc' import { acquireRedlockMutex } from './redlockMutex/acquire' import { refreshRedlockMutex } from './redlockMutex/refresh' import { releaseRedlockMutex } from './redlockMutex/release' import { LockOptions, RedisClient } from './types' export default class RedlockMutex extends Lock { protected _kind = 'redlock-mutex' protected _key: string protected _clients: RedisClient[] constructor( clients: RedisClient[], key: string, options: LockOptions = defaultTimeoutOptions ) { super(options) if (!clients || !Array.isArray(clients)) { throw new Error('"clients" array is required') } if (!key) { throw new Error('"key" is required') } if (typeof key !== 'string') { throw new Error('"key" must be a string') } this._clients = clients this._key = `mutex:${key}` } protected async _refresh(): Promise { return await refreshRedlockMutex( this._clients, this._key, this._identifier, this._acquireOptions.lockTimeout ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireRedlockMutex( this._clients, this._key, this._acquireOptions, abortSignal ) } protected async _release(): Promise { await releaseRedlockMutex(this._clients, this._key, this._identifier) } } ================================================ FILE: src/RedlockSemaphore.ts ================================================ import RedlockMutex from './RedlockMutex' import { acquireRedlockSemaphore } from './redlockSemaphore/acquire' import { refreshRedlockSemaphore } from './redlockSemaphore/refresh' import { releaseRedlockSemaphore } from './redlockSemaphore/release' import { LockOptions, RedisClient } from './types' export default class RedlockSemaphore extends RedlockMutex { protected _kind = 'redlock-semaphore' protected _limit: number constructor( clients: RedisClient[], key: string, limit: number, options?: LockOptions ) { super(clients, key, options) if (!limit) { throw new Error('"limit" is required') } if (typeof limit !== 'number') { throw new Error('"limit" must be a number') } this._key = `semaphore:${key}` this._limit = limit } protected async _refresh(): Promise { return await refreshRedlockSemaphore( this._clients, this._key, this._limit, this._acquireOptions ) } protected async _acquire(abortSignal?: AbortSignal): Promise { return await acquireRedlockSemaphore( this._clients, this._key, this._limit, this._acquireOptions, abortSignal ) } protected async _release(): Promise { await releaseRedlockSemaphore(this._clients, this._key, this._identifier) } } ================================================ FILE: src/errors/LostLockError.ts ================================================ export default class LostLockError extends Error {} ================================================ FILE: src/errors/TimeoutError.ts ================================================ export default class TimeoutError extends Error {} ================================================ FILE: src/index.ts ================================================ import MultiSemaphore from './RedisMultiSemaphore' import Mutex from './RedisMutex' import Semaphore from './RedisSemaphore' import RedlockMultiSemaphore from './RedlockMultiSemaphore' import RedlockMutex from './RedlockMutex' import RedlockSemaphore from './RedlockSemaphore' import LostLockError from './errors/LostLockError' import TimeoutError from './errors/TimeoutError' export { defaultTimeoutOptions } from './misc' export { Mutex, Semaphore, MultiSemaphore, RedlockMutex, RedlockSemaphore, RedlockMultiSemaphore, LostLockError, TimeoutError } export type { LockLostCallback, TimeoutOptions, LockOptions } from './types' ================================================ FILE: src/misc.ts ================================================ import LostLockError from './errors/LostLockError' export const defaultTimeoutOptions = { lockTimeout: 10000, acquireTimeout: 10000, acquireAttemptsLimit: Number.POSITIVE_INFINITY, retryInterval: 10 } export function defaultOnLockLost(err: LostLockError): never { throw err } ================================================ FILE: src/multiSemaphore/acquire/index.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../../types' import { delay } from '../../utils' import { acquireLua } from './lua' const debug = createDebug('redis-semaphore:multi-semaphore:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireSemaphore( client: RedisClient, key: string, limit: number, permits: number, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout let now while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, limit, lockTimeout, 'attempt', attempt) const result = await acquireLua(client, [ key, limit, permits, identifier, lockTimeout, now ]) debug(key, 'result', typeof result, result) if (result === 1) { debug(key, identifier, 'acquired') return true } else { await delay(retryInterval, abortSignal) } } debug(key, identifier, limit, lockTimeout, 'timeout or reach limit') return false } ================================================ FILE: src/multiSemaphore/acquire/lua.ts ================================================ import { createEval } from '../../utils/index' export const acquireLua = createEval< [string, number, number, string, number, number], 0 | 1 >( ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local permits = tonumber(ARGV[2]) local identifier = ARGV[3] local lockTimeout = tonumber(ARGV[4]) local now = tonumber(ARGV[5]) local expiredTimestamp = now - lockTimeout local args = {} redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) if (redis.call('zcard', key) + permits) <= limit then for i=0, permits - 1 do table.insert(args, now) table.insert(args, identifier .. '_' .. i) end redis.call('zadd', key, unpack(args)) redis.call('pexpire', key, lockTimeout) return 1 else return 0 end`, 1 ) ================================================ FILE: src/multiSemaphore/refresh/index.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../../types' import { refreshLua } from './lua' const debug = createDebug('redis-semaphore:multi-semaphore:refresh') export interface Options { identifier: string lockTimeout: number } export async function refreshSemaphore( client: RedisClient, key: string, limit: number, permits: number, options: Options ): Promise { const { identifier, lockTimeout } = options const now = Date.now() debug(key, identifier, now) const result = await refreshLua(client, [ key, limit, permits, identifier, lockTimeout, now ]) debug('result', typeof result, result) return result === 1 } ================================================ FILE: src/multiSemaphore/refresh/lua.ts ================================================ import { createEval } from '../../utils/index' export const refreshLua = createEval< [string, number, number, string, number, number], 0 | 1 >( ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local permits = tonumber(ARGV[2]) local identifier = ARGV[3] local lockTimeout = tonumber(ARGV[4]) local now = tonumber(ARGV[5]) local expiredTimestamp = now - lockTimeout local args = {} redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) if redis.call('zscore', key, identifier .. '_0') then for i=0, permits - 1 do table.insert(args, now) table.insert(args, identifier .. '_' .. i) end redis.call('zadd', key, unpack(args)) redis.call('pexpire', key, lockTimeout) return 1 else return 0 end`, 1 ) ================================================ FILE: src/multiSemaphore/release/index.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../../types' import { releaseLua } from './lua' const debug = createDebug('redis-semaphore:multi-semaphore:release') export interface Options { identifier: string lockTimeout: number now: number } export async function releaseSemaphore( client: RedisClient, key: string, permits: number, identifier: string ): Promise { debug(key, identifier, permits) const result = await releaseLua(client, [key, permits, identifier]) debug('result', typeof result, result) } ================================================ FILE: src/multiSemaphore/release/lua.ts ================================================ import { createEval } from '../../utils/index' export const releaseLua = createEval<[string, number, string], number>( ` local key = KEYS[1] local permits = tonumber(ARGV[1]) local identifier = ARGV[2] local args = {} for i=0, permits - 1 do table.insert(args, identifier .. '_' .. i) end return redis.call('zrem', key, unpack(args)) `, 1 ) ================================================ FILE: src/mutex/acquire.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../types' import { delay } from '../utils' const debug = createDebug('redis-semaphore:mutex:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireMutex( client: RedisClient, key: string, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout while (Date.now() < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, 'attempt', attempt) const result = await client.set(key, identifier, 'PX', lockTimeout, 'NX') debug('result', typeof result, result) if (result === 'OK') { debug(key, identifier, 'acquired') return true } else { await delay(retryInterval, abortSignal) } } debug(key, identifier, 'timeout or reach limit') return false } ================================================ FILE: src/mutex/refresh.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../types' import { createEval } from '../utils/index' const debug = createDebug('redis-semaphore:mutex:refresh') export const expireIfEqualLua = createEval<[string, string, number], 0 | 1>( ` local key = KEYS[1] local identifier = ARGV[1] local lockTimeout = ARGV[2] local value = redis.call('get', key) if value == identifier then redis.call('pexpire', key, lockTimeout) return 1 end return 0 `, 1 ) export async function refreshMutex( client: RedisClient, key: string, identifier: string, lockTimeout: number ): Promise { debug(key, identifier) const result = await expireIfEqualLua(client, [key, identifier, lockTimeout]) debug('result', typeof result, result) // support options.stringNumbers return +result === 1 } ================================================ FILE: src/mutex/release.ts ================================================ import createDebug from 'debug' import { createEval } from '../utils/index' import type { RedisClient } from '../types' const debug = createDebug('redis-semaphore:mutex:release') export const delIfEqualLua = createEval<[string, string], 0 | 1>( ` local key = KEYS[1] local identifier = ARGV[1] if redis.call('get', key) == identifier then return redis.call('del', key) end return 0 `, 1 ) export async function releaseMutex( client: RedisClient, key: string, identifier: string ): Promise { debug(key, identifier) const result = await delIfEqualLua(client, [key, identifier]) debug('result', typeof result, result) } ================================================ FILE: src/redlockMultiSemaphore/acquire.ts ================================================ import createDebug from 'debug' import { acquireLua } from '../multiSemaphore/acquire/lua' import { RedisClient } from '../types' import { delay } from '../utils' import { getQuorum, smartSum } from '../utils/redlock' const debug = createDebug('redis-semaphore:redlock-multi-semaphore:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireRedlockMultiSemaphore( clients: RedisClient[], key: string, limit: number, permits: number, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout const quorum = getQuorum(clients.length) let now: number while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, 'attempt', attempt) const promises = clients.map(client => acquireLua(client, [key, limit, permits, identifier, lockTimeout, now]) .then(result => +result) .catch(() => 0) ) const results = await Promise.all(promises) if (results.reduce(smartSum, 0) >= quorum) { debug(key, identifier, 'acquired') return true } else { const promises = clients.map(client => client.zrem(key, identifier).catch(() => 0) ) await Promise.all(promises) await delay(retryInterval, abortSignal) } } debug(key, identifier, 'timeout or reach limit') return false } ================================================ FILE: src/redlockMultiSemaphore/refresh.ts ================================================ import createDebug from 'debug' import { acquireLua } from '../multiSemaphore/acquire/lua' import { refreshLua } from '../multiSemaphore/refresh/lua' import { releaseLua } from '../multiSemaphore/release/lua' import { RedisClient } from '../types' import { getQuorum, smartSum } from '../utils/redlock' const debug = createDebug('redis-semaphore:redlock-semaphore:refresh') interface Options { identifier: string lockTimeout: number } export async function refreshRedlockMultiSemaphore( clients: RedisClient[], key: string, limit: number, permits: number, options: Options ): Promise { const { identifier, lockTimeout } = options const now = Date.now() debug(key, identifier, now) const quorum = getQuorum(clients.length) const promises = clients.map(client => refreshLua(client, [key, limit, permits, identifier, lockTimeout, now]) .then(result => +result) .catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) const refreshedCount = results.reduce(smartSum, 0) if (refreshedCount >= quorum) { debug(key, identifier, 'refreshed') if (refreshedCount < clients.length) { debug(key, identifier, 'try to acquire on failed nodes') const promises = results .reduce((failedClients, result, index) => { if (!result) { failedClients.push(clients[index]) } return failedClients }, []) .map(client => acquireLua(client, [ key, limit, permits, identifier, lockTimeout, now ]) .then(result => +result) .catch(() => 0) ) const acquireResults = await Promise.all(promises) debug(key, identifier, 'acquire on failed nodes results', acquireResults) } return true } else { const promises = clients.map(client => releaseLua(client, [key, permits, identifier]).catch(() => 0) ) await Promise.all(promises) return false } } ================================================ FILE: src/redlockMultiSemaphore/release.ts ================================================ import createDebug from 'debug' import { releaseLua } from '../multiSemaphore/release/lua' import { RedisClient } from '../types' const debug = createDebug('redis-semaphore:redlock-mutex:release') export async function releaseRedlockMultiSemaphore( clients: RedisClient[], key: string, permits: number, identifier: string ): Promise { debug(key, identifier) const promises = clients.map(client => releaseLua(client, [key, permits, identifier]).catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) } ================================================ FILE: src/redlockMutex/acquire.ts ================================================ import createDebug from 'debug' import { delIfEqualLua } from '../mutex/release' import { RedisClient } from '../types' import { delay } from '../utils' import { getQuorum, smartSum } from '../utils/redlock' const debug = createDebug('redis-semaphore:redlock-mutex:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireRedlockMutex( clients: RedisClient[], key: string, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout const quorum = getQuorum(clients.length) while (Date.now() < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, 'attempt', attempt) const promises = clients.map(client => client .set(key, identifier, 'PX', lockTimeout, 'NX') .then(result => (result === 'OK' ? 1 : 0)) .catch(() => 0) ) const results = await Promise.all(promises) if (results.reduce(smartSum, 0) >= quorum) { debug(key, identifier, 'acquired') return true } else { const promises = clients.map(client => delIfEqualLua(client, [key, identifier]).catch(() => 0) ) await Promise.all(promises) await delay(retryInterval, abortSignal) } } debug(key, identifier, 'timeout or reach limit') return false } ================================================ FILE: src/redlockMutex/refresh.ts ================================================ import createDebug from 'debug' import { expireIfEqualLua } from '../mutex/refresh' import { delIfEqualLua } from '../mutex/release' import { RedisClient } from '../types' import { getQuorum, smartSum } from '../utils/redlock' const debug = createDebug('redis-semaphore:redlock-mutex:refresh') export async function refreshRedlockMutex( clients: RedisClient[], key: string, identifier: string, lockTimeout: number ): Promise { debug(key, identifier) const quorum = getQuorum(clients.length) const promises = clients.map(client => expireIfEqualLua(client, [key, identifier, lockTimeout]) .then(result => +result) .catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) const refreshedCount = results.reduce(smartSum, 0) if (refreshedCount >= quorum) { debug(key, identifier, 'refreshed') if (refreshedCount < clients.length) { debug(key, identifier, 'try to acquire on failed nodes') const promises = results .reduce((failedClients, result, index) => { if (!result) { failedClients.push(clients[index]) } return failedClients }, []) .map(client => client .set(key, identifier, 'PX', lockTimeout, 'NX') .then(result => (result === 'OK' ? 1 : 0)) .catch(() => 0) ) const acquireResults = await Promise.all(promises) debug(key, identifier, 'acquire on failed nodes results', acquireResults) } return true } else { const promises = clients.map(client => delIfEqualLua(client, [key, identifier]).catch(() => 0) ) await Promise.all(promises) return false } } ================================================ FILE: src/redlockMutex/release.ts ================================================ import createDebug from 'debug' import { delIfEqualLua } from '../mutex/release' import type { RedisClient } from '../types' const debug = createDebug('redis-semaphore:redlock-mutex:release') export async function releaseRedlockMutex( clients: RedisClient[], key: string, identifier: string ): Promise { debug(key, identifier) const promises = clients.map(client => delIfEqualLua(client, [key, identifier]).catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) } ================================================ FILE: src/redlockSemaphore/acquire.ts ================================================ import createDebug from 'debug' import { acquireLua } from '../semaphore/acquire/lua' import { RedisClient } from '../types' import { delay } from '../utils' import { getQuorum, smartSum } from '../utils/redlock' const debug = createDebug('redis-semaphore:redlock-semaphore:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireRedlockSemaphore( clients: RedisClient[], key: string, limit: number, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout const quorum = getQuorum(clients.length) let now: number while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, 'attempt', attempt) const promises = clients.map(client => acquireLua(client, [key, limit, identifier, lockTimeout, now]) .then(result => +result) .catch(() => 0) ) const results = await Promise.all(promises) if (results.reduce(smartSum, 0) >= quorum) { debug(key, identifier, 'acquired') return true } else { const promises = clients.map(client => client.zrem(key, identifier).catch(() => 0) ) await Promise.all(promises) await delay(retryInterval, abortSignal) } } debug(key, identifier, 'timeout or reach limit') return false } ================================================ FILE: src/redlockSemaphore/refresh.ts ================================================ import createDebug from 'debug' import { acquireLua } from '../semaphore/acquire/lua' import { refreshLua } from '../semaphore/refresh/lua' import { getQuorum, smartSum } from '../utils/redlock' import type { RedisClient } from '../types' const debug = createDebug('redis-semaphore:redlock-semaphore:refresh') interface Options { identifier: string lockTimeout: number } export async function refreshRedlockSemaphore( clients: RedisClient[], key: string, limit: number, options: Options ): Promise { const { identifier, lockTimeout } = options const now = Date.now() debug(key, identifier, now) const quorum = getQuorum(clients.length) const promises = clients.map(client => refreshLua(client, [key, limit, identifier, lockTimeout, now]) .then(result => +result) .catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) const refreshedCount = results.reduce(smartSum, 0) if (refreshedCount >= quorum) { debug(key, identifier, 'refreshed') if (refreshedCount < clients.length) { debug(key, identifier, 'try to acquire on failed nodes') const promises = results .reduce((failedClients, result, index) => { if (!result) { failedClients.push(clients[index]) } return failedClients }, []) .map(client => acquireLua(client, [key, limit, identifier, lockTimeout, now]) .then(result => +result) .catch(() => 0) ) const acquireResults = await Promise.all(promises) debug(key, identifier, 'acquire on failed nodes results', acquireResults) } return true } else { const promises = clients.map(client => client.zrem(key, identifier).catch(() => 0) ) await Promise.all(promises) return false } } ================================================ FILE: src/redlockSemaphore/release.ts ================================================ import createDebug from 'debug' import type { RedisClient } from '../types' const debug = createDebug('redis-semaphore:redlock-mutex:release') export async function releaseRedlockSemaphore( clients: RedisClient[], key: string, identifier: string ): Promise { debug(key, identifier) const promises = clients.map(client => client.zrem(key, identifier).catch(() => 0) ) const results = await Promise.all(promises) debug('results', results) } ================================================ FILE: src/semaphore/acquire/index.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../../types' import { delay } from '../../utils' import { acquireLua } from './lua' const debug = createDebug('redis-semaphore:semaphore:acquire') export interface Options { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } export async function acquireSemaphore( client: RedisClient, key: string, limit: number, options: Options, abortSignal?: AbortSignal ): Promise { const { identifier, lockTimeout, acquireTimeout, acquireAttemptsLimit, retryInterval } = options let attempt = 0 const end = Date.now() + acquireTimeout let now while ((now = Date.now()) < end && ++attempt <= acquireAttemptsLimit) { abortSignal?.throwIfAborted() debug(key, identifier, limit, lockTimeout, 'attempt', attempt) const result = await acquireLua(client, [ key, limit, identifier, lockTimeout, now ]) debug(key, 'result', typeof result, result) // support options.stringNumbers if (+result === 1) { debug(key, identifier, 'acquired') return true } else { await delay(retryInterval, abortSignal) } } debug(key, identifier, limit, lockTimeout, 'timeout or reach limit') return false } ================================================ FILE: src/semaphore/acquire/lua.ts ================================================ import { createEval } from '../../utils/index' export const acquireLua = createEval< [string, number, string, number, number], 0 | 1 >( ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local identifier = ARGV[2] local lockTimeout = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) local expiredTimestamp = now - lockTimeout redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) if redis.call('zcard', key) < limit then redis.call('zadd', key, now, identifier) redis.call('pexpire', key, lockTimeout) return 1 else return 0 end`, 1 ) ================================================ FILE: src/semaphore/refresh/index.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../../types' import { refreshLua } from './lua' const debug = createDebug('redis-semaphore:semaphore:refresh') export interface Options { identifier: string lockTimeout: number } export async function refreshSemaphore( client: RedisClient, key: string, limit: number, options: Options ): Promise { const { identifier, lockTimeout } = options const now = Date.now() debug(key, identifier, now) const result = await refreshLua(client, [ key, limit, identifier, lockTimeout, now ]) debug('result', typeof result, result) // support options.stringNumbers return +result === 1 } ================================================ FILE: src/semaphore/refresh/lua.ts ================================================ import { createEval } from '../../utils/index' export const refreshLua = createEval< [string, number, string, number, number], 0 | 1 >( ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local identifier = ARGV[2] local lockTimeout = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) local expiredTimestamp = now - lockTimeout redis.call('zremrangebyscore', key, '-inf', expiredTimestamp) if redis.call('zscore', key, identifier) then redis.call('zadd', key, now, identifier) redis.call('pexpire', key, lockTimeout) return 1 else return 0 end`, 1 ) ================================================ FILE: src/semaphore/release.ts ================================================ import createDebug from 'debug' import { RedisClient } from '../types' const debug = createDebug('redis-semaphore:semaphore:release') export async function releaseSemaphore( client: RedisClient, key: string, identifier: string ): Promise { debug(key, identifier) const result = await client.zrem(key, identifier) debug('result', typeof result, result) } ================================================ FILE: src/types.ts ================================================ import LostLockError from './errors/LostLockError' import { Lock } from './Lock' import type * as ioredis from 'ioredis' /** * ioredis-like Redis client */ export type RedisClient = Pick< ioredis.Redis, 'eval' | 'evalsha' | 'get' | 'set' | 'zrem' > & Partial> export interface LockLostCallback { (this: Lock, err: LostLockError): void } export interface TimeoutOptions { lockTimeout?: number acquireTimeout?: number acquireAttemptsLimit?: number retryInterval?: number refreshInterval?: number } export interface LockOptions extends TimeoutOptions { /** * @deprecated Use `identifier` + `acquiredExternally: true` instead. Will be removed in next major release. */ externallyAcquiredIdentifier?: string /** * @deprecated Provide custom `identifier` instead. Will be removed in next major release. */ identifierSuffix?: string /** * Identifier of lock. By default is `crypto.randomUUID()`. * * Must be unique between parallel executors otherwise locks with same identifier *can* be treated as the same lock holder. * * Override only if you know what you are doing, see `acquireExternally` option. */ identifier?: string /** * 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. */ acquiredExternally?: true onLockLost?: LockLostCallback } export interface AcquireOptions { identifier: string lockTimeout: number acquireTimeout: number acquireAttemptsLimit: number retryInterval: number } ================================================ FILE: src/utils/createEval.ts ================================================ import { createHash } from 'crypto' import createDebug from 'debug' import { RedisClient } from '../types' import { getConnectionName } from './index' const debug = createDebug('redis-semaphore:eval') function createSHA1(script: string): string { return createHash('sha1').update(script, 'utf8').digest('hex') } function isNoScriptError(err: Error): boolean { return err.toString().indexOf('NOSCRIPT') !== -1 } export default function createEval, Result>( script: string, keysCount: number ): (client: RedisClient, args: Args) => Promise { const sha1 = createSHA1(script) debug('creating script:', script, 'sha1:', sha1) return async function optimizedEval( client: RedisClient, args: Args ): Promise { const connectionName = getConnectionName(client) const evalSHAArgs = [sha1, keysCount, ...args] debug(connectionName, sha1, 'attempt, args:', evalSHAArgs) try { return (await client.evalsha(sha1, keysCount, ...args)) as Promise } catch (err) { if (err instanceof Error && isNoScriptError(err)) { const evalArgs = [script, keysCount, ...args] debug(connectionName, sha1, 'fallback to eval, args:', evalArgs) return (await client.eval( script, keysCount, ...args )) as Promise } else { throw err } } } } ================================================ FILE: src/utils/index.ts ================================================ import { RedisClient } from '../types' import createEval from './createEval' export { createEval } export function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { cleanup(); resolve(); }, ms); const onAbort = (): void => { cleanup(); reject(signal!.reason as Error); }; const cleanup = (): void => { clearTimeout(timeoutId); signal?.removeEventListener('abort', onAbort); }; if (signal?.aborted) { onAbort() } signal?.addEventListener('abort', onAbort); }) } export function getConnectionName(client: RedisClient): string { const connectionName = client.options?.connectionName return connectionName ? `<${connectionName}>` : '' } ================================================ FILE: src/utils/redlock.ts ================================================ export function getQuorum(clientCount: number): number { return Math.round((clientCount + 1) / 2) } export function smartSum(count: number, zeroOrOne: number): number { return count + zeroOrOne } ================================================ FILE: test/init.test.ts ================================================ import { init, removeAllListeners } from './unhandledRejection' before(() => { init() }) after(() => { removeAllListeners() }) ================================================ FILE: test/redisClient.ts ================================================ import Redis from 'ioredis' import RedisMock from 'ioredis-mock' import { once } from 'node:events' function createClient(num: number) { const serverURL = process.env[`REDIS_URI${num}`] || `redis://127.0.0.1:${6000 + num}` const client = new Redis(serverURL, { connectionName: `client${num}`, lazyConnect: true, enableOfflineQueue: false, autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20) // https://github.com/luin/ioredis#auto-reconnect // retryStrategy is a function that will be called when the connection is lost. // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect. retryStrategy() { return 100 // for tests we disable increasing timeout } }) client.on('error', err => { console.log('Redis client error:', err.message) }) return client } function createClientMock(num: number) { return new RedisMock(`redis://mock:${4200 + num}`, { connectionName: `client-mock${num}`, lazyConnect: true, enableOfflineQueue: false, autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20) // https://github.com/luin/ioredis#auto-reconnect // retryStrategy is a function that will be called when the connection is lost. // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect. retryStrategy() { return 100 // for tests we disable increasing timeout } }) } export const client1 = createClient(1) export const client2 = createClient(2) export const client3 = createClient(3) export const allClients = [client1, client2, client3] export const clientMock1 = createClientMock(1) export const clientMock2 = createClientMock(2) export const clientMock3 = createClientMock(3) export const allClientMocks = [clientMock1, clientMock2, clientMock3] before(async () => { await Promise.all(allClients.map(c => c.connect())) await Promise.all(allClientMocks.map(c => c.connect())) }) beforeEach(async () => { await Promise.all( allClients.map(c => { if (c.status !== 'ready') { console.warn( `client ${c.options.connectionName} status = ${c.status}. Wait for ready.` ) return once(c, 'ready') } return null }) ) await Promise.all(allClients.map(c => c.flushdb())) await Promise.all(allClientMocks.map(c => c.flushdb())) }) after(async () => { await Promise.all(allClients.map(c => c.quit())) await Promise.all(allClientMocks.map(c => c.quit())) // allClients.forEach(c => c.disconnect()) }) ================================================ FILE: test/setup.ts ================================================ import chai from 'chai' import chaiAsPromised from 'chai-as-promised' import sinonChai from 'sinon-chai' chai.use(chaiAsPromised) chai.use(sinonChai) ================================================ FILE: test/shell.test.ts ================================================ import { downRedisServer, upRedisServer } from './shell' describe('TEST UTILS', () => { describe('shell', () => { it('should up redis server', async function () { this.timeout(30000) await upRedisServer(1) }) it('should down and up redis servers', async function () { this.timeout(30000) await downRedisServer(1) await upRedisServer(1) }) }) }) ================================================ FILE: test/shell.ts ================================================ import { exec } from 'child_process' import { delay } from '../src/utils/index' const LOGGING = !!process.env.LOGSHELL function sh(cmd: string) { return new Promise((resolve, reject) => { const cp = exec(cmd, (err, stdout, stderr) => { if (stdout && LOGGING) { console.log(`[${cp.pid}] stdout:`) console.log(stdout) } if (stderr && LOGGING) { console.log(`[${cp.pid}] stderr:`) console.log(stderr) } if (err) { reject(err) } else { resolve() } }) console.log(`[${cp.pid}] ${cmd}`) }) } export async function upRedisServer(num: number) { const port = 6000 + num await sh( `docker compose up -d redis${num} && yarn wait-for --redis redis://127.0.0.1:${port}` ) } export async function downRedisServer(num: number) { const port = 6000 + num await sh(`docker compose stop redis${num}`) let tries = 0 while (true) { try { console.log(`wait server${num} shut down... ${++tries}`) await sh(`yarn wait-for --redis redis://127.0.0.1:${port} -c 1`) await delay(100) } catch { break } } } ================================================ FILE: test/src/Lock.test.ts ================================================ import { LockOptions } from '../../src' import { Lock } from '../../src/Lock' import { delay } from '../../src/utils' describe('Lock', () => { describe('refresh and release race condition', () => { class TestLock extends Lock { protected _kind = 'test-lock' protected _key: string constructor(key: string, options: LockOptions) { super(options) this._key = key } protected async _refresh(): Promise { await delay(200) return false } protected async _acquire(): Promise { return true } protected async _release(): Promise {} } it('should not throw LostLock error when refresh started but not finished before release happened', async function () { const lock = new TestLock('key', { lockTimeout: 1000, acquireTimeout: 1000, refreshInterval: 50 }) try { await lock.acquire() await delay(100) } finally { await lock.release() } }) }) }) ================================================ FILE: test/src/RedisMultiSemaphore.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import MultiSemaphore from '../../src/RedisMultiSemaphore' import Semaphore from '../../src/RedisSemaphore' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { client1 as client, clientMock1 as clientMock } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } describe('MultiSemaphore', () => { it('should fail on invalid arguments', () => { expect( () => new MultiSemaphore(null as unknown as Redis, 'key', 5, 2) ).to.throw('"client" is required') expect(() => new MultiSemaphore(client, '', 5, 2)).to.throw( '"key" is required' ) expect( () => new MultiSemaphore(client, 1 as unknown as string, 5, 2) ).to.throw('"key" must be a string') expect(() => new MultiSemaphore(client, 'key', 0, 2)).to.throw( '"limit" is required' ) expect( () => new MultiSemaphore(client, 'key', '10' as unknown as number, 2) ).to.throw('"limit" must be a number') expect(() => new MultiSemaphore(client, 'key', 5, 0)).to.throw( '"permits" is required' ) expect( () => new MultiSemaphore(client, 'key', 5, '2' as unknown as number) ).to.throw('"permits" must be a number') }) it('should acquire and release semaphore', async () => { const semaphore1 = new MultiSemaphore(client, 'key', 3, 2) const semaphore2 = new MultiSemaphore(client, 'key', 3, 1) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier + '_0', semaphore1.identifier + '_1', semaphore2.identifier + '_0' ]) await semaphore1.release() expect(semaphore1.isAcquired).to.be.false expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier + '_0' ]) await semaphore2.release() expect(semaphore2.isAcquired).to.be.false expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should reject after timeout', async () => { const semaphore1 = new MultiSemaphore(client, 'key', 3, 3, timeoutOptions) const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) await semaphore1.acquire() await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire multi-semaphore semaphore:key timeout' ) await semaphore1.release() expect(await client.get('semaphore:key')).to.be.eql(null) }) it('should abort when the given signal is aborted', async () => { const semaphore1 = new MultiSemaphore(client, 'key', 3, 3, timeoutOptions) const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) await semaphore1.acquire() await expect(semaphore2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await semaphore1.release() expect(await client.get('semaphore:key')).to.be.eql(null) }) it('should refresh lock every refreshInterval ms until release', async () => { const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier + '_0', semaphore1.identifier + '_1', semaphore2.identifier + '_0' ]) await semaphore1.release() expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier + '_0' ]) await semaphore2.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should stop refreshing lock if stopped', async () => { const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) const semaphore2 = new MultiSemaphore(client, 'key', 3, 1, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await semaphore1.stopRefresh() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier + '_0' ]) await semaphore2.stopRefresh() await delay(400) expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should acquire maximum LIMIT semaphores', async () => { const s = () => new MultiSemaphore(client, 'key', 3, 1, { acquireTimeout: 1000, lockTimeout: 50, retryInterval: 10, refreshInterval: 0 // disable refresh }) const pr1 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) await delay(5) const pr2 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) await pr1 const ids1 = await client.zrange('semaphore:key', 0, -1) expect(ids1.length).to.be.eql(3) await pr2 const ids2 = await client.zrange('semaphore:key', 0, -1) expect(ids2.length).to.be.eql(3) expect(ids2) .to.not.include(ids1[0]) .and.not.include(ids1[1]) .and.not.include(ids1[2]) }) it('should support externally acquired semaphore (deprecated interface)', async () => { const externalSemaphore = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, externallyAcquiredIdentifier: externalSemaphore.identifier }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ localSemaphore.identifier + '_0', localSemaphore.identifier + '_1' ]) await localSemaphore.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should support externally acquired semaphore', async () => { const externalSemaphore = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, identifier: externalSemaphore.identifier, acquiredExternally: true }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ localSemaphore.identifier + '_0', localSemaphore.identifier + '_1' ]) await localSemaphore.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock is lost between refreshes', async () => { const semaphore = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) await semaphore.acquire() await client.del('semaphore:key') await client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) await delay(200) expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided', async () => { const onLockLostCallback = sinon.spy(function (this: MultiSemaphore) { expect(this.isAcquired).to.be.false }) const semaphore = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, onLockLost: onLockLostCallback }) await semaphore.acquire() expect(semaphore.isAcquired).to.be.true await client.del('semaphore:key') await client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) await delay(200) expect(semaphore.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) describe('reusable', () => { it('autorefresh enabled', async function () { this.timeout(10000) const semaphore1 = new MultiSemaphore(client, 'key', 4, 2, timeoutOptions) const semaphore2 = new MultiSemaphore(client, 'key', 4, 2, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() }) it('autorefresh disabled', async () => { const noRefreshOptions = { ...timeoutOptions, refreshInterval: 0, acquireTimeout: 10 } const semaphore1 = new MultiSemaphore( client, 'key', 4, 2, noRefreshOptions ) const semaphore2 = new MultiSemaphore( client, 'key', 4, 2, noRefreshOptions ) const semaphore3 = new MultiSemaphore( client, 'key', 4, 2, noRefreshOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) // [0/2] await semaphore1.acquire() // [1/2] await delay(80) await semaphore2.acquire() // [2/2] await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire multi-semaphore semaphore:key timeout' ) // rejectes after 10ms // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) // semaphore1 will expire after 300 - 90 = 210ms await delay(210) // [1/2] await semaphore3.acquire() }) }) describe('Compatibility with Semaphore', () => { it('should work with Semaphore', async () => { const multiSemaphore1 = new MultiSemaphore( client, 'key', 3, 2, timeoutOptions ) const multiSemaphore2 = new MultiSemaphore( client, 'key', 3, 2, timeoutOptions ) const semaphore1 = new Semaphore(client, 'key', 3, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 3, timeoutOptions) await multiSemaphore1.acquire() await semaphore1.acquire() expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ multiSemaphore1.identifier + '_0', multiSemaphore1.identifier + '_1', semaphore1.identifier ]) await expect(multiSemaphore2.acquire()).to.be.rejectedWith( 'Acquire multi-semaphore semaphore:key timeout' ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire semaphore semaphore:key timeout' ) await multiSemaphore1.release() expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore1.identifier ]) await semaphore1.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) }) describe('[Node shutdown]', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(async () => { throwUnhandledRejection() await upRedisServer(1) }) it('should lost lock when node become alive', async function () { this.timeout(60000) const onLockLostCallback = sinon.spy(function (this: Semaphore) { expect(this.isAcquired).to.be.false }) const semaphore1 = new MultiSemaphore(client, 'key', 3, 2, { ...timeoutOptions, onLockLost: onLockLostCallback }) await semaphore1.acquire() await downRedisServer(1) console.log('SHUT DOWN') await delay(1000) await upRedisServer(1) console.log('ONLINE') // semaphore was expired, key was deleted in redis // give refresh mechanism time to detect lock lost // (includes reconnection time) await delay(1000) const data1 = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') // console.log(data) expect(data1).to.be.eql([]) // lock was not refreshed by semaphore1, so semaphore2 can acquire the lock const semaphore2 = new MultiSemaphore(client, 'key', 3, 2, timeoutOptions) await semaphore2.acquire() const data2 = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') expect(data2).to.include(semaphore2.identifier + '_0') expect(data2).to.include(semaphore2.identifier + '_1') await Promise.all([semaphore1.release(), semaphore2.release()]) }) }) describe('ioredis-mock support', () => { it('should acquire and release semaphore', async () => { const semaphore1 = new MultiSemaphore(clientMock, 'key', 3, 2) const semaphore2 = new MultiSemaphore(clientMock, 'key', 3, 1) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true expect(await clientMock.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier + '_0', semaphore1.identifier + '_1', semaphore2.identifier + '_0' ]) await semaphore1.release() expect(semaphore1.isAcquired).to.be.false expect(await clientMock.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier + '_0' ]) await semaphore2.release() expect(semaphore2.isAcquired).to.be.false expect(await clientMock.zcard('semaphore:key')).to.be.eql(0) }) }) }) ================================================ FILE: test/src/RedisMutex.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import Mutex from '../../src/RedisMutex' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { client1 as client, clientMock1 as clientMock } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } describe('Mutex', () => { it('should fail on invalid arguments', () => { expect(() => new Mutex(null as unknown as Redis, 'key')).to.throw( '"client" is required' ) expect(() => new Mutex(client, '')).to.throw('"key" is required') expect(() => new Mutex(client, 1 as unknown as string)).to.throw( '"key" must be a string' ) expect(() => new Mutex(client, 'key', { identifier: '' })).to.throw( 'identifier must be not empty random string' ) expect( () => new Mutex(client, 'key', { acquiredExternally: true }) ).to.throw('acquiredExternally=true meanless without custom identifier') expect( () => new Mutex(client, 'key', { externallyAcquiredIdentifier: '123', identifier: '123' }) ).to.throw( 'Invalid usage. Use custom identifier and acquiredExternally: true' ) expect( () => new Mutex(client, 'key', { externallyAcquiredIdentifier: '123', acquiredExternally: true, identifier: '123' }) ).to.throw( 'Invalid usage. Use custom identifier and acquiredExternally: true' ) }) it('should set default options', () => { expect(new Mutex(client, 'key', {})).to.be.ok expect(new Mutex(client, 'key')).to.be.ok }) it('should set random UUID as identifier', () => { expect(new Mutex(client, 'key').identifier).to.match( /^[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}$/ ) }) it('should add identifier suffix', () => { expect( new Mutex(client, 'key', { identifierSuffix: 'abc' }).identifier ).to.match( /^[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$/ ) }) it('should use custom identifier if provided', () => { expect( new Mutex(client, 'key', { identifier: 'abc' }).identifier ).to.be.eql('abc') }) it('should acquire and release lock', async () => { const mutex = new Mutex(client, 'key') expect(mutex.isAcquired).to.be.false await mutex.acquire() expect(mutex.isAcquired).to.be.true expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(mutex.isAcquired).to.be.false expect(await client.get('mutex:key')).to.be.eql(null) }) it('should reject after timeout', async () => { const mutex1 = new Mutex(client, 'key', timeoutOptions) const mutex2 = new Mutex(client, 'key', timeoutOptions) await mutex1.acquire() await expect(mutex2.acquire()).to.be.rejectedWith( 'Acquire mutex mutex:key timeout' ) await mutex1.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should abort when the given signal is aborted', async () => { const mutex1 = new Mutex(client, 'key', timeoutOptions) const mutex2 = new Mutex(client, 'key', timeoutOptions) await mutex1.acquire() await expect(mutex2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await mutex1.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should return false for tryAcquire after timeout', async () => { const mutex1 = new Mutex(client, 'key', timeoutOptions) const mutex2 = new Mutex(client, 'key', timeoutOptions) await mutex1.acquire() const result = await mutex2.tryAcquire() expect(result).to.be.false await mutex1.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should return true for successful tryAcquire', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) const result = await mutex.tryAcquire() expect(result).to.be.true await mutex.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should refresh lock every refreshInterval ms until release', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) await mutex.acquire() await delay(400) expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should stop refreshing lock every refreshInterval ms if stopped', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) await mutex.acquire() mutex.stopRefresh() await delay(400) expect(await client.get('mutex:key')).to.be.eql(null) }) it('should not call _refresh if already refreshing', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) let callCount = 0 Object.assign(mutex, { _refresh: () => delay(100).then(() => { callCount++ return true }) }) await mutex.acquire() await delay(400) expect(callCount).to.be.eql(2) // not floor(400/80) = 9 }) it('should support externally acquired mutex (deprecated interface)', async () => { const externalMutex = new Mutex(client, 'key', { ...timeoutOptions, refreshInterval: 0 }) const localMutex = new Mutex(client, 'key', { ...timeoutOptions, externallyAcquiredIdentifier: externalMutex.identifier }) await externalMutex.acquire() await localMutex.acquire() await delay(400) expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier) await localMutex.release() expect(await client.get('mutex:key')).to.be.eql(null) }) it('should support externally acquired mutex', async () => { const externalMutex = new Mutex(client, 'key', { ...timeoutOptions, refreshInterval: 0 }) const localMutex = new Mutex(client, 'key', { ...timeoutOptions, identifier: externalMutex.identifier, acquiredExternally: true }) await externalMutex.acquire() await localMutex.acquire() await delay(400) expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier) await localMutex.release() expect(await client.get('mutex:key')).to.be.eql(null) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock was lost between refreshes (another instance acquired)', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) await mutex.acquire() expect(mutex.isAcquired).to.be.true await client.set('mutex:key', '222') // another instance await delay(200) expect(mutex.isAcquired).to.be.false expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should throw unhandled error if lock was lost between refreshes (lock expired)', async () => { const mutex = new Mutex(client, 'key', timeoutOptions) await mutex.acquire() expect(mutex.isAcquired).to.be.true await client.del('mutex:key') // expired await delay(200) expect(mutex.isAcquired).to.be.false expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided (another instance acquired)', async () => { const onLockLostCallback = sinon.spy(function (this: Mutex) { expect(this.isAcquired).to.be.false }) const mutex = new Mutex(client, 'key', { ...timeoutOptions, onLockLost: onLockLostCallback }) await mutex.acquire() expect(mutex.isAcquired).to.be.true await client.set('mutex:key', '222') // another instance await delay(200) expect(mutex.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) it('should call onLockLost callback if provided (lock expired)', async () => { const onLockLostCallback = sinon.spy(function (this: Mutex) { expect(this.isAcquired).to.be.false }) const mutex = new Mutex(client, 'key', { ...timeoutOptions, onLockLost: onLockLostCallback }) await mutex.acquire() expect(mutex.isAcquired).to.be.true await client.del('mutex:key') // expired await delay(200) expect(mutex.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) it('should be reusable', async function () { this.timeout(10000) const mutex = new Mutex(client, 'key', timeoutOptions) /* Lifecycle 1 */ await mutex.acquire() await delay(300) expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(await client.get('mutex:key')).to.be.eql(null) await delay(300) expect(await client.get('mutex:key')).to.be.eql(null) await delay(300) /* Lifecycle 2 */ await mutex.acquire() await delay(300) expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(await client.get('mutex:key')).to.be.eql(null) await delay(300) expect(await client.get('mutex:key')).to.be.eql(null) await delay(300) /* Lifecycle 3 */ await mutex.acquire() await delay(300) expect(await client.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(await client.get('mutex:key')).to.be.eql(null) await delay(300) expect(await client.get('mutex:key')).to.be.eql(null) }) describe('[Node shutdown]', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(async () => { throwUnhandledRejection() await upRedisServer(1) }) it('should lost lock when node become alive', async function () { this.timeout(60000) const onLockLostCallback = sinon.spy(function (this: Mutex) { expect(this.isAcquired).to.be.false }) const mutex1 = new Mutex(client, 'key', { ...timeoutOptions, onLockLost: onLockLostCallback }) await mutex1.acquire() await downRedisServer(1) await delay(1000) // lock expired now await upRedisServer(1) // mutex was expired, key was deleted in redis // give refresh mechanism time to detect lock lost // (includes client reconnection time) await delay(1000) expect(await client.get('mutex:key')).to.be.eql(null) expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true // lock was not reacquired by mutex1, so mutex2 can acquire the lock const mutex2 = new Mutex(client, 'key', timeoutOptions) await mutex2.acquire() expect(await client.get('mutex:key')).to.be.eql(mutex2.identifier) await Promise.all([mutex1.release(), mutex2.release()]) }) }) describe('ioredis-mock support', async () => { it('should acquire and release lock', async () => { const mutex = new Mutex(clientMock, 'key') expect(mutex.isAcquired).to.be.false await mutex.acquire() expect(mutex.isAcquired).to.be.true expect(await clientMock.get('mutex:key')).to.be.eql(mutex.identifier) await mutex.release() expect(mutex.isAcquired).to.be.false expect(await clientMock.get('mutex:key')).to.be.eql(null) }) }) }) ================================================ FILE: test/src/RedisSemaphore.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import Semaphore from '../../src/RedisSemaphore' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { client1 as client, clientMock1 as clientMock } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } describe('Semaphore', () => { it('should fail on invalid arguments', () => { expect(() => new Semaphore(null as unknown as Redis, 'key', 5)).to.throw( '"client" is required' ) expect(() => new Semaphore(client, '', 5)).to.throw('"key" is required') expect(() => new Semaphore(client, 1 as unknown as string, 5)).to.throw( '"key" must be a string' ) expect(() => new Semaphore(client, 'key', 0)).to.throw( '"limit" is required' ) expect( () => new Semaphore(client, 'key', '10' as unknown as number) ).to.throw('"limit" must be a number') }) it('should acquire and release semaphore', async () => { const semaphore1 = new Semaphore(client, 'key', 2) const semaphore2 = new Semaphore(client, 'key', 2) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier, semaphore2.identifier ]) await semaphore1.release() expect(semaphore1.isAcquired).to.be.false expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier ]) await semaphore2.release() expect(semaphore2.isAcquired).to.be.false expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should reject after timeout', async () => { const semaphore1 = new Semaphore(client, 'key', 1, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 1, timeoutOptions) await semaphore1.acquire() await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire semaphore semaphore:key timeout' ) await semaphore1.release() expect(await client.get('semaphore:key')).to.be.eql(null) }) it('should abort when the given signal is aborted', async () => { const semaphore1 = new Semaphore(client, 'key', 1, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 1, timeoutOptions) await semaphore1.acquire() await expect(semaphore2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await semaphore1.release() expect(await client.get('semaphore:key')).to.be.eql(null) }) it('should refresh lock every refreshInterval ms until release', async () => { const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier, semaphore2.identifier ]) await semaphore1.release() expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier ]) await semaphore2.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should stop refreshing lock if stopped', async () => { const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await semaphore1.stopRefresh() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier ]) await semaphore2.stopRefresh() await delay(400) expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should acquire maximum LIMIT semaphores', async () => { const s = () => new Semaphore(client, 'key', 3, { acquireTimeout: 1000, lockTimeout: 50, retryInterval: 10, refreshInterval: 0 // disable refresh }) const pr1 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) await delay(5) const pr2 = Promise.all([s().acquire(), s().acquire(), s().acquire()]) await pr1 const ids1 = await client.zrange('semaphore:key', 0, -1) expect(ids1.length).to.be.eql(3) await pr2 const ids2 = await client.zrange('semaphore:key', 0, -1) expect(ids2.length).to.be.eql(3) expect(ids2) .to.not.include(ids1[0]) .and.not.include(ids1[1]) .and.not.include(ids1[2]) }) it('should support externally acquired semaphore (deprecated interface)', async () => { const externalSemaphore = new Semaphore(client, 'key', 3, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new Semaphore(client, 'key', 3, { ...timeoutOptions, externallyAcquiredIdentifier: externalSemaphore.identifier }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ localSemaphore.identifier ]) await localSemaphore.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) it('should support externally acquired semaphore', async () => { const externalSemaphore = new Semaphore(client, 'key', 3, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new Semaphore(client, 'key', 3, { ...timeoutOptions, identifier: externalSemaphore.identifier, acquiredExternally: true }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ localSemaphore.identifier ]) await localSemaphore.release() expect(await client.zcard('semaphore:key')).to.be.eql(0) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock is lost between refreshes', async () => { const semaphore = new Semaphore(client, 'key', 3, timeoutOptions) await semaphore.acquire() await client.del('semaphore:key') await client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) await delay(200) expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided', async () => { const onLockLostCallback = sinon.spy(function (this: Semaphore) { expect(this.isAcquired).to.be.false }) const semaphore = new Semaphore(client, 'key', 3, { ...timeoutOptions, onLockLost: onLockLostCallback }) await semaphore.acquire() expect(semaphore.isAcquired).to.be.true await client.del('semaphore:key') await client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) await delay(200) expect(semaphore.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) describe('reusable', () => { it('autorefresh enabled', async () => { const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions) const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() }) it('autorefresh disabled', async () => { const noRefreshOptions = { ...timeoutOptions, refreshInterval: 0, acquireTimeout: 10 } const semaphore1 = new Semaphore(client, 'key', 2, noRefreshOptions) const semaphore2 = new Semaphore(client, 'key', 2, noRefreshOptions) const semaphore3 = new Semaphore(client, 'key', 2, noRefreshOptions) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) // [0/2] await semaphore1.acquire() // [1/2] await delay(80) await semaphore2.acquire() // [2/2] await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire semaphore semaphore:key timeout' ) // rejectes after 10ms // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) // semaphore1 will expire after 300 - 90 = 210ms await delay(210) // [1/2] await semaphore3.acquire() }) }) describe('[Node shutdown]', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(async () => { throwUnhandledRejection() await upRedisServer(1) }) it('should lost lock when node become alive', async function () { this.timeout(60000) const onLockLostCallbacks = [1, 2, 3].map(() => sinon.spy(function (this: Semaphore) { expect(this.isAcquired).to.be.false }) ) const semaphores1 = [1, 2, 3].map( (n, i) => new Semaphore(client, 'key', 3, { ...timeoutOptions, onLockLost: onLockLostCallbacks[i] }) ) await Promise.all(semaphores1.map(s => s.acquire())) await downRedisServer(1) console.log('SHUT DOWN') await delay(1000) await upRedisServer(1) console.log('ONLINE') // semaphore was expired, key was deleted in redis // give refresh mechanism time to detect lock lost // (includes reconnection time) await delay(1000) const data = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES') expect(data).to.be.eql([]) // console.log(data) // expect(data).to.include(semaphore11.identifier) // expect(data).to.include(semaphore12.identifier) // expect(data).to.include(semaphore13.identifier) // lock was not reacquired by semaphore1[1-3], so semaphore2 can acquire the lock const semaphore2 = new Semaphore(client, 'key', 3, timeoutOptions) await semaphore2.acquire() expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore2.identifier ]) await Promise.all([ ...semaphores1.map(s => s.release()), semaphore2.release() ]) }) }) describe('ioredis-mock support', async () => { it('should acquire and release semaphore', async () => { const semaphore1 = new Semaphore(clientMock, 'key', 2) const semaphore2 = new Semaphore(clientMock, 'key', 2) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true expect(await clientMock.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore1.identifier, semaphore2.identifier ]) await semaphore1.release() expect(semaphore1.isAcquired).to.be.false expect(await clientMock.zrange('semaphore:key', 0, -1)).to.be.eql([ semaphore2.identifier ]) await semaphore2.release() expect(semaphore2.isAcquired).to.be.false expect(await clientMock.zcard('semaphore:key')).to.be.eql(0) }) }) }) ================================================ FILE: test/src/RedlockMultiSemaphore.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import RedlockMultiSemaphore from '../../src/RedlockMultiSemaphore' import RedlockSemaphore from '../../src/RedlockSemaphore' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { allClientMocks, allClients, client1, client2, client3 } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } async function expectZRangeAllEql(key: string, values: string[]) { const results = await Promise.all([ client1.zrange(key, 0, -1), client2.zrange(key, 0, -1), client3.zrange(key, 0, -1) ]) expect(results).to.be.eql([values, values, values]) } async function expectZRangeAllHaveMembers(key: string, values: string[]) { const results = await Promise.all([ client1.zrange(key, 0, -1), client2.zrange(key, 0, -1), client3.zrange(key, 0, -1) ]) for (const result of results) { expect(result).to.have.members(values) } } async function expectZCardAllEql(key: string, count: number) { const results = await Promise.all([ client1.zcard(key), client2.zcard(key), client3.zcard(key) ]) expect(results).to.be.eql([count, count, count]) } describe('RedlockMultiSemaphore', () => { it('should fail on invalid arguments', () => { expect( () => new RedlockMultiSemaphore(null as unknown as Redis[], 'key', 5, 2) ).to.throw('"clients" array is required') expect(() => new RedlockMultiSemaphore(allClients, '', 5, 2)).to.throw( '"key" is required' ) expect( () => new RedlockMultiSemaphore(allClients, 1 as unknown as string, 5, 2) ).to.throw('"key" must be a string') expect(() => new RedlockMultiSemaphore(allClients, 'key', 0, 2)).to.throw( '"limit" is required' ) expect( () => new RedlockMultiSemaphore( allClients, 'key', '10' as unknown as number, 2 ) ).to.throw('"limit" must be a number') expect(() => new RedlockMultiSemaphore(allClients, 'key', 5, 0)).to.throw( '"permits" is required' ) expect( () => new RedlockMultiSemaphore( allClients, 'key', 5, '2' as unknown as number ) ).to.throw('"permits" must be a number') }) it('should acquire and release semaphore', async () => { const semaphore1 = new RedlockMultiSemaphore(allClients, 'key', 3, 2) const semaphore2 = new RedlockMultiSemaphore(allClients, 'key', 3, 1) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true await expectZRangeAllHaveMembers('semaphore:key', [ semaphore1.identifier + '_0', semaphore1.identifier + '_1', semaphore2.identifier + '_0' ]) await semaphore1.release() expect(semaphore1.isAcquired).to.be.false await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) await semaphore2.release() expect(semaphore2.isAcquired).to.be.false await expectZCardAllEql('semaphore:key', 0) }) it('should reject after timeout', async () => { const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 3, 3, timeoutOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await semaphore1.acquire() await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-multi-semaphore semaphore:key timeout' ) await semaphore1.release() await expectZCardAllEql('semaphore:key', 0) }) it('should abort when the given signal is aborted', async () => { const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 3, 3, timeoutOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await semaphore1.acquire() await expect(semaphore2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await semaphore1.release() await expectZCardAllEql('semaphore:key', 0) }) it('should refresh lock every refreshInterval ms until release', async () => { const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(400) await expectZRangeAllHaveMembers('semaphore:key', [ semaphore1.identifier + '_0', semaphore1.identifier + '_1', semaphore2.identifier + '_0' ]) await semaphore1.release() await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) await semaphore2.release() await expectZCardAllEql('semaphore:key', 0) }) it('should stop refreshing lock if stopped', async () => { const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() semaphore1.stopRefresh() await delay(400) await expectZRangeAllEql('semaphore:key', [semaphore2.identifier + '_0']) semaphore2.stopRefresh() await delay(400) await expectZCardAllEql('semaphore:key', 0) }) it('should acquire maximum LIMIT semaphores', async () => { const s = () => new RedlockMultiSemaphore(allClients, 'key', 3, 1, { acquireTimeout: 1000, lockTimeout: 50, retryInterval: 10, refreshInterval: 0 // disable refresh }) const set1 = [s(), s(), s()] const pr1 = Promise.all(set1.map(sem => sem.acquire())) await delay(5) const set2 = [s(), s(), s()] const pr2 = Promise.all(set2.map(sem => sem.acquire())) await pr1 await expectZRangeAllHaveMembers('semaphore:key', [ set1[0].identifier + '_0', set1[1].identifier + '_0', set1[2].identifier + '_0' ]) await expectZCardAllEql('semaphore:key', 3) await pr2 await expectZRangeAllHaveMembers('semaphore:key', [ set2[0].identifier + '_0', set2[1].identifier + '_0', set2[2].identifier + '_0' ]) await expectZCardAllEql('semaphore:key', 3) }) it('should support externally acquired semaphore (deprecated interface)', async () => { const externalSemaphore = new RedlockMultiSemaphore( allClients, 'key', 3, 2, { ...timeoutOptions, refreshInterval: 0 } ) const localSemaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { ...timeoutOptions, externallyAcquiredIdentifier: externalSemaphore.identifier }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) await expectZRangeAllHaveMembers('semaphore:key', [ localSemaphore.identifier + '_0', localSemaphore.identifier + '_1' ]) await localSemaphore.release() await expectZCardAllEql('semaphore:key', 0) }) it('should support externally acquired semaphore', async () => { const externalSemaphore = new RedlockMultiSemaphore( allClients, 'key', 3, 2, { ...timeoutOptions, refreshInterval: 0 } ) const localSemaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { ...timeoutOptions, identifier: externalSemaphore.identifier, acquiredExternally: true }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) await expectZRangeAllHaveMembers('semaphore:key', [ localSemaphore.identifier + '_0', localSemaphore.identifier + '_1' ]) await localSemaphore.release() await expectZCardAllEql('semaphore:key', 0) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock is lost between refreshes', async () => { const semaphore = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) await semaphore.acquire() await Promise.all(allClients.map(client => client.del('semaphore:key'))) await Promise.all( allClients.map(client => client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) ) ) await delay(200) expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided', async () => { const onLockLostCallback = sinon.spy(function ( this: RedlockMultiSemaphore ) { expect(this.isAcquired).to.be.false }) const semaphore = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { ...timeoutOptions, onLockLost: onLockLostCallback }) await semaphore.acquire() expect(semaphore.isAcquired).to.be.true await Promise.all(allClients.map(client => client.del('semaphore:key'))) await Promise.all( allClients.map(client => client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) ) ) await delay(200) expect(semaphore.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) describe('reusable', () => { it('autorefresh enabled', async function () { this.timeout(10000) const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 4, 2, timeoutOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 4, 2, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() }) it('autorefresh disabled', async () => { const noRefreshOptions = { ...timeoutOptions, refreshInterval: 0, acquireTimeout: 10 } const semaphore1 = new RedlockMultiSemaphore( allClients, 'key', 4, 2, noRefreshOptions ) const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 4, 2, noRefreshOptions ) const semaphore3 = new RedlockMultiSemaphore( allClients, 'key', 4, 2, noRefreshOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(300) await semaphore1.release() await semaphore2.release() await delay(300) // [0/2] await semaphore1.acquire() // [1/2] await delay(80) await semaphore2.acquire() // [2/2] await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire redlock-multi-semaphore semaphore:key timeout' ) // rejectes after 10ms // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) // semaphore1 will expire after 300 - 90 = 210ms await delay(210) // [1/2] await semaphore3.acquire() }) }) describe('Compatibility with Semaphore', () => { it('should work with Semaphore', async () => { const multiSemaphore1 = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) const multiSemaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) const semaphore1 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await multiSemaphore1.acquire() await semaphore1.acquire() await expectZRangeAllHaveMembers('semaphore:key', [ multiSemaphore1.identifier + '_0', multiSemaphore1.identifier + '_1', semaphore1.identifier ]) await expect(multiSemaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-multi-semaphore semaphore:key timeout' ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) await multiSemaphore1.release() await expectZRangeAllEql('semaphore:key', [semaphore1.identifier]) await semaphore1.release() await expectZCardAllEql('semaphore:key', 0) }) }) describe('[Node shutdown]', () => { afterEach(async () => { await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) }) it('should handle server shutdown if quorum is alive', async function () { this.timeout(60000) const semaphore11 = new RedlockMultiSemaphore( allClients, 'key', 3, 2, timeoutOptions ) const semaphore12 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await Promise.all([semaphore11.acquire(), semaphore12.acquire()]) // await downRedisServer(1) console.log('SHUT DOWN 1') await delay(1000) // lock survive in server2 and server3 // semaphore2 will NOT be able to acquire the lock const semaphore2 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server1 has expired now await upRedisServer(1) console.log('ONLINE 1') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier + '_0', semaphore11.identifier + '_1', semaphore12.identifier + '_0' ]) // // await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) // lock survive in server1 and server3 // semaphore3 will NOT be able to acquire the lock const semaphore3 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server2 has expired now await upRedisServer(2) console.log('ONLINE 2') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier + '_0', semaphore11.identifier + '_1', semaphore12.identifier + '_0' ]) // // await downRedisServer(3) console.log('SHUT DOWN 3') await delay(1000) // lock survive in server1 and server2 // semaphore4 will NOT be able to acquire the lock const semaphore4 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore4.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server1 has expired now await upRedisServer(3) console.log('ONLINE 3') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier + '_0', semaphore11.identifier + '_1', semaphore12.identifier + '_0' ]) // await Promise.all([semaphore11.release(), semaphore12.release()]) }) it('should fail and release if quorum become dead', async function () { this.timeout(60000) const onLockLostCallbacks = [1, 2].map(() => sinon.spy(function (this: RedlockMultiSemaphore) { expect(this.isAcquired).to.be.false }) ) const semaphore11 = new RedlockMultiSemaphore(allClients, 'key', 3, 2, { ...timeoutOptions, onLockLost: onLockLostCallbacks[0] }) const semaphore12 = new RedlockMultiSemaphore(allClients, 'key', 3, 1, { ...timeoutOptions, onLockLost: onLockLostCallbacks[1] }) await Promise.all([semaphore11.acquire(), semaphore12.acquire()]) await downRedisServer(1) console.log('SHUT DOWN 1') await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) for (const lostCb of onLockLostCallbacks) { expect(lostCb).to.be.called expect(lostCb.firstCall.firstArg instanceof LostLockError).to.be.true } // released lock on server3 expect(await client3.zrange('semaphore:key', 0, -1)).to.be.eql([]) // semaphore2 will NOT be able to acquire the lock const semaphore2 = new RedlockMultiSemaphore( allClients, 'key', 3, 1, timeoutOptions ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-multi-semaphore semaphore:key timeout' ) }) }) describe('ioredis-mock support', () => { it('should acquire and release semaphore', async () => { const semaphore1 = new RedlockMultiSemaphore(allClientMocks, 'key', 3, 2) const semaphore2 = new RedlockMultiSemaphore(allClientMocks, 'key', 3, 1) expect(semaphore1.isAcquired).to.be.false expect(semaphore2.isAcquired).to.be.false await semaphore1.acquire() expect(semaphore1.isAcquired).to.be.true await semaphore2.acquire() expect(semaphore2.isAcquired).to.be.true await semaphore1.release() expect(semaphore1.isAcquired).to.be.false await semaphore2.release() expect(semaphore2.isAcquired).to.be.false }) }) }) ================================================ FILE: test/src/RedlockMutex.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import RedlockMutex from '../../src/RedlockMutex' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { allClientMocks, allClients, client1, client2, client3 } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } async function expectGetAll( key: string, value: string | null, clients = allClients ) { await expect( Promise.all([clients[0].get(key), clients[1].get(key), clients[2].get(key)]) ).to.become([value, value, value]) } describe('RedlockMutex', () => { it('should fail on invalid arguments', () => { expect(() => new RedlockMutex(null as unknown as Redis[], 'key')).to.throw( '"clients" array is required' ) expect(() => new RedlockMutex(allClients, '')).to.throw('"key" is required') expect(() => new RedlockMutex(allClients, 1 as unknown as string)).to.throw( '"key" must be a string' ) }) it('should acquire and release lock', async () => { const mutex = new RedlockMutex(allClients, 'key') expect(mutex.isAcquired).to.be.false await mutex.acquire() expect(mutex.isAcquired).to.be.true await expectGetAll('mutex:key', mutex.identifier) await mutex.release() expect(mutex.isAcquired).to.be.false await expectGetAll('mutex:key', null) }) it('should reject after timeout', async () => { const mutex1 = new RedlockMutex(allClients, 'key', timeoutOptions) const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex1.acquire() await expect(mutex2.acquire()).to.be.rejectedWith( 'Acquire redlock-mutex mutex:key timeout' ) await mutex1.release() await expectGetAll('mutex:key', null) }) it('The operation was aborted due to timeout', async () => { const mutex1 = new RedlockMutex(allClients, 'key', timeoutOptions) const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex1.acquire() await expect(mutex2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await mutex1.release() await expectGetAll('mutex:key', null) }) it('should refresh lock every refreshInterval ms until release', async () => { const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex.acquire() await delay(400) await expectGetAll('mutex:key', mutex.identifier) await mutex.release() await expectGetAll('mutex:key', null) }) it('should stop refreshing if stopped', async () => { const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex.acquire() mutex.stopRefresh() await delay(400) await expectGetAll('mutex:key', null) }) it('should support externally acquired mutex (deprecated interface)', async () => { const externalMutex = new RedlockMutex(allClients, 'key', { ...timeoutOptions, refreshInterval: 0 }) const localMutex = new RedlockMutex(allClients, 'key', { ...timeoutOptions, externallyAcquiredIdentifier: externalMutex.identifier }) await externalMutex.acquire() await localMutex.acquire() await delay(400) await expectGetAll('mutex:key', localMutex.identifier) await localMutex.release() await expectGetAll('mutex:key', null) }) it('should support externally acquired mutex', async () => { const externalMutex = new RedlockMutex(allClients, 'key', { ...timeoutOptions, refreshInterval: 0 }) const localMutex = new RedlockMutex(allClients, 'key', { ...timeoutOptions, identifier: externalMutex.identifier, acquiredExternally: true }) await externalMutex.acquire() await localMutex.acquire() await delay(400) await expectGetAll('mutex:key', localMutex.identifier) await localMutex.release() await expectGetAll('mutex:key', null) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock is lost between refreshes', async () => { const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex.acquire() await Promise.all([ client1.set('mutex:key', '222'), // another instance client2.set('mutex:key', '222'), // another instance client3.set('mutex:key', '222') // another instance ]) await delay(200) expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided', async () => { const onLockLostCallback = sinon.spy(function (this: RedlockMutex) { expect(this.isAcquired).to.be.false }) const mutex = new RedlockMutex(allClients, 'key', { ...timeoutOptions, onLockLost: onLockLostCallback }) await mutex.acquire() expect(mutex.isAcquired).to.be.true await Promise.all([ client1.set('mutex:key', '222'), // another instance client2.set('mutex:key', '222'), // another instance client3.set('mutex:key', '222') // another instance ]) await delay(200) expect(mutex.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) it('should be reusable', async () => { const mutex = new RedlockMutex(allClients, 'key', timeoutOptions) /* Lifecycle 1 */ await mutex.acquire() await delay(100) await expectGetAll('mutex:key', mutex.identifier) await mutex.release() await expectGetAll('mutex:key', null) await delay(100) await expectGetAll('mutex:key', null) await delay(100) /* Lifecycle 2 */ await mutex.acquire() await delay(100) await expectGetAll('mutex:key', mutex.identifier) await mutex.release() await expectGetAll('mutex:key', null) await delay(100) await expectGetAll('mutex:key', null) await delay(100) /* Lifecycle 3 */ await mutex.acquire() await delay(100) await expectGetAll('mutex:key', mutex.identifier) await mutex.release() await expectGetAll('mutex:key', null) await delay(100) await expectGetAll('mutex:key', null) }) describe('[Node shutdown]', () => { afterEach(async () => { await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) }) it('should handle server shutdown if quorum is alive', async function () { this.timeout(60000) const mutex1 = new RedlockMutex(allClients, 'key', timeoutOptions) await mutex1.acquire() // await downRedisServer(1) console.log('SHUT DOWN 1') await delay(1000) // lock survive in server2 and server3 // mutex2 will NOT be able to acquire the lock const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) await expect(mutex2.acquire()).to.be.rejectedWith( 'Acquire redlock-mutex mutex:key timeout' ) // key in server1 has expired now await upRedisServer(1) console.log('ONLINE 1') // let mutex1 to refresh lock on server1 await delay(1000) expect(await client1.get('mutex:key')).to.be.eql(mutex1.identifier) // // await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) // lock survive in server1 and server3 // mutex3 will NOT be able to acquire the lock const mutex3 = new RedlockMutex(allClients, 'key', timeoutOptions) await expect(mutex3.acquire()).to.be.rejectedWith( 'Acquire redlock-mutex mutex:key timeout' ) // key in server2 has expired now await upRedisServer(2) console.log('ONLINE 2') // let mutex1 to refresh lock on server2 await delay(1000) expect(await client2.get('mutex:key')).to.be.eql(mutex1.identifier) // // await downRedisServer(3) console.log('SHUT DOWN 3') await delay(1000) // lock survive in server1 and server2 // mutex4 will NOT be able to acquire the lock const mutex4 = new RedlockMutex(allClients, 'key', timeoutOptions) await expect(mutex4.acquire()).to.be.rejectedWith( 'Acquire redlock-mutex mutex:key timeout' ) // key in server3 has expired now await upRedisServer(3) console.log('ONLINE 3') // let mutex1 to refresh lock on server3 await delay(1000) expect(await client3.get('mutex:key')).to.be.eql(mutex1.identifier) // await mutex1.release() }) it('should fail and release when quorum is become dead', async function () { this.timeout(60000) const onLockLostCallback = sinon.spy(function (this: RedlockMutex) { expect(this.isAcquired).to.be.false }) const mutex1 = new RedlockMutex(allClients, 'key', { ...timeoutOptions, onLockLost: onLockLostCallback }) await mutex1.acquire() await downRedisServer(1) console.log('SHUT DOWN 1') await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true // released lock on server3 expect(await client3.get('mutex:key')).to.be.eql(null) // mutex2 will NOT be able to acquire the lock cause quorum is dead const mutex2 = new RedlockMutex(allClients, 'key', timeoutOptions) await expect(mutex2.acquire()).to.be.rejectedWith( 'Acquire redlock-mutex mutex:key timeout' ) }) }) describe('ioredis-mock support', () => { it('should acquire and release lock', async () => { const mutex = new RedlockMutex(allClientMocks, 'key') expect(mutex.isAcquired).to.be.false await mutex.acquire() console.log('acquired!') expect(mutex.isAcquired).to.be.true await expectGetAll('mutex:key', mutex.identifier, allClientMocks) await mutex.release() expect(mutex.isAcquired).to.be.false await expectGetAll('mutex:key', null, allClientMocks) }) }) }) ================================================ FILE: test/src/RedlockSemaphore.test.ts ================================================ import { expect } from 'chai' import { Redis } from 'ioredis' import sinon from 'sinon' import LostLockError from '../../src/errors/LostLockError' import RedlockSemaphore from '../../src/RedlockSemaphore' import { TimeoutOptions } from '../../src/types' import { delay } from '../../src/utils/index' import { allClientMocks, allClients, client1, client2, client3 } from '../redisClient' import { downRedisServer, upRedisServer } from '../shell' import { catchUnhandledRejection, throwUnhandledRejection, unhandledRejectionSpy } from '../unhandledRejection' const timeoutOptions: TimeoutOptions = { lockTimeout: 300, acquireTimeout: 100, refreshInterval: 80, retryInterval: 10 } async function expectZRangeAllEql(key: string, values: string[]) { const results = await Promise.all([ client1.zrange(key, 0, -1), client2.zrange(key, 0, -1), client3.zrange(key, 0, -1) ]) expect(results).to.be.eql([values, values, values]) } async function expectZRangeAllHaveMembers(key: string, values: string[]) { const results = await Promise.all([ client1.zrange(key, 0, -1), client2.zrange(key, 0, -1), client3.zrange(key, 0, -1) ]) for (const result of results) { expect(result).to.have.members(values) } } async function expectZCardAllEql(key: string, count: number) { const results = await Promise.all([ client1.zcard(key), client2.zcard(key), client3.zcard(key) ]) expect(results).to.be.eql([count, count, count]) } describe('RedlockSemaphore', () => { it('should fail on invalid arguments', () => { expect( () => new RedlockSemaphore(null as unknown as Redis[], 'key', 5) ).to.throw('"clients" array is required') expect(() => new RedlockSemaphore(allClients, '', 5)).to.throw( '"key" is required' ) expect( () => new RedlockSemaphore(allClients, 1 as unknown as string, 5) ).to.throw('"key" must be a string') expect(() => new RedlockSemaphore(allClients, 'key', 0)).to.throw( '"limit" is required' ) expect( () => new RedlockSemaphore(allClients, 'key', '10' as unknown as number) ).to.throw('"limit" must be a number') }) it('should acquire and release semaphore', async () => { const semaphore1 = new RedlockSemaphore(allClients, 'key', 2) const semaphore2 = new RedlockSemaphore(allClients, 'key', 2) await semaphore1.acquire() await semaphore2.acquire() await expectZRangeAllHaveMembers('semaphore:key', [ semaphore1.identifier, semaphore2.identifier ]) await semaphore1.release() await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) await semaphore2.release() await expectZCardAllEql('semaphore:key', 0) }) it('should reject after timeout', async () => { const semaphore1 = new RedlockSemaphore( allClients, 'key', 1, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 1, timeoutOptions ) await semaphore1.acquire() await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) await semaphore1.release() await expectZCardAllEql('semaphore:key', 0) }) it('The operation was aborted due to timeout', async () => { const semaphore1 = new RedlockSemaphore( allClients, 'key', 1, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 1, timeoutOptions ) await semaphore1.acquire() await expect(semaphore2.acquire(AbortSignal.timeout(10))).to.be.rejectedWith( 'The operation was aborted due to timeout' ) await semaphore1.release() await expectZCardAllEql('semaphore:key', 0) }) it('should refresh lock every refreshInterval ms until release', async () => { const semaphore1 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(400) await expectZRangeAllHaveMembers('semaphore:key', [ semaphore1.identifier, semaphore2.identifier ]) await semaphore1.release() await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) await semaphore2.release() await expectZCardAllEql('semaphore:key', 0) }) it('should stop refreshing lock if stopped', async () => { const semaphore1 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() semaphore1.stopRefresh() await delay(400) await expectZRangeAllEql('semaphore:key', [semaphore2.identifier]) semaphore2.stopRefresh() await delay(400) await expectZCardAllEql('semaphore:key', 0) }) it('should acquire maximum LIMIT semaphores', async () => { const s = () => new RedlockSemaphore(allClients, 'key', 3, { acquireTimeout: 1000, lockTimeout: 50, retryInterval: 10, refreshInterval: 0 // disable refresh }) const set1 = [s(), s(), s()] const pr1 = Promise.all(set1.map(sem => sem.acquire())) await delay(5) const set2 = [s(), s(), s()] const pr2 = Promise.all(set2.map(sem => sem.acquire())) await pr1 await expectZRangeAllHaveMembers('semaphore:key', [ set1[0].identifier, set1[1].identifier, set1[2].identifier ]) await expectZCardAllEql('semaphore:key', 3) await pr2 await expectZRangeAllHaveMembers('semaphore:key', [ set2[0].identifier, set2[1].identifier, set2[2].identifier ]) await expectZCardAllEql('semaphore:key', 3) }) it('should support externally acquired semaphore (deprecated interface)', async () => { const externalSemaphore = new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, externallyAcquiredIdentifier: externalSemaphore.identifier }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) await expectZRangeAllEql('semaphore:key', [localSemaphore.identifier]) await localSemaphore.release() await expectZCardAllEql('semaphore:key', 0) }) it('should support externally acquired semaphore', async () => { const externalSemaphore = new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, refreshInterval: 0 }) const localSemaphore = new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, identifier: externalSemaphore.identifier, acquiredExternally: true }) await externalSemaphore.acquire() await localSemaphore.acquire() await delay(400) await expectZRangeAllEql('semaphore:key', [localSemaphore.identifier]) await localSemaphore.release() await expectZCardAllEql('semaphore:key', 0) }) describe('lost lock case', () => { beforeEach(() => { catchUnhandledRejection() }) afterEach(() => { throwUnhandledRejection() }) it('should throw unhandled error if lock is lost between refreshes', async () => { const semaphore = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await semaphore.acquire() await Promise.all(allClients.map(client => client.del('semaphore:key'))) await Promise.all( allClients.map(client => client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) ) ) await delay(200) expect(unhandledRejectionSpy).to.be.called expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError) .to.be.true }) it('should call onLockLost callback if provided', async () => { const onLockLostCallback = sinon.spy(function (this: RedlockSemaphore) { expect(this.isAcquired).to.be.false }) const semaphore = new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, onLockLost: onLockLostCallback }) await semaphore.acquire() expect(semaphore.isAcquired).to.be.true await Promise.all(allClients.map(client => client.del('semaphore:key'))) await Promise.all( allClients.map(client => client.zadd( 'semaphore:key', Date.now(), 'aaa', Date.now(), 'bbb', Date.now(), 'ccc' ) ) ) await delay(200) expect(semaphore.isAcquired).to.be.false expect(unhandledRejectionSpy).to.not.called expect(onLockLostCallback).to.be.called expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to .be.true }) }) describe('reusable', () => { it('autorefresh enabled', async () => { const semaphore1 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 2, timeoutOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(100) await semaphore1.release() await semaphore2.release() await delay(100) await semaphore1.acquire() await semaphore2.acquire() await delay(100) await semaphore1.release() await semaphore2.release() await delay(100) await semaphore1.acquire() await semaphore2.acquire() await delay(100) await semaphore1.release() await semaphore2.release() }) it('autorefresh disabled', async () => { const noRefreshOptions = { ...timeoutOptions, refreshInterval: 0, acquireTimeout: 10 } const semaphore1 = new RedlockSemaphore( allClients, 'key', 2, noRefreshOptions ) const semaphore2 = new RedlockSemaphore( allClients, 'key', 2, noRefreshOptions ) const semaphore3 = new RedlockSemaphore( allClients, 'key', 2, noRefreshOptions ) await semaphore1.acquire() await semaphore2.acquire() await delay(100) await semaphore1.release() await semaphore2.release() await delay(100) // [0/2] await semaphore1.acquire() // [1/2] await delay(80) await semaphore2.acquire() // [2/2] await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // rejectes after 10ms // since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout) // semaphore1 will expire after 300 - 90 = 210ms await delay(210) // [1/2] await semaphore3.acquire() }) }) describe('[Node shutdown]', () => { afterEach(async () => { await Promise.all([upRedisServer(1), upRedisServer(2), upRedisServer(3)]) }) it('should handle server shutdown if quorum is alive', async function () { this.timeout(60000) const semaphore11 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) const semaphore12 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) const semaphore13 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await Promise.all([ semaphore11.acquire(), semaphore12.acquire(), semaphore13.acquire() ]) // await downRedisServer(1) console.log('SHUT DOWN 1') await delay(1000) // lock survive in server2 and server3 // semaphore2 will NOT be able to acquire the lock const semaphore2 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server1 has expired now await upRedisServer(1) console.log('ONLINE 1') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier, semaphore12.identifier, semaphore13.identifier ]) // // await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) // lock survive in server1 and server3 // semaphore3 will NOT be able to acquire the lock const semaphore3 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore3.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server2 has expired now await upRedisServer(2) console.log('ONLINE 2') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier, semaphore12.identifier, semaphore13.identifier ]) // // await downRedisServer(3) console.log('SHUT DOWN 3') await delay(1000) // lock survive in server1 and server2 // semaphore4 will NOT be able to acquire the lock const semaphore4 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore4.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) // key in server1 has expired now await upRedisServer(3) console.log('ONLINE 3') // let semaphore1[1-3] to refresh lock on server1 await delay(1000) expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ semaphore11.identifier, semaphore12.identifier, semaphore13.identifier ]) // await Promise.all([ semaphore11.release(), semaphore12.release(), semaphore13.release() ]) }) it('should fail and release when quorum become dead', async function () { this.timeout(60000) const onLockLostCallbacks = [1, 2, 3].map(() => sinon.spy(function (this: RedlockSemaphore) { expect(this.isAcquired).to.be.false }) ) const semaphores1 = [1, 2, 3].map( (n, i) => new RedlockSemaphore(allClients, 'key', 3, { ...timeoutOptions, onLockLost: onLockLostCallbacks[i] }) ) await Promise.all(semaphores1.map(s => s.acquire())) expect(await client1.zrange('semaphore:key', 0, -1)).to.have.members([ semaphores1[0].identifier, semaphores1[1].identifier, semaphores1[2].identifier ]) expect(await client2.zrange('semaphore:key', 0, -1)).to.have.members([ semaphores1[0].identifier, semaphores1[1].identifier, semaphores1[2].identifier ]) expect(await client3.zrange('semaphore:key', 0, -1)).to.have.members([ semaphores1[0].identifier, semaphores1[1].identifier, semaphores1[2].identifier ]) await downRedisServer(1) console.log('SHUT DOWN 1') await downRedisServer(2) console.log('SHUT DOWN 2') await delay(1000) for (const lostCb of onLockLostCallbacks) { expect(lostCb).to.be.called expect(lostCb.firstCall.firstArg instanceof LostLockError).to.be.true } // released lock on server3 expect(await client3.zrange('semaphore:key', 0, -1)).to.be.eql([]) // semaphore2 will NOT be able to acquire the lock const semaphore2 = new RedlockSemaphore( allClients, 'key', 3, timeoutOptions ) await expect(semaphore2.acquire()).to.be.rejectedWith( 'Acquire redlock-semaphore semaphore:key timeout' ) }) }) describe('ioredis-mock support', () => { it('should acquire and release semaphore', async () => { const semaphore1 = new RedlockSemaphore(allClientMocks, 'key', 2) const semaphore2 = new RedlockSemaphore(allClientMocks, 'key', 2) await semaphore1.acquire() await semaphore2.acquire() await semaphore1.release() await semaphore2.release() }) }) }) ================================================ FILE: test/src/index.test.ts ================================================ import { expect } from 'chai' import { defaultTimeoutOptions, MultiSemaphore, Mutex, RedlockMultiSemaphore, RedlockMutex, RedlockSemaphore, Semaphore } from '../../src/index' describe('index', () => { it('should export public API', () => { expect(Mutex).to.be.ok expect(Semaphore).to.be.ok expect(MultiSemaphore).to.be.ok expect(RedlockMutex).to.be.ok expect(RedlockSemaphore).to.be.ok expect(RedlockMultiSemaphore).to.be.ok expect(defaultTimeoutOptions).to.be.ok }) }) ================================================ FILE: test/src/multiSemaphore/acquire/index.test.ts ================================================ import { expect } from 'chai' import { acquireSemaphore as acquire, Options } from '../../../../src/multiSemaphore/acquire/index' import { client1 as client } from '../../../redisClient' const opts = (id: string, overrides?: Partial): Options => ({ identifier: id, acquireTimeout: 50, acquireAttemptsLimit: Number.POSITIVE_INFINITY, lockTimeout: 100, retryInterval: 10, ...overrides }) describe('multiSemaphore acquire', () => { it('should return true for success acquire', async () => { const result = await acquire(client, 'key', 1, 1, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) expect(result).to.be.true }) it('should return false when timeout', async () => { const result1 = await acquire(client, 'key', 2, 1, opts('111')) // expire after 100ms const result2 = await acquire(client, 'key', 2, 1, opts('112')) // expire after 100ms const result3 = await acquire(client, 'key', 2, 1, opts('113')) // timeout after 50ms expect(result1).to.be.true expect(result2).to.be.true expect(result3).to.be.false }) it('should return false after acquireAttemptsLimit', async () => { const result1 = await acquire(client, 'key', 2, 1, opts('111')) // expire after 100ms const result2 = await acquire(client, 'key', 2, 1, opts('112')) // expire after 100ms const result3 = await acquire( client, 'key', 2, 1, opts('113', { acquireAttemptsLimit: 1, acquireTimeout: Number.POSITIVE_INFINITY }) ) // no timeout, attempt limit = 1 expect(result1).to.be.true expect(result2).to.be.true expect(result3).to.be.false }) }) ================================================ FILE: test/src/multiSemaphore/acquire/internal.test.ts ================================================ import { expect } from 'chai' import { acquireLua } from '../../../../src/multiSemaphore/acquire/lua' import { client1 as client } from '../../../redisClient' interface Options { identifier: string lockTimeout: number now: number } const opts = (id: string, nowOffset = 0): Options => ({ identifier: id, lockTimeout: 500, now: new Date().getTime() + nowOffset }) async function acquire(options: Options) { const { identifier, lockTimeout, now } = options return await acquireLua(client, ['key', 1, 1, identifier, lockTimeout, now]) } describe('multiSemaphore acquire internal', () => { it('should return 1 for success acquire', async () => { const result = await acquire(opts('111')) expect(result).to.be.eql(1) expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) }) it('should return 0 for failure acquire', async () => { const result1 = await acquire(opts('111')) const result2 = await acquire(opts('112')) expect(await client.zrange('key', 0, -1)).to.be.eql(['111_0']) expect(result1).to.be.eql(1) expect(result2).to.be.eql(0) }) describe('TIME SHIFT case', () => { it('should handle time difference less than lockTimeout (nodeA has faster clocks)', async () => { // lockTimeout = 500ms // nodeA is for 450ms faster than nodeB const resultA = await acquire(opts('111', 450)) const resultB = await acquire(opts('112', 0)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(0) }) it('should handle time difference less than lockTimeout (nodeA has slower clocks)', async () => { // lockTimeout = 500ms // nodeB is for 450ms faster than nodeA const resultA = await acquire(opts('111', 0)) const resultB = await acquire(opts('112', 450)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(0) }) it('cant handle time difference greater than lockTimeout (nodeA has slower clocks)', async () => { // lockTimeout = 500ms // nodeB is for 550ms faster than nodeA const resultA = await acquire(opts('111', 0)) const resultB = await acquire(opts('112', 550)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(1) // Semaphore stealed... // This happens due removing "expired" nodeA lock (at nodeB "now" nodeA lock has been expired 50ms ago) // Unfortunatelly "fair" semaphore described here // 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/ // also has the same problem }) }) }) ================================================ FILE: test/src/multiSemaphore/refresh/index.test.ts ================================================ import { expect } from 'chai' import { Options, refreshSemaphore as refresh } from '../../../../src/multiSemaphore/refresh/index' import { client1 as client } from '../../../redisClient' const opts = (id: string): Options => ({ identifier: id, lockTimeout: 100 }) describe('multiSemaphore refresh', () => { it('should return false if resource is already acquired', async () => { const now = '' + (Date.now() - 10) await client.zadd('key', now, '222', now, '333', now, '444') const result = await refresh(client, 'key', 3, 2, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) expect(result).to.be.false }) it('should return false if resource is already acquired, but some expired', async () => { const now = '' + (Date.now() - 10) const oldNow = '' + (Date.now() - 10000) await client.zadd('key', oldNow, '222', oldNow, '333', now, '444') expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) const result = await refresh(client, 'key', 3, 2, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['444']) expect(result).to.be.false }) it('should return false if resource is not acquired', async () => { const result = await refresh(client, 'key', 3, 2, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql([]) expect(result).to.be.false }) it('should return true for success refresh', async () => { const now = '' + (Date.now() - 10) await client.zadd('key', now, '111_0', now, '111_1', now, '333') expect(await client.zrange('key', 0, -1)).to.be.eql([ '111_0', '111_1', '333' ]) const result = await refresh(client, 'key', 3, 2, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql([ '333', '111_0', '111_1' ]) expect(result).to.be.true }) }) ================================================ FILE: test/src/multiSemaphore/release/index.test.ts ================================================ import { expect } from 'chai' import { releaseSemaphore as release } from '../../../../src/multiSemaphore/release/index' import { client1 as client } from '../../../redisClient' describe('multiSemaphore release', () => { it('should remove key after success release', async () => { await client.zadd('key', '' + Date.now(), '111_0') expect(await client.zcard('key')).to.be.eql(1) await release(client, 'key', 1, '111') expect(await client.zcard('key')).to.be.eql(0) }) it('should do nothing if resource is not locked', async () => { expect(await client.zcard('key')).to.be.eql(0) await release(client, 'key', 1, '111') expect(await client.zcard('key')).to.be.eql(0) }) }) ================================================ FILE: test/src/mutex/acquire.test.ts ================================================ import { expect } from 'chai' import { acquireMutex as acquire, Options } from '../../../src/mutex/acquire' import { client1 as client } from '../../redisClient' const opts = (id: string, overrides?: Partial): Options => ({ identifier: id, acquireTimeout: 50, acquireAttemptsLimit: Number.POSITIVE_INFINITY, lockTimeout: 100, retryInterval: 10, ...overrides }) describe('mutex acquire', () => { it('should return true for success lock', async () => { const result = await acquire(client, 'key', opts('111')) expect(result).to.be.true }) it('should return false when timeout', async () => { const result1 = await acquire(client, 'key', opts('111')) const result2 = await acquire(client, 'key', opts('222')) expect(result1).to.be.true expect(result2).to.be.false }) it('should return false after acquireAttemptsLimit', async () => { const result1 = await acquire(client, 'key', opts('111')) const result2 = await acquire( client, 'key', opts('222', { acquireAttemptsLimit: 1, acquireTimeout: Number.POSITIVE_INFINITY }) ) expect(result1).to.be.true expect(result2).to.be.false }) it('should set identifier for key', async () => { await acquire(client, 'key1', opts('111')) const value = await client.get('key1') expect(value).to.be.eql('111') }) it('should set TTL for key', async () => { await acquire(client, 'key2', opts('111')) const ttl = await client.pttl('key2') expect(ttl).to.be.gte(90) expect(ttl).to.be.lte(100) }) it('should wait for auto-release', async () => { const start1 = Date.now() await acquire(client, 'key', opts('111')) const start2 = Date.now() await acquire(client, 'key', opts('222')) const now = Date.now() expect(start2 - start1).to.be.gte(0) expect(start2 - start1).to.be.lt(10) expect(now - start1).to.be.gte(50) expect(now - start2).to.be.gte(50) }) it('should wait per key', async () => { const start1 = Date.now() await Promise.all([ acquire(client, 'key1', opts('a1')), acquire(client, 'key2', opts('a2')) ]) const start2 = Date.now() await Promise.all([ acquire(client, 'key1', opts('b1')), acquire(client, 'key2', opts('b2')) ]) const now = Date.now() expect(start2 - start1).to.be.gte(0) expect(start2 - start1).to.be.lt(10) expect(now - start1).to.be.gte(50) expect(now - start2).to.be.gte(50) }) }) ================================================ FILE: test/src/mutex/refresh.test.ts ================================================ import { expect } from 'chai' import { refreshMutex as refresh } from '../../../src/mutex/refresh' import { client1 as client } from '../../redisClient' describe('mutex refresh', () => { it('should return false if resource is already acquired by different instance', async () => { await client.set('key', '222') const result = await refresh(client, 'key', '111', 10000) expect(result).to.be.false }) it('should return false if resource is not acquired', async () => { const result = await refresh(client, 'key', '111', 10000) expect(result).to.be.false }) it('should return true for success refresh', async () => { await client.set('key', '111') const result = await refresh(client, 'key', '111', 20000) expect(result).to.be.true expect(await client.pttl('key')).to.be.gte(10000) }) }) ================================================ FILE: test/src/mutex/release.test.ts ================================================ import { expect } from 'chai' import { releaseMutex as release } from '../../../src/mutex/release' import { client1 as client } from '../../redisClient' describe('Mutex release', () => { it('should remove key after release', async () => { await client.set('key', '111') await release(client, 'key', '111') expect(await client.get('key')).to.be.eql(null) }) it('should do nothing if resource is not locked', async () => { expect(await client.get('key')).to.be.eql(null) await release(client, 'key', '111') expect(await client.get('key')).to.be.eql(null) }) }) ================================================ FILE: test/src/redlockMutex/acquire.test.ts ================================================ import { expect } from 'chai' import { acquireRedlockMutex as acquire, Options } from '../../../src/redlockMutex/acquire' import { allClients } from '../../redisClient' const opts = (id: string, overrides?: Partial): Options => ({ identifier: id, acquireTimeout: 50, acquireAttemptsLimit: Number.POSITIVE_INFINITY, lockTimeout: 100, retryInterval: 10, ...overrides }) describe('redlockMutex acquire', () => { it('should return true for success lock', async () => { const result = await acquire(allClients, 'key', opts('111')) expect(result).to.be.true }) it('should return false when timeout', async () => { const result1 = await acquire(allClients, 'key', opts('111')) const result2 = await acquire(allClients, 'key', opts('222')) expect(result1).to.be.true expect(result2).to.be.false }) it('should return false after acquireAttemptsLimit', async () => { const result1 = await acquire(allClients, 'key', opts('111')) const result2 = await acquire( allClients, 'key', opts('222', { acquireAttemptsLimit: 1, acquireTimeout: Number.POSITIVE_INFINITY }) ) expect(result1).to.be.true expect(result2).to.be.false }) it('should set identifier for key', async () => { await acquire(allClients, 'key1', opts('111')) const values = await Promise.all( allClients.map(client => client.get('key1')) ) expect(values).to.be.eql(['111', '111', '111']) }) it('should set TTL for key', async () => { await acquire(allClients, 'key2', opts('111')) const ttls = await Promise.all( allClients.map(client => client.pttl('key2')) ) for (const ttl of ttls) { if (ttl === -2) { continue } expect(ttl).to.be.gte(90) expect(ttl).to.be.lte(100) } }) it('should wait for auto-release', async () => { const start1 = Date.now() await acquire(allClients, 'key', opts('111')) const start2 = Date.now() await acquire(allClients, 'key', opts('222')) const now = Date.now() expect(start2 - start1).to.be.gte(0) expect(start2 - start1).to.be.lt(10) expect(now - start1).to.be.gte(50) expect(now - start2).to.be.gte(50) }) it('should wait per key', async () => { const start1 = Date.now() await Promise.all([ acquire(allClients, 'key1', opts('a1')), acquire(allClients, 'key2', opts('a2')) ]) const start2 = Date.now() await Promise.all([ acquire(allClients, 'key1', opts('b1')), acquire(allClients, 'key2', opts('b2')) ]) const now = Date.now() expect(start2 - start1).to.be.gte(0) expect(start2 - start1).to.be.lt(10) expect(now - start1).to.be.gte(50) expect(now - start2).to.be.gte(50) }) }) ================================================ FILE: test/src/redlockMutex/refresh.test.ts ================================================ import { expect } from 'chai' import { refreshRedlockMutex as refresh } from '../../../src/redlockMutex/refresh' import { allClients, client1, client2, client3 } from '../../redisClient' describe('redlockMutex refresh', () => { it('should return false if resource is acquired by different instance on quorum', async () => { await client1.set('key', '111') await client2.set('key', '222') await client3.set('key', '222') const result = await refresh(allClients, 'key', '111', 10000) expect(result).to.be.false }) it('should return true if resource is acquired on quorum', async () => { await client1.set('key', '111') await client2.set('key', '111') const result = await refresh(allClients, 'key', '111', 20000) expect(result).to.be.true expect(await client1.pttl('key')).to.be.gte(10000) expect(await client2.pttl('key')).to.be.gte(10000) }) it('should return false if resource is not acquired on quorum', async () => { await client1.set('key', '111') const result = await refresh(allClients, 'key', '111', 10000) expect(result).to.be.false }) }) ================================================ FILE: test/src/redlockMutex/release.test.ts ================================================ import { expect } from 'chai' import { releaseRedlockMutex as release } from '../../../src/redlockMutex/release' import { allClients, client1 } from '../../redisClient' describe('redlockMutex release', () => { it('should remove key after release', async () => { await client1.set('key', '111') await release(allClients, 'key', '111') expect(await client1.get('key')).to.be.eql(null) }) it('should do nothing if resource is not locked', async () => { expect(await client1.get('key')).to.be.eql(null) await release(allClients, 'key', '111') expect(await client1.get('key')).to.be.eql(null) }) }) ================================================ FILE: test/src/semaphore/acquire/index.test.ts ================================================ import { expect } from 'chai' import { acquireSemaphore as acquire, Options } from '../../../../src/semaphore/acquire/index' import { client1 as client } from '../../../redisClient' const opts = (id: string, overrides?: Partial): Options => ({ identifier: id, acquireTimeout: 50, acquireAttemptsLimit: Number.POSITIVE_INFINITY, lockTimeout: 100, retryInterval: 10, ...overrides }) describe('semaphore acquire', () => { it('should return true for success acquire', async () => { const result = await acquire(client, 'key', 1, opts('111')) expect(result).to.be.true }) it('should return false when timeout', async () => { const result1 = await acquire(client, 'key', 2, opts('111')) // expire after 100ms const result2 = await acquire(client, 'key', 2, opts('112')) // expire after 100ms const result3 = await acquire(client, 'key', 2, opts('113')) // timeout after 50ms expect(result1).to.be.true expect(result2).to.be.true expect(result3).to.be.false }) it('should return false after acquireAttemptsLimit', async () => { const result1 = await acquire(client, 'key', 2, opts('111')) // expire after 100ms const result2 = await acquire(client, 'key', 2, opts('112')) // expire after 100ms const result3 = await acquire( client, 'key', 2, opts('113', { acquireAttemptsLimit: 1, acquireTimeout: Number.POSITIVE_INFINITY }) ) // no timeout, acquire limit = 1 expect(result1).to.be.true expect(result2).to.be.true expect(result3).to.be.false }) }) ================================================ FILE: test/src/semaphore/acquire/internal.test.ts ================================================ import { expect } from 'chai' import { acquireLua } from '../../../../src/semaphore/acquire/lua' import { client1 as client } from '../../../redisClient' interface Options { identifier: string lockTimeout: number now: number } const opts = (id: string, nowOffset = 0): Options => ({ identifier: id, lockTimeout: 500, now: new Date().getTime() + nowOffset }) async function acquire(options: Options) { const { identifier, lockTimeout, now } = options return await acquireLua(client, ['key', 1, identifier, lockTimeout, now]) } describe('semaphore acquire internal', () => { it('should return 1 for success acquire', async () => { const result = await acquire(opts('111')) expect(result).to.be.eql(1) }) it('should return 0 for failure acquire', async () => { const result1 = await acquire(opts('111')) const result2 = await acquire(opts('112')) expect(result1).to.be.eql(1) expect(result2).to.be.eql(0) }) describe('TIME SHIFT case', () => { it('should handle time difference less than lockTimeout (nodeA has faster clocks)', async () => { // lockTimeout = 500ms // nodeA is for 450ms faster than nodeB const resultA = await acquire(opts('111', 450)) const resultB = await acquire(opts('112', 0)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(0) }) it('should handle time difference less than lockTimeout (nodeA has slower clocks)', async () => { // lockTimeout = 500ms // nodeB is for 450ms faster than nodeA const resultA = await acquire(opts('111', 0)) const resultB = await acquire(opts('112', 450)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(0) }) it('cant handle time difference greater than lockTimeout (nodeA has slower clocks)', async () => { // lockTimeout = 500ms // nodeB is for 550ms faster than nodeA const resultA = await acquire(opts('111', 0)) const resultB = await acquire(opts('112', 550)) expect(resultA).to.be.eql(1) expect(resultB).to.be.eql(1) // Semaphore stealed... // This happens due removing "expired" nodeA lock (at nodeB "now" nodeA lock has been expired 50ms ago) // Unfortunatelly "fair" semaphore described here // 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/ // also has the same problem }) }) }) ================================================ FILE: test/src/semaphore/refresh/index.test.ts ================================================ import { expect } from 'chai' import { Options, refreshSemaphore as refresh } from '../../../../src/semaphore/refresh/index' import { client1 as client } from '../../../redisClient' const opts = (id: string): Options => ({ identifier: id, lockTimeout: 100 }) describe('semaphore refresh', () => { it('should return false if resource is already acquired', async () => { const now = '' + (Date.now() - 10) await client.zadd('key', now, '222', now, '333', now, '444') const result = await refresh(client, 'key', 3, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) expect(result).to.be.false }) it('should return false if resource is already acquired, but some expired', async () => { const now = '' + (Date.now() - 10) const oldNow = '' + (Date.now() - 10000) await client.zadd('key', oldNow, '222', now, '333', now, '444') expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '444']) const result = await refresh(client, 'key', 3, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['333', '444']) expect(result).to.be.false }) it('should return false if resource is not acquired', async () => { const result = await refresh(client, 'key', 3, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql([]) expect(result).to.be.false }) it('should return true for success refresh', async () => { const now = '' + (Date.now() - 10) await client.zadd('key', now, '111', now, '222', now, '333') expect(await client.zrange('key', 0, -1)).to.be.eql(['111', '222', '333']) const result = await refresh(client, 'key', 3, opts('111')) expect(await client.zrange('key', 0, -1)).to.be.eql(['222', '333', '111']) expect(result).to.be.true }) }) ================================================ FILE: test/src/semaphore/release.test.ts ================================================ import { expect } from 'chai' import { releaseSemaphore as release } from '../../../src/semaphore/release' import { client1 as client } from '../../redisClient' describe('semaphore release', () => { it('should remove key after success release', async () => { await client.zadd('key', '' + Date.now(), '111') expect(await client.zcard('key')).to.be.eql(1) await release(client, 'key', '111') expect(await client.zcard('key')).to.be.eql(0) }) it('should do nothing if resource is not locked', async () => { expect(await client.zcard('key')).to.be.eql(0) await release(client, 'key', '111') expect(await client.zcard('key')).to.be.eql(0) }) }) ================================================ FILE: test/src/utils/eval.test.ts ================================================ import { expect } from 'chai' import { createEval } from '../../../src/utils/index' import { client1 as client } from '../../redisClient' describe('utils createEval', () => { it('should return function', async () => { expect(createEval('return 5', 0)).to.be.a('function') }) it('should call evalsha or fallback to eval', async () => { const now = Date.now() const SCRIPT = `return ${now}` const execScript = createEval(SCRIPT, 0) const result = await execScript(client, []) expect(result).to.be.eql(now) expect(Date.now() - now).to.be.lt(50) }) it('should handle eval errors', async () => { const execScript = createEval('return asdfkasjdf', 0) await expect(execScript(client, [])).to.be.rejected }) }) ================================================ FILE: test/src/utils/index.test.ts ================================================ import { expect } from 'chai' import Redis from 'ioredis' import { getConnectionName } from '../../../src/utils/index' import { client1 } from '../../redisClient' describe('utils getConnectionName', () => { it('should return connection name', async () => { expect(getConnectionName(client1)).to.be.eql('') }) it('should return unknown if connection name not configured', () => { const client = new Redis('redis://127.0.0.1:6000', { lazyConnect: true, enableOfflineQueue: false, autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic) maxRetriesPerRequest: 0 // dont retry, fail faster (default is 20) }) expect(getConnectionName(client)).to.be.eql('') }) }) ================================================ FILE: test/src/utils/redlock.test.ts ================================================ import { expect } from 'chai' import { getQuorum } from '../../../src/utils/redlock' describe('redlockMutex utils', () => { describe('getQuorum', () => { function makeTest(count: number, expectedResult: number) { it(`should return valid majority for ${count} nodes`, () => { expect(getQuorum(count)).to.be.eql(expectedResult) expect(getQuorum(2)).to.be.eql(2) expect(getQuorum(3)).to.be.eql(2) expect(getQuorum(4)).to.be.eql(3) expect(getQuorum(5)).to.be.eql(3) expect(getQuorum(6)).to.be.eql(4) expect(getQuorum(7)).to.be.eql(4) expect(getQuorum(8)).to.be.eql(5) expect(getQuorum(9)).to.be.eql(5) }) } // makeTest(0, 1) makeTest(1, 1) makeTest(2, 2) makeTest(3, 2) makeTest(4, 3) makeTest(5, 3) makeTest(6, 4) makeTest(7, 4) makeTest(8, 5) makeTest(9, 5) }) }) ================================================ FILE: test/unhandledRejection.ts ================================================ import sinon from 'sinon' function throwReason(reason: any) { console.log('unhandled rejection:', reason) throw reason } export const unhandledRejectionSpy = sinon.spy() export function catchUnhandledRejection() { unhandledRejectionSpy.resetHistory() process.removeListener('unhandledRejection', throwReason) process.on('unhandledRejection', unhandledRejectionSpy) } export function throwUnhandledRejection() { process.removeListener('unhandledRejection', unhandledRejectionSpy) process.on('unhandledRejection', throwReason) } export function init() { process.on('unhandledRejection', throwReason) } export function removeAllListeners() { process.removeListener('unhandledRejection', unhandledRejectionSpy) process.removeListener('unhandledRejection', throwReason) } ================================================ FILE: tsconfig.build-commonjs.json ================================================ { "extends": "./tsconfig.json", "include": ["src/**/*"], "exclude": ["node_modules", "*.test.ts"], "compilerOptions": { "module": "CommonJS", "outDir": "lib" } } ================================================ FILE: tsconfig.build-es.json ================================================ { "extends": "./tsconfig.json", "include": ["src/**/*"], "exclude": ["node_modules", "*.test.ts"], "compilerOptions": { "module": "ESNext", "outDir": "es" } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "lib": ["ES6"], "declaration": true, "esModuleInterop": true, "moduleResolution": "Node", "sourceMap": true, "strict": true } }