├── .github ├── docker │ ├── yarn.lock │ ├── cluster.redis.conf │ └── await.sh └── workflows │ └── nodejs.yml ├── .yarnrc.yml ├── .gitignore ├── LICENSE.md ├── package.json ├── src ├── scripts.ts ├── unit.spec.ts ├── system.spec.ts └── index.ts ├── docker-compose.yml └── README.md /.github/docker/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/docker/cluster.redis.conf: -------------------------------------------------------------------------------- 1 | port 6379 2 | cluster-enabled yes 3 | cluster-config-file nodes.conf 4 | cluster-node-timeout 5000 5 | appendonly yes 6 | -------------------------------------------------------------------------------- /.github/docker/await.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while [ ! -d "$1" ] && [ ! -f "$1" ]; do 4 | 5 | echo "Waiting for '$1' to exist..." 6 | sleep 1 7 | 8 | done 9 | 10 | eval "${*:2}" -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.6.0.cjs 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | src/generated 3 | .transpiled 4 | .nyc_output 5 | .eslintcache 6 | 7 | # TypeScript incremental compilation cache 8 | *.tsbuildinfo 9 | 10 | # Stock 11 | *.seed 12 | *.log 13 | *.csv 14 | *.dat 15 | *.out 16 | *.pid 17 | *.gz 18 | *.orig 19 | 20 | work 21 | /build 22 | pids 23 | logs 24 | results 25 | coverage 26 | lib-cov 27 | html-report 28 | xunit.xml 29 | node_modules 30 | npm-debug.log 31 | 32 | .project 33 | .idea 34 | .settings 35 | .iml 36 | *.sublime-workspace 37 | *.sublime-project 38 | 39 | .DS_Store* 40 | ehthumbs.db 41 | Icon? 42 | Thumbs.db 43 | .AppleDouble 44 | .LSOverride 45 | .Spotlight-V100 46 | .Trashes 47 | 48 | .yarn/* 49 | !.yarn/patches 50 | !.yarn/plugins 51 | !.yarn/releases 52 | !.yarn/sdks 53 | !.yarn/versions 54 | 55 | .node_repl_history 56 | 57 | # TypeScript incremental compilation cache 58 | # Added by coconfig 59 | .eslintignore 60 | .npmignore 61 | tsconfig.json 62 | tsconfig.build.json 63 | .prettierrc.js 64 | .eslintrc.js 65 | .commitlintrc.json 66 | vitest.config.ts 67 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mike Marcacci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sesamecare-oss/redlock", 3 | "version": "3.6.0", 4 | "description": "A modern node.js redlock implementation for distributed redis locks", 5 | "main": "build/index.js", 6 | "types": "build/index.d.ts", 7 | "author": "Developers ", 8 | "license": "UNLICENSED", 9 | "packageManager": "yarn@3.6.0", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.build.json", 12 | "clean": "yarn dlx rimraf ./dist", 13 | "lint": "eslint .", 14 | "postinstall": "coconfig", 15 | "test": "vitest", 16 | "test:unit": "vitest unit.spec.ts", 17 | "test:system": "vitest system.spec.ts" 18 | }, 19 | "keywords": [ 20 | "typescript", 21 | "sesame" 22 | ], 23 | "engines": { 24 | "node": ">=16" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/sesamecare/redlock.git" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "release": { 34 | "branches": [ 35 | "main" 36 | ], 37 | "plugins": [ 38 | "@semantic-release/commit-analyzer", 39 | "@semantic-release/release-notes-generator", 40 | [ 41 | "@semantic-release/exec", 42 | { 43 | "publishCmd": "yarn dlx pinst --disable" 44 | } 45 | ], 46 | "@semantic-release/npm", 47 | "@semantic-release/github" 48 | ] 49 | }, 50 | "config": { 51 | "coconfig": "@openapi-typescript-infra/coconfig" 52 | }, 53 | "devDependencies": { 54 | "@openapi-typescript-infra/coconfig": "^4.2.2", 55 | "@semantic-release/exec": "^6.0.3", 56 | "@semantic-release/github": "^9.2.1", 57 | "@types/node": "^16.18.60", 58 | "@typescript-eslint/eslint-plugin": "^6.9.1", 59 | "@typescript-eslint/parser": "^6.9.1", 60 | "coconfig": "^1.0.0", 61 | "eslint": "^8.52.0", 62 | "eslint-config-prettier": "^9.0.0", 63 | "eslint-import-resolver-typescript": "^3.6.1", 64 | "eslint-plugin-import": "^2.29.0", 65 | "ioredis": "^5.3.2", 66 | "typescript": "^5.2.2", 67 | "vitest": "^0.34.6" 68 | }, 69 | "peerDependencies": { 70 | "ioredis": ">=5" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/scripts.ts: -------------------------------------------------------------------------------- 1 | import { Redis as IORedisClient, Cluster as IORedisCluster, Result } from 'ioredis'; 2 | type Client = IORedisClient | IORedisCluster; 3 | 4 | // Define script constants. 5 | const DB_SELECT_SCRIPT = ` 6 | -- Protected call to execute SELECT command if supported 7 | redis.pcall("SELECT", tonumber(ARGV[1])) 8 | `; 9 | 10 | const ACQUIRE_SCRIPT = ` 11 | ${DB_SELECT_SCRIPT} 12 | 13 | -- Return 0 if an entry already exists. 14 | for i, key in ipairs(KEYS) do 15 | if redis.call("exists", key) == 1 then 16 | return 0 17 | end 18 | end 19 | 20 | -- Create an entry for each provided key. 21 | for i, key in ipairs(KEYS) do 22 | redis.call("set", key, ARGV[2], "PX", ARGV[3]) 23 | end 24 | 25 | -- Return the number of entries added. 26 | return #KEYS 27 | `; 28 | 29 | const EXTEND_SCRIPT = ` 30 | ${DB_SELECT_SCRIPT} 31 | 32 | -- Return 0 if an entry exists with a *different* lock value. 33 | for i, key in ipairs(KEYS) do 34 | if redis.call("get", key) ~= ARGV[2] then 35 | return 0 36 | end 37 | end 38 | 39 | -- Update the entry for each provided key. 40 | for i, key in ipairs(KEYS) do 41 | redis.call("set", key, ARGV[2], "PX", ARGV[3]) 42 | end 43 | 44 | -- Return the number of entries updated. 45 | return #KEYS 46 | `; 47 | 48 | const RELEASE_SCRIPT = ` 49 | ${DB_SELECT_SCRIPT} 50 | 51 | local count = 0 52 | for i, key in ipairs(KEYS) do 53 | -- Only remove entries for *this* lock value. 54 | if redis.call("get", key) == ARGV[2] then 55 | redis.pcall("del", key) 56 | count = count + 1 57 | end 58 | end 59 | 60 | -- Return the number of entries removed. 61 | return count 62 | `; 63 | 64 | declare module 'ioredis' { 65 | interface RedisCommander { 66 | acquireLock(keys: number, ...args: (string | number)[]): Result; 67 | extendLock(keys: number, ...args: (string | number)[]): Result; 68 | releaseLock(keys: number, ...args: (string | number)[]): Result; 69 | } 70 | } 71 | 72 | export function ensureCommands(client: Client) { 73 | if (typeof client.acquireLock === 'function') { 74 | return; 75 | } 76 | client.defineCommand('acquireLock', { 77 | lua: ACQUIRE_SCRIPT, 78 | }); 79 | client.defineCommand('extendLock', { 80 | lua: EXTEND_SCRIPT, 81 | }); 82 | client.defineCommand('releaseLock', { 83 | lua: RELEASE_SCRIPT, 84 | }); 85 | } -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node build, test and publish 2 | 3 | on: 4 | pull_request: 5 | types: [assigned, opened, synchronize, reopened] 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | prepare: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Cleanup stale actions 18 | uses: styfle/cancel-workflow-action@0.12.1 19 | with: 20 | access_token: ${{ github.token }} 21 | 22 | build: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 5 25 | 26 | strategy: 27 | matrix: 28 | node-version: [22, 20, 18, 16] 29 | 30 | env: 31 | YARN_IGNORE_NODE: 1 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: yarn 40 | - name: Enable docker layer cache 41 | uses: satackey/action-docker-layer-caching@v0.0.11 42 | continue-on-error: true 43 | - name: Pull docker images 44 | run: docker-compose pull 45 | continue-on-error: true 46 | env: 47 | NODE_VERSION: ${{ matrix.node-version }} 48 | - name: Lint the source 49 | run: docker compose run --rm --no-TTY builder yarn lint 50 | env: 51 | NODE_VERSION: ${{ matrix.node-version }} 52 | - name: Transpile into dist 53 | run: docker compose run --rm --no-TTY builder yarn build 54 | env: 55 | NODE_VERSION: ${{ matrix.node-version }} 56 | - name: Run tests 57 | run: docker compose run --rm --no-TTY runner yarn test 58 | env: 59 | NODE_VERSION: ${{ matrix.node-version }} 60 | - name: Stop containers 61 | if: always() 62 | run: docker compose down 63 | env: 64 | NODE_VERSION: ${{ matrix.node-version }} 65 | 66 | publish-npm: 67 | needs: build 68 | timeout-minutes: 5 69 | if: github.ref == 'refs/heads/main' 70 | permissions: 71 | contents: write 72 | issues: write 73 | id-token: write 74 | pull-requests: write 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: actions/setup-node@v4 79 | with: 80 | node-version: 22 81 | cache: yarn 82 | - run: yarn install --immutable 83 | - run: yarn build 84 | - name: Release 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | NPM_TOKEN: ${{ secrets.SESAMECARE_OSS_NPM_TOKEN }} 88 | run: | 89 | yarn dlx semantic-release 90 | -------------------------------------------------------------------------------- /src/unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from 'ioredis'; 2 | import { beforeAll, describe, expect, test, vi } from 'vitest'; 3 | 4 | import { Redlock, Lock, ExecutionError, Settings } from './index'; 5 | 6 | // Mock all resources from ioredis 7 | vi.mock('ioredis'); 8 | 9 | describe('Redlock Settings', () => { 10 | test('Default settings are applied if none are provided', () => { 11 | const client = new Redis(); 12 | const redlock = new Redlock([client]); 13 | const defaultSettings: Readonly = { 14 | driftFactor: 0.01, 15 | retryCount: 10, 16 | retryDelay: 200, 17 | retryJitter: 100, 18 | automaticExtensionThreshold: 500, 19 | db: 0, 20 | }; 21 | expect(redlock.settings.driftFactor).toBe(defaultSettings.driftFactor); 22 | expect(redlock.settings.retryCount).toBe(defaultSettings.retryCount); 23 | expect(redlock.settings.retryDelay).toBe(defaultSettings.retryDelay); 24 | expect(redlock.settings.retryJitter).toBe(defaultSettings.retryJitter); 25 | expect(redlock.settings.automaticExtensionThreshold).toBe( 26 | defaultSettings.automaticExtensionThreshold, 27 | ); 28 | expect(redlock.settings.db).toBe(defaultSettings.db); 29 | }); 30 | 31 | test.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])( 32 | 'Valid Redis DB setting (%i) is accepted', 33 | (db: number) => { 34 | const client = new Redis(); 35 | const redlock = new Redlock([client], { db }); 36 | expect(redlock.settings.db).toBe(db); 37 | }, 38 | ); 39 | 40 | test('Redis DB setting defaults to 0 when not provided', () => { 41 | const client = new Redis(); 42 | const redlock = new Redlock([client]); 43 | expect(redlock.settings.db).toBe(0); 44 | }); 45 | 46 | test.each([-1, 16, 0.5, 3.1514])( 47 | 'Redis DB defaults to 0 when value (%s) is outside of acceptable range (0-15)', 48 | (db: number) => { 49 | const client = new Redis(); 50 | const redlock = new Redlock([client], { db }); 51 | expect(redlock.settings.db).toBe(0); 52 | }, 53 | ); 54 | }); 55 | 56 | describe('Redlock', () => { 57 | let redisClient: Redis; 58 | let redlock: Redlock; 59 | const defaultSettings: Partial = { 60 | driftFactor: 0.01, 61 | retryCount: 3, 62 | retryDelay: 200, 63 | retryJitter: 100, 64 | automaticExtensionThreshold: 500, 65 | }; 66 | 67 | beforeAll(() => { 68 | redisClient = new Redis(); 69 | redlock = new Redlock([redisClient], defaultSettings); 70 | }); 71 | 72 | describe('acquire()', () => { 73 | test('Acquire a lock successfully', async () => { 74 | redisClient.acquireLock = vi.fn().mockResolvedValue(1); 75 | 76 | const lock = await redlock.acquire(['resource1'], 1000); 77 | expect(lock).toBeInstanceOf(Lock); 78 | expect(lock.resources).toEqual(['resource1']); 79 | expect(lock.value).toBeTruthy(); 80 | }); 81 | 82 | test('Fail to acquire a lock if resource is already locked', async () => { 83 | redisClient.acquireLock = vi.fn().mockResolvedValue(0); 84 | 85 | await expect(redlock.acquire(['resource1'], 1000)).rejects.toThrow( 86 | 'The operation was unable to achieve a quorum during its retry window.', 87 | ); 88 | }); 89 | 90 | test('Fail to acquire a lock on error', async () => { 91 | redisClient.acquireLock = vi.fn().mockRejectedValue(new Error()); 92 | 93 | await expect(redlock.acquire(['resource1'], 1000)).rejects.toThrow( 94 | 'The operation was unable to achieve a quorum during its retry window.', 95 | ); 96 | }); 97 | }); 98 | 99 | describe('release()', () => { 100 | test('Release a lock successfully', async () => { 101 | redisClient.releaseLock = vi.fn().mockResolvedValue(1); 102 | 103 | const lock = new Lock(redlock, ['resource1'], 'lock_value', [], Date.now() + 1000); 104 | const result = await redlock.release(lock); 105 | expect(result.attempts.length).toBeGreaterThan(0); 106 | }); 107 | 108 | test('Handle errors during release', async () => { 109 | redisClient.releaseLock = vi.fn().mockRejectedValue(new Error()); 110 | 111 | const lock = new Lock(redlock, ['resource1'], 'lock_value', [], Date.now() + 1000); 112 | await expect(redlock.release(lock)).rejects.toThrow( 113 | 'The operation was unable to achieve a quorum during its retry window.', 114 | ); 115 | }); 116 | }); 117 | 118 | describe('extend()', () => { 119 | test('Extend a lock successfully', async () => { 120 | redisClient.extendLock = vi.fn().mockResolvedValue(1); 121 | 122 | const lock = new Lock(redlock, ['resource1'], 'lock_value', [], Date.now() + 1000); 123 | const extendedLock = await redlock.extend(lock, 1000); 124 | expect(extendedLock).toBeInstanceOf(Lock); 125 | }); 126 | 127 | test('Fail to extend an expired lock', async () => { 128 | redisClient.extendLock = vi.fn().mockResolvedValue(0); 129 | 130 | const lock = new Lock(redlock, ['resource1'], 'lock_value', [], Date.now() - 1000); 131 | await expect(redlock.extend(lock, 1000)).rejects.toBeInstanceOf(ExecutionError); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | volumes: 4 | node_modules: 5 | 6 | networks: 7 | redis: 8 | driver: bridge 9 | ipam: 10 | config: 11 | - subnet: 10.0.0.0/16 12 | 13 | services: 14 | # Single standalone instance. (NOT highly available, correct) 15 | redis-single-instance: 16 | image: redis:6 17 | 18 | # Multiple standalone instances. (highly availability, correct) 19 | redis-multi-instance-a: 20 | image: redis:6 21 | redis-multi-instance-b: 22 | image: redis:6 23 | redis-multi-instance-c: 24 | image: redis:6 25 | 26 | # Single cluster. (highly availability, potentially incorrect) 27 | redis-single-cluster-1: 28 | image: redis:6 29 | command: redis-server /etc/redis/redis.conf 30 | volumes: 31 | - type: bind 32 | source: ./.github/docker/cluster.redis.conf 33 | target: /etc/redis/redis.conf 34 | networks: 35 | redis: 36 | ipv4_address: 10.0.0.11 37 | redis-single-cluster-2: 38 | image: redis:6 39 | command: redis-server /etc/redis/redis.conf 40 | volumes: 41 | - type: bind 42 | source: ./.github/docker/cluster.redis.conf 43 | target: /etc/redis/redis.conf 44 | networks: 45 | redis: 46 | ipv4_address: 10.0.0.12 47 | redis-single-cluster-3: 48 | image: redis:6 49 | command: redis-server /etc/redis/redis.conf 50 | volumes: 51 | - type: bind 52 | source: ./.github/docker/cluster.redis.conf 53 | target: /etc/redis/redis.conf 54 | networks: 55 | redis: 56 | ipv4_address: 10.0.0.13 57 | redis-single-cluster-4: 58 | image: redis:6 59 | command: redis-server /etc/redis/redis.conf 60 | volumes: 61 | - type: bind 62 | source: ./.github/docker/cluster.redis.conf 63 | target: /etc/redis/redis.conf 64 | networks: 65 | redis: 66 | ipv4_address: 10.0.0.14 67 | redis-single-cluster-5: 68 | image: redis:6 69 | command: redis-server /etc/redis/redis.conf 70 | volumes: 71 | - type: bind 72 | source: ./.github/docker/cluster.redis.conf 73 | target: /etc/redis/redis.conf 74 | networks: 75 | redis: 76 | ipv4_address: 10.0.0.15 77 | redis-single-cluster-6: 78 | image: redis:6 79 | command: redis-server /etc/redis/redis.conf 80 | volumes: 81 | - type: bind 82 | source: ./.github/docker/cluster.redis.conf 83 | target: /etc/redis/redis.conf 84 | networks: 85 | redis: 86 | ipv4_address: 10.0.0.16 87 | redis-single-cluster: 88 | image: redis:6 89 | command: redis-cli -p 6379 --cluster create 10.0.0.11:6379 10.0.0.12:6379 10.0.0.13:6379 10.0.0.14:6379 10.0.0.14:6379 10.0.0.16:6379 --cluster-replicas 1 --cluster-yes 90 | depends_on: 91 | - redis-single-cluster-1 92 | - redis-single-cluster-2 93 | - redis-single-cluster-3 94 | - redis-single-cluster-4 95 | - redis-single-cluster-5 96 | - redis-single-cluster-6 97 | networks: 98 | redis: 99 | ipv4_address: 10.0.0.2 100 | 101 | # Multi cluster. (highly availability, correct, probably excessive) 102 | redis-multi-cluster-a-1: 103 | image: redis:6 104 | command: redis-server /etc/redis/redis.conf 105 | volumes: 106 | - type: bind 107 | source: ./.github/docker/cluster.redis.conf 108 | target: /etc/redis/redis.conf 109 | networks: 110 | redis: 111 | ipv4_address: 10.0.1.11 112 | redis-multi-cluster-a-2: 113 | image: redis:6 114 | command: redis-server /etc/redis/redis.conf 115 | volumes: 116 | - type: bind 117 | source: ./.github/docker/cluster.redis.conf 118 | target: /etc/redis/redis.conf 119 | networks: 120 | redis: 121 | ipv4_address: 10.0.1.12 122 | redis-multi-cluster-a-3: 123 | image: redis:6 124 | command: redis-server /etc/redis/redis.conf 125 | volumes: 126 | - type: bind 127 | source: ./.github/docker/cluster.redis.conf 128 | target: /etc/redis/redis.conf 129 | networks: 130 | redis: 131 | ipv4_address: 10.0.1.13 132 | redis-multi-cluster-a: 133 | image: redis:6 134 | command: redis-cli -p 6379 --cluster create 10.0.1.11:6379 10.0.1.12:6379 10.0.1.13:6379 --cluster-replicas 0 --cluster-yes 135 | depends_on: 136 | - redis-multi-cluster-a-1 137 | - redis-multi-cluster-a-2 138 | - redis-multi-cluster-a-3 139 | networks: 140 | redis: 141 | ipv4_address: 10.0.1.2 142 | 143 | redis-multi-cluster-b-1: 144 | image: redis:6 145 | command: redis-server /etc/redis/redis.conf 146 | volumes: 147 | - type: bind 148 | source: ./.github/docker/cluster.redis.conf 149 | target: /etc/redis/redis.conf 150 | networks: 151 | redis: 152 | ipv4_address: 10.0.2.11 153 | redis-multi-cluster-b-2: 154 | image: redis:6 155 | command: redis-server /etc/redis/redis.conf 156 | volumes: 157 | - type: bind 158 | source: ./.github/docker/cluster.redis.conf 159 | target: /etc/redis/redis.conf 160 | networks: 161 | redis: 162 | ipv4_address: 10.0.2.12 163 | redis-multi-cluster-b-3: 164 | image: redis:6 165 | command: redis-server /etc/redis/redis.conf 166 | volumes: 167 | - type: bind 168 | source: ./.github/docker/cluster.redis.conf 169 | target: /etc/redis/redis.conf 170 | networks: 171 | redis: 172 | ipv4_address: 10.0.2.13 173 | redis-multi-cluster-b: 174 | image: redis:6 175 | command: redis-cli -p 6379 --cluster create 10.0.2.11:6379 10.0.2.12:6379 10.0.2.13:6379 --cluster-replicas 0 --cluster-yes 176 | depends_on: 177 | - redis-multi-cluster-b-1 178 | - redis-multi-cluster-b-2 179 | - redis-multi-cluster-b-3 180 | networks: 181 | redis: 182 | ipv4_address: 10.0.2.2 183 | 184 | redis-multi-cluster-c-1: 185 | image: redis:6 186 | command: redis-server /etc/redis/redis.conf 187 | volumes: 188 | - type: bind 189 | source: ./.github/docker/cluster.redis.conf 190 | target: /etc/redis/redis.conf 191 | networks: 192 | redis: 193 | ipv4_address: 10.0.3.11 194 | redis-multi-cluster-c-2: 195 | image: redis:6 196 | command: redis-server /etc/redis/redis.conf 197 | volumes: 198 | - type: bind 199 | source: ./.github/docker/cluster.redis.conf 200 | target: /etc/redis/redis.conf 201 | networks: 202 | redis: 203 | ipv4_address: 10.0.3.12 204 | redis-multi-cluster-c-3: 205 | image: redis:6 206 | command: redis-server /etc/redis/redis.conf 207 | volumes: 208 | - type: bind 209 | source: ./.github/docker/cluster.redis.conf 210 | target: /etc/redis/redis.conf 211 | networks: 212 | redis: 213 | ipv4_address: 10.0.3.13 214 | redis-multi-cluster-c: 215 | image: redis:6 216 | command: redis-cli -p 6379 --cluster create 10.0.3.11:6379 10.0.3.12:6379 10.0.3.13:6379 --cluster-replicas 0 --cluster-yes 217 | depends_on: 218 | - redis-multi-cluster-c-1 219 | - redis-multi-cluster-c-2 220 | - redis-multi-cluster-c-3 221 | networks: 222 | redis: 223 | ipv4_address: 10.0.3.2 224 | 225 | # This container installs node modules into the node_modules volume. 226 | installer: 227 | image: "node:${NODE_VERSION}" 228 | working_dir: /workspace 229 | command: yarn install --immutable 230 | environment: 231 | NODE_ENV: development 232 | YARN_IGNORE_NODE: 1 233 | volumes: 234 | - type: bind 235 | source: . 236 | target: /workspace 237 | - type: volume 238 | source: node_modules 239 | target: /workspace/node_modules 240 | 241 | # This container watches for changes and builds the application. 242 | builder: 243 | depends_on: 244 | - installer 245 | image: "node:${NODE_VERSION}" 246 | working_dir: /workspace 247 | entrypoint: ./.github/docker/await.sh node_modules/.bin/tsc 248 | command: yarn build:development 249 | tty: true 250 | environment: 251 | NODE_ENV: development 252 | YARN_IGNORE_NODE: 1 253 | volumes: 254 | - type: bind 255 | source: . 256 | target: /workspace 257 | - type: volume 258 | source: node_modules 259 | target: /workspace/node_modules 260 | 261 | # This container runs the tests. 262 | runner: 263 | depends_on: 264 | - builder 265 | - redis-single-instance 266 | - redis-multi-instance-a 267 | - redis-multi-instance-b 268 | - redis-multi-instance-c 269 | - redis-single-cluster-1 270 | - redis-single-cluster-2 271 | - redis-single-cluster-3 272 | - redis-single-cluster-4 273 | - redis-single-cluster-5 274 | - redis-single-cluster-6 275 | - redis-single-cluster 276 | - redis-multi-cluster-a-1 277 | - redis-multi-cluster-a-2 278 | - redis-multi-cluster-a-3 279 | - redis-multi-cluster-a 280 | - redis-multi-cluster-b-1 281 | - redis-multi-cluster-b-2 282 | - redis-multi-cluster-b-3 283 | - redis-multi-cluster-b 284 | - redis-multi-cluster-c-1 285 | - redis-multi-cluster-c-2 286 | - redis-multi-cluster-c-3 287 | - redis-multi-cluster-c 288 | image: "node:${NODE_VERSION}" 289 | working_dir: /workspace 290 | entrypoint: ./.github/docker/await.sh node_modules/.bin/vitest ./.github/docker/await.sh build/index.js 291 | command: yarn test:development 292 | tty: true 293 | environment: 294 | NODE_ENV: development 295 | volumes: 296 | - type: bind 297 | source: . 298 | target: /workspace 299 | - type: volume 300 | source: node_modules 301 | target: /workspace/node_modules 302 | networks: 303 | default: 304 | redis: 305 | ipv4_address: 10.0.10.3 306 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Node build, test and publish](https://github.com/sesamecare/redlock/actions/workflows/nodejs.yml/badge.svg)](https://github.com/sesamecare/redlock/actions/workflows/nodejs.yml) 2 | [![Current Version](https://badgen.net/npm/v/@sesamecare-oss/redlock)](https://npm.im/@sesamecare-oss/redlock) 3 | [![Supported Node.js Versions](https://badgen.net/npm/node/@sesamecare-oss/redlock)](https://npm.im/@sesamecare-oss/redlock) 4 | 5 | # Redlock 6 | 7 | This is a Node.js implementation of the [redlock](http://redis.io/topics/distlock) algorithm for distributed redis locks. It provides strong guarantees in both single-redis and multi-redis environments, and provides fault tolerance through use of multiple independent redis instances or clusters. 8 | 9 | > Note! 10 | > This is a derivative of node-redlock, rewritten in Typescript with ioredis@5. 11 | 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [Error Handling](#error-handling) 15 | - [API](#api) 16 | - [Guidance](#guidance) 17 | 18 | ## Installation 19 | 20 | ```bash 21 | npm install --save @sesamecare-oss/redlock 22 | ``` 23 | 24 | ## Configuration 25 | 26 | Redlock is designed to use [ioredis](https://github.com/luin/ioredis) to keep its client connections and handle the cluster protocols. 27 | 28 | A redlock object is instantiated with an array of at least one redis client and an optional `options` object. Properties of the Redlock object should NOT be changed after it is first used, as doing so could have unintended consequences for live locks. 29 | 30 | ```ts 31 | import Redis from "ioredis"; 32 | import { Redlock } from "@sesamecare-oss/redlock"; 33 | 34 | const redisA = new Redis({ host: "a.redis.example.com" }); 35 | const redisB = new Redis({ host: "b.redis.example.com" }); 36 | const redisC = new Redis({ host: "c.redis.example.com" }); 37 | 38 | const redlock = new Redlock( 39 | // You should have one client for each independent redis node 40 | // or cluster. 41 | [redisA, redisB, redisC], 42 | { 43 | // The expected clock drift; for more details see: 44 | // http://redis.io/topics/distlock 45 | driftFactor: 0.01, // multiplied by lock ttl to determine drift time 46 | 47 | // The max number of times Redlock will attempt to lock a resource 48 | // before erroring. 49 | retryCount: 10, 50 | 51 | // the time in ms between attempts 52 | retryDelay: 200, // time in ms 53 | 54 | // the max time in ms randomly added to retries 55 | // to improve performance under high contention 56 | // see https://www.awsarchitectureblog.com/2015/03/backoff.html 57 | retryJitter: 200, // time in ms 58 | 59 | // The minimum remaining time on a lock before an extension is automatically 60 | // attempted with the `using` API. 61 | automaticExtensionThreshold: 500, // time in ms 62 | } 63 | ); 64 | ``` 65 | 66 | ## Usage 67 | 68 | The `using` method wraps and executes a routine in the context of an auto-extending lock, returning a promise of the routine's value. In the case that auto-extension fails, an AbortSignal will be updated to indicate that abortion of the routine is in order, and to pass along the encountered error. 69 | 70 | The first parameter is an array of resources to lock; the second is the requested lock duration in milliseconds, which MUST NOT contain values after the decimal. The routine you provide receives the signal and a context object which reflects the current lock (which has other metadata) and a count of the number of extensions that have been acquired. 71 | 72 | ```ts 73 | await redlock.using([senderId, recipientId], 5000, async (signal, context) => { 74 | // Do something... 75 | await something(); 76 | 77 | // Make sure any attempted lock extension has not failed. 78 | if (signal.aborted) { 79 | throw signal.error; 80 | } 81 | 82 | // Do something else... 83 | await somethingElse(); 84 | }); 85 | ``` 86 | 87 | Alternatively, locks can be acquired and released directly: 88 | 89 | ```ts 90 | // Acquire a lock. 91 | let lock = await redlock.acquire(["a"], 5000); 92 | try { 93 | // Do something... 94 | await something(); 95 | 96 | // Extend the lock. Note that this returns a new `Lock` instance. 97 | lock = await lock.extend(5000); 98 | 99 | // Do something else... 100 | await somethingElse(); 101 | } finally { 102 | // Release the lock. 103 | await lock.release(); 104 | } 105 | ``` 106 | 107 | ### Use in CommonJS Projects 108 | 109 | Beginning in version 5, this package is published primarily as an ECMAScript module. While this is universally accepted as the format of the future, there remain some interoperability quirks when used in CommonJS node applications. For major version 5, this package **also** distributes a copy transpiled to CommonJS. Please ensure that your project either uses either the ECMAScript or CommonJS version **but NOT both**. 110 | 111 | The `Redlock` class is published as the "default" export, and can be imported with: 112 | 113 | ```ts 114 | const { Redlock } = require("@sesamecare-oss/redlock"); 115 | ``` 116 | 117 | In version 6, this package will stop distributing the CommonJS version. 118 | 119 | ## Error Handling 120 | 121 | Because redlock is designed for high availability, it does not care if a minority of redis instances/clusters fail at an operation. 122 | 123 | However, it can be helpful to monitor and log such cases. Redlock emits an "error" event whenever it encounters an error, even if the error is ignored in its normal operation. 124 | 125 | ```ts 126 | redlock.on("error", (error) => { 127 | // Ignore cases where a resource is explicitly marked as locked on a client. 128 | if (error instanceof ResourceLockedError) { 129 | return; 130 | } 131 | 132 | // Log all other errors. 133 | console.error(error); 134 | }); 135 | ``` 136 | 137 | Additionally, a per-attempt and per-client stats (including errors) are made available on the `attempt` propert of both `Lock` and `ExecutionError` classes. 138 | 139 | ## API 140 | 141 | Please view the (very concise) source code or TypeScript definitions for a detailed breakdown of the API. 142 | 143 | ## Guidance 144 | 145 | ### Contributing 146 | 147 | Please see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for information on developing, running, and testing this library. 148 | 149 | ### Using a specific DB index 150 | 151 | If you're using only one `Redis` client, with only one redis instance which has cluster mode **disabled**, you can set a `db` property in the `options` configuration to specify the DB index in which to store the lock records. For example: 152 | 153 | ```ts 154 | import Redis from "ioredis"; 155 | import { Redlock } from "@sesamecare-oss/redlock"; 156 | 157 | const redis = new Redis({ host: "a.redis.example.com" }); 158 | 159 | const redlock = new Redlock( 160 | [redis], 161 | { 162 | driftFactor: 0.01, // multiplied by lock ttl to determine drift time 163 | retryCount: 10, 164 | retryDelay: 200, // time in ms 165 | retryJitter: 200, // time in ms 166 | automaticExtensionThreshold: 500, // time in ms 167 | db: 2 // DB to select and use for lock records 168 | } 169 | ); 170 | ``` 171 | 172 | Note that the `db` value is ignored for redis servers with cluster mode enabled. If a value outside of the range 0-15 is provided, the configuration defaults back to `0`. 173 | 174 | ### High-Availability Recommendations 175 | 176 | - Use at least 3 independent servers or clusters 177 | - Use an odd number of independent redis **_servers_** for most installations 178 | - Use an odd number of independent redis **_clusters_** for massive installations 179 | - When possible, distribute redis nodes across different physical machines 180 | 181 | ### Using Cluster/Sentinel 182 | 183 | **_Please make sure to use a client with built-in cluster support, such as [ioredis](https://github.com/luin/ioredis)._** 184 | 185 | It is completely possible to use a _single_ redis cluster or sentinal configuration by passing one preconfigured client to redlock. While you do gain high availability and vastly increased throughput under this scheme, the failure modes are a bit different, and it becomes theoretically possible that a lock is acquired twice: 186 | 187 | Assume you are using eventually-consistent redis replication, and you acquire a lock for a resource. Immediately after acquiring your lock, the redis master for that shard crashes. Redis does its thing and fails over to the slave which hasn't yet synced your lock. If another process attempts to acquire a lock for the same resource, it will succeed! 188 | 189 | This is why redlock allows you to specify multiple independent nodes/clusters: by requiring consensus between them, we can safely take out or fail-over a minority of nodes without invalidating active locks. 190 | 191 | To learn more about the the algorithm, check out the [redis distlock page](http://redis.io/topics/distlock). 192 | 193 | Also note that when acquiring a lock on multiple resources, commands are executed in a single call to redis. Redis clusters require that all keys exist in a command belong to the same node. **If you are using a redis cluster or clusters and need to lock multiple resources together you MUST use [redis hash tags](https://redis.io/topics/cluster-spec#keys-hash-tags) (ie. use `ignored{considered}ignored{ignored}` notation in resource strings) to ensure that all keys resolve to the same node.** Chosing what data to include must be done thoughtfully, because representing the same conceptual resource in more than one way defeats the purpose of acquiring a lock. Accordingly, it's generally wise to use a single very generic prefix to ensure that ALL lock keys resolve to the same node, such as `{redlock}my_resource`. This is the most straightforward strategy and may be appropriate when the cluster has additional purposes. However, when locks will always naturally share a common attribute (for example, an organization/tenant ID), this may be used for better key distribution and cluster utilization. You can also acheive ideal utilization by completely omiting a hash tag if you do _not_ need to lock multiple resources at the same time. 194 | 195 | ### How do I check if something is locked? 196 | 197 | The purpose of redlock is to provide exclusivity guarantees on a resource over a duration of time, and is not designed to report the ownership status of a resource. For example, if you are on the smaller side of a network partition you will fail to acquire a lock, but you don't know if the lock exists on the other side; all you know is that you can't guarantee exclusivity on yours. This is further complicated by retry behavior, and even moreso when acquiring a lock on more than one resource. 198 | 199 | That said, for many tasks it's sufficient to attempt a lock with `retryCount=0`, and treat a failure as the resource being "locked" or (more correctly) "unavailable". 200 | 201 | Note that with `retryCount=-1` there will be unlimited retries until the lock is aquired. 202 | -------------------------------------------------------------------------------- /src/system.spec.ts: -------------------------------------------------------------------------------- 1 | import Redis, { Cluster } from 'ioredis'; 2 | import { beforeEach, describe, expect, test } from 'vitest'; 3 | 4 | import { ExecutionError, Redlock, ResourceLockedError } from './index'; 5 | 6 | async function waitForCluster(redis: Cluster): Promise { 7 | async function checkIsReady(): Promise { 8 | return (await redis.cluster('INFO')).match(/^cluster_state:(.+)$/m)?.[1] === 'ok'; 9 | } 10 | 11 | let isReady = await checkIsReady(); 12 | while (!isReady) { 13 | console.log('Waiting for cluster to be ready...'); 14 | await new Promise((resolve) => setTimeout(resolve, 1000)); 15 | isReady = await checkIsReady(); 16 | } 17 | 18 | async function checkIsWritable(): Promise { 19 | try { 20 | return ((await redis.set('isWritable', 'true')) as string) === 'OK'; 21 | } catch (error) { 22 | console.error(`Cluster unable to receive writes: ${error}`); 23 | return false; 24 | } 25 | } 26 | 27 | let isWritable = await checkIsWritable(); 28 | while (!isWritable) { 29 | console.log('Waiting for cluster to be writable...'); 30 | await new Promise((resolve) => setTimeout(resolve, 1000)); 31 | isWritable = await checkIsWritable(); 32 | } 33 | } 34 | 35 | function is(actual: T, expected: T, message?: string): void { 36 | expect(actual, message).toBe(expected); 37 | } 38 | 39 | describe.each([ 40 | { type: 'instance' }, // Defaults to db 0 41 | { type: 'instance', db: 2 }, // Test using db 2 42 | { type: 'cluster' }, // DB select not supported in cluster mode 43 | ])('$type', ({ type, db = 0 }) => { 44 | const redis = 45 | type === 'instance' 46 | ? new Redis({ host: 'redis-single-instance' }) 47 | : new Cluster([{ host: 'redis-single-cluster-1' }]); 48 | 49 | beforeEach(async () => { 50 | if (redis instanceof Cluster && redis.isCluster) { 51 | await waitForCluster(redis); 52 | } else { 53 | await redis.select(db); 54 | } 55 | await redis.keys('*').then((keys) => (keys?.length ? redis.del(keys) : null)); 56 | }); 57 | 58 | test(`${type} - db ${db} - refuses to use a non-integer duration`, async () => { 59 | try { 60 | const redlock = new Redlock([redis], { db }); 61 | const duration = Number.MAX_SAFE_INTEGER / 10; 62 | 63 | // Acquire a lock. 64 | await redlock.acquire(['{redlock}float'], duration); 65 | 66 | expect.fail('Expected the function to throw.'); 67 | } catch (error) { 68 | expect((error as Error).message).toBe('Duration must be an integer value in milliseconds.'); 69 | } 70 | }); 71 | 72 | test(`${type} - db ${db} - acquires, extends, and releases a single lock`, async () => { 73 | const redlock = new Redlock([redis], { db }); 74 | 75 | const duration = Math.floor(Number.MAX_SAFE_INTEGER / 10); 76 | 77 | // Acquire a lock. 78 | let lock = await redlock.acquire(['{redlock}a'], duration); 79 | expect(await redis.get('{redlock}a'), 'The lock value was incorrect.').toBe(lock.value); 80 | expect( 81 | Math.floor((await redis.pttl('{redlock}a')) / 200), 82 | 'The lock expiration was off by more than 200ms', 83 | ).toBe(Math.floor(duration / 200)); 84 | 85 | // Extend the lock. 86 | lock = await lock.extend(3 * duration); 87 | expect(await redis.get('{redlock}a'), 'The lock value was incorrect.').toBe(lock.value); 88 | expect( 89 | Math.floor((await redis.pttl('{redlock}a')) / 200), 90 | 'The lock expiration was off by more than 200ms', 91 | ).toBe(Math.floor((3 * duration) / 200)); 92 | 93 | // Release the lock. 94 | await lock.release(); 95 | expect(await redis.get('{redlock}a')).toBeNull(); 96 | }); 97 | 98 | test(`${type} - db ${db} - acquires, extends, and releases a multi-resource lock`, async () => { 99 | const redlock = new Redlock([redis], { db }); 100 | 101 | const duration = Math.floor(Number.MAX_SAFE_INTEGER / 10); 102 | 103 | // Acquire a lock. 104 | let lock = await redlock.acquire(['{redlock}a1', '{redlock}a2'], duration); 105 | is(await redis.get('{redlock}a1'), lock.value, 'The lock value was incorrect.'); 106 | is(await redis.get('{redlock}a2'), lock.value, 'The lock value was incorrect.'); 107 | is( 108 | Math.floor((await redis.pttl('{redlock}a1')) / 200), 109 | Math.floor(duration / 200), 110 | 'The lock expiration was off by more than 200ms', 111 | ); 112 | is( 113 | Math.floor((await redis.pttl('{redlock}a2')) / 200), 114 | Math.floor(duration / 200), 115 | 'The lock expiration was off by more than 200ms', 116 | ); 117 | 118 | // Extend the lock. 119 | lock = await lock.extend(3 * duration); 120 | is(await redis.get('{redlock}a1'), lock.value, 'The lock value was incorrect.'); 121 | is(await redis.get('{redlock}a2'), lock.value, 'The lock value was incorrect.'); 122 | is( 123 | Math.floor((await redis.pttl('{redlock}a1')) / 200), 124 | Math.floor((3 * duration) / 200), 125 | 'The lock expiration was off by more than 200ms', 126 | ); 127 | is( 128 | Math.floor((await redis.pttl('{redlock}a2')) / 200), 129 | Math.floor((3 * duration) / 200), 130 | 'The lock expiration was off by more than 200ms', 131 | ); 132 | 133 | // Release the lock. 134 | await lock.release(); 135 | is(await redis.get('{redlock}a1'), null); 136 | is(await redis.get('{redlock}a2'), null); 137 | }); 138 | 139 | test(`${type} - db ${db} - locks fail when redis is unreachable`, async () => { 140 | const redis = new Redis({ 141 | host: '127.0.0.1', 142 | port: 6380, 143 | maxRetriesPerRequest: 0, 144 | autoResendUnfulfilledCommands: false, 145 | autoResubscribe: false, 146 | retryStrategy: () => null, 147 | reconnectOnError: () => false, 148 | }); 149 | 150 | redis.on('error', () => { 151 | // ignore redis-generated errors 152 | }); 153 | 154 | const redlock = new Redlock([redis], { db }); 155 | 156 | const duration = Math.floor(Number.MAX_SAFE_INTEGER / 10); 157 | try { 158 | await redlock.acquire(['{redlock}b'], duration); 159 | throw new Error('This lock should not be acquired.'); 160 | } catch (error) { 161 | if (!(error instanceof ExecutionError)) { 162 | throw error; 163 | } 164 | 165 | is( 166 | error.attempts.length, 167 | 11, 168 | 'A failed acquisition must have the configured number of retries.', 169 | ); 170 | 171 | for (const e of await Promise.allSettled(error.attempts)) { 172 | is(e.status, 'fulfilled'); 173 | if (e.status === 'fulfilled') { 174 | for (const v of e.value?.votesAgainst?.values() || []) { 175 | is(v.message, 'Connection is closed.'); 176 | } 177 | } 178 | } 179 | } 180 | }); 181 | 182 | test(`${type} - db ${db} - locks automatically expire`, async () => { 183 | const redlock = new Redlock([redis], { db }); 184 | 185 | const duration = 200; 186 | 187 | // Acquire a lock. 188 | const lock = await redlock.acquire(['{redlock}d'], duration); 189 | is(await redis.get('{redlock}d'), lock.value, 'The lock value was incorrect.'); 190 | 191 | // Wait until the lock expires. 192 | await new Promise((resolve) => setTimeout(resolve, 300, undefined)); 193 | 194 | // Attempt to acquire another lock on the same resource. 195 | const lock2 = await redlock.acquire(['{redlock}d'], duration); 196 | is(await redis.get('{redlock}d'), lock2.value, 'The lock value was incorrect.'); 197 | 198 | // Release the lock. 199 | await lock2.release(); 200 | is(await redis.get('{redlock}d'), null); 201 | }); 202 | 203 | test(`${type} - db ${db} - individual locks are exclusive`, async () => { 204 | const redlock = new Redlock([redis], { db }); 205 | 206 | const duration = Math.floor(Number.MAX_SAFE_INTEGER / 10); 207 | 208 | // Acquire a lock. 209 | const lock = await redlock.acquire(['{redlock}c'], duration); 210 | is(await redis.get('{redlock}c'), lock.value, 'The lock value was incorrect.'); 211 | is( 212 | Math.floor((await redis.pttl('{redlock}c')) / 200), 213 | Math.floor(duration / 200), 214 | 'The lock expiration was off by more than 200ms', 215 | ); 216 | 217 | // Attempt to acquire another lock on the same resource. 218 | try { 219 | await redlock.acquire(['{redlock}c'], duration); 220 | throw new Error('This lock should not be acquired.'); 221 | } catch (error) { 222 | if (!(error instanceof ExecutionError)) { 223 | throw error; 224 | } 225 | 226 | is( 227 | error.attempts.length, 228 | 11, 229 | 'A failed acquisition must have the configured number of retries.', 230 | ); 231 | 232 | for (const e of await Promise.allSettled(error.attempts)) { 233 | is(e.status, 'fulfilled'); 234 | if (e.status === 'fulfilled') { 235 | for (const v of e.value?.votesAgainst?.values() || []) { 236 | expect(v, 'The error must be a ResourceLockedError.').toBeInstanceOf( 237 | ResourceLockedError, 238 | ); 239 | } 240 | } 241 | } 242 | } 243 | 244 | // Release the lock. 245 | await lock.release(); 246 | is(await redis.get('{redlock}c'), null); 247 | }); 248 | 249 | test(`${type} - db ${db} - overlapping multi-locks are exclusive`, async () => { 250 | const redlock = new Redlock([redis], { db }); 251 | 252 | const duration = Math.floor(Number.MAX_SAFE_INTEGER / 10); 253 | 254 | // Acquire a lock. 255 | const lock = await redlock.acquire(['{redlock}c1', '{redlock}c2'], duration); 256 | is(await redis.get('{redlock}c1'), lock.value, 'The lock value was incorrect.'); 257 | is(await redis.get('{redlock}c2'), lock.value, 'The lock value was incorrect.'); 258 | is( 259 | Math.floor((await redis.pttl('{redlock}c1')) / 200), 260 | Math.floor(duration / 200), 261 | 'The lock expiration was off by more than 200ms', 262 | ); 263 | is( 264 | Math.floor((await redis.pttl('{redlock}c2')) / 200), 265 | Math.floor(duration / 200), 266 | 'The lock expiration was off by more than 200ms', 267 | ); 268 | 269 | // Attempt to acquire another lock with overlapping resources 270 | try { 271 | await redlock.acquire(['{redlock}c2', '{redlock}c3'], duration); 272 | throw new Error('This lock should not be acquired.'); 273 | } catch (error) { 274 | if (!(error instanceof ExecutionError)) { 275 | throw error; 276 | } 277 | 278 | is( 279 | await redis.get('{redlock}c1'), 280 | lock.value, 281 | 'The original lock value must not be changed.', 282 | ); 283 | is( 284 | await redis.get('{redlock}c2'), 285 | lock.value, 286 | 'The original lock value must not be changed.', 287 | ); 288 | is(await redis.get('{redlock}c3'), null, 'The new resource must remain unlocked.'); 289 | 290 | is( 291 | error.attempts.length, 292 | 11, 293 | 'A failed acquisition must have the configured number of retries.', 294 | ); 295 | 296 | for (const e of await Promise.allSettled(error.attempts)) { 297 | is(e.status, 'fulfilled'); 298 | if (e.status === 'fulfilled') { 299 | for (const v of e.value?.votesAgainst?.values() || []) { 300 | expect(v, 'The error must be a ResourceLockedError.').toBeInstanceOf( 301 | ResourceLockedError, 302 | ); 303 | } 304 | } 305 | } 306 | } 307 | 308 | // Release the lock. 309 | await lock.release(); 310 | is(await redis.get('{redlock}c1'), null); 311 | is(await redis.get('{redlock}c2'), null); 312 | is(await redis.get('{redlock}c3'), null); 313 | }); 314 | 315 | test(`${type} - db ${db} - the \`using\` helper acquires, extends, and releases locks`, async () => { 316 | const redlock = new Redlock([redis], { db }); 317 | 318 | const duration = 500; 319 | 320 | const valueP: Promise = redlock.using( 321 | ['{redlock}x'], 322 | duration, 323 | { 324 | automaticExtensionThreshold: 200, 325 | }, 326 | async (signal) => { 327 | const lockValue = await redis.get('{redlock}x'); 328 | expect(typeof lockValue, 'The lock value was not correctly acquired.').toBe('string'); 329 | 330 | // Wait to ensure that the lock is extended 331 | await new Promise((resolve) => setTimeout(resolve, 700, undefined)); 332 | 333 | is(signal.aborted, false, 'The signal must not be aborted.'); 334 | is(signal.error, undefined, 'The signal must not have an error.'); 335 | 336 | is(await redis.get('{redlock}x'), lockValue, 'The lock value should not have changed.'); 337 | 338 | return lockValue; 339 | }, 340 | ); 341 | 342 | await valueP; 343 | 344 | is(await redis.get('{redlock}x'), null, 'The lock was not released.'); 345 | }); 346 | 347 | test(`${type} - db ${db} - the \`using\` helper is exclusive`, async () => { 348 | const redlock = new Redlock([redis], { db }); 349 | 350 | const duration = 500; 351 | 352 | let locked = false; 353 | const [lock1, lock2] = await Promise.all([ 354 | await redlock.using( 355 | ['{redlock}y'], 356 | duration, 357 | { 358 | automaticExtensionThreshold: 200, 359 | }, 360 | async (signal) => { 361 | is(locked, false, 'The resource must not already be locked.'); 362 | locked = true; 363 | 364 | const lockValue = await redis.get('{redlock}y'); 365 | expect(typeof lockValue === 'string', 'The lock value was not correctly acquired.'); 366 | 367 | // Wait to ensure that the lock is extended 368 | await new Promise((resolve) => setTimeout(resolve, 700, undefined)); 369 | 370 | is(signal.error, undefined, 'The signal must not have an error.'); 371 | is(signal.aborted, false, 'The signal must not be aborted.'); 372 | 373 | is(await redis.get('{redlock}y'), lockValue, 'The lock value should not have changed.'); 374 | 375 | locked = false; 376 | return lockValue; 377 | }, 378 | ), 379 | await redlock.using( 380 | ['{redlock}y'], 381 | duration, 382 | { 383 | automaticExtensionThreshold: 200, 384 | }, 385 | async (signal) => { 386 | is(locked, false, 'The resource must not already be locked.'); 387 | locked = true; 388 | 389 | const lockValue = await redis.get('{redlock}y'); 390 | expect(typeof lockValue === 'string', 'The lock value was not correctly acquired.'); 391 | 392 | // Wait to ensure that the lock is extended 393 | await new Promise((resolve) => setTimeout(resolve, 700, undefined)); 394 | 395 | is(signal.error, undefined, 'The signal must not have an error.'); 396 | is(signal.aborted, false, 'The signal must not be aborted.'); 397 | 398 | is(await redis.get('{redlock}y'), lockValue, 'The lock value should not have changed.'); 399 | 400 | locked = false; 401 | return lockValue; 402 | }, 403 | ), 404 | ]); 405 | 406 | expect(lock1, 'The locks must be different.').not.toBe(lock2); 407 | 408 | is(await redis.get('{redlock}y'), null, 'The lock was not released.'); 409 | }); 410 | }); 411 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes, createHash } from 'crypto'; 2 | import { EventEmitter } from 'events'; 3 | 4 | import type { Redis as IORedisClient, Cluster as IORedisCluster } from 'ioredis'; 5 | 6 | import { ensureCommands } from './scripts'; 7 | 8 | type Client = IORedisClient | IORedisCluster; 9 | 10 | export type ClientExecutionResult = 11 | | { 12 | client: Client; 13 | vote: 'for'; 14 | value: number; 15 | } 16 | | { 17 | client: Client; 18 | vote: 'against'; 19 | error: Error; 20 | }; 21 | 22 | /* 23 | * This object contains a summary of results. 24 | */ 25 | export type ExecutionStats = { 26 | readonly membershipSize: number; 27 | readonly quorumSize: number; 28 | readonly votesFor: Set; 29 | readonly votesAgainst: Map; 30 | }; 31 | 32 | /* 33 | * This object contains a summary of results. Because the result of an attempt 34 | * can sometimes be determined before all requests are finished, each attempt 35 | * contains a Promise that will resolve ExecutionStats once all requests are 36 | * finished. A rejection of these promises should be considered undefined 37 | * behavior and should cause a crash. 38 | */ 39 | export type ExecutionResult = { 40 | attempts: ReadonlyArray>; 41 | start: number; 42 | }; 43 | 44 | /** 45 | * 46 | */ 47 | export interface Settings { 48 | readonly driftFactor?: number; 49 | readonly retryCount?: number; 50 | readonly retryDelay?: number; 51 | readonly retryJitter?: number; 52 | readonly automaticExtensionThreshold?: number; 53 | readonly db?: number; 54 | } 55 | 56 | // Define default settings. 57 | const defaultSettings: Readonly> = { 58 | driftFactor: 0.01, 59 | retryCount: 10, 60 | retryDelay: 200, 61 | retryJitter: 100, 62 | automaticExtensionThreshold: 500, 63 | db: 0 64 | }; 65 | 66 | // Modifyng this object is forbidden. 67 | Object.freeze(defaultSettings); 68 | 69 | /* 70 | * This error indicates a failure due to the existence of another lock for one 71 | * or more of the requested resources. 72 | */ 73 | export class ResourceLockedError extends Error { 74 | constructor(public readonly message: string) { 75 | super(); 76 | this.name = 'ResourceLockedError'; 77 | } 78 | } 79 | 80 | /* 81 | * This error indicates a failure of an operation to pass with a quorum. 82 | */ 83 | export class ExecutionError extends Error { 84 | constructor( 85 | public readonly message: string, 86 | public readonly attempts: ReadonlyArray>, 87 | ) { 88 | super(); 89 | this.name = 'ExecutionError'; 90 | } 91 | } 92 | 93 | /* 94 | * An object of this type is returned when a resource is successfully locked. It 95 | * contains convenience methods `release` and `extend` which perform the 96 | * associated Redlock method on itself. 97 | */ 98 | export class Lock { 99 | constructor( 100 | public readonly redlock: Redlock, 101 | public readonly resources: string[], 102 | public readonly value: string, 103 | public readonly attempts: ReadonlyArray>, 104 | public expiration: number, 105 | public readonly settings?: Partial, 106 | ) {} 107 | 108 | async release(): Promise { 109 | return this.redlock.release(this); 110 | } 111 | 112 | async extend(duration: number): Promise { 113 | return this.redlock.extend(this, duration, this.settings); 114 | } 115 | } 116 | 117 | export type RedlockAbortSignal = AbortSignal & { error?: Error }; 118 | 119 | export interface RedlockUsingContext { 120 | lock: Lock; 121 | extensions: number; 122 | } 123 | 124 | /** 125 | * A redlock object is instantiated with an array of at least one redis client 126 | * and an optional `options` object. Properties of the Redlock object should NOT 127 | * be changed after it is first used, as doing so could have unintended 128 | * consequences for live locks. 129 | */ 130 | export class Redlock extends EventEmitter { 131 | public readonly clients: Set; 132 | public readonly settings: Required; 133 | 134 | public constructor(clients: Iterable, settings: Settings = {}) { 135 | super(); 136 | 137 | // Prevent crashes on error events. 138 | this.on('error', () => { 139 | // Because redlock is designed for high availability, it does not care if 140 | // a minority of redis instances/clusters fail at an operation. 141 | // 142 | // However, it can be helpful to monitor and log such cases. Redlock emits 143 | // an "error" event whenever it encounters an error, even if the error is 144 | // ignored in its normal operation. 145 | // 146 | // This function serves to prevent node's default behavior of crashing 147 | // when an "error" event is emitted in the absence of listeners. 148 | }); 149 | 150 | // Create a new array of client, to ensure no accidental mutation. 151 | this.clients = new Set(clients); 152 | if (this.clients.size === 0) { 153 | throw new Error('Redlock must be instantiated with at least one redis client.'); 154 | } 155 | 156 | // Customize the settings for this instance. 157 | this.settings = { 158 | driftFactor: 159 | typeof settings.driftFactor === 'number' 160 | ? settings.driftFactor 161 | : defaultSettings.driftFactor, 162 | retryCount: 163 | typeof settings.retryCount === 'number' ? settings.retryCount : defaultSettings.retryCount, 164 | retryDelay: 165 | typeof settings.retryDelay === 'number' ? settings.retryDelay : defaultSettings.retryDelay, 166 | retryJitter: 167 | typeof settings.retryJitter === 'number' 168 | ? settings.retryJitter 169 | : defaultSettings.retryJitter, 170 | automaticExtensionThreshold: 171 | typeof settings.automaticExtensionThreshold === 'number' 172 | ? settings.automaticExtensionThreshold 173 | : defaultSettings.automaticExtensionThreshold, 174 | db: 175 | (typeof settings.db === 'number' && Number.isInteger(settings.db) && settings.db >= 0 && settings.db <= 15) 176 | // settings.db value must be a number and between 0 and 15, inclusive. 177 | ? settings.db 178 | : defaultSettings.db, 179 | }; 180 | } 181 | 182 | /** 183 | * Generate a sha1 hash compatible with redis evalsha. 184 | */ 185 | private _hash(value: string): string { 186 | return createHash('sha1').update(value).digest('hex'); 187 | } 188 | 189 | /** 190 | * Generate a cryptographically random string. 191 | */ 192 | private _random(): string { 193 | return randomBytes(16).toString('hex'); 194 | } 195 | 196 | /** 197 | * This method runs `.quit()` on all client connections. 198 | */ 199 | public async quit(): Promise { 200 | const results = []; 201 | for (const client of this.clients) { 202 | results.push(client.quit()); 203 | } 204 | 205 | await Promise.all(results); 206 | } 207 | 208 | /** 209 | * This method acquires a locks on the resources for the duration specified by 210 | * the `duration`. 211 | */ 212 | public async acquire( 213 | resources: string[], 214 | duration: number, 215 | settings?: Partial, 216 | ): Promise { 217 | if (Math.floor(duration) !== duration) { 218 | throw new Error('Duration must be an integer value in milliseconds.'); 219 | } 220 | 221 | const value = this._random(); 222 | 223 | try { 224 | const { attempts, start } = await this._execute( 225 | 'acquireLock', 226 | resources, 227 | [this.settings.db, value, duration], 228 | settings, 229 | ); 230 | 231 | // Add 2 milliseconds to the drift to account for Redis expires precision, 232 | // which is 1 ms, plus the configured allowable drift factor. 233 | const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; 234 | 235 | return new Lock(this, resources, value, attempts, start + duration - drift, settings); 236 | } catch (error) { 237 | // If there was an error acquiring the lock, release any partial lock 238 | // state that may exist on a minority of clients. 239 | await this._execute('releaseLock', resources, [this.settings.db, value], { 240 | retryCount: 0, 241 | }).catch(() => { 242 | // Any error here will be ignored. 243 | }); 244 | 245 | throw error; 246 | } 247 | } 248 | 249 | /** 250 | * This method unlocks the provided lock from all servers still persisting it. 251 | * It will fail with an error if it is unable to release the lock on a quorum 252 | * of nodes, but will make no attempt to restore the lock in the case of a 253 | * failure to release. It is safe to re-attempt a release or to ignore the 254 | * error, as the lock will automatically expire after its timeout. 255 | */ 256 | public async release(lock: Lock, settings?: Partial): Promise { 257 | // Immediately invalidate the lock. 258 | lock.expiration = 0; 259 | 260 | // Attempt to release the lock. 261 | return this._execute('releaseLock', lock.resources, [this.settings.db, lock.value], settings); 262 | } 263 | 264 | /** 265 | * This method extends a valid lock by the provided `duration`. 266 | */ 267 | public async extend( 268 | existing: Lock, 269 | duration: number, 270 | settings?: Partial, 271 | ): Promise { 272 | if (Math.floor(duration) !== duration) { 273 | throw new Error('Duration must be an integer value in milliseconds.'); 274 | } 275 | 276 | // The lock has already expired. 277 | if (existing.expiration < Date.now()) { 278 | throw new ExecutionError('Cannot extend an already-expired lock.', []); 279 | } 280 | 281 | const { attempts, start } = await this._execute( 282 | 'extendLock', 283 | existing.resources, 284 | [this.settings.db, existing.value, duration], 285 | settings, 286 | ); 287 | 288 | // Invalidate the existing lock. 289 | existing.expiration = 0; 290 | 291 | // Add 2 milliseconds to the drift to account for Redis expires precision, 292 | // which is 1 ms, plus the configured allowable drift factor. 293 | const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; 294 | 295 | return new Lock(this, existing.resources, existing.value, attempts, start + duration - drift, settings); 296 | } 297 | 298 | /** 299 | * Execute a script on all clients. The resulting promise is resolved or 300 | * rejected as soon as this quorum is reached; the resolution or rejection 301 | * will contains a `stats` property that is resolved once all votes are in. 302 | */ 303 | private async _execute( 304 | command: 'acquireLock' | 'extendLock' | 'releaseLock', 305 | keys: string[], 306 | args: (string | number)[], 307 | _settings?: Partial, 308 | ): Promise { 309 | const settings = _settings 310 | ? { 311 | ...this.settings, 312 | ..._settings, 313 | } 314 | : this.settings; 315 | 316 | // For the purpose of easy config serialization, we treat a retryCount of 317 | // -1 a equivalent to Infinity. 318 | const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1; 319 | 320 | const attempts: Promise[] = []; 321 | 322 | while (attempts.length < maxAttempts) { 323 | const { vote, stats, start } = await this._attemptOperation(command, keys, args); 324 | 325 | attempts.push(stats); 326 | 327 | // The operation achieved a quorum in favor. 328 | if (vote === 'for') { 329 | return { attempts, start }; 330 | } 331 | 332 | // Wait before reattempting. 333 | if (attempts.length < maxAttempts) { 334 | await new Promise((resolve) => { 335 | setTimeout( 336 | resolve, 337 | Math.max( 338 | 0, 339 | settings.retryDelay + Math.floor((Math.random() * 2 - 1) * settings.retryJitter), 340 | ), 341 | undefined, 342 | ); 343 | }); 344 | } 345 | } 346 | 347 | throw new ExecutionError( 348 | 'The operation was unable to achieve a quorum during its retry window.', 349 | attempts, 350 | ); 351 | } 352 | 353 | private async _attemptOperation( 354 | script: 'acquireLock' | 'extendLock' | 'releaseLock', 355 | keys: string[], 356 | args: (string | number)[], 357 | ): Promise< 358 | | { vote: 'for'; stats: Promise; start: number } 359 | | { vote: 'against'; stats: Promise; start: number } 360 | > { 361 | const start = Date.now(); 362 | 363 | return await new Promise((resolve) => { 364 | const clientResults = []; 365 | for (const client of this.clients) { 366 | clientResults.push(this._attemptOperationOnClient(client, script, keys, args)); 367 | } 368 | 369 | const stats: ExecutionStats = { 370 | membershipSize: clientResults.length, 371 | quorumSize: Math.floor(clientResults.length / 2) + 1, 372 | votesFor: new Set(), 373 | votesAgainst: new Map(), 374 | }; 375 | 376 | let done: () => void; 377 | const statsPromise = new Promise((resolve) => { 378 | done = () => resolve(stats); 379 | }); 380 | 381 | // This is the expected flow for all successful and unsuccessful requests. 382 | const onResultResolve = (clientResult: ClientExecutionResult): void => { 383 | switch (clientResult.vote) { 384 | case 'for': 385 | stats.votesFor.add(clientResult.client); 386 | break; 387 | case 'against': 388 | stats.votesAgainst.set(clientResult.client, clientResult.error); 389 | break; 390 | } 391 | 392 | // A quorum has determined a success. 393 | if (stats.votesFor.size === stats.quorumSize) { 394 | resolve({ 395 | vote: 'for', 396 | stats: statsPromise, 397 | start, 398 | }); 399 | } 400 | 401 | // A quorum has determined a failure. 402 | if (stats.votesAgainst.size === stats.quorumSize) { 403 | resolve({ 404 | vote: 'against', 405 | stats: statsPromise, 406 | start, 407 | }); 408 | } 409 | 410 | // All votes are in. 411 | if (stats.votesFor.size + stats.votesAgainst.size === stats.membershipSize) { 412 | done(); 413 | } 414 | }; 415 | 416 | // This is unexpected and should crash to prevent undefined behavior. 417 | const onResultReject = (error: Error): void => { 418 | throw error; 419 | }; 420 | 421 | for (const result of clientResults) { 422 | result.then(onResultResolve, onResultReject); 423 | } 424 | }); 425 | } 426 | 427 | private async _attemptOperationOnClient( 428 | client: Client, 429 | script: 'acquireLock' | 'extendLock' | 'releaseLock', 430 | keys: string[], 431 | args: (string | number)[], 432 | ): Promise { 433 | try { 434 | ensureCommands(client); 435 | const shaResult = await client[script](keys.length, ...keys, ...args); 436 | // Attempt to evaluate the script by its hash. 437 | 438 | if (typeof shaResult !== 'number') { 439 | throw new Error(`Unexpected result of type ${typeof shaResult} returned from redis.`); 440 | } 441 | 442 | const result = shaResult; 443 | 444 | // One or more of the resources was already locked. 445 | if (result !== keys.length) { 446 | throw new ResourceLockedError( 447 | `The operation was applied to: ${result} of the ${keys.length} requested resources.`, 448 | ); 449 | } 450 | 451 | return { 452 | vote: 'for', 453 | client, 454 | value: result, 455 | }; 456 | } catch (error) { 457 | if (!(error instanceof Error)) { 458 | throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`); 459 | } 460 | 461 | // Emit the error on the redlock instance for observability. 462 | this.emit('error', error); 463 | 464 | return { 465 | vote: 'against', 466 | client, 467 | error, 468 | }; 469 | } 470 | } 471 | 472 | /** 473 | * Wrap and execute a routine in the context of an auto-extending lock, 474 | * returning a promise of the routine's value. In the case that auto-extension 475 | * fails, an AbortSignal will be updated to indicate that abortion of the 476 | * routine is in order, and to pass along the encountered error. 477 | * 478 | * @example 479 | * ```ts 480 | * await redlock.using([senderId, recipientId], 5000, { retryCount: 5 }, async (signal) => { 481 | * const senderBalance = await getBalance(senderId); 482 | * const recipientBalance = await getBalance(recipientId); 483 | * 484 | * if (senderBalance < amountToSend) { 485 | * throw new Error("Insufficient balance."); 486 | * } 487 | * 488 | * // The abort signal will be true if: 489 | * // 1. the above took long enough that the lock needed to be extended 490 | * // 2. redlock was unable to extend the lock 491 | * // 492 | * // In such a case, exclusivity can no longer be guaranteed for further 493 | * // operations, and should be handled as an exceptional case. 494 | * if (signal.aborted) { 495 | * throw signal.error; 496 | * } 497 | * 498 | * await setBalances([ 499 | * {id: senderId, balance: senderBalance - amountToSend}, 500 | * {id: recipientId, balance: recipientBalance + amountToSend}, 501 | * ]); 502 | * }); 503 | * ``` 504 | */ 505 | 506 | public async using( 507 | resources: string[], 508 | duration: number, 509 | settings: Partial, 510 | routine?: (signal: RedlockAbortSignal, context: RedlockUsingContext) => Promise, 511 | ): Promise; 512 | 513 | public async using( 514 | resources: string[], 515 | duration: number, 516 | routine: (signal: RedlockAbortSignal, context: RedlockUsingContext) => Promise, 517 | ): Promise; 518 | 519 | public async using( 520 | resources: string[], 521 | duration: number, 522 | settingsOrRoutine: 523 | | undefined 524 | | Partial 525 | | ((signal: RedlockAbortSignal, context: RedlockUsingContext) => Promise), 526 | optionalRoutine?: (signal: RedlockAbortSignal, context: RedlockUsingContext) => Promise, 527 | ): Promise { 528 | if (Math.floor(duration) !== duration) { 529 | throw new Error('Duration must be an integer value in milliseconds.'); 530 | } 531 | 532 | const settings = 533 | settingsOrRoutine && typeof settingsOrRoutine !== 'function' 534 | ? { 535 | ...this.settings, 536 | ...settingsOrRoutine, 537 | } 538 | : this.settings; 539 | 540 | const routine = optionalRoutine ?? settingsOrRoutine; 541 | if (typeof routine !== 'function') { 542 | throw new Error('INVARIANT: routine is not a function.'); 543 | } 544 | 545 | if (settings.automaticExtensionThreshold > duration - 100) { 546 | throw new Error( 547 | 'A lock `duration` must be at least 100ms greater than the `automaticExtensionThreshold` setting.', 548 | ); 549 | } 550 | 551 | // The AbortController/AbortSignal pattern allows the routine to be notified 552 | // of a failure to extend the lock, and subsequent expiration. In the event 553 | // of an abort, the error object will be made available at `signal.error`. 554 | const controller = new AbortController(); 555 | 556 | const signal = controller.signal as RedlockAbortSignal; 557 | 558 | function queue(): void { 559 | timeout = setTimeout( 560 | () => (extension = extend()), 561 | context.lock.expiration - Date.now() - settings.automaticExtensionThreshold, 562 | ); 563 | } 564 | 565 | async function extend(): Promise { 566 | timeout = undefined; 567 | 568 | try { 569 | context.lock = await context.lock.extend(duration); 570 | context.extensions += 1; 571 | queue(); 572 | } catch (error) { 573 | if (!(error instanceof Error)) { 574 | throw new Error(`Unexpected thrown ${typeof error}: ${error}.`); 575 | } 576 | 577 | if (context.lock.expiration > Date.now()) { 578 | return (extension = extend()); 579 | } 580 | 581 | signal.error = error instanceof Error ? error : new Error(`${error}`); 582 | controller.abort(); 583 | } 584 | } 585 | 586 | let timeout: undefined | NodeJS.Timeout; 587 | let extension: undefined | Promise; 588 | const context: RedlockUsingContext = { 589 | lock: await this.acquire(resources, duration, settings), 590 | extensions: 0, 591 | }; 592 | queue(); 593 | 594 | try { 595 | return await routine(signal, context); 596 | } finally { 597 | // Clean up the timer. 598 | if (timeout) { 599 | clearTimeout(timeout); 600 | timeout = undefined; 601 | } 602 | 603 | // Wait for an in-flight extension to finish. 604 | if (extension) { 605 | await extension.catch(() => { 606 | // An error here doesn't matter at all, because the routine has 607 | // already completed, and a release will be attempted regardless. The 608 | // only reason for waiting here is to prevent possible contention 609 | // between the extension and release. 610 | }); 611 | } 612 | 613 | await context.lock.release(); 614 | } 615 | } 616 | } 617 | --------------------------------------------------------------------------------