Skip to content

Commit

Permalink
feat: add timeout parameter to readLock and writeLock methods
Browse files Browse the repository at this point in the history
  • Loading branch information
izure1 committed Nov 23, 2024
1 parent 8b0e8e5 commit 0eb3d00
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 40 deletions.
57 changes: 45 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ await ryoiki.readLock([0, 10], async (_lockId) => {
}).finally(() => ryoiki.readUnlock(lockId)) // Always unlock
```

## Key Concepts
### Key Concepts

### 1. **Default Lock Range**
#### 1. **Default Lock Range**

- If the first parameter of `readLock` or `writeLock` is omitted, it defaults to `[-Infinity, Infinity]`.

Expand All @@ -50,15 +50,22 @@ await ryoiki.readLock([0, 10], async (_lockId) => {
}).finally(() => ryoiki.readUnlock(lockId))
```

### 2. **Lock Waiting Behavior**
#### 2. **Lock Waiting Behavior**

- **Read Lock**:
- Can execute immediately if overlapping with other read locks.
- Waits if overlapping with a write lock.
- **Write Lock**:
- Waits if overlapping with other read or write locks.

### 3. **Unlocking**
#### 3. **Timeout Behavior**

- Both `readLock` and `writeLock` now support an optional `timeout` parameter.
- **Timeout**: The lock request will wait for the specified time in milliseconds before throwing an error if the lock cannot be acquired.
- Defaults to `Infinity`, meaning it will wait indefinitely unless otherwise specified.
- If the lock cannot be acquired within the given `timeout` period, a timeout error is thrown.

#### 4. **Unlocking**

- Always use `finally` to release locks, even if an error occurs in the callback.
- Correct Usage:
Expand All @@ -71,21 +78,47 @@ await ryoiki.readLock([0, 10], async (_lockId) => {
}).finally(() => ryoiki.writeUnlock(lockId)) // Always unlock
```

#### 5. **Locks, Deadlocks, and Caution**

- **`readLock`** and **`writeLock`** are used to manage access to data by locking specific ranges.
- A **read lock** allows multiple readers but waits if a write lock exists.
- A **write lock** prevents both read and write locks in the same range, ensuring exclusive access.

- **Deadlock** occurs when two or more processes are unable to proceed because each is waiting for the other to release a lock.
In the context of `Ryoiki`, this could happen if:
- A `readLock` is waiting for a `writeLock` to release, and the `writeLock` is waiting for a `readLock` to release.

To prevent deadlocks:
- Ensure that locks are always released as soon as they are no longer needed.
- Use `timeout` to avoid waiting indefinitely.

- For a deeper understanding of these concepts, you can refer to these Wikipedia articles:
- [Read-Write Lock](https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock)
- [Deadlock](https://en.wikipedia.org/wiki/Deadlock)

## 📖 API

### `readLock(range?: [number, number], callback: (lockId: string) => Promise<T>): Promise<T>`
### `readLock(range?: [number, number], callback: (lockId: string) => Promise<T>, timeout?: number): Promise<T>`

- **Description**: Requests a read lock for the specified range.
- **Parameters**:
- `range`: Range to lock as `[start, end]`. Defaults to `[-Infinity, Infinity]`.
- `callback`: Async function executed after lock is acquired.
- `lockId`: Unique ID for the current lock.
- `timeout`: Optional timeout in milliseconds. If the lock cannot be acquired within the specified time, the operation will throw a timeout error.
- Defaults to `Infinity` (wait indefinitely).
- **Returns**: The result of the callback function.

### `writeLock(range?: [number, number], callback: (lockId: string) => Promise<T>): Promise<T>`
### `writeLock(range?: [number, number], callback: (lockId: string) => Promise<T>, timeout?: number): Promise<T>`

- **Description**: Requests a write lock for the specified range.
- **Notes**: Default behavior is the same as `readLock`.
- **Parameters**:
- `range`: Range to lock as `[start, end]`. Defaults to `[-Infinity, Infinity]`.
- `callback`: Async function executed after lock is acquired.
- `lockId`: Unique ID for the current lock.
- `timeout`: Optional timeout in milliseconds. If the lock cannot be acquired within the specified time, the operation will throw a timeout error.
- Defaults to `Infinity` (wait indefinitely).
- **Returns**: The result of the callback function.

### `readUnlock(lockId: string): void`

Expand All @@ -107,24 +140,24 @@ await ryoiki.readLock([0, 10], async (_lockId) => {

## 🌟 Examples

### Read and Write Operations
### Read and Write Operations with Timeout

```typescript
const ryoiki = new Ryoiki()

let lockId: string

// Read Lock
// Read Lock with timeout
await ryoiki.readLock([0, 10], async (_lockId) => {
lockId = _lockId
console.log('Reading from range [0, 10]')
}).finally(() => ryoiki.readUnlock(lockId))
}, 1000).finally(() => ryoiki.readUnlock(lockId)) // Always unlock

// Write Lock
// Write Lock with timeout
await ryoiki.writeLock([5, 15], async (_lockId) => {
lockId = _lockId
console.log('Writing to range [5, 15]')
}).finally(() => ryoiki.writeUnlock(lockId))
}, 1000).finally(() => ryoiki.writeUnlock(lockId)) // Always unlock
```

## 📜 License
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@izure/ryoiki",
"version": "1.0.0",
"version": "1.1.0",
"description": "A range-based locking library for JavaScript, enabling concurrent read locks and exclusive write locks with seamless management of overlapping ranges.",
"author": "izure1 <[email protected]>",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ryoiki",
"version": "1.0.0",
"version": "1.1.0",
"description": "A range-based locking library for JavaScript, enabling concurrent read locks and exclusive write locks with seamless management of overlapping ranges.",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
Expand Down
128 changes: 102 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class Ryoiki {
return new Error(`The '${lockId}' task not existing in task queue.`)
}

protected static ERR_TIMEOUT(lockId: string, timeout: number): Error {
return new Error(`The task with ID '${lockId}' failed to acquire the lock within the timeout(${timeout}ms).`)
}

/**
* Constructs a new instance of the Ryoiki class.
*/
Expand Down Expand Up @@ -97,6 +101,30 @@ export class Ryoiki {
}
}

private _handleOverload<T>(
args: any[],
handlers: Record<string, (...parsedArgs: any[]) => T>,
argPatterns: Record<string, any[]>
): T {
for (const [key, pattern] of Object.entries(argPatterns)) {
if (this._matchArgs(args, pattern)) {
return handlers[key](...args)
}
}
throw new Error('Invalid arguments')
}

private _matchArgs(args: any[], pattern: any[]): boolean {
return args.every((arg, index) => {
const expectedType = pattern[index]
if (expectedType === undefined) return typeof arg === 'undefined'
if (expectedType === Function) return typeof arg === 'function'
if (expectedType === Number) return typeof arg === 'number'
if (expectedType === Array) return Array.isArray(arg)
return false
})
}

private _createRandomId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2)
Expand Down Expand Up @@ -125,12 +153,22 @@ export class Ryoiki {
private _lock<T>(
queue: TaskUnits,
range: RyoikiRange,
timeout: number,
task: TaskCallback<T>,
condition: TaskUnit['condition']
): Promise<T> {
return new Promise((resolve, reject) => {
let timeoutId: any = null
if (timeout >= 0) {
timeoutId = setTimeout(() => {
reject(Ryoiki.ERR_TIMEOUT(id, timeout))
}, timeout)
}
const id = this._createRandomId()
const alloc = async () => {
if (timeoutId !== null) {
clearTimeout(timeoutId)
}
const [err, v] = await Ryoiki.CatchError<T>(task(id))
if (err) reject(err)
else resolve(v)
Expand All @@ -148,41 +186,60 @@ export class Ryoiki {
* Acquires a read lock for the entire range.
* @template T - The return type of the task.
* @param task - The task to execute within the lock.
* @param timeout - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* The task receives the lock ID as an argument.
* @returns A promise resolving to the result of the task execution.
*/
readLock<T>(task: TaskCallback<T>): Promise<T>
readLock<T>(task: TaskCallback<T>, timeout?: number|undefined): Promise<T>
/**
* Acquires a read lock for a specific range.
* @template T - The return type of the task.
* @param range - The range to lock, specified as a tuple [start, end].
* @param task - The task to execute within the lock.
* @param timeout - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* The task receives the lock ID as an argument.
* @returns A promise resolving to the result of the task execution.
*/
readLock<T>(range: RyoikiRange, task: TaskCallback<T>): Promise<T>
readLock<T>(range: RyoikiRange, task: TaskCallback<T>, timeout?: number|undefined): Promise<T>
/**
* Internal implementation of the read lock. Handles both overloads.
* @template T - The return type of the task.
* @param arg0 - Either a range or a task callback.
* If a range is provided, the task is the second argument.
* @param arg1 - The task to execute, required if a range is provided.
* @param arg2 - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* @returns A promise resolving to the result of the task execution.
*/
readLock<T>(arg0: RyoikiRange|TaskCallback<T>, arg1?: TaskCallback<T>): Promise<T> {
let range: RyoikiRange
let task: TaskCallback<T>
if (arg1) {
range = arg0 as RyoikiRange
task = arg1
}
else {
range = [-Infinity, Infinity]
task = arg0 as TaskCallback<T>
}
readLock<T>(
arg0: RyoikiRange|TaskCallback<T>,
arg1?: TaskCallback<T>|number|undefined,
arg2?: number|undefined
): Promise<T> {
const [range, task, timeout] = this._handleOverload(
[arg0, arg1, arg2],
{
rangeTask: (range, task) => [range, task, -1],
rangeTaskTimeout: (range, task, timeout) => [range, task, timeout],
task: (task) => [[-Infinity, Infinity], task, -1],
taskTimeout: (task, timeout) => [[-Infinity, Infinity], task, timeout],
},
{
task: [Function],
taskTimeout: [Function, Number],
rangeTask: [Array, Function],
rangeTaskTimeout: [Array, Function, Number],
}
)
return this._lock(
this.readQueue,
range,
timeout,
task,
() => !this.rangeOverlapping(this.writings, range)
)
Expand All @@ -192,41 +249,60 @@ export class Ryoiki {
* Acquires a write lock for the entire range.
* @template T - The return type of the task.
* @param task - The task to execute within the lock.
* @param timeout - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* The task receives the lock ID as an argument.
* @returns A promise resolving to the result of the task execution.
*/
writeLock<T>(task: TaskCallback<T>): Promise<T>
writeLock<T>(task: TaskCallback<T>, timeout?: number|undefined): Promise<T>
/**
* Acquires a write lock for a specific range.
* @template T - The return type of the task.
* @param range - The range to lock, specified as a tuple [start, end].
* @param task - The task to execute within the lock.
* @param timeout - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* The task receives the lock ID as an argument.
* @returns A promise resolving to the result of the task execution.
*/
writeLock<T>(range: RyoikiRange, task: TaskCallback<T>): Promise<T>
writeLock<T>(range: RyoikiRange, task: TaskCallback<T>, timeout?: number|undefined): Promise<T>
/**
* Internal implementation of the write lock. Handles both overloads.
* @template T - The return type of the task.
* @param arg0 - Either a range or a task callback.
* If a range is provided, the task is the second argument.
* @param arg1 - The task to execute, required if a range is provided.
* @param arg2 - The timeout for acquiring the lock.
* If the lock cannot be acquired within this period, an error will be thrown.
* If this value is not provided, no timeout will be set.
* @returns A promise resolving to the result of the task execution.
*/
writeLock<T>(arg0: RyoikiRange|TaskCallback<T>, arg1?: TaskCallback<T>): Promise<T> {
let range: RyoikiRange
let task: TaskCallback<T>
if (arg1) {
range = arg0 as RyoikiRange
task = arg1
}
else {
range = [-Infinity, Infinity]
task = arg0 as TaskCallback<T>
}
writeLock<T>(
arg0: RyoikiRange|TaskCallback<T>,
arg1?: TaskCallback<T>|number|undefined,
arg2?: number|undefined
): Promise<T> {
const [range, task, timeout] = this._handleOverload(
[arg0, arg1, arg2],
{
rangeTask: (range, task) => [range, task, -1],
rangeTaskTimeout: (range, task, timeout) => [range, task, timeout],
task: (task) => [[-Infinity, Infinity], task, -1],
taskTimeout: (task, timeout) => [[-Infinity, Infinity], task, timeout],
},
{
task: [Function],
taskTimeout: [Function, Number],
rangeTask: [Array, Function],
rangeTaskTimeout: [Array, Function, Number],
}
)
return this._lock(
this.writeQueue,
range,
timeout,
task,
() => {
return (
Expand Down
44 changes: 44 additions & 0 deletions test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,48 @@ describe('Ryoiki', () => {
expect(b).resolves.toEqual([1, 2]),
])
})

test('timeout', async () => {
const { ryoiki, sample, read, write } = create(10)

let lockA: string
const a = ryoiki.readLock(ryoiki.range(0, 2), async (_lockId) => {
lockA = _lockId
await delay(1000)
return read(0, 2)
}).finally(() => ryoiki.readUnlock(lockA))

let lockB: string
const b = ryoiki.readLock(ryoiki.range(1, 2), async (_lockId) => {
lockB = _lockId
return read(1, 3)
}, 500).finally(() => ryoiki.readUnlock(lockB))

await Promise.all([
expect(a).resolves.toEqual([0, 1]),
expect(b).resolves.toEqual([1, 2]),
])

let lockC: string
const c = ryoiki.writeLock(ryoiki.range(0, 2), async (_lockId) => {
lockC = _lockId
await write(0, [1, 2])
await delay(1000)
return sample.slice(0, 2)
}).finally(() => ryoiki.writeUnlock(lockC))

let lockD: string
const d = ryoiki.writeLock(ryoiki.range(1, 2), async (_lockId) => {
lockD = _lockId
await write(1, [3, 4])
return sample.slice(1, 3)
}, 500).finally(() => ryoiki.writeUnlock(lockD))

await Promise.all([
expect(c).resolves.toEqual([1, 2]),
expect(d).rejects.toThrow(),
])

expect(sample.slice(1, 3)).toEqual([2, 2])
})
})

0 comments on commit 0eb3d00

Please sign in to comment.