effect-distributed-lock 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -56
- package/examples/index.ts +49 -21
- package/package.json +2 -2
- package/redis-semaphore/.codeclimate.yml +5 -0
- package/redis-semaphore/.fossa.yml +14 -0
- package/redis-semaphore/.github/dependabot.yml +6 -0
- package/redis-semaphore/.github/workflows/branches.yml +39 -0
- package/redis-semaphore/.github/workflows/pull-requests.yml +35 -0
- package/redis-semaphore/.mocharc.yaml +6 -0
- package/redis-semaphore/.prettierrc +6 -0
- package/redis-semaphore/.snyk +4 -0
- package/redis-semaphore/.yarnrc.yml +2 -0
- package/redis-semaphore/CHANGELOG.md +70 -0
- package/redis-semaphore/Dockerfile +5 -0
- package/redis-semaphore/LICENSE +21 -0
- package/redis-semaphore/README.md +445 -0
- package/redis-semaphore/docker-compose.yml +31 -0
- package/redis-semaphore/eslint.config.mjs +73 -0
- package/redis-semaphore/package.json +79 -0
- package/redis-semaphore/setup-redis-servers.sh +2 -0
- package/redis-semaphore/src/Lock.ts +172 -0
- package/redis-semaphore/src/RedisMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedisMutex.ts +45 -0
- package/redis-semaphore/src/RedisSemaphore.ts +49 -0
- package/redis-semaphore/src/RedlockMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedlockMutex.ts +52 -0
- package/redis-semaphore/src/RedlockSemaphore.ts +49 -0
- package/redis-semaphore/src/errors/LostLockError.ts +1 -0
- package/redis-semaphore/src/errors/TimeoutError.ts +1 -0
- package/redis-semaphore/src/index.ts +23 -0
- package/redis-semaphore/src/misc.ts +12 -0
- package/redis-semaphore/src/multiSemaphore/acquire/index.ts +53 -0
- package/redis-semaphore/src/multiSemaphore/acquire/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/refresh/index.ts +32 -0
- package/redis-semaphore/src/multiSemaphore/refresh/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/release/index.ts +22 -0
- package/redis-semaphore/src/multiSemaphore/release/lua.ts +17 -0
- package/redis-semaphore/src/mutex/acquire.ts +42 -0
- package/redis-semaphore/src/mutex/refresh.ts +37 -0
- package/redis-semaphore/src/mutex/release.ts +30 -0
- package/redis-semaphore/src/redlockMultiSemaphore/acquire.ts +56 -0
- package/redis-semaphore/src/redlockMultiSemaphore/refresh.ts +68 -0
- package/redis-semaphore/src/redlockMultiSemaphore/release.ts +19 -0
- package/redis-semaphore/src/redlockMutex/acquire.ts +54 -0
- package/redis-semaphore/src/redlockMutex/refresh.ts +53 -0
- package/redis-semaphore/src/redlockMutex/release.ts +19 -0
- package/redis-semaphore/src/redlockSemaphore/acquire.ts +55 -0
- package/redis-semaphore/src/redlockSemaphore/refresh.ts +60 -0
- package/redis-semaphore/src/redlockSemaphore/release.ts +18 -0
- package/redis-semaphore/src/semaphore/acquire/index.ts +52 -0
- package/redis-semaphore/src/semaphore/acquire/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/refresh/index.ts +31 -0
- package/redis-semaphore/src/semaphore/refresh/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/release.ts +14 -0
- package/redis-semaphore/src/types.ts +63 -0
- package/redis-semaphore/src/utils/createEval.ts +45 -0
- package/redis-semaphore/src/utils/index.ts +13 -0
- package/redis-semaphore/src/utils/redlock.ts +7 -0
- package/redis-semaphore/test/init.test.ts +9 -0
- package/redis-semaphore/test/redisClient.ts +82 -0
- package/redis-semaphore/test/setup.ts +6 -0
- package/redis-semaphore/test/shell.test.ts +15 -0
- package/redis-semaphore/test/shell.ts +48 -0
- package/redis-semaphore/test/src/Lock.test.ts +37 -0
- package/redis-semaphore/test/src/RedisMultiSemaphore.test.ts +425 -0
- package/redis-semaphore/test/src/RedisMutex.test.ts +334 -0
- package/redis-semaphore/test/src/RedisSemaphore.test.ts +367 -0
- package/redis-semaphore/test/src/RedlockMultiSemaphore.test.ts +671 -0
- package/redis-semaphore/test/src/RedlockMutex.test.ts +328 -0
- package/redis-semaphore/test/src/RedlockSemaphore.test.ts +579 -0
- package/redis-semaphore/test/src/index.test.ts +22 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/index.test.ts +51 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/internal.test.ts +67 -0
- package/redis-semaphore/test/src/multiSemaphore/refresh/index.test.ts +52 -0
- package/redis-semaphore/test/src/multiSemaphore/release/index.test.ts +18 -0
- package/redis-semaphore/test/src/mutex/acquire.test.ts +78 -0
- package/redis-semaphore/test/src/mutex/refresh.test.ts +22 -0
- package/redis-semaphore/test/src/mutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/redlockMutex/acquire.test.ts +90 -0
- package/redis-semaphore/test/src/redlockMutex/refresh.test.ts +27 -0
- package/redis-semaphore/test/src/redlockMutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/semaphore/acquire/index.test.ts +49 -0
- package/redis-semaphore/test/src/semaphore/acquire/internal.test.ts +65 -0
- package/redis-semaphore/test/src/semaphore/refresh/index.test.ts +44 -0
- package/redis-semaphore/test/src/semaphore/release.test.ts +18 -0
- package/redis-semaphore/test/src/utils/eval.test.ts +22 -0
- package/redis-semaphore/test/src/utils/index.test.ts +19 -0
- package/redis-semaphore/test/src/utils/redlock.test.ts +31 -0
- package/redis-semaphore/test/unhandledRejection.ts +28 -0
- package/redis-semaphore/tsconfig.build-commonjs.json +9 -0
- package/redis-semaphore/tsconfig.build-es.json +9 -0
- package/redis-semaphore/tsconfig.json +11 -0
- package/redis-semaphore/yarn.lock +5338 -0
- package/src/Backing.ts +87 -0
- package/src/DistributedSemaphore.ts +448 -0
- package/src/Errors.ts +3 -15
- package/src/RedisBacking.ts +165 -59
- package/src/index.ts +28 -12
- package/src/DistributedMutex.ts +0 -356
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { expect } from 'chai'
|
|
2
|
+
import { Redis } from 'ioredis'
|
|
3
|
+
import sinon from 'sinon'
|
|
4
|
+
import LostLockError from '../../src/errors/LostLockError'
|
|
5
|
+
import Mutex from '../../src/RedisMutex'
|
|
6
|
+
import { TimeoutOptions } from '../../src/types'
|
|
7
|
+
import { delay } from '../../src/utils/index'
|
|
8
|
+
import { client1 as client, clientMock1 as clientMock } from '../redisClient'
|
|
9
|
+
import { downRedisServer, upRedisServer } from '../shell'
|
|
10
|
+
import {
|
|
11
|
+
catchUnhandledRejection,
|
|
12
|
+
throwUnhandledRejection,
|
|
13
|
+
unhandledRejectionSpy
|
|
14
|
+
} from '../unhandledRejection'
|
|
15
|
+
|
|
16
|
+
const timeoutOptions: TimeoutOptions = {
|
|
17
|
+
lockTimeout: 300,
|
|
18
|
+
acquireTimeout: 100,
|
|
19
|
+
refreshInterval: 80,
|
|
20
|
+
retryInterval: 10
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Mutex', () => {
|
|
24
|
+
it('should fail on invalid arguments', () => {
|
|
25
|
+
expect(() => new Mutex(null as unknown as Redis, 'key')).to.throw(
|
|
26
|
+
'"client" is required'
|
|
27
|
+
)
|
|
28
|
+
expect(() => new Mutex(client, '')).to.throw('"key" is required')
|
|
29
|
+
expect(() => new Mutex(client, 1 as unknown as string)).to.throw(
|
|
30
|
+
'"key" must be a string'
|
|
31
|
+
)
|
|
32
|
+
expect(() => new Mutex(client, 'key', { identifier: '' })).to.throw(
|
|
33
|
+
'identifier must be not empty random string'
|
|
34
|
+
)
|
|
35
|
+
expect(
|
|
36
|
+
() => new Mutex(client, 'key', { acquiredExternally: true })
|
|
37
|
+
).to.throw('acquiredExternally=true meanless without custom identifier')
|
|
38
|
+
expect(
|
|
39
|
+
() =>
|
|
40
|
+
new Mutex(client, 'key', {
|
|
41
|
+
externallyAcquiredIdentifier: '123',
|
|
42
|
+
identifier: '123'
|
|
43
|
+
})
|
|
44
|
+
).to.throw(
|
|
45
|
+
'Invalid usage. Use custom identifier and acquiredExternally: true'
|
|
46
|
+
)
|
|
47
|
+
expect(
|
|
48
|
+
() =>
|
|
49
|
+
new Mutex(client, 'key', {
|
|
50
|
+
externallyAcquiredIdentifier: '123',
|
|
51
|
+
acquiredExternally: true,
|
|
52
|
+
identifier: '123'
|
|
53
|
+
})
|
|
54
|
+
).to.throw(
|
|
55
|
+
'Invalid usage. Use custom identifier and acquiredExternally: true'
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
it('should set default options', () => {
|
|
59
|
+
expect(new Mutex(client, 'key', {})).to.be.ok
|
|
60
|
+
expect(new Mutex(client, 'key')).to.be.ok
|
|
61
|
+
})
|
|
62
|
+
it('should set random UUID as identifier', () => {
|
|
63
|
+
expect(new Mutex(client, 'key').identifier).to.match(
|
|
64
|
+
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
it('should add identifier suffix', () => {
|
|
68
|
+
expect(
|
|
69
|
+
new Mutex(client, 'key', { identifierSuffix: 'abc' }).identifier
|
|
70
|
+
).to.match(
|
|
71
|
+
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}-abc$/
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
it('should use custom identifier if provided', () => {
|
|
75
|
+
expect(
|
|
76
|
+
new Mutex(client, 'key', { identifier: 'abc' }).identifier
|
|
77
|
+
).to.be.eql('abc')
|
|
78
|
+
})
|
|
79
|
+
it('should acquire and release lock', async () => {
|
|
80
|
+
const mutex = new Mutex(client, 'key')
|
|
81
|
+
expect(mutex.isAcquired).to.be.false
|
|
82
|
+
await mutex.acquire()
|
|
83
|
+
expect(mutex.isAcquired).to.be.true
|
|
84
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
85
|
+
await mutex.release()
|
|
86
|
+
expect(mutex.isAcquired).to.be.false
|
|
87
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
88
|
+
})
|
|
89
|
+
it('should reject after timeout', async () => {
|
|
90
|
+
const mutex1 = new Mutex(client, 'key', timeoutOptions)
|
|
91
|
+
const mutex2 = new Mutex(client, 'key', timeoutOptions)
|
|
92
|
+
await mutex1.acquire()
|
|
93
|
+
await expect(mutex2.acquire()).to.be.rejectedWith(
|
|
94
|
+
'Acquire mutex mutex:key timeout'
|
|
95
|
+
)
|
|
96
|
+
await mutex1.release()
|
|
97
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
98
|
+
})
|
|
99
|
+
it('should return false for tryAcquire after timeout', async () => {
|
|
100
|
+
const mutex1 = new Mutex(client, 'key', timeoutOptions)
|
|
101
|
+
const mutex2 = new Mutex(client, 'key', timeoutOptions)
|
|
102
|
+
await mutex1.acquire()
|
|
103
|
+
const result = await mutex2.tryAcquire()
|
|
104
|
+
expect(result).to.be.false
|
|
105
|
+
await mutex1.release()
|
|
106
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
107
|
+
})
|
|
108
|
+
it('should return true for successful tryAcquire', async () => {
|
|
109
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
110
|
+
const result = await mutex.tryAcquire()
|
|
111
|
+
expect(result).to.be.true
|
|
112
|
+
await mutex.release()
|
|
113
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
114
|
+
})
|
|
115
|
+
it('should refresh lock every refreshInterval ms until release', async () => {
|
|
116
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
117
|
+
await mutex.acquire()
|
|
118
|
+
await delay(400)
|
|
119
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
120
|
+
await mutex.release()
|
|
121
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
122
|
+
})
|
|
123
|
+
it('should stop refreshing lock every refreshInterval ms if stopped', async () => {
|
|
124
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
125
|
+
await mutex.acquire()
|
|
126
|
+
mutex.stopRefresh()
|
|
127
|
+
await delay(400)
|
|
128
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
129
|
+
})
|
|
130
|
+
it('should not call _refresh if already refreshing', async () => {
|
|
131
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
132
|
+
let callCount = 0
|
|
133
|
+
Object.assign(mutex, {
|
|
134
|
+
_refresh: () =>
|
|
135
|
+
delay(100).then(() => {
|
|
136
|
+
callCount++
|
|
137
|
+
return true
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
await mutex.acquire()
|
|
141
|
+
await delay(400)
|
|
142
|
+
expect(callCount).to.be.eql(2) // not floor(400/80) = 9
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should support externally acquired mutex (deprecated interface)', async () => {
|
|
146
|
+
const externalMutex = new Mutex(client, 'key', {
|
|
147
|
+
...timeoutOptions,
|
|
148
|
+
refreshInterval: 0
|
|
149
|
+
})
|
|
150
|
+
const localMutex = new Mutex(client, 'key', {
|
|
151
|
+
...timeoutOptions,
|
|
152
|
+
externallyAcquiredIdentifier: externalMutex.identifier
|
|
153
|
+
})
|
|
154
|
+
await externalMutex.acquire()
|
|
155
|
+
await localMutex.acquire()
|
|
156
|
+
await delay(400)
|
|
157
|
+
expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier)
|
|
158
|
+
await localMutex.release()
|
|
159
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
160
|
+
})
|
|
161
|
+
it('should support externally acquired mutex', async () => {
|
|
162
|
+
const externalMutex = new Mutex(client, 'key', {
|
|
163
|
+
...timeoutOptions,
|
|
164
|
+
refreshInterval: 0
|
|
165
|
+
})
|
|
166
|
+
const localMutex = new Mutex(client, 'key', {
|
|
167
|
+
...timeoutOptions,
|
|
168
|
+
identifier: externalMutex.identifier,
|
|
169
|
+
acquiredExternally: true
|
|
170
|
+
})
|
|
171
|
+
await externalMutex.acquire()
|
|
172
|
+
await localMutex.acquire()
|
|
173
|
+
await delay(400)
|
|
174
|
+
expect(await client.get('mutex:key')).to.be.eql(localMutex.identifier)
|
|
175
|
+
await localMutex.release()
|
|
176
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
177
|
+
})
|
|
178
|
+
describe('lost lock case', () => {
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
catchUnhandledRejection()
|
|
181
|
+
})
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
throwUnhandledRejection()
|
|
184
|
+
})
|
|
185
|
+
it('should throw unhandled error if lock was lost between refreshes (another instance acquired)', async () => {
|
|
186
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
187
|
+
await mutex.acquire()
|
|
188
|
+
expect(mutex.isAcquired).to.be.true
|
|
189
|
+
await client.set('mutex:key', '222') // another instance
|
|
190
|
+
await delay(200)
|
|
191
|
+
expect(mutex.isAcquired).to.be.false
|
|
192
|
+
expect(unhandledRejectionSpy).to.be.called
|
|
193
|
+
expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError)
|
|
194
|
+
.to.be.true
|
|
195
|
+
})
|
|
196
|
+
it('should throw unhandled error if lock was lost between refreshes (lock expired)', async () => {
|
|
197
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
198
|
+
await mutex.acquire()
|
|
199
|
+
expect(mutex.isAcquired).to.be.true
|
|
200
|
+
await client.del('mutex:key') // expired
|
|
201
|
+
await delay(200)
|
|
202
|
+
expect(mutex.isAcquired).to.be.false
|
|
203
|
+
expect(unhandledRejectionSpy).to.be.called
|
|
204
|
+
expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError)
|
|
205
|
+
.to.be.true
|
|
206
|
+
})
|
|
207
|
+
it('should call onLockLost callback if provided (another instance acquired)', async () => {
|
|
208
|
+
const onLockLostCallback = sinon.spy(function (this: Mutex) {
|
|
209
|
+
expect(this.isAcquired).to.be.false
|
|
210
|
+
})
|
|
211
|
+
const mutex = new Mutex(client, 'key', {
|
|
212
|
+
...timeoutOptions,
|
|
213
|
+
onLockLost: onLockLostCallback
|
|
214
|
+
})
|
|
215
|
+
await mutex.acquire()
|
|
216
|
+
expect(mutex.isAcquired).to.be.true
|
|
217
|
+
await client.set('mutex:key', '222') // another instance
|
|
218
|
+
await delay(200)
|
|
219
|
+
expect(mutex.isAcquired).to.be.false
|
|
220
|
+
expect(unhandledRejectionSpy).to.not.called
|
|
221
|
+
expect(onLockLostCallback).to.be.called
|
|
222
|
+
expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to
|
|
223
|
+
.be.true
|
|
224
|
+
})
|
|
225
|
+
it('should call onLockLost callback if provided (lock expired)', async () => {
|
|
226
|
+
const onLockLostCallback = sinon.spy(function (this: Mutex) {
|
|
227
|
+
expect(this.isAcquired).to.be.false
|
|
228
|
+
})
|
|
229
|
+
const mutex = new Mutex(client, 'key', {
|
|
230
|
+
...timeoutOptions,
|
|
231
|
+
onLockLost: onLockLostCallback
|
|
232
|
+
})
|
|
233
|
+
await mutex.acquire()
|
|
234
|
+
expect(mutex.isAcquired).to.be.true
|
|
235
|
+
await client.del('mutex:key') // expired
|
|
236
|
+
await delay(200)
|
|
237
|
+
expect(mutex.isAcquired).to.be.false
|
|
238
|
+
expect(unhandledRejectionSpy).to.not.called
|
|
239
|
+
expect(onLockLostCallback).to.be.called
|
|
240
|
+
expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to
|
|
241
|
+
.be.true
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
it('should be reusable', async function () {
|
|
245
|
+
this.timeout(10000)
|
|
246
|
+
const mutex = new Mutex(client, 'key', timeoutOptions)
|
|
247
|
+
|
|
248
|
+
/* Lifecycle 1 */
|
|
249
|
+
await mutex.acquire()
|
|
250
|
+
await delay(300)
|
|
251
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
252
|
+
await mutex.release()
|
|
253
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
254
|
+
await delay(300)
|
|
255
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
256
|
+
|
|
257
|
+
await delay(300)
|
|
258
|
+
|
|
259
|
+
/* Lifecycle 2 */
|
|
260
|
+
await mutex.acquire()
|
|
261
|
+
await delay(300)
|
|
262
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
263
|
+
await mutex.release()
|
|
264
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
265
|
+
await delay(300)
|
|
266
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
267
|
+
|
|
268
|
+
await delay(300)
|
|
269
|
+
|
|
270
|
+
/* Lifecycle 3 */
|
|
271
|
+
await mutex.acquire()
|
|
272
|
+
await delay(300)
|
|
273
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
274
|
+
await mutex.release()
|
|
275
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
276
|
+
await delay(300)
|
|
277
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
278
|
+
})
|
|
279
|
+
describe('[Node shutdown]', () => {
|
|
280
|
+
beforeEach(() => {
|
|
281
|
+
catchUnhandledRejection()
|
|
282
|
+
})
|
|
283
|
+
afterEach(async () => {
|
|
284
|
+
throwUnhandledRejection()
|
|
285
|
+
await upRedisServer(1)
|
|
286
|
+
})
|
|
287
|
+
it('should lost lock when node become alive', async function () {
|
|
288
|
+
this.timeout(60000)
|
|
289
|
+
const onLockLostCallback = sinon.spy(function (this: Mutex) {
|
|
290
|
+
expect(this.isAcquired).to.be.false
|
|
291
|
+
})
|
|
292
|
+
const mutex1 = new Mutex(client, 'key', {
|
|
293
|
+
...timeoutOptions,
|
|
294
|
+
onLockLost: onLockLostCallback
|
|
295
|
+
})
|
|
296
|
+
await mutex1.acquire()
|
|
297
|
+
await downRedisServer(1)
|
|
298
|
+
|
|
299
|
+
await delay(1000)
|
|
300
|
+
// lock expired now
|
|
301
|
+
|
|
302
|
+
await upRedisServer(1)
|
|
303
|
+
// mutex was expired, key was deleted in redis
|
|
304
|
+
// give refresh mechanism time to detect lock lost
|
|
305
|
+
// (includes client reconnection time)
|
|
306
|
+
await delay(1000)
|
|
307
|
+
|
|
308
|
+
expect(await client.get('mutex:key')).to.be.eql(null)
|
|
309
|
+
expect(onLockLostCallback).to.be.called
|
|
310
|
+
expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to
|
|
311
|
+
.be.true
|
|
312
|
+
|
|
313
|
+
// lock was not reacquired by mutex1, so mutex2 can acquire the lock
|
|
314
|
+
|
|
315
|
+
const mutex2 = new Mutex(client, 'key', timeoutOptions)
|
|
316
|
+
await mutex2.acquire()
|
|
317
|
+
expect(await client.get('mutex:key')).to.be.eql(mutex2.identifier)
|
|
318
|
+
|
|
319
|
+
await Promise.all([mutex1.release(), mutex2.release()])
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
describe('ioredis-mock support', async () => {
|
|
323
|
+
it('should acquire and release lock', async () => {
|
|
324
|
+
const mutex = new Mutex(clientMock, 'key')
|
|
325
|
+
expect(mutex.isAcquired).to.be.false
|
|
326
|
+
await mutex.acquire()
|
|
327
|
+
expect(mutex.isAcquired).to.be.true
|
|
328
|
+
expect(await clientMock.get('mutex:key')).to.be.eql(mutex.identifier)
|
|
329
|
+
await mutex.release()
|
|
330
|
+
expect(mutex.isAcquired).to.be.false
|
|
331
|
+
expect(await clientMock.get('mutex:key')).to.be.eql(null)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { expect } from 'chai'
|
|
2
|
+
import { Redis } from 'ioredis'
|
|
3
|
+
import sinon from 'sinon'
|
|
4
|
+
import LostLockError from '../../src/errors/LostLockError'
|
|
5
|
+
import Semaphore from '../../src/RedisSemaphore'
|
|
6
|
+
import { TimeoutOptions } from '../../src/types'
|
|
7
|
+
import { delay } from '../../src/utils/index'
|
|
8
|
+
import { client1 as client, clientMock1 as clientMock } from '../redisClient'
|
|
9
|
+
import { downRedisServer, upRedisServer } from '../shell'
|
|
10
|
+
import {
|
|
11
|
+
catchUnhandledRejection,
|
|
12
|
+
throwUnhandledRejection,
|
|
13
|
+
unhandledRejectionSpy
|
|
14
|
+
} from '../unhandledRejection'
|
|
15
|
+
|
|
16
|
+
const timeoutOptions: TimeoutOptions = {
|
|
17
|
+
lockTimeout: 300,
|
|
18
|
+
acquireTimeout: 100,
|
|
19
|
+
refreshInterval: 80,
|
|
20
|
+
retryInterval: 10
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Semaphore', () => {
|
|
24
|
+
it('should fail on invalid arguments', () => {
|
|
25
|
+
expect(() => new Semaphore(null as unknown as Redis, 'key', 5)).to.throw(
|
|
26
|
+
'"client" is required'
|
|
27
|
+
)
|
|
28
|
+
expect(() => new Semaphore(client, '', 5)).to.throw('"key" is required')
|
|
29
|
+
expect(() => new Semaphore(client, 1 as unknown as string, 5)).to.throw(
|
|
30
|
+
'"key" must be a string'
|
|
31
|
+
)
|
|
32
|
+
expect(() => new Semaphore(client, 'key', 0)).to.throw(
|
|
33
|
+
'"limit" is required'
|
|
34
|
+
)
|
|
35
|
+
expect(
|
|
36
|
+
() => new Semaphore(client, 'key', '10' as unknown as number)
|
|
37
|
+
).to.throw('"limit" must be a number')
|
|
38
|
+
})
|
|
39
|
+
it('should acquire and release semaphore', async () => {
|
|
40
|
+
const semaphore1 = new Semaphore(client, 'key', 2)
|
|
41
|
+
const semaphore2 = new Semaphore(client, 'key', 2)
|
|
42
|
+
expect(semaphore1.isAcquired).to.be.false
|
|
43
|
+
expect(semaphore2.isAcquired).to.be.false
|
|
44
|
+
|
|
45
|
+
await semaphore1.acquire()
|
|
46
|
+
expect(semaphore1.isAcquired).to.be.true
|
|
47
|
+
await semaphore2.acquire()
|
|
48
|
+
expect(semaphore2.isAcquired).to.be.true
|
|
49
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
50
|
+
semaphore1.identifier,
|
|
51
|
+
semaphore2.identifier
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
await semaphore1.release()
|
|
55
|
+
expect(semaphore1.isAcquired).to.be.false
|
|
56
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([
|
|
57
|
+
semaphore2.identifier
|
|
58
|
+
])
|
|
59
|
+
await semaphore2.release()
|
|
60
|
+
expect(semaphore2.isAcquired).to.be.false
|
|
61
|
+
expect(await client.zcard('semaphore:key')).to.be.eql(0)
|
|
62
|
+
})
|
|
63
|
+
it('should reject after timeout', async () => {
|
|
64
|
+
const semaphore1 = new Semaphore(client, 'key', 1, timeoutOptions)
|
|
65
|
+
const semaphore2 = new Semaphore(client, 'key', 1, timeoutOptions)
|
|
66
|
+
await semaphore1.acquire()
|
|
67
|
+
await expect(semaphore2.acquire()).to.be.rejectedWith(
|
|
68
|
+
'Acquire semaphore semaphore:key timeout'
|
|
69
|
+
)
|
|
70
|
+
await semaphore1.release()
|
|
71
|
+
expect(await client.get('semaphore:key')).to.be.eql(null)
|
|
72
|
+
})
|
|
73
|
+
it('should refresh lock every refreshInterval ms until release', async () => {
|
|
74
|
+
const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
75
|
+
const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
76
|
+
await semaphore1.acquire()
|
|
77
|
+
await semaphore2.acquire()
|
|
78
|
+
await delay(400)
|
|
79
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
80
|
+
semaphore1.identifier,
|
|
81
|
+
semaphore2.identifier
|
|
82
|
+
])
|
|
83
|
+
await semaphore1.release()
|
|
84
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([
|
|
85
|
+
semaphore2.identifier
|
|
86
|
+
])
|
|
87
|
+
await semaphore2.release()
|
|
88
|
+
expect(await client.zcard('semaphore:key')).to.be.eql(0)
|
|
89
|
+
})
|
|
90
|
+
it('should stop refreshing lock if stopped', async () => {
|
|
91
|
+
const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
92
|
+
const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
93
|
+
await semaphore1.acquire()
|
|
94
|
+
await semaphore2.acquire()
|
|
95
|
+
await semaphore1.stopRefresh()
|
|
96
|
+
await delay(400)
|
|
97
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.be.eql([
|
|
98
|
+
semaphore2.identifier
|
|
99
|
+
])
|
|
100
|
+
await semaphore2.stopRefresh()
|
|
101
|
+
await delay(400)
|
|
102
|
+
expect(await client.zcard('semaphore:key')).to.be.eql(0)
|
|
103
|
+
})
|
|
104
|
+
it('should acquire maximum LIMIT semaphores', async () => {
|
|
105
|
+
const s = () =>
|
|
106
|
+
new Semaphore(client, 'key', 3, {
|
|
107
|
+
acquireTimeout: 1000,
|
|
108
|
+
lockTimeout: 50,
|
|
109
|
+
retryInterval: 10,
|
|
110
|
+
refreshInterval: 0 // disable refresh
|
|
111
|
+
})
|
|
112
|
+
const pr1 = Promise.all([s().acquire(), s().acquire(), s().acquire()])
|
|
113
|
+
await delay(5)
|
|
114
|
+
const pr2 = Promise.all([s().acquire(), s().acquire(), s().acquire()])
|
|
115
|
+
await pr1
|
|
116
|
+
const ids1 = await client.zrange('semaphore:key', 0, -1)
|
|
117
|
+
expect(ids1.length).to.be.eql(3)
|
|
118
|
+
await pr2
|
|
119
|
+
const ids2 = await client.zrange('semaphore:key', 0, -1)
|
|
120
|
+
expect(ids2.length).to.be.eql(3)
|
|
121
|
+
expect(ids2)
|
|
122
|
+
.to.not.include(ids1[0])
|
|
123
|
+
.and.not.include(ids1[1])
|
|
124
|
+
.and.not.include(ids1[2])
|
|
125
|
+
})
|
|
126
|
+
it('should support externally acquired semaphore (deprecated interface)', async () => {
|
|
127
|
+
const externalSemaphore = new Semaphore(client, 'key', 3, {
|
|
128
|
+
...timeoutOptions,
|
|
129
|
+
refreshInterval: 0
|
|
130
|
+
})
|
|
131
|
+
const localSemaphore = new Semaphore(client, 'key', 3, {
|
|
132
|
+
...timeoutOptions,
|
|
133
|
+
externallyAcquiredIdentifier: externalSemaphore.identifier
|
|
134
|
+
})
|
|
135
|
+
await externalSemaphore.acquire()
|
|
136
|
+
await localSemaphore.acquire()
|
|
137
|
+
await delay(400)
|
|
138
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
139
|
+
localSemaphore.identifier
|
|
140
|
+
])
|
|
141
|
+
await localSemaphore.release()
|
|
142
|
+
expect(await client.zcard('semaphore:key')).to.be.eql(0)
|
|
143
|
+
})
|
|
144
|
+
it('should support externally acquired semaphore', async () => {
|
|
145
|
+
const externalSemaphore = new Semaphore(client, 'key', 3, {
|
|
146
|
+
...timeoutOptions,
|
|
147
|
+
refreshInterval: 0
|
|
148
|
+
})
|
|
149
|
+
const localSemaphore = new Semaphore(client, 'key', 3, {
|
|
150
|
+
...timeoutOptions,
|
|
151
|
+
identifier: externalSemaphore.identifier,
|
|
152
|
+
acquiredExternally: true
|
|
153
|
+
})
|
|
154
|
+
await externalSemaphore.acquire()
|
|
155
|
+
await localSemaphore.acquire()
|
|
156
|
+
await delay(400)
|
|
157
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
158
|
+
localSemaphore.identifier
|
|
159
|
+
])
|
|
160
|
+
await localSemaphore.release()
|
|
161
|
+
expect(await client.zcard('semaphore:key')).to.be.eql(0)
|
|
162
|
+
})
|
|
163
|
+
describe('lost lock case', () => {
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
catchUnhandledRejection()
|
|
166
|
+
})
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
throwUnhandledRejection()
|
|
169
|
+
})
|
|
170
|
+
it('should throw unhandled error if lock is lost between refreshes', async () => {
|
|
171
|
+
const semaphore = new Semaphore(client, 'key', 3, timeoutOptions)
|
|
172
|
+
await semaphore.acquire()
|
|
173
|
+
await client.del('semaphore:key')
|
|
174
|
+
await client.zadd(
|
|
175
|
+
'semaphore:key',
|
|
176
|
+
Date.now(),
|
|
177
|
+
'aaa',
|
|
178
|
+
Date.now(),
|
|
179
|
+
'bbb',
|
|
180
|
+
Date.now(),
|
|
181
|
+
'ccc'
|
|
182
|
+
)
|
|
183
|
+
await delay(200)
|
|
184
|
+
expect(unhandledRejectionSpy).to.be.called
|
|
185
|
+
expect(unhandledRejectionSpy.firstCall.firstArg instanceof LostLockError)
|
|
186
|
+
.to.be.true
|
|
187
|
+
})
|
|
188
|
+
it('should call onLockLost callback if provided', async () => {
|
|
189
|
+
const onLockLostCallback = sinon.spy(function (this: Semaphore) {
|
|
190
|
+
expect(this.isAcquired).to.be.false
|
|
191
|
+
})
|
|
192
|
+
const semaphore = new Semaphore(client, 'key', 3, {
|
|
193
|
+
...timeoutOptions,
|
|
194
|
+
onLockLost: onLockLostCallback
|
|
195
|
+
})
|
|
196
|
+
await semaphore.acquire()
|
|
197
|
+
expect(semaphore.isAcquired).to.be.true
|
|
198
|
+
await client.del('semaphore:key')
|
|
199
|
+
await client.zadd(
|
|
200
|
+
'semaphore:key',
|
|
201
|
+
Date.now(),
|
|
202
|
+
'aaa',
|
|
203
|
+
Date.now(),
|
|
204
|
+
'bbb',
|
|
205
|
+
Date.now(),
|
|
206
|
+
'ccc'
|
|
207
|
+
)
|
|
208
|
+
await delay(200)
|
|
209
|
+
expect(semaphore.isAcquired).to.be.false
|
|
210
|
+
expect(unhandledRejectionSpy).to.not.called
|
|
211
|
+
expect(onLockLostCallback).to.be.called
|
|
212
|
+
expect(onLockLostCallback.firstCall.firstArg instanceof LostLockError).to
|
|
213
|
+
.be.true
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
describe('reusable', () => {
|
|
217
|
+
it('autorefresh enabled', async () => {
|
|
218
|
+
const semaphore1 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
219
|
+
const semaphore2 = new Semaphore(client, 'key', 2, timeoutOptions)
|
|
220
|
+
|
|
221
|
+
await semaphore1.acquire()
|
|
222
|
+
await semaphore2.acquire()
|
|
223
|
+
await delay(300)
|
|
224
|
+
await semaphore1.release()
|
|
225
|
+
await semaphore2.release()
|
|
226
|
+
|
|
227
|
+
await delay(300)
|
|
228
|
+
|
|
229
|
+
await semaphore1.acquire()
|
|
230
|
+
await semaphore2.acquire()
|
|
231
|
+
await delay(300)
|
|
232
|
+
await semaphore1.release()
|
|
233
|
+
await semaphore2.release()
|
|
234
|
+
|
|
235
|
+
await delay(300)
|
|
236
|
+
|
|
237
|
+
await semaphore1.acquire()
|
|
238
|
+
await semaphore2.acquire()
|
|
239
|
+
await delay(300)
|
|
240
|
+
await semaphore1.release()
|
|
241
|
+
await semaphore2.release()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('autorefresh disabled', async () => {
|
|
245
|
+
const noRefreshOptions = {
|
|
246
|
+
...timeoutOptions,
|
|
247
|
+
refreshInterval: 0,
|
|
248
|
+
acquireTimeout: 10
|
|
249
|
+
}
|
|
250
|
+
const semaphore1 = new Semaphore(client, 'key', 2, noRefreshOptions)
|
|
251
|
+
const semaphore2 = new Semaphore(client, 'key', 2, noRefreshOptions)
|
|
252
|
+
const semaphore3 = new Semaphore(client, 'key', 2, noRefreshOptions)
|
|
253
|
+
|
|
254
|
+
await semaphore1.acquire()
|
|
255
|
+
await semaphore2.acquire()
|
|
256
|
+
await delay(300)
|
|
257
|
+
await semaphore1.release()
|
|
258
|
+
await semaphore2.release()
|
|
259
|
+
|
|
260
|
+
await delay(300)
|
|
261
|
+
|
|
262
|
+
// [0/2]
|
|
263
|
+
await semaphore1.acquire()
|
|
264
|
+
// [1/2]
|
|
265
|
+
await delay(80)
|
|
266
|
+
await semaphore2.acquire()
|
|
267
|
+
// [2/2]
|
|
268
|
+
await expect(semaphore3.acquire()).to.be.rejectedWith(
|
|
269
|
+
'Acquire semaphore semaphore:key timeout'
|
|
270
|
+
) // rejectes after 10ms
|
|
271
|
+
|
|
272
|
+
// since semaphore1.acquire() elapsed 80ms (delay) + 10ms (semaphore3 timeout)
|
|
273
|
+
// semaphore1 will expire after 300 - 90 = 210ms
|
|
274
|
+
await delay(210)
|
|
275
|
+
|
|
276
|
+
// [1/2]
|
|
277
|
+
await semaphore3.acquire()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
describe('[Node shutdown]', () => {
|
|
281
|
+
beforeEach(() => {
|
|
282
|
+
catchUnhandledRejection()
|
|
283
|
+
})
|
|
284
|
+
afterEach(async () => {
|
|
285
|
+
throwUnhandledRejection()
|
|
286
|
+
await upRedisServer(1)
|
|
287
|
+
})
|
|
288
|
+
it('should lost lock when node become alive', async function () {
|
|
289
|
+
this.timeout(60000)
|
|
290
|
+
const onLockLostCallbacks = [1, 2, 3].map(() =>
|
|
291
|
+
sinon.spy(function (this: Semaphore) {
|
|
292
|
+
expect(this.isAcquired).to.be.false
|
|
293
|
+
})
|
|
294
|
+
)
|
|
295
|
+
const semaphores1 = [1, 2, 3].map(
|
|
296
|
+
(n, i) =>
|
|
297
|
+
new Semaphore(client, 'key', 3, {
|
|
298
|
+
...timeoutOptions,
|
|
299
|
+
onLockLost: onLockLostCallbacks[i]
|
|
300
|
+
})
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
await Promise.all(semaphores1.map(s => s.acquire()))
|
|
304
|
+
|
|
305
|
+
await downRedisServer(1)
|
|
306
|
+
console.log('SHUT DOWN')
|
|
307
|
+
|
|
308
|
+
await delay(1000)
|
|
309
|
+
|
|
310
|
+
await upRedisServer(1)
|
|
311
|
+
console.log('ONLINE')
|
|
312
|
+
|
|
313
|
+
// semaphore was expired, key was deleted in redis
|
|
314
|
+
// give refresh mechanism time to detect lock lost
|
|
315
|
+
// (includes reconnection time)
|
|
316
|
+
await delay(1000)
|
|
317
|
+
|
|
318
|
+
const data = await client.zrange('semaphore:key', 0, -1, 'WITHSCORES')
|
|
319
|
+
expect(data).to.be.eql([])
|
|
320
|
+
|
|
321
|
+
// console.log(data)
|
|
322
|
+
// expect(data).to.include(semaphore11.identifier)
|
|
323
|
+
// expect(data).to.include(semaphore12.identifier)
|
|
324
|
+
// expect(data).to.include(semaphore13.identifier)
|
|
325
|
+
|
|
326
|
+
// lock was not reacquired by semaphore1[1-3], so semaphore2 can acquire the lock
|
|
327
|
+
|
|
328
|
+
const semaphore2 = new Semaphore(client, 'key', 3, timeoutOptions)
|
|
329
|
+
await semaphore2.acquire()
|
|
330
|
+
|
|
331
|
+
expect(await client.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
332
|
+
semaphore2.identifier
|
|
333
|
+
])
|
|
334
|
+
|
|
335
|
+
await Promise.all([
|
|
336
|
+
...semaphores1.map(s => s.release()),
|
|
337
|
+
semaphore2.release()
|
|
338
|
+
])
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
describe('ioredis-mock support', async () => {
|
|
342
|
+
it('should acquire and release semaphore', async () => {
|
|
343
|
+
const semaphore1 = new Semaphore(clientMock, 'key', 2)
|
|
344
|
+
const semaphore2 = new Semaphore(clientMock, 'key', 2)
|
|
345
|
+
expect(semaphore1.isAcquired).to.be.false
|
|
346
|
+
expect(semaphore2.isAcquired).to.be.false
|
|
347
|
+
|
|
348
|
+
await semaphore1.acquire()
|
|
349
|
+
expect(semaphore1.isAcquired).to.be.true
|
|
350
|
+
await semaphore2.acquire()
|
|
351
|
+
expect(semaphore2.isAcquired).to.be.true
|
|
352
|
+
expect(await clientMock.zrange('semaphore:key', 0, -1)).to.have.members([
|
|
353
|
+
semaphore1.identifier,
|
|
354
|
+
semaphore2.identifier
|
|
355
|
+
])
|
|
356
|
+
|
|
357
|
+
await semaphore1.release()
|
|
358
|
+
expect(semaphore1.isAcquired).to.be.false
|
|
359
|
+
expect(await clientMock.zrange('semaphore:key', 0, -1)).to.be.eql([
|
|
360
|
+
semaphore2.identifier
|
|
361
|
+
])
|
|
362
|
+
await semaphore2.release()
|
|
363
|
+
expect(semaphore2.isAcquired).to.be.false
|
|
364
|
+
expect(await clientMock.zcard('semaphore:key')).to.be.eql(0)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
})
|