Showing preview only (204K chars total). Download the full file or copy to clipboard to get everything.
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](<https://en.wikipedia.org/wiki/Lock_(computer_science)>) and [Semaphore](<https://en.wikipedia.org/wiki/Semaphore_(programming)>) 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:<key>`)
- `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:<key>`)
- `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:<key>`)
- `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:<key>`)
- `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:<key>`)
- `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:<key>`)
- `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<typeof setInterval>
protected _refreshing = false
protected _acquired = false
protected _acquiredExternally = false
protected _onLockLost: LockLostCallback
protected abstract _refresh(): Promise<boolean>
protected abstract _acquire(abortSignal?: AbortSignal): Promise<boolean>
protected abstract _release(): Promise<void>
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<void> => {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
return await refreshSemaphore(
this._client,
this._key,
this._limit,
this._permits,
this._acquireOptions
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireSemaphore(
this._client,
this._key,
this._limit,
this._permits,
this._acquireOptions,
abortSignal
)
}
protected async _release(): Promise<void> {
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<boolean> {
return await refreshMutex(
this._client,
this._key,
this._identifier,
this._acquireOptions.lockTimeout
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireMutex(this._client, this._key, this._acquireOptions, abortSignal)
}
protected async _release(): Promise<void> {
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<boolean> {
return await refreshSemaphore(
this._client,
this._key,
this._limit,
this._acquireOptions
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireSemaphore(
this._client,
this._key,
this._limit,
this._acquireOptions,
abortSignal
)
}
protected async _release(): Promise<void> {
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<boolean> {
return await refreshRedlockMultiSemaphore(
this._clients,
this._key,
this._limit,
this._permits,
this._acquireOptions
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireRedlockMultiSemaphore(
this._clients,
this._key,
this._limit,
this._permits,
this._acquireOptions,
abortSignal
)
}
protected async _release(): Promise<void> {
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<boolean> {
return await refreshRedlockMutex(
this._clients,
this._key,
this._identifier,
this._acquireOptions.lockTimeout
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireRedlockMutex(
this._clients,
this._key,
this._acquireOptions,
abortSignal
)
}
protected async _release(): Promise<void> {
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<boolean> {
return await refreshRedlockSemaphore(
this._clients,
this._key,
this._limit,
this._acquireOptions
)
}
protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
return await acquireRedlockSemaphore(
this._clients,
this._key,
this._limit,
this._acquireOptions,
abortSignal
)
}
protected async _release(): Promise<void> {
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<boolean> {
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<boolean> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<boolean> {
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<boolean> {
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<RedisClient[]>((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<void> {
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<boolean> {
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<boolean> {
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<RedisClient[]>((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<void> {
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<boolean> {
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<boolean> {
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<RedisClient[]>((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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<Pick<ioredis.Redis, 'options'>>
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<Args extends Array<number | string>, Result>(
script: string,
keysCount: number
): (client: RedisClient, args: Args) => Promise<Result> {
const sha1 = createSHA1(script)
debug('creating script:', script, 'sha1:', sha1)
return async function optimizedEval(
client: RedisClient,
args: Args
): Promise<Result> {
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<Result>
} 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<Result>
} 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<void> {
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}>` : '<unknown client>'
}
================================================
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<void>((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<boolean> {
await delay(200)
return false
}
protected async _acquire(): Promise<boolean> {
return true
}
protected async _release(): Promise<void> {}
}
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()])
// <Server1Failure>
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'
])
// </Server1Failure>
// <Server2Failure>
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'
])
// </Server2Failure>
// <Server3Failure>
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'
])
// </Server3Failure>
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()
// <Server1Failure>
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)
// </Server1Failure>
// <Server2Failure>
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)
// </Server2Failure>
// <Server3Failure>
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)
// </Server3Failure>
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()
])
// <Server1Failure>
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
])
// </Server1Failure>
// <Server2Failure>
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
])
// </Server2Failure>
// <Server3Failure>
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
])
// </Server3Failure>
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>): 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>): 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>): 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>): 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('<client1>')
})
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('<unknown client>')
})
})
================================================
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
}
}
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
SYMBOL INDEX (112 symbols across 42 files)
FILE: src/Lock.ts
constant REFRESH_INTERVAL_COEF (line 8) | const REFRESH_INTERVAL_COEF = 0.8
method constructor (line 28) | constructor({
method identifier (line 73) | get identifier(): string {
method isAcquired (line 77) | get isAcquired(): boolean {
method getIdentifier (line 81) | private getIdentifier(identifierSuffix: string | undefined): string {
method _startRefresh (line 86) | private _startRefresh(): void {
method stopRefresh (line 94) | stopRefresh(): void {
method acquire (line 135) | async acquire(abortSignal?: AbortSignal): Promise<void> {
method tryAcquire (line 143) | async tryAcquire(abortSignal?: AbortSignal): Promise<boolean> {
method release (line 159) | async release(): Promise<void> {
FILE: src/RedisMultiSemaphore.ts
class RedisMultiSemaphore (line 7) | class RedisMultiSemaphore extends RedisSemaphore {
method constructor (line 11) | constructor(
method _refresh (line 28) | protected async _refresh(): Promise<boolean> {
method _acquire (line 38) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 49) | protected async _release(): Promise<void> {
FILE: src/RedisMutex.ts
class RedisMutex (line 9) | class RedisMutex extends Lock {
method constructor (line 14) | constructor(client: RedisClient, key: string, options?: LockOptions) {
method _refresh (line 29) | protected async _refresh(): Promise<boolean> {
method _acquire (line 38) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 42) | protected async _release(): Promise<void> {
FILE: src/RedisSemaphore.ts
class RedisSemaphore (line 7) | class RedisSemaphore extends RedisMutex {
method constructor (line 11) | constructor(
method _refresh (line 28) | protected async _refresh(): Promise<boolean> {
method _acquire (line 37) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 47) | protected async _release(): Promise<void> {
FILE: src/RedlockMultiSemaphore.ts
class RedlockMultiSemaphore (line 7) | class RedlockMultiSemaphore extends RedlockSemaphore {
method constructor (line 11) | constructor(
method _refresh (line 28) | protected async _refresh(): Promise<boolean> {
method _acquire (line 38) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 49) | protected async _release(): Promise<void> {
FILE: src/RedlockMutex.ts
class RedlockMutex (line 8) | class RedlockMutex extends Lock {
method constructor (line 13) | constructor(
method _refresh (line 32) | protected async _refresh(): Promise<boolean> {
method _acquire (line 41) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 50) | protected async _release(): Promise<void> {
FILE: src/RedlockSemaphore.ts
class RedlockSemaphore (line 7) | class RedlockSemaphore extends RedlockMutex {
method constructor (line 11) | constructor(
method _refresh (line 28) | protected async _refresh(): Promise<boolean> {
method _acquire (line 37) | protected async _acquire(abortSignal?: AbortSignal): Promise<boolean> {
method _release (line 47) | protected async _release(): Promise<void> {
FILE: src/errors/LostLockError.ts
class LostLockError (line 1) | class LostLockError extends Error {}
FILE: src/errors/TimeoutError.ts
class TimeoutError (line 1) | class TimeoutError extends Error {}
FILE: src/misc.ts
function defaultOnLockLost (line 10) | function defaultOnLockLost(err: LostLockError): never {
FILE: src/multiSemaphore/acquire/index.ts
type Options (line 8) | interface Options {
function acquireSemaphore (line 16) | async function acquireSemaphore(
FILE: src/multiSemaphore/refresh/index.ts
type Options (line 7) | interface Options {
function refreshSemaphore (line 12) | async function refreshSemaphore(
FILE: src/multiSemaphore/release/index.ts
type Options (line 7) | interface Options {
function releaseSemaphore (line 13) | async function releaseSemaphore(
FILE: src/mutex/acquire.ts
type Options (line 7) | interface Options {
function acquireMutex (line 15) | async function acquireMutex(
FILE: src/mutex/refresh.ts
function refreshMutex (line 25) | async function refreshMutex(
FILE: src/mutex/release.ts
function releaseMutex (line 22) | async function releaseMutex(
FILE: src/redlockMultiSemaphore/acquire.ts
type Options (line 9) | interface Options {
function acquireRedlockMultiSemaphore (line 17) | async function acquireRedlockMultiSemaphore(
FILE: src/redlockMultiSemaphore/refresh.ts
type Options (line 10) | interface Options {
function refreshRedlockMultiSemaphore (line 15) | async function refreshRedlockMultiSemaphore(
FILE: src/redlockMultiSemaphore/release.ts
function releaseRedlockMultiSemaphore (line 7) | async function releaseRedlockMultiSemaphore(
FILE: src/redlockMutex/acquire.ts
type Options (line 9) | interface Options {
function acquireRedlockMutex (line 17) | async function acquireRedlockMutex(
FILE: src/redlockMutex/refresh.ts
function refreshRedlockMutex (line 9) | async function refreshRedlockMutex(
FILE: src/redlockMutex/release.ts
function releaseRedlockMutex (line 8) | async function releaseRedlockMutex(
FILE: src/redlockSemaphore/acquire.ts
type Options (line 9) | interface Options {
function acquireRedlockSemaphore (line 17) | async function acquireRedlockSemaphore(
FILE: src/redlockSemaphore/refresh.ts
type Options (line 10) | interface Options {
function refreshRedlockSemaphore (line 15) | async function refreshRedlockSemaphore(
FILE: src/redlockSemaphore/release.ts
function releaseRedlockSemaphore (line 7) | async function releaseRedlockSemaphore(
FILE: src/semaphore/acquire/index.ts
type Options (line 8) | interface Options {
function acquireSemaphore (line 16) | async function acquireSemaphore(
FILE: src/semaphore/refresh/index.ts
type Options (line 7) | interface Options {
function refreshSemaphore (line 12) | async function refreshSemaphore(
FILE: src/semaphore/release.ts
function releaseSemaphore (line 6) | async function releaseSemaphore(
FILE: src/types.ts
type RedisClient (line 9) | type RedisClient = Pick<
type LockLostCallback (line 15) | interface LockLostCallback {
type TimeoutOptions (line 19) | interface TimeoutOptions {
type LockOptions (line 27) | interface LockOptions extends TimeoutOptions {
type AcquireOptions (line 57) | interface AcquireOptions {
FILE: src/utils/createEval.ts
function createSHA1 (line 8) | function createSHA1(script: string): string {
function isNoScriptError (line 12) | function isNoScriptError(err: Error): boolean {
function createEval (line 16) | function createEval<Args extends Array<number | string>, Result>(
FILE: src/utils/index.ts
function delay (line 6) | function delay(ms: number, signal?: AbortSignal): Promise<void> {
function getConnectionName (line 30) | function getConnectionName(client: RedisClient): string {
FILE: src/utils/redlock.ts
function getQuorum (line 1) | function getQuorum(clientCount: number): number {
function smartSum (line 5) | function smartSum(count: number, zeroOrOne: number): number {
FILE: test/redisClient.ts
function createClient (line 5) | function createClient(num: number) {
function createClientMock (line 28) | function createClientMock(num: number) {
FILE: test/shell.ts
constant LOGGING (line 5) | const LOGGING = !!process.env.LOGSHELL
function sh (line 7) | function sh(cmd: string) {
function upRedisServer (line 28) | async function upRedisServer(num: number) {
function downRedisServer (line 35) | async function downRedisServer(num: number) {
FILE: test/src/Lock.test.ts
class TestLock (line 7) | class TestLock extends Lock {
method constructor (line 10) | constructor(key: string, options: LockOptions) {
method _refresh (line 14) | protected async _refresh(): Promise<boolean> {
method _acquire (line 18) | protected async _acquire(): Promise<boolean> {
method _release (line 21) | protected async _release(): Promise<void> {}
FILE: test/src/RedlockMultiSemaphore.test.ts
function expectZRangeAllEql (line 30) | async function expectZRangeAllEql(key: string, values: string[]) {
function expectZRangeAllHaveMembers (line 39) | async function expectZRangeAllHaveMembers(key: string, values: string[]) {
function expectZCardAllEql (line 50) | async function expectZCardAllEql(key: string, count: number) {
FILE: test/src/RedlockMutex.test.ts
function expectGetAll (line 29) | async function expectGetAll(
FILE: test/src/RedlockSemaphore.test.ts
function expectZRangeAllEql (line 29) | async function expectZRangeAllEql(key: string, values: string[]) {
function expectZRangeAllHaveMembers (line 38) | async function expectZRangeAllHaveMembers(key: string, values: string[]) {
function expectZCardAllEql (line 49) | async function expectZCardAllEql(key: string, count: number) {
FILE: test/src/multiSemaphore/acquire/internal.test.ts
type Options (line 6) | interface Options {
function acquire (line 18) | async function acquire(options: Options) {
FILE: test/src/semaphore/acquire/internal.test.ts
type Options (line 6) | interface Options {
function acquire (line 18) | async function acquire(options: Options) {
FILE: test/src/utils/redlock.test.ts
function makeTest (line 7) | function makeTest(count: number, expectedResult: number) {
FILE: test/unhandledRejection.ts
function throwReason (line 3) | function throwReason(reason: any) {
function catchUnhandledRejection (line 10) | function catchUnhandledRejection() {
function throwUnhandledRejection (line 16) | function throwUnhandledRejection() {
function init (line 21) | function init() {
function removeAllListeners (line 25) | function removeAllListeners() {
Condensed preview — 90 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (199K chars).
[
{
"path": ".codeclimate.yml",
"chars": 74,
"preview": "version: '2'\nplugins:\n eslint:\n enabled: true\n channel: 'eslint-6'\n"
},
{
"path": ".fossa.yml",
"chars": 296,
"preview": "# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli)\n# Visit https://fossa.com to learn more\n\nversion: 2\ncli:\n"
},
{
"path": ".github/dependabot.yml",
"chars": 108,
"preview": "version: 2\nupdates:\n - package-ecosystem: 'npm'\n directory: '/'\n schedule:\n interval: 'monthly'\n"
},
{
"path": ".github/workflows/branches.yml",
"chars": 833,
"preview": "name: CI (push)\n\non:\n push:\n branches:\n - master\n workflow_dispatch:\n\njobs:\n integration-test:\n runs-on: u"
},
{
"path": ".github/workflows/pull-requests.yml",
"chars": 698,
"preview": "name: CI (PR)\n\non:\n pull_request:\n branches:\n - master\n workflow_dispatch:\n\njobs:\n integration-test:\n runs"
},
{
"path": ".gitignore",
"chars": 949,
"preview": "# Logs\nlogs\n*.log\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverag"
},
{
"path": ".mocharc.yaml",
"chars": 94,
"preview": "extension: ts\nrecursive: true\ntimeout: 5s\nrequire:\n - '@swc-node/register'\n - test/setup.ts\n"
},
{
"path": ".prettierrc",
"chars": 96,
"preview": "{\n \"semi\": false,\n \"singleQuote\": true,\n \"trailingComma\": \"none\",\n \"arrowParens\": \"avoid\"\n}\n"
},
{
"path": ".snyk",
"chars": 118,
"preview": "# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.\nversion: v1.13.1\nignore: {}\npatch: {}\n"
},
{
"path": ".yarnrc.yml",
"chars": 54,
"preview": "nodeLinker: node-modules\ndefaultSemverRangePrefix: ''\n"
},
{
"path": "CHANGELOG.md",
"chars": 2235,
"preview": "### redis-semaphore@5.7.0\n\n- Added `AbortSignal` to acquire\n\n### redis-semaphore@5.6.2\n\n- Fixed implicit import from `sr"
},
{
"path": "Dockerfile",
"chars": 89,
"preview": "FROM node:alpine\nRUN npm i -g @swarthy/wait-for@2.0.2\nVOLUME /app\nWORKDIR /app\nUSER node\n"
},
{
"path": "LICENSE",
"chars": 1075,
"preview": "MIT License\n\nCopyright (c) 2018 Alexander Mochalin\n\nPermission is hereby granted, free of charge, to any person obtainin"
},
{
"path": "README.md",
"chars": 15569,
"preview": "# redis-semaphore\n\n[![NPM version][npm-image]][npm-url]\n[![Build status][ci-image]][ci-url]\n![FOSSA Status][typescript-i"
},
{
"path": "docker-compose.yml",
"chars": 625,
"preview": "version: '3.7'\n\nservices:\n waiter:\n image: node:24-alpine\n volumes:\n - ./:/app\n working_dir: /app\n com"
},
{
"path": "eslint.config.mjs",
"chars": 2072,
"preview": "import typescript from '@typescript-eslint/eslint-plugin'\nimport typescriptParser from '@typescript-eslint/parser'\nimpor"
},
{
"path": "package.json",
"chars": 2255,
"preview": "{\n \"name\": \"redis-semaphore\",\n \"version\": \"5.7.0\",\n \"description\": \"Distributed mutex and semaphore based on Redis\",\n"
},
{
"path": "setup-redis-servers.sh",
"chars": 52,
"preview": "#!/bin/sh\ndocker compose up -d redis1 redis2 redis3\n"
},
{
"path": "src/Lock.ts",
"chars": 5219,
"preview": "import createDebug from 'debug'\nimport * as crypto from 'node:crypto'\nimport LostLockError from './errors/LostLockError'"
},
{
"path": "src/RedisMultiSemaphore.ts",
"chars": 1392,
"preview": "import { acquireSemaphore } from './multiSemaphore/acquire'\nimport { refreshSemaphore } from './multiSemaphore/refresh'\n"
},
{
"path": "src/RedisMutex.ts",
"chars": 1245,
"preview": "import type { RedisClient } from './types'\n\nimport { Lock } from './Lock'\nimport { acquireMutex } from './mutex/acquire'"
},
{
"path": "src/RedisSemaphore.ts",
"chars": 1260,
"preview": "import RedisMutex from './RedisMutex'\nimport { acquireSemaphore } from './semaphore/acquire'\nimport { refreshSemaphore }"
},
{
"path": "src/RedlockMultiSemaphore.ts",
"chars": 1508,
"preview": "import { acquireRedlockMultiSemaphore } from './redlockMultiSemaphore/acquire'\nimport { refreshRedlockMultiSemaphore } f"
},
{
"path": "src/RedlockMutex.ts",
"chars": 1449,
"preview": "import { Lock } from './Lock'\nimport { defaultTimeoutOptions } from './misc'\nimport { acquireRedlockMutex } from './redl"
},
{
"path": "src/RedlockSemaphore.ts",
"chars": 1346,
"preview": "import RedlockMutex from './RedlockMutex'\nimport { acquireRedlockSemaphore } from './redlockSemaphore/acquire'\nimport { "
},
{
"path": "src/errors/LostLockError.ts",
"chars": 52,
"preview": "export default class LostLockError extends Error {}\n"
},
{
"path": "src/errors/TimeoutError.ts",
"chars": 51,
"preview": "export default class TimeoutError extends Error {}\n"
},
{
"path": "src/index.ts",
"chars": 648,
"preview": "import MultiSemaphore from './RedisMultiSemaphore'\nimport Mutex from './RedisMutex'\nimport Semaphore from './RedisSemaph"
},
{
"path": "src/misc.ts",
"chars": 288,
"preview": "import LostLockError from './errors/LostLockError'\n\nexport const defaultTimeoutOptions = {\n lockTimeout: 10000,\n acqui"
},
{
"path": "src/multiSemaphore/acquire/index.ts",
"chars": 1346,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../../types'\nimport { delay } from '../../utils'\nimport { a"
},
{
"path": "src/multiSemaphore/acquire/lua.ts",
"chars": 779,
"preview": "import { createEval } from '../../utils/index'\n\nexport const acquireLua = createEval<\n [string, number, number, string,"
},
{
"path": "src/multiSemaphore/refresh/index.ts",
"chars": 697,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../../types'\nimport { refreshLua } from './lua'\n\nconst debu"
},
{
"path": "src/multiSemaphore/refresh/lua.ts",
"chars": 779,
"preview": "import { createEval } from '../../utils/index'\n\nexport const refreshLua = createEval<\n [string, number, number, string,"
},
{
"path": "src/multiSemaphore/release/index.ts",
"chars": 551,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../../types'\nimport { releaseLua } from './lua'\n\nconst debu"
},
{
"path": "src/multiSemaphore/release/lua.ts",
"chars": 366,
"preview": "import { createEval } from '../../utils/index'\n\nexport const releaseLua = createEval<[string, number, string], number>(\n"
},
{
"path": "src/mutex/acquire.ts",
"chars": 1134,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../types'\nimport { delay } from '../utils'\n\nconst debug = c"
},
{
"path": "src/mutex/refresh.ts",
"chars": 843,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../types'\nimport { createEval } from '../utils/index'\n\ncons"
},
{
"path": "src/mutex/release.ts",
"chars": 661,
"preview": "import createDebug from 'debug'\nimport { createEval } from '../utils/index'\n\nimport type { RedisClient } from '../types'"
},
{
"path": "src/redlockMultiSemaphore/acquire.ts",
"chars": 1662,
"preview": "import createDebug from 'debug'\nimport { acquireLua } from '../multiSemaphore/acquire/lua'\nimport { RedisClient } from '"
},
{
"path": "src/redlockMultiSemaphore/refresh.ts",
"chars": 2071,
"preview": "import createDebug from 'debug'\nimport { acquireLua } from '../multiSemaphore/acquire/lua'\nimport { refreshLua } from '."
},
{
"path": "src/redlockMultiSemaphore/release.ts",
"chars": 563,
"preview": "import createDebug from 'debug'\nimport { releaseLua } from '../multiSemaphore/release/lua'\nimport { RedisClient } from '"
},
{
"path": "src/redlockMutex/acquire.ts",
"chars": 1591,
"preview": "import createDebug from 'debug'\nimport { delIfEqualLua } from '../mutex/release'\nimport { RedisClient } from '../types'\n"
},
{
"path": "src/redlockMutex/refresh.ts",
"chars": 1736,
"preview": "import createDebug from 'debug'\nimport { expireIfEqualLua } from '../mutex/refresh'\nimport { delIfEqualLua } from '../mu"
},
{
"path": "src/redlockMutex/release.ts",
"chars": 525,
"preview": "import createDebug from 'debug'\nimport { delIfEqualLua } from '../mutex/release'\n\nimport type { RedisClient } from '../t"
},
{
"path": "src/redlockSemaphore/acquire.ts",
"chars": 1618,
"preview": "import createDebug from 'debug'\nimport { acquireLua } from '../semaphore/acquire/lua'\nimport { RedisClient } from '../ty"
},
{
"path": "src/redlockSemaphore/refresh.ts",
"chars": 1864,
"preview": "import createDebug from 'debug'\nimport { acquireLua } from '../semaphore/acquire/lua'\nimport { refreshLua } from '../sem"
},
{
"path": "src/redlockSemaphore/release.ts",
"chars": 468,
"preview": "import createDebug from 'debug'\n\nimport type { RedisClient } from '../types'\n\nconst debug = createDebug('redis-semaphore"
},
{
"path": "src/semaphore/acquire/index.ts",
"chars": 1344,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../../types'\nimport { delay } from '../../utils'\nimport { a"
},
{
"path": "src/semaphore/acquire/lua.ts",
"chars": 592,
"preview": "import { createEval } from '../../utils/index'\n\nexport const acquireLua = createEval<\n [string, number, string, number,"
},
{
"path": "src/semaphore/refresh/index.ts",
"chars": 695,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../../types'\nimport { refreshLua } from './lua'\n\nconst debu"
},
{
"path": "src/semaphore/refresh/lua.ts",
"chars": 597,
"preview": "import { createEval } from '../../utils/index'\n\nexport const refreshLua = createEval<\n [string, number, string, number,"
},
{
"path": "src/semaphore/release.ts",
"chars": 374,
"preview": "import createDebug from 'debug'\nimport { RedisClient } from '../types'\n\nconst debug = createDebug('redis-semaphore:semap"
},
{
"path": "src/types.ts",
"chars": 1711,
"preview": "import LostLockError from './errors/LostLockError'\nimport { Lock } from './Lock'\n\nimport type * as ioredis from 'ioredis"
},
{
"path": "src/utils/createEval.ts",
"chars": 1421,
"preview": "import { createHash } from 'crypto'\nimport createDebug from 'debug'\nimport { RedisClient } from '../types'\nimport { getC"
},
{
"path": "src/utils/index.ts",
"chars": 827,
"preview": "import { RedisClient } from '../types'\nimport createEval from './createEval'\n\nexport { createEval }\n\nexport function del"
},
{
"path": "src/utils/redlock.ts",
"chars": 201,
"preview": "export function getQuorum(clientCount: number): number {\n return Math.round((clientCount + 1) / 2)\n}\n\nexport function s"
},
{
"path": "test/init.test.ts",
"chars": 133,
"preview": "import { init, removeAllListeners } from './unhandledRejection'\n\nbefore(() => {\n init()\n})\n\nafter(() => {\n removeAllLi"
},
{
"path": "test/redisClient.ts",
"chars": 2885,
"preview": "import Redis from 'ioredis'\nimport RedisMock from 'ioredis-mock'\nimport { once } from 'node:events'\n\nfunction createClie"
},
{
"path": "test/setup.ts",
"chars": 151,
"preview": "import chai from 'chai'\nimport chaiAsPromised from 'chai-as-promised'\nimport sinonChai from 'sinon-chai'\n\nchai.use(chaiA"
},
{
"path": "test/shell.test.ts",
"chars": 396,
"preview": "import { downRedisServer, upRedisServer } from './shell'\n\ndescribe('TEST UTILS', () => {\n describe('shell', () => {\n "
},
{
"path": "test/shell.ts",
"chars": 1151,
"preview": "import { exec } from 'child_process'\n\nimport { delay } from '../src/utils/index'\n\nconst LOGGING = !!process.env.LOGSHELL"
},
{
"path": "test/src/Lock.test.ts",
"chars": 1050,
"preview": "import { LockOptions } from '../../src'\nimport { Lock } from '../../src/Lock'\nimport { delay } from '../../src/utils'\n\nd"
},
{
"path": "test/src/RedisMultiSemaphore.test.ts",
"chars": 14652,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/RedisMutex.test.ts",
"chars": 12327,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/RedisSemaphore.test.ts",
"chars": 12619,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/RedlockMultiSemaphore.test.ts",
"chars": 19610,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/RedlockMutex.test.ts",
"chars": 10868,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/RedlockSemaphore.test.ts",
"chars": 17027,
"preview": "import { expect } from 'chai'\nimport { Redis } from 'ioredis'\nimport sinon from 'sinon'\nimport LostLockError from '../.."
},
{
"path": "test/src/index.test.ts",
"chars": 517,
"preview": "import { expect } from 'chai'\nimport {\n defaultTimeoutOptions,\n MultiSemaphore,\n Mutex,\n RedlockMultiSemaphore,\n Re"
},
{
"path": "test/src/multiSemaphore/acquire/index.test.ts",
"chars": 1694,
"preview": "import { expect } from 'chai'\n\nimport {\n acquireSemaphore as acquire,\n Options\n} from '../../../../src/multiSemaphore/"
},
{
"path": "test/src/multiSemaphore/acquire/internal.test.ts",
"chars": 2625,
"preview": "import { expect } from 'chai'\n\nimport { acquireLua } from '../../../../src/multiSemaphore/acquire/lua'\nimport { client1 "
},
{
"path": "test/src/multiSemaphore/refresh/index.test.ts",
"chars": 1893,
"preview": "import { expect } from 'chai'\n\nimport {\n Options,\n refreshSemaphore as refresh\n} from '../../../../src/multiSemaphore/"
},
{
"path": "test/src/multiSemaphore/release/index.test.ts",
"chars": 709,
"preview": "import { expect } from 'chai'\n\nimport { releaseSemaphore as release } from '../../../../src/multiSemaphore/release/index"
},
{
"path": "test/src/mutex/acquire.test.ts",
"chars": 2504,
"preview": "import { expect } from 'chai'\n\nimport { acquireMutex as acquire, Options } from '../../../src/mutex/acquire'\nimport { cl"
},
{
"path": "test/src/mutex/refresh.test.ts",
"chars": 836,
"preview": "import { expect } from 'chai'\n\nimport { refreshMutex as refresh } from '../../../src/mutex/refresh'\nimport { client1 as "
},
{
"path": "test/src/mutex/release.test.ts",
"chars": 593,
"preview": "import { expect } from 'chai'\n\nimport { releaseMutex as release } from '../../../src/mutex/release'\nimport { client1 as "
},
{
"path": "test/src/redlockMutex/acquire.test.ts",
"chars": 2784,
"preview": "import { expect } from 'chai'\n\nimport {\n acquireRedlockMutex as acquire,\n Options\n} from '../../../src/redlockMutex/ac"
},
{
"path": "test/src/redlockMutex/refresh.test.ts",
"chars": 1117,
"preview": "import { expect } from 'chai'\n\nimport { refreshRedlockMutex as refresh } from '../../../src/redlockMutex/refresh'\nimport"
},
{
"path": "test/src/redlockMutex/release.test.ts",
"chars": 628,
"preview": "import { expect } from 'chai'\n\nimport { releaseRedlockMutex as release } from '../../../src/redlockMutex/release'\nimport"
},
{
"path": "test/src/semaphore/acquire/index.test.ts",
"chars": 1590,
"preview": "import { expect } from 'chai'\n\nimport {\n acquireSemaphore as acquire,\n Options\n} from '../../../../src/semaphore/acqui"
},
{
"path": "test/src/semaphore/acquire/internal.test.ts",
"chars": 2478,
"preview": "import { expect } from 'chai'\n\nimport { acquireLua } from '../../../../src/semaphore/acquire/lua'\nimport { client1 as cl"
},
{
"path": "test/src/semaphore/refresh/index.test.ts",
"chars": 1815,
"preview": "import { expect } from 'chai'\n\nimport {\n Options,\n refreshSemaphore as refresh\n} from '../../../../src/semaphore/refre"
},
{
"path": "test/src/semaphore/release.test.ts",
"chars": 679,
"preview": "import { expect } from 'chai'\n\nimport { releaseSemaphore as release } from '../../../src/semaphore/release'\nimport { cli"
},
{
"path": "test/src/utils/eval.test.ts",
"chars": 753,
"preview": "import { expect } from 'chai'\n\nimport { createEval } from '../../../src/utils/index'\nimport { client1 as client } from '"
},
{
"path": "test/src/utils/index.test.ts",
"chars": 785,
"preview": "import { expect } from 'chai'\nimport Redis from 'ioredis'\nimport { getConnectionName } from '../../../src/utils/index'\ni"
},
{
"path": "test/src/utils/redlock.test.ts",
"chars": 900,
"preview": "import { expect } from 'chai'\n\nimport { getQuorum } from '../../../src/utils/redlock'\n\ndescribe('redlockMutex utils', ()"
},
{
"path": "test/unhandledRejection.ts",
"chars": 793,
"preview": "import sinon from 'sinon'\n\nfunction throwReason(reason: any) {\n console.log('unhandled rejection:', reason)\n throw rea"
},
{
"path": "tsconfig.build-commonjs.json",
"chars": 180,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"include\": [\"src/**/*\"],\n \"exclude\": [\"node_modules\", \"*.test.ts\"],\n \"compilerOpti"
},
{
"path": "tsconfig.build-es.json",
"chars": 177,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"include\": [\"src/**/*\"],\n \"exclude\": [\"node_modules\", \"*.test.ts\"],\n \"compilerOpti"
},
{
"path": "tsconfig.json",
"chars": 203,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"lib\": [\"ES6\"],\n \"declaration\": true,\n \"esModuleInterop\": tru"
}
]
About this extraction
This page contains the full source code of the swarthy/redis-semaphore GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 90 files (183.5 KB), approximately 49.9k tokens, and a symbol index with 112 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.