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.
Files changed (99) hide show
  1. package/README.md +156 -56
  2. package/examples/index.ts +49 -21
  3. package/package.json +2 -2
  4. package/redis-semaphore/.codeclimate.yml +5 -0
  5. package/redis-semaphore/.fossa.yml +14 -0
  6. package/redis-semaphore/.github/dependabot.yml +6 -0
  7. package/redis-semaphore/.github/workflows/branches.yml +39 -0
  8. package/redis-semaphore/.github/workflows/pull-requests.yml +35 -0
  9. package/redis-semaphore/.mocharc.yaml +6 -0
  10. package/redis-semaphore/.prettierrc +6 -0
  11. package/redis-semaphore/.snyk +4 -0
  12. package/redis-semaphore/.yarnrc.yml +2 -0
  13. package/redis-semaphore/CHANGELOG.md +70 -0
  14. package/redis-semaphore/Dockerfile +5 -0
  15. package/redis-semaphore/LICENSE +21 -0
  16. package/redis-semaphore/README.md +445 -0
  17. package/redis-semaphore/docker-compose.yml +31 -0
  18. package/redis-semaphore/eslint.config.mjs +73 -0
  19. package/redis-semaphore/package.json +79 -0
  20. package/redis-semaphore/setup-redis-servers.sh +2 -0
  21. package/redis-semaphore/src/Lock.ts +172 -0
  22. package/redis-semaphore/src/RedisMultiSemaphore.ts +56 -0
  23. package/redis-semaphore/src/RedisMutex.ts +45 -0
  24. package/redis-semaphore/src/RedisSemaphore.ts +49 -0
  25. package/redis-semaphore/src/RedlockMultiSemaphore.ts +56 -0
  26. package/redis-semaphore/src/RedlockMutex.ts +52 -0
  27. package/redis-semaphore/src/RedlockSemaphore.ts +49 -0
  28. package/redis-semaphore/src/errors/LostLockError.ts +1 -0
  29. package/redis-semaphore/src/errors/TimeoutError.ts +1 -0
  30. package/redis-semaphore/src/index.ts +23 -0
  31. package/redis-semaphore/src/misc.ts +12 -0
  32. package/redis-semaphore/src/multiSemaphore/acquire/index.ts +53 -0
  33. package/redis-semaphore/src/multiSemaphore/acquire/lua.ts +31 -0
  34. package/redis-semaphore/src/multiSemaphore/refresh/index.ts +32 -0
  35. package/redis-semaphore/src/multiSemaphore/refresh/lua.ts +31 -0
  36. package/redis-semaphore/src/multiSemaphore/release/index.ts +22 -0
  37. package/redis-semaphore/src/multiSemaphore/release/lua.ts +17 -0
  38. package/redis-semaphore/src/mutex/acquire.ts +42 -0
  39. package/redis-semaphore/src/mutex/refresh.ts +37 -0
  40. package/redis-semaphore/src/mutex/release.ts +30 -0
  41. package/redis-semaphore/src/redlockMultiSemaphore/acquire.ts +56 -0
  42. package/redis-semaphore/src/redlockMultiSemaphore/refresh.ts +68 -0
  43. package/redis-semaphore/src/redlockMultiSemaphore/release.ts +19 -0
  44. package/redis-semaphore/src/redlockMutex/acquire.ts +54 -0
  45. package/redis-semaphore/src/redlockMutex/refresh.ts +53 -0
  46. package/redis-semaphore/src/redlockMutex/release.ts +19 -0
  47. package/redis-semaphore/src/redlockSemaphore/acquire.ts +55 -0
  48. package/redis-semaphore/src/redlockSemaphore/refresh.ts +60 -0
  49. package/redis-semaphore/src/redlockSemaphore/release.ts +18 -0
  50. package/redis-semaphore/src/semaphore/acquire/index.ts +52 -0
  51. package/redis-semaphore/src/semaphore/acquire/lua.ts +25 -0
  52. package/redis-semaphore/src/semaphore/refresh/index.ts +31 -0
  53. package/redis-semaphore/src/semaphore/refresh/lua.ts +25 -0
  54. package/redis-semaphore/src/semaphore/release.ts +14 -0
  55. package/redis-semaphore/src/types.ts +63 -0
  56. package/redis-semaphore/src/utils/createEval.ts +45 -0
  57. package/redis-semaphore/src/utils/index.ts +13 -0
  58. package/redis-semaphore/src/utils/redlock.ts +7 -0
  59. package/redis-semaphore/test/init.test.ts +9 -0
  60. package/redis-semaphore/test/redisClient.ts +82 -0
  61. package/redis-semaphore/test/setup.ts +6 -0
  62. package/redis-semaphore/test/shell.test.ts +15 -0
  63. package/redis-semaphore/test/shell.ts +48 -0
  64. package/redis-semaphore/test/src/Lock.test.ts +37 -0
  65. package/redis-semaphore/test/src/RedisMultiSemaphore.test.ts +425 -0
  66. package/redis-semaphore/test/src/RedisMutex.test.ts +334 -0
  67. package/redis-semaphore/test/src/RedisSemaphore.test.ts +367 -0
  68. package/redis-semaphore/test/src/RedlockMultiSemaphore.test.ts +671 -0
  69. package/redis-semaphore/test/src/RedlockMutex.test.ts +328 -0
  70. package/redis-semaphore/test/src/RedlockSemaphore.test.ts +579 -0
  71. package/redis-semaphore/test/src/index.test.ts +22 -0
  72. package/redis-semaphore/test/src/multiSemaphore/acquire/index.test.ts +51 -0
  73. package/redis-semaphore/test/src/multiSemaphore/acquire/internal.test.ts +67 -0
  74. package/redis-semaphore/test/src/multiSemaphore/refresh/index.test.ts +52 -0
  75. package/redis-semaphore/test/src/multiSemaphore/release/index.test.ts +18 -0
  76. package/redis-semaphore/test/src/mutex/acquire.test.ts +78 -0
  77. package/redis-semaphore/test/src/mutex/refresh.test.ts +22 -0
  78. package/redis-semaphore/test/src/mutex/release.test.ts +17 -0
  79. package/redis-semaphore/test/src/redlockMutex/acquire.test.ts +90 -0
  80. package/redis-semaphore/test/src/redlockMutex/refresh.test.ts +27 -0
  81. package/redis-semaphore/test/src/redlockMutex/release.test.ts +17 -0
  82. package/redis-semaphore/test/src/semaphore/acquire/index.test.ts +49 -0
  83. package/redis-semaphore/test/src/semaphore/acquire/internal.test.ts +65 -0
  84. package/redis-semaphore/test/src/semaphore/refresh/index.test.ts +44 -0
  85. package/redis-semaphore/test/src/semaphore/release.test.ts +18 -0
  86. package/redis-semaphore/test/src/utils/eval.test.ts +22 -0
  87. package/redis-semaphore/test/src/utils/index.test.ts +19 -0
  88. package/redis-semaphore/test/src/utils/redlock.test.ts +31 -0
  89. package/redis-semaphore/test/unhandledRejection.ts +28 -0
  90. package/redis-semaphore/tsconfig.build-commonjs.json +9 -0
  91. package/redis-semaphore/tsconfig.build-es.json +9 -0
  92. package/redis-semaphore/tsconfig.json +11 -0
  93. package/redis-semaphore/yarn.lock +5338 -0
  94. package/src/Backing.ts +87 -0
  95. package/src/DistributedSemaphore.ts +448 -0
  96. package/src/Errors.ts +3 -15
  97. package/src/RedisBacking.ts +165 -59
  98. package/src/index.ts +28 -12
  99. package/src/DistributedMutex.ts +0 -356
@@ -0,0 +1,25 @@
1
+ import { createEval } from '../../utils/index'
2
+
3
+ export const refreshLua = createEval<
4
+ [string, number, string, number, number],
5
+ 0 | 1
6
+ >(
7
+ `
8
+ local key = KEYS[1]
9
+ local limit = tonumber(ARGV[1])
10
+ local identifier = ARGV[2]
11
+ local lockTimeout = tonumber(ARGV[3])
12
+ local now = tonumber(ARGV[4])
13
+ local expiredTimestamp = now - lockTimeout
14
+
15
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
16
+
17
+ if redis.call('zscore', key, identifier) then
18
+ redis.call('zadd', key, now, identifier)
19
+ redis.call('pexpire', key, lockTimeout)
20
+ return 1
21
+ else
22
+ return 0
23
+ end`,
24
+ 1
25
+ )
@@ -0,0 +1,14 @@
1
+ import createDebug from 'debug'
2
+ import { RedisClient } from '../types'
3
+
4
+ const debug = createDebug('redis-semaphore:semaphore:release')
5
+
6
+ export async function releaseSemaphore(
7
+ client: RedisClient,
8
+ key: string,
9
+ identifier: string
10
+ ): Promise<void> {
11
+ debug(key, identifier)
12
+ const result = await client.zrem(key, identifier)
13
+ debug('result', typeof result, result)
14
+ }
@@ -0,0 +1,63 @@
1
+ import LostLockError from './errors/LostLockError'
2
+ import { Lock } from './Lock'
3
+
4
+ import type * as ioredis from 'ioredis'
5
+
6
+ /**
7
+ * ioredis-like Redis client
8
+ */
9
+ export type RedisClient = Pick<
10
+ ioredis.Redis,
11
+ 'eval' | 'evalsha' | 'get' | 'set' | 'zrem'
12
+ > &
13
+ Partial<Pick<ioredis.Redis, 'options'>>
14
+
15
+ export interface LockLostCallback {
16
+ (this: Lock, err: LostLockError): void
17
+ }
18
+
19
+ export interface TimeoutOptions {
20
+ lockTimeout?: number
21
+ acquireTimeout?: number
22
+ acquireAttemptsLimit?: number
23
+ retryInterval?: number
24
+ refreshInterval?: number
25
+ }
26
+
27
+ export interface LockOptions extends TimeoutOptions {
28
+ /**
29
+ * @deprecated Use `identifier` + `acquiredExternally: true` instead. Will be removed in next major release.
30
+ */
31
+ externallyAcquiredIdentifier?: string
32
+
33
+ /**
34
+ * @deprecated Provide custom `identifier` instead. Will be removed in next major release.
35
+ */
36
+ identifierSuffix?: string
37
+
38
+ /**
39
+ * Identifier of lock. By default is `crypto.randomUUID()`.
40
+ *
41
+ * Must be unique between parallel executors otherwise locks with same identifier *can* be treated as the same lock holder.
42
+ *
43
+ * Override only if you know what you are doing, see `acquireExternally` option.
44
+ */
45
+ identifier?: string
46
+
47
+ /**
48
+ * If `identifier` provided and `acquiredExternally` is `true` then `_refresh` will be used instead of `_acquire` in `.tryAcquire()`/`.acquire()`.
49
+ *
50
+ * Useful for lock sharing between processes: acquire in scheduler, refresh and release in handler.
51
+ */
52
+ acquiredExternally?: true
53
+
54
+ onLockLost?: LockLostCallback
55
+ }
56
+
57
+ export interface AcquireOptions {
58
+ identifier: string
59
+ lockTimeout: number
60
+ acquireTimeout: number
61
+ acquireAttemptsLimit: number
62
+ retryInterval: number
63
+ }
@@ -0,0 +1,45 @@
1
+ import { createHash } from 'crypto'
2
+ import createDebug from 'debug'
3
+ import { RedisClient } from '../types'
4
+ import { getConnectionName } from './index'
5
+
6
+ const debug = createDebug('redis-semaphore:eval')
7
+
8
+ function createSHA1(script: string): string {
9
+ return createHash('sha1').update(script, 'utf8').digest('hex')
10
+ }
11
+
12
+ function isNoScriptError(err: Error): boolean {
13
+ return err.toString().indexOf('NOSCRIPT') !== -1
14
+ }
15
+
16
+ export default function createEval<Args extends Array<number | string>, Result>(
17
+ script: string,
18
+ keysCount: number
19
+ ): (client: RedisClient, args: Args) => Promise<Result> {
20
+ const sha1 = createSHA1(script)
21
+ debug('creating script:', script, 'sha1:', sha1)
22
+ return async function optimizedEval(
23
+ client: RedisClient,
24
+ args: Args
25
+ ): Promise<Result> {
26
+ const connectionName = getConnectionName(client)
27
+ const evalSHAArgs = [sha1, keysCount, ...args]
28
+ debug(connectionName, sha1, 'attempt, args:', evalSHAArgs)
29
+ try {
30
+ return (await client.evalsha(sha1, keysCount, ...args)) as Promise<Result>
31
+ } catch (err) {
32
+ if (err instanceof Error && isNoScriptError(err)) {
33
+ const evalArgs = [script, keysCount, ...args]
34
+ debug(connectionName, sha1, 'fallback to eval, args:', evalArgs)
35
+ return (await client.eval(
36
+ script,
37
+ keysCount,
38
+ ...args
39
+ )) as Promise<Result>
40
+ } else {
41
+ throw err
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,13 @@
1
+ import { RedisClient } from '../types'
2
+ import createEval from './createEval'
3
+
4
+ export { createEval }
5
+
6
+ export async function delay(ms: number): Promise<void> {
7
+ return await new Promise(resolve => setTimeout(resolve, ms))
8
+ }
9
+
10
+ export function getConnectionName(client: RedisClient): string {
11
+ const connectionName = client.options?.connectionName
12
+ return connectionName ? `<${connectionName}>` : '<unknown client>'
13
+ }
@@ -0,0 +1,7 @@
1
+ export function getQuorum(clientCount: number): number {
2
+ return Math.round((clientCount + 1) / 2)
3
+ }
4
+
5
+ export function smartSum(count: number, zeroOrOne: number): number {
6
+ return count + zeroOrOne
7
+ }
@@ -0,0 +1,9 @@
1
+ import { init, removeAllListeners } from './unhandledRejection'
2
+
3
+ before(() => {
4
+ init()
5
+ })
6
+
7
+ after(() => {
8
+ removeAllListeners()
9
+ })
@@ -0,0 +1,82 @@
1
+ import Redis from 'ioredis'
2
+ import RedisMock from 'ioredis-mock'
3
+ import { once } from 'stream'
4
+
5
+ function createClient(num: number) {
6
+ const serverURL =
7
+ process.env[`REDIS_URI${num}`] || `redis://127.0.0.1:${6000 + num}`
8
+ const client = new Redis(serverURL, {
9
+ connectionName: `client${num}`,
10
+ lazyConnect: true,
11
+ enableOfflineQueue: false,
12
+ autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic)
13
+ maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20)
14
+
15
+ // https://github.com/luin/ioredis#auto-reconnect
16
+ // retryStrategy is a function that will be called when the connection is lost.
17
+ // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect.
18
+ retryStrategy() {
19
+ return 100 // for tests we disable increasing timeout
20
+ }
21
+ })
22
+ client.on('error', err => {
23
+ console.log('Redis client error:', err.message)
24
+ })
25
+ return client
26
+ }
27
+
28
+ function createClientMock(num: number) {
29
+ return new RedisMock(`redis://mock:${4200 + num}`, {
30
+ connectionName: `client-mock${num}`,
31
+ lazyConnect: true,
32
+ enableOfflineQueue: false,
33
+ autoResendUnfulfilledCommands: false, // dont queue commands while server is offline (dont break test logic)
34
+ maxRetriesPerRequest: 0, // dont retry, fail faster (default is 20)
35
+
36
+ // https://github.com/luin/ioredis#auto-reconnect
37
+ // retryStrategy is a function that will be called when the connection is lost.
38
+ // The argument times means this is the nth reconnection being made and the return value represents how long (in ms) to wait to reconnect.
39
+ retryStrategy() {
40
+ return 100 // for tests we disable increasing timeout
41
+ }
42
+ })
43
+ }
44
+
45
+ export const client1 = createClient(1)
46
+ export const client2 = createClient(2)
47
+ export const client3 = createClient(3)
48
+
49
+ export const allClients = [client1, client2, client3]
50
+
51
+ export const clientMock1 = createClientMock(1)
52
+ export const clientMock2 = createClientMock(2)
53
+ export const clientMock3 = createClientMock(3)
54
+
55
+ export const allClientMocks = [clientMock1, clientMock2, clientMock3]
56
+
57
+ before(async () => {
58
+ await Promise.all(allClients.map(c => c.connect()))
59
+ await Promise.all(allClientMocks.map(c => c.connect()))
60
+ })
61
+
62
+ beforeEach(async () => {
63
+ await Promise.all(
64
+ allClients.map(c => {
65
+ if (c.status !== 'ready') {
66
+ console.warn(
67
+ `client ${c.options.connectionName} status = ${c.status}. Wait for ready.`
68
+ )
69
+ return once(c, 'ready')
70
+ }
71
+ return null
72
+ })
73
+ )
74
+ await Promise.all(allClients.map(c => c.flushdb()))
75
+ await Promise.all(allClientMocks.map(c => c.flushdb()))
76
+ })
77
+
78
+ after(async () => {
79
+ await Promise.all(allClients.map(c => c.quit()))
80
+ await Promise.all(allClientMocks.map(c => c.quit()))
81
+ // allClients.forEach(c => c.disconnect())
82
+ })
@@ -0,0 +1,6 @@
1
+ import chai from 'chai'
2
+ import chaiAsPromised from 'chai-as-promised'
3
+ import sinonChai from 'sinon-chai'
4
+
5
+ chai.use(chaiAsPromised)
6
+ chai.use(sinonChai)
@@ -0,0 +1,15 @@
1
+ import { downRedisServer, upRedisServer } from './shell'
2
+
3
+ describe('TEST UTILS', () => {
4
+ describe('shell', () => {
5
+ it('should up redis server', async function () {
6
+ this.timeout(30000)
7
+ await upRedisServer(1)
8
+ })
9
+ it('should down and up redis servers', async function () {
10
+ this.timeout(30000)
11
+ await downRedisServer(1)
12
+ await upRedisServer(1)
13
+ })
14
+ })
15
+ })
@@ -0,0 +1,48 @@
1
+ import { exec } from 'child_process'
2
+
3
+ import { delay } from '../src/utils/index'
4
+
5
+ const LOGGING = !!process.env.LOGSHELL
6
+
7
+ function sh(cmd: string) {
8
+ return new Promise<void>((resolve, reject) => {
9
+ const cp = exec(cmd, (err, stdout, stderr) => {
10
+ if (stdout && LOGGING) {
11
+ console.log(`[${cp.pid}] stdout:`)
12
+ console.log(stdout)
13
+ }
14
+ if (stderr && LOGGING) {
15
+ console.log(`[${cp.pid}] stderr:`)
16
+ console.log(stderr)
17
+ }
18
+ if (err) {
19
+ reject(err)
20
+ } else {
21
+ resolve()
22
+ }
23
+ })
24
+ console.log(`[${cp.pid}] ${cmd}`)
25
+ })
26
+ }
27
+
28
+ export async function upRedisServer(num: number) {
29
+ const port = 6000 + num
30
+ await sh(
31
+ `docker compose up -d redis${num} && yarn wait-for --redis redis://127.0.0.1:${port}`
32
+ )
33
+ }
34
+
35
+ export async function downRedisServer(num: number) {
36
+ const port = 6000 + num
37
+ await sh(`docker compose stop redis${num}`)
38
+ let tries = 0
39
+ while (true) {
40
+ try {
41
+ console.log(`wait server${num} shut down... ${++tries}`)
42
+ await sh(`yarn wait-for --redis redis://127.0.0.1:${port} -c 1`)
43
+ await delay(100)
44
+ } catch {
45
+ break
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,37 @@
1
+ import { LockOptions } from '../../src'
2
+ import { Lock } from '../../src/Lock'
3
+ import { delay } from '../../src/utils'
4
+
5
+ describe('Lock', () => {
6
+ describe('refresh and release race condition', () => {
7
+ class TestLock extends Lock {
8
+ protected _kind = 'test-lock'
9
+ protected _key: string
10
+ constructor(key: string, options: LockOptions) {
11
+ super(options)
12
+ this._key = key
13
+ }
14
+ protected async _refresh(): Promise<boolean> {
15
+ await delay(200)
16
+ return false
17
+ }
18
+ protected async _acquire(): Promise<boolean> {
19
+ return true
20
+ }
21
+ protected async _release(): Promise<void> {}
22
+ }
23
+ it('should not throw LostLock error when refresh started but not finished before release happened', async function () {
24
+ const lock = new TestLock('key', {
25
+ lockTimeout: 1000,
26
+ acquireTimeout: 1000,
27
+ refreshInterval: 50
28
+ })
29
+ try {
30
+ await lock.acquire()
31
+ await delay(100)
32
+ } finally {
33
+ await lock.release()
34
+ }
35
+ })
36
+ })
37
+ })