Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor!: do not throw anymore when cant acquire a lock #5

Merged
merged 3 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 31 additions & 25 deletions docs/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,38 @@ Acquire the lock. If the lock is already acquired, it will wait until it is rele

```ts
const lock = verrou.createLock('key', '10s')
await lock.acquire()
await lock.acquire({ retry: { timeout: 1000 } })
await lock.acquire({ retry: { timeout: '1s' } })
const acquired = await lock.acquire()
const acquired = await lock.acquire({ retry: { timeout: 1000 } })
const acquired = await lock.acquire({ retry: { timeout: '1s' } })

if (acquired) {
await criticalSection()
}
```

Accept an optional object with the following properties:

- `retry`: An object with the following properties:
- `timeout`: The maximum time to wait for the lock to be acquired. If the timeout is reached, an `E_LOCK_TIMEOUT` error will be thrown. Defaults to `Infinity`.
- `timeout`: The maximum time to wait for the lock to be acquired. Defaults to `Infinity`.
- `delay`: The delay in miliseconds between each retry. Defaults to `250`
- `attempts`: The maximum number of attempts to acquire the lock.

`acquire` will return a boolean indicating if the lock was acquired or not

### `acquireImmediately`

Try to acquire the lock immediately ( without retrying ). If the lock is already acquired, it will return `false` immediately.

```ts
import { errors } from '@verrou/core'

const lock = verrou.createLock('key')
const acquired = await lock.acquireImmediately()
if (acquired) {
await criticalSection()
}
```

### `release`

Release the lock. Note that only the lock owner can release the lock.
Expand All @@ -39,45 +59,31 @@ await lock.release()

### `run`

Acquire the lock, run the callback, and release the lock.
Acquire the lock, run the callback, and release the lock. The method will return a tuple with the first value being a boolean indicating if the lock was acquired or not, and the second value being the result of the callback.

```ts
const lock = verrou.createLock('key')
await lock.run(() => {
const [executed, result] = await lock.run(() => {
// do something
return 'result'
})
```


### `runImmediately`

Same as `run`, but try to acquire the lock immediately ( without retrying ). If the lock is already acquired, it will throws a `E_LOCK_ALREADY_ACQUIRED` error.
Same as `run`, but try to acquire the lock immediately ( without retrying ).

```ts
const lock = verrou.createLock('key')
await lock.runImmediately(async () => {
const [executed, result] = await lock.runImmediately(async () => {
// do something
return 'result'
})
```

Accept an optional object with the same properties as `acquire`.

### `acquireImmediately`

Try to acquire the lock immediately ( without retrying ). If the lock is already acquired, it will throws a `E_LOCK_ALREADY_ACQUIRED` error.

```ts
import { errors } from '@verrou/core'

const lock = verrou.createLock('key')
try {
await lock.acquireImmediately()
} catch (err) {
if (err instanceof E_LOCK_ALREADY_ACQUIRED) {

}
}
```

### `isLocked`

Check if the lock is acquired.
Expand Down
9 changes: 3 additions & 6 deletions docs/content/docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,13 @@ const verrou = new Verrou({

```ts
// title: Manual lock
import { Verrou, E_LOCK_TIMEOUT } from '@verrou/core'
import { Verrou } from '@verrou/core'

const lock = verrou.createLock('my-resource')
const acquired = await lock.acquire()

try {
await lock.acquire()
await doSomething()
} catch (error) {
if (error instanceof E_LOCK_TIMEOUT) {
// handle timeout
}
} finally {
await lock.release()
}
Expand Down
44 changes: 21 additions & 23 deletions docs/content/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ import { verrou } from './verrou.js'
const lock = verrou.createLock('my-resource', null)

// We gonna wait for the lock to be acquired
await lock.acquire()

// Do your critical code here
doSomething()
if (await lock.acquire()) {
// Do your critical code here
doSomething()

// Once you are done, release the lock.
await lock.release()
// Once you are done, release the lock.
await lock.release()
}
```

But we are still missing error handling. What if my `doSomething` method throws an error? The lock will never be released. To prevent this, always make sure to wrap your code with a try/catch/finaly block.
Expand All @@ -53,10 +53,10 @@ But we are still missing error handling. What if my `doSomething` method throws
import { verrou } from './verrou.js'

const lock = verrou.createLock('my-resource', null)
const acquired = await lock.acquire()
if (!acquired) return 'Lock not acquired'

try {
await lock.acquire()

// Do your critical code here
doSomething()
} catch (error) {
Expand Down Expand Up @@ -115,29 +115,27 @@ await lock.acquire({

In general, you will either use the `retry.attempts` or `retry.timeout` options.

### Handling errors
### Handling lock acquisition failure

If ever you can't acquire a lock, an error will be thrown. You can catch it and handle it like this :
If ever you can't acquire a lock, `acquire` and `acquireImmediately` will return `false`. You can check if the lock was acquired by checking this value.

```ts
import { errors } from '@verrou/core'

try {
await lock.acquire()
} catch (error) {
if (error instanceof errors.E_LOCK_TIMEOUT) {
// Handle the error
}
const acquired = await lock.acquire()
if (!acquired) {
return 'Lock not acquired'
}
```

await lock.run(async () => {
// Do your critical code here
doSomething()
}).catch(error => {
if (error instanceof errors.E_LOCK_TIMEOUT) {
// Handle the error
}
`run` and `runImmediately` methods will return a tuple with the first value being a boolean indicating if the lock was acquired or not.

```ts
const [acquired, result] = await lock.run(async () => {
return doSomething()
})

if (!acquired) return 'Lock not acquired'
```

### Sharing a lock between multiple processes
Expand Down
1 change: 1 addition & 0 deletions packages/verrou/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "^3.529.1",
"@japa/expect-type": "2.0.1",
"@types/better-sqlite3": "^7.6.9",
"@types/pg": "^8.11.2",
"@types/proper-lockfile": "^4.1.4",
Expand Down
18 changes: 1 addition & 17 deletions packages/verrou/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { createError } from '@poppinss/utils'

/**
* Thrown when the lock is not acquired in the allotted time
*/
export const E_LOCK_TIMEOUT = createError(
`Lock was not acquired in the allotted time`,
'E_LOCK_TIMEOUT',
)

/**
* Thrown when user tries to update/release/extend a lock that is not acquired by them
*/
export const E_LOCK_NOT_OWNED = createError(
'Looks like you are trying to update a lock that is not acquired by you',
'Looks like you are trying to update or release a lock that is not acquired by you',
'E_LOCK_NOT_OWNED',
)

Expand All @@ -23,11 +15,3 @@ export const E_LOCK_STORAGE_ERROR = createError<[{ message: string }]>(
'Lock storage error: %s',
'E_LOCK_STORAGE_ERROR',
)

/**
* Thrown when user tries to acquire a lock that is already acquired by someone else
*/
export const E_LOCK_ALREADY_ACQUIRED = createError(
'Lock is already acquired',
'E_LOCK_ALREDY_ACQUIRED',
)
29 changes: 18 additions & 11 deletions packages/verrou/src/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { setTimeout } from 'node:timers/promises'
import { InvalidArgumentsException } from '@poppinss/utils'

import { resolveDuration } from './helpers.js'
import { E_LOCK_ALREADY_ACQUIRED, E_LOCK_TIMEOUT } from './errors.js'
import type {
Duration,
LockAcquireOptions,
Expand Down Expand Up @@ -88,13 +87,13 @@ export class Lock {
/**
* Check if we reached the maximum number of attempts
*/
if (attemptsDone === attemptsMax) throw new E_LOCK_TIMEOUT()
if (attemptsDone === attemptsMax) return false

/**
* Or check if we reached the timeout
*/
const elapsed = Date.now() - start
if (timeout && elapsed > timeout) throw new E_LOCK_TIMEOUT()
if (timeout && elapsed > timeout) return false

/**
* Otherwise wait for the delay and try again
Expand All @@ -103,28 +102,33 @@ export class Lock {
}

this.#config.logger.debug({ key: this.#key }, 'Lock acquired')
return true
}

/**
* Try to acquire the lock immediately or throw an error
*/
async acquireImmediately() {
const result = await this.#lockStore.save(this.#key, this.#owner, this.#ttl)
if (!result) throw new E_LOCK_ALREADY_ACQUIRED()
if (!result) return false
this.#expirationTime = this.#ttl ? Date.now() + this.#ttl : null

this.#config.logger.debug({ key: this.#key }, 'Lock acquired with acquireImmediately()')
return true
}

/**
* Acquire the lock, run the callback and release the lock automatically
* after the callback is done.
* Also returns the callback return value
*/
async run<T>(callback: () => Promise<T>): Promise<T> {
async run<T>(callback: () => Promise<T>): Promise<[true, T] | [false, null]> {
const handle = await this.acquire()
if (!handle) return [false, null]

try {
await this.acquire()
return await callback()
const result = await callback()
return [true, result]
} finally {
await this.release()
}
Expand All @@ -134,12 +138,15 @@ export class Lock {
* Same as `run` but try to acquire the lock immediately
* Or throw an error if the lock is already acquired
*/
async runImmediately<T>(callback: () => Promise<T>): Promise<T> {
async runImmediately<T>(callback: () => Promise<T>): Promise<[true, T] | [false, null]> {
const handle = await this.acquireImmediately()
if (!handle) return [false, null]

try {
await this.acquireImmediately()
return await callback()
const result = await callback()
return [true, result]
} finally {
await this.release()
if (handle) await this.release()
}
}

Expand Down
21 changes: 11 additions & 10 deletions packages/verrou/src/test_suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type { Group } from '@japa/runner/core'
import type { test as JapaTest } from '@japa/runner'
import { setTimeout as sleep } from 'node:timers/promises'

import { E_LOCK_NOT_OWNED } from '../index.js'
import { LockFactory } from './lock_factory.js'
import type { LockStore } from './types/main.js'
import { E_LOCK_NOT_OWNED, E_LOCK_TIMEOUT } from '../index.js'

export function registerStoreTestSuite(options: {
test: typeof JapaTest
Expand Down Expand Up @@ -73,32 +73,33 @@ export function registerStoreTestSuite(options: {
// @ts-expect-error poppinss/utils typing bug
}).throws(E_LOCK_NOT_OWNED.message, E_LOCK_NOT_OWNED)

test('throws timeout error when lock is not acquired in time', async () => {
test('acquire returns false when lock is not acquired in time', async ({ assert }) => {
const provider = new LockFactory(options.createStore(), {
retry: { timeout: 500 },
})
const lock = provider.createLock('foo')

await lock.acquire()

await lock.acquire()
// @ts-expect-error poppinss/utils typing bug
}).throws(E_LOCK_TIMEOUT.message, E_LOCK_TIMEOUT)
const handle = await lock.acquire()
assert.isFalse(handle)
})

test('run passes result', async ({ assert }) => {
const provider = new LockFactory(options.createStore())
const lock = provider.createLock('foo')

const result = await lock.run(async () => 'hello world')
const [executed, result] = await lock.run(async () => 'hello world')

assert.equal(result, 'hello world')
assert.deepEqual(executed, true)
assert.deepEqual(result, 'hello world')
})

test('run passes result from a promise', async ({ assert }) => {
const provider = new LockFactory(options.createStore())
const lock = provider.createLock('foo')

const result = await lock.run(async () => Promise.resolve('hello world'))
const [, result] = await lock.run(async () => Promise.resolve('hello world'))

assert.equal(result, 'hello world')
})
Expand Down Expand Up @@ -139,13 +140,13 @@ export function registerStoreTestSuite(options: {

assert.isFalse(flag)

const result = await lock.run(async () => {
const [, result] = await lock.run(async () => {
assert.isTrue(flag)
return '42'
})

assert.isTrue(flag)
assert.equal(result, '42')
assert.deepEqual(result, '42')
})

test('exceptions during run do not leave mutex in locked state', async ({ assert }) => {
Expand Down
Loading
Loading