Skip to content

Commit b94065c

Browse files
committed
feat: add isExpired and getRemainingTime methods
1 parent 26b712f commit b94065c

3 files changed

Lines changed: 160 additions & 19 deletions

File tree

docs/content/docs/api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Check if the lock is expired.
7676

7777
```ts
7878
const lock = verrou.createLock('key', '10s')
79-
const isExpired = await lock.isExpired()
79+
const isExpired = lock.isExpired()
8080
```
8181

8282
### `extend`
@@ -104,7 +104,7 @@ Get the remaining time before the lock expires.
104104
const lock = verrou.createLock('key', '10s')
105105
await lock.acquire()
106106

107-
const remainingTime = await lock.getRemainingTime()
107+
const remainingTime = lock.getRemainingTime()
108108
```
109109

110110
### `forceRelease`

packages/verrou/src/lock.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,26 @@ import { resolveDuration } from './helpers.js'
66
import type { Duration, LockAcquireOptions, LockFactoryConfig, LockStore } from './types/main.js'
77

88
export class Lock {
9+
#key: string
910
#owner: string
11+
#lockStore: LockStore
12+
#ttl: number | null = null
13+
#config: LockFactoryConfig
14+
#expirationTime: number | null = null
1015

1116
constructor(
12-
protected readonly key: string,
13-
protected readonly lockStore: LockStore,
14-
protected config: LockFactoryConfig,
17+
key: string,
18+
lockStore: LockStore,
19+
config: LockFactoryConfig,
1520
owner?: string,
16-
protected ttl?: number | null,
21+
ttl?: number | null,
1722
) {
23+
this.#key = key
24+
this.#config = config
25+
this.#lockStore = lockStore
26+
this.#expirationTime = null
1827
this.#owner = owner ?? this.#generateOwner()
28+
this.#ttl = ttl ?? null
1929
}
2030

2131
/**
@@ -36,26 +46,32 @@ export class Lock {
3646
* Acquire the lock
3747
*/
3848
async acquire(options?: LockAcquireOptions) {
49+
this.#expirationTime = null
50+
3951
let attemptsDone = 0
4052
const start = Date.now()
4153
const attemptsMax =
42-
options?.retry?.attempts ?? this.config.retry.attempts ?? Number.POSITIVE_INFINITY
54+
options?.retry?.attempts ?? this.#config.retry.attempts ?? Number.POSITIVE_INFINITY
4355

4456
while (attemptsDone++ < attemptsMax) {
45-
const result = await this.lockStore.save(this.key, this.#owner, this.ttl)
46-
if (result) break
57+
const now = Date.now()
58+
const result = await this.#lockStore.save(this.#key, this.#owner, this.#ttl)
59+
if (result) {
60+
this.#expirationTime = this.#ttl ? now + this.#ttl : null
61+
break
62+
}
4763

4864
if (attemptsDone === attemptsMax) throw new E_LOCK_TIMEOUT()
4965

5066
const elapsed = Date.now() - start
51-
if (this.config.retry.timeout && elapsed > this.config.retry.timeout) {
67+
if (this.#config.retry.timeout && elapsed > this.#config.retry.timeout) {
5268
throw new E_LOCK_TIMEOUT()
5369
}
5470

55-
await setTimeout(this.config.retry.delay ?? 250)
71+
await setTimeout(this.#config.retry.delay ?? 250)
5672
}
5773

58-
this.config.logger.debug({ key: this.key }, 'Lock acquired')
74+
this.#config.logger.debug({ key: this.#key }, 'Lock acquired')
5975
}
6076

6177
/**
@@ -76,37 +92,48 @@ export class Lock {
7692
* Force release the lock
7793
*/
7894
async forceRelease() {
79-
await this.lockStore.forceRelease(this.key)
95+
await this.#lockStore.forceRelease(this.#key)
8096
}
8197

8298
/**
8399
* Release the lock
84100
*/
85101
async release() {
86-
await this.lockStore.delete(this.key, this.#owner)
102+
await this.#lockStore.delete(this.#key, this.#owner)
87103
}
88104

89105
/**
90106
* Returns true if the lock is expired
91107
*/
92-
async isExpired() {
93-
return false
108+
isExpired() {
109+
if (this.#expirationTime === null) return false
110+
return this.#expirationTime < Date.now()
111+
}
112+
113+
/**
114+
* Get the remaining time before the lock expires
115+
*/
116+
getRemainingTime() {
117+
if (this.#expirationTime === null) return null
118+
return this.#expirationTime - Date.now()
94119
}
95120

96121
/**
97122
* Extends the lock TTL
98123
*/
99124
async extend(ttl?: Duration) {
100-
const resolvedTtl = ttl ? resolveDuration(ttl) : this.ttl
125+
const resolvedTtl = ttl ? resolveDuration(ttl) : this.#ttl
101126
if (!resolvedTtl) throw new InvalidArgumentsException('Cannot extend a lock without TTL')
102127

103-
await this.lockStore.extend(this.key, this.#owner, resolvedTtl)
128+
const now = Date.now()
129+
await this.#lockStore.extend(this.#key, this.#owner, resolvedTtl)
130+
this.#expirationTime = now + resolvedTtl
104131
}
105132

106133
/**
107134
* Returns true if the lock is currently locked
108135
*/
109136
async isLocked() {
110-
return await this.lockStore.exists(this.key)
137+
return await this.#lockStore.exists(this.#key)
111138
}
112139
}

packages/verrou/tests/lock.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from '@japa/runner'
22
import { noopLogger } from 'typescript-log'
3+
import { setTimeout } from 'node:timers/promises'
34

45
import { Lock } from '../src/lock.js'
56
import { E_LOCK_TIMEOUT } from '../src/errors.js'
@@ -156,4 +157,117 @@ test.group('Lock', () => {
156157
const lock = new Lock('foo', new FakeStore(), { retry: {}, logger: noopLogger() }, 'bar', 1000)
157158
await lock.extend('2s')
158159
})
160+
161+
test('isExpired is false when lock has no expiration time', async ({ assert }) => {
162+
const store = new MemoryStore()
163+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() })
164+
165+
assert.deepEqual(lock.isExpired(), false)
166+
167+
await lock.acquire()
168+
169+
assert.deepEqual(lock.isExpired(), false)
170+
})
171+
172+
test('isExpired is true when lock has expired', async ({ assert }) => {
173+
const store = new MemoryStore()
174+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 100)
175+
176+
assert.deepEqual(lock.isExpired(), false)
177+
178+
await lock.acquire()
179+
180+
assert.deepEqual(lock.isExpired(), false)
181+
await setTimeout(200)
182+
assert.deepEqual(lock.isExpired(), true)
183+
})
184+
185+
test('isExpired is extended when extending the lock', async ({ assert }) => {
186+
const store = new MemoryStore()
187+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 100)
188+
189+
assert.deepEqual(lock.isExpired(), false)
190+
191+
await lock.acquire()
192+
193+
assert.deepEqual(lock.isExpired(), false)
194+
await lock.extend(200)
195+
await setTimeout(100)
196+
assert.deepEqual(lock.isExpired(), false)
197+
await setTimeout(200)
198+
assert.deepEqual(lock.isExpired(), true)
199+
})
200+
201+
test('getRemainingTime returns null when lock has no expiration time', async ({ assert }) => {
202+
const store = new MemoryStore()
203+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() })
204+
205+
assert.deepEqual(lock.getRemainingTime(), null)
206+
207+
await lock.acquire()
208+
209+
assert.deepEqual(lock.getRemainingTime(), null)
210+
})
211+
212+
test('getRemainingTime returns remaining time when lock has expiration time', async ({
213+
assert,
214+
}) => {
215+
const store = new MemoryStore()
216+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 100)
217+
218+
assert.deepEqual(lock.getRemainingTime(), null)
219+
220+
await lock.acquire()
221+
222+
assert.closeTo(lock.getRemainingTime()!, 100, 10)
223+
await setTimeout(200)
224+
assert.closeTo(lock.getRemainingTime()!, -100, 10)
225+
})
226+
227+
test('getRemainingTime is extended when extending the lock', async ({ assert }) => {
228+
const store = new MemoryStore()
229+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 100)
230+
231+
assert.deepEqual(lock.getRemainingTime(), null)
232+
233+
await lock.acquire()
234+
235+
assert.closeTo(lock.getRemainingTime()!, 100, 10)
236+
await lock.extend(200)
237+
assert.closeTo(lock.getRemainingTime()!, 200, 10)
238+
})
239+
240+
test('getRemainingTime doesnt get extended when extend fails', async ({ assert }) => {
241+
class FakeStore extends NullStore {
242+
async extend() {
243+
throw new Error('foo')
244+
}
245+
}
246+
247+
const store = new FakeStore()
248+
const lock = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 100)
249+
250+
assert.deepEqual(lock.getRemainingTime(), null)
251+
252+
await lock.acquire()
253+
254+
assert.closeTo(lock.getRemainingTime()!, 100, 10)
255+
await assert.rejects(() => lock.extend(200))
256+
assert.closeTo(lock.getRemainingTime()!, 100, 10)
257+
})
258+
259+
test('expiration time is null when lock is not acquired', async ({ assert }) => {
260+
const store = new MemoryStore()
261+
const lock = new Lock('foo', store, { retry: { attempts: 1 }, logger: noopLogger() })
262+
const lock2 = new Lock('foo', store, { retry: {}, logger: noopLogger() }, undefined, 1000)
263+
264+
assert.deepEqual(lock.getRemainingTime(), null)
265+
266+
await lock2.acquire()
267+
assert.closeTo(lock2.getRemainingTime()!, 1000, 10)
268+
269+
await lock.acquire().catch(() => {})
270+
assert.deepEqual(lock.getRemainingTime(), null)
271+
assert.closeTo(lock2.getRemainingTime()!, 1000, 200)
272+
})
159273
})

0 commit comments

Comments
 (0)