Skip to content

Commit 4570195

Browse files
committed
feat: add extend method
1 parent 1377001 commit 4570195

10 files changed

Lines changed: 159 additions & 24 deletions

File tree

packages/verrou/src/drivers/database.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import knex, { type Knex } from 'knex'
22

3-
import { E_RELEASE_NOT_OWNED } from '../errors.js'
4-
import type { DatabaseStoreOptions, Duration, LockStore } from '../types/main.js'
3+
import { E_LOCK_NOT_OWNED, E_RELEASE_NOT_OWNED } from '../errors.js'
4+
import type { DatabaseStoreOptions, LockStore } from '../types/main.js'
55

66
/**
77
* Create a new database store
@@ -88,10 +88,6 @@ export class DatabaseStore implements LockStore {
8888
return result?.owner
8989
}
9090

91-
extend(_key: string, _duration: Duration): Promise<void> {
92-
throw new Error('Method not implemented.')
93-
}
94-
9591
/**
9692
* Save a new lock
9793
*
@@ -137,6 +133,19 @@ export class DatabaseStore implements LockStore {
137133
await this.#connection.table(this.#tableName).where('key', key).delete()
138134
}
139135

136+
/**
137+
* Extend a lock
138+
*/
139+
async extend(key: string, owner: string, duration: number) {
140+
const updated = await this.#connection
141+
.table(this.#tableName)
142+
.where('key', key)
143+
.where('owner', owner)
144+
.update({ expiration: Date.now() + duration })
145+
146+
if (updated === 0) throw new E_LOCK_NOT_OWNED()
147+
}
148+
140149
/**
141150
* Check if a lock exists
142151
*/

packages/verrou/src/drivers/dynamodb.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import {
88
ResourceInUseException,
99
} from '@aws-sdk/client-dynamodb'
1010

11-
import { E_RELEASE_NOT_OWNED } from '../errors.js'
12-
import type { DynamoDbOptions } from '../types/drivers.js'
13-
import type { Duration, LockStore } from '../types/main.js'
11+
import type { LockStore, DynamoDbOptions } from '../types/main.js'
12+
import { E_LOCK_NOT_OWNED, E_RELEASE_NOT_OWNED } from '../errors.js'
1413

1514
/**
1615
* Create a DynamoDB store.
@@ -143,8 +142,24 @@ export class DynamoDBStore implements LockStore {
143142
return result.Item !== undefined && !isExpired
144143
}
145144

146-
async extend(_key: string, _duration: Duration) {
147-
throw new Error('Method not implemented.')
145+
async extend(key: string, owner: string, duration: number) {
146+
const command = new PutItemCommand({
147+
TableName: this.#tableName,
148+
Item: {
149+
key: { S: key },
150+
owner: { S: owner },
151+
expires_at: { N: (Date.now() + duration).toString() },
152+
},
153+
ConditionExpression: '#owner = :owner',
154+
ExpressionAttributeNames: { '#owner': 'owner' },
155+
ExpressionAttributeValues: { ':owner': { S: owner } },
156+
})
157+
158+
try {
159+
await this.#client.send(command)
160+
} catch (err) {
161+
throw new E_LOCK_NOT_OWNED()
162+
}
148163
}
149164

150165
/**

packages/verrou/src/drivers/memory.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Mutex, tryAcquire } from 'async-mutex'
22
import type { MutexInterface } from 'async-mutex'
33

4-
import { E_RELEASE_NOT_OWNED } from '../errors.js'
5-
import type { Duration, LockStore } from '../types/main.js'
4+
import type { LockStore } from '../types/main.js'
5+
import { E_LOCK_NOT_OWNED, E_RELEASE_NOT_OWNED } from '../errors.js'
66

77
type MemoryLockEntry = {
88
mutex: MutexInterface
@@ -48,7 +48,15 @@ export class MemoryStore implements LockStore {
4848
return lock.expiresAt && lock.expiresAt < Date.now()
4949
}
5050

51-
async extend(_key: string, _duration: Duration) {}
51+
/**
52+
* Extend a lock
53+
*/
54+
async extend(key: string, owner: string, duration: number) {
55+
const lock = this.#locks.get(key)
56+
if (!lock || lock.owner !== owner) throw new E_LOCK_NOT_OWNED()
57+
58+
lock.expiresAt = this.#computeExpiresAt(duration)
59+
}
5260

5361
/**
5462
* Save a lock

packages/verrou/src/drivers/redis.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Redis as IoRedis } from 'ioredis'
22

3-
import { E_RELEASE_NOT_OWNED } from '../errors.js'
4-
import type { Duration, LockStore, RedisStoreOptions } from '../types/main.js'
3+
import { E_LOCK_NOT_OWNED, E_RELEASE_NOT_OWNED } from '../errors.js'
4+
import type { LockStore, RedisStoreOptions } from '../types/main.js'
55

66
/**
77
* Create a new Redis store
@@ -24,10 +24,6 @@ export class RedisStore implements LockStore {
2424
}
2525
}
2626

27-
async extend(_key: string, _duration: Duration) {
28-
throw new Error('Method not implemented.')
29-
}
30-
3127
/**
3228
* Delete a lock
3329
*/
@@ -72,6 +68,22 @@ export class RedisStore implements LockStore {
7268
return result === 1
7369
}
7470

71+
/**
72+
* Extend a lock
73+
*/
74+
async extend(key: string, owner: string, duration: number) {
75+
const lua = `
76+
if redis.call("get", KEYS[1]) == ARGV[1] then
77+
return redis.call("pexpire", KEYS[1], ARGV[2])
78+
else
79+
return 0
80+
end
81+
`
82+
83+
const result = await this.#connection.eval(lua, 1, key, owner, duration)
84+
if (result === 0) throw new E_LOCK_NOT_OWNED()
85+
}
86+
7587
/**
7688
* Disconnect from Redis
7789
*/

packages/verrou/src/errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@ export const E_LOCK_TIMEOUT = createError(
99
`Lock was not acquired in the allotted time`,
1010
'E_LOCK_TIMEOUT',
1111
)
12+
13+
export const E_LOCK_NOT_OWNED = createError(
14+
'Looks like you are trying to update a lock that is not acquired by you',
15+
'E_LOCK_NOT_OWNED',
16+
)
17+
18+
export const E_LOCK_STORAGE_ERROR = createError<[{ message: string }]>(
19+
'Lock storage error: %s',
20+
'E_LOCK_STORAGE_ERROR',
21+
)

packages/verrou/src/lock.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { setTimeout } from 'node:timers/promises'
2+
import { InvalidArgumentsException } from '@poppinss/utils'
23

34
import { E_LOCK_TIMEOUT } from './errors.js'
4-
import type { LockAcquireOptions, LockFactoryConfig, LockStore } from './types/main.js'
5+
import { resolveDuration } from './helpers.js'
6+
import type { Duration, LockAcquireOptions, LockFactoryConfig, LockStore } from './types/main.js'
57

68
export class Lock {
79
#owner: string
@@ -91,6 +93,16 @@ export class Lock {
9193
return false
9294
}
9395

96+
/**
97+
* Extends the lock TTL
98+
*/
99+
async extend(ttl?: Duration) {
100+
const resolvedTtl = ttl ? resolveDuration(ttl) : this.ttl
101+
if (!resolvedTtl) throw new InvalidArgumentsException('Cannot extend a lock without TTL')
102+
103+
await this.lockStore.extend(this.key, this.#owner, resolvedTtl)
104+
}
105+
94106
/**
95107
* Returns true if the lock is currently locked
96108
*/

packages/verrou/src/types/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export interface LockStore {
7676
/**
7777
* Extend the lock expiration
7878
*/
79-
extend(key: string, duration: Duration): Promise<void>
79+
extend(key: string, owner: string, duration: number): Promise<void>
8080

8181
/**
8282
* Disconnect the store

packages/verrou/test_helpers/driver_test_suite.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import type { Group } from '@japa/runner/core'
22
import type { test as JapaTest } from '@japa/runner'
33
import { setTimeout as sleep } from 'node:timers/promises'
44

5-
import { E_LOCK_TIMEOUT } from '../index.js'
65
import { LockFactory } from '../src/lock_factory.js'
76
import type { LockStore } from '../src/types/main.js'
7+
import { E_LOCK_NOT_OWNED, E_LOCK_TIMEOUT } from '../index.js'
88

99
export function registerStoreTestSuite<T extends { new (options: any): LockStore }>(options: {
1010
test: typeof JapaTest
@@ -304,4 +304,34 @@ export function registerStoreTestSuite<T extends { new (options: any): LockStore
304304

305305
assert.isFalse(await lock.isLocked())
306306
})
307+
308+
test('extend extends the lock ttl', async ({ assert }) => {
309+
const provider = new LockFactory(new store(config))
310+
const lock = provider.createLock('foo', 1000)
311+
312+
await lock.acquire()
313+
314+
assert.isTrue(await lock.isLocked())
315+
316+
await sleep(500)
317+
await lock.extend(1000)
318+
319+
assert.isTrue(await lock.isLocked())
320+
321+
await sleep(500)
322+
323+
assert.isTrue(await lock.isLocked())
324+
325+
await sleep(500)
326+
327+
assert.isFalse(await lock.isLocked())
328+
})
329+
330+
test('extend throws if lock is not acquired', async () => {
331+
const provider = new LockFactory(new store(config))
332+
const lock = provider.createLock('foo', 1000)
333+
334+
await lock.extend()
335+
// @ts-expect-error poppinss/utils typing bug
336+
}).throws(E_LOCK_NOT_OWNED.message, E_LOCK_NOT_OWNED)
307337
}

packages/verrou/test_helpers/null_store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export class NullStore implements LockStore {
1717
return
1818
}
1919

20-
async extend(_key: string, _duration: Duration): Promise<void> {
20+
async extend(_key: string, _owner: string, _duration: number): Promise<void> {
2121
return
2222
}
2323

packages/verrou/tests/lock.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,43 @@ test.group('Lock', () => {
113113

114114
assert.deepEqual(result, 'foo')
115115
})
116+
117+
test('use default ttl when not specified', async ({ assert }) => {
118+
assert.plan(1)
119+
120+
class FakeStore extends NullStore {
121+
async extend(_key: string, _owner: string, duration: number) {
122+
assert.deepEqual(duration, 1000)
123+
}
124+
}
125+
126+
const lock = new Lock('foo', new FakeStore(), { retry: {}, logger: noopLogger() }, 'bar', 1000)
127+
await lock.extend()
128+
})
129+
130+
test('use specific ttl when specified', async ({ assert }) => {
131+
assert.plan(1)
132+
133+
class FakeStore extends NullStore {
134+
async extend(_key: string, _owner: string, duration: number) {
135+
assert.deepEqual(duration, 2000)
136+
}
137+
}
138+
139+
const lock = new Lock('foo', new FakeStore(), { retry: {}, logger: noopLogger() }, 'bar', 1000)
140+
await lock.extend(2000)
141+
})
142+
143+
test('resolve string ttl', async ({ assert }) => {
144+
assert.plan(1)
145+
146+
class FakeStore extends NullStore {
147+
async extend(_key: string, _owner: string, duration: number) {
148+
assert.deepEqual(duration, 2000)
149+
}
150+
}
151+
152+
const lock = new Lock('foo', new FakeStore(), { retry: {}, logger: noopLogger() }, 'bar', 1000)
153+
await lock.extend('2s')
154+
})
116155
})

0 commit comments

Comments
 (0)