Initial Commit
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# distributed-lock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a `DistributedMigrationLock` that coordinates migration locking across multiple application instances using a pluggable `DistributedLockAdapter`. Also exports `InMemoryDistributedLockAdapter` for testing and single-node use. The distributed lock uses the same exponential back-off polling strategy as the memory lock, delegating actual lock storage to the adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- `DistributedMigrationLock` — delegates to any `DistributedLockAdapter` with configurable `key` and `ttl`.
|
||||
- `InMemoryDistributedLockAdapter` — TTL-based in-memory adapter for testing without external infrastructure.
|
||||
- Exponential back-off polling from 50 ms up to 1 s ceiling.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DistributedMigrationLock, InMemoryDistributedLockAdapter } from "@suaveplan/migration";
|
||||
|
||||
const lock = new DistributedMigrationLock({
|
||||
adapter: new InMemoryDistributedLockAdapter(),
|
||||
key: "app_migration_lock",
|
||||
ttl: 60_000,
|
||||
});
|
||||
|
||||
const acquired = await lock.acquire(30_000);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DistributedMigrationLock } from "@suaveplan/migration";
|
||||
import type { DistributedLockAdapter } from "@suaveplan/migration";
|
||||
|
||||
class PgAdvisoryLockAdapter implements DistributedLockAdapter {
|
||||
async acquire(key: string, ttl: number) { /* pg_try_advisory_lock */ return true; }
|
||||
async release(key: string) { /* pg_advisory_unlock */ }
|
||||
}
|
||||
|
||||
const lock = new DistributedMigrationLock({ adapter: new PgAdvisoryLockAdapter() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DistributedMigrationLock implements MigrationLockAdapter`
|
||||
|
||||
**Constructor**: `(config: DistributedLockConfig)`
|
||||
|
||||
- `config.adapter` — `DistributedLockAdapter` implementation.
|
||||
- `config.key?` — lock key (default `"migration_lock"`).
|
||||
- `config.ttl?` — lock TTL in ms (default `60_000`).
|
||||
|
||||
### `class InMemoryDistributedLockAdapter implements DistributedLockAdapter`
|
||||
|
||||
- `acquire(key, ttl): Promise<boolean>` — acquires if no unexpired entry for `key` exists.
|
||||
- `release(key): Promise<void>` — removes the entry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isLocked` uses a probe key (`<key>__probe`) with zero TTL to test whether anything is competing for the lock — this avoids reading lock state directly, which the interface does not expose.
|
||||
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DistributedMigrationLock,
|
||||
InMemoryDistributedLockAdapter,
|
||||
} from "./distributed-lock.js";
|
||||
|
||||
describe("InMemoryDistributedLockAdapter", () => {
|
||||
let adapter: InMemoryDistributedLockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InMemoryDistributedLockAdapter();
|
||||
});
|
||||
|
||||
it("acquires a free lock", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.acquire("key1", 5000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a held lock", { timeout: 5000 }, async () => {
|
||||
await adapter.acquire("key1", 5000);
|
||||
const result = await adapter.acquire("key1", 5000);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("acquires after release", { timeout: 5000 }, async () => {
|
||||
await adapter.acquire("key1", 5000);
|
||||
await adapter.release("key1");
|
||||
const result = await adapter.acquire("key1", 5000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("different keys are independent", { timeout: 5000 }, async () => {
|
||||
await adapter.acquire("key1", 5000);
|
||||
const result = await adapter.acquire("key2", 5000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("expired lock can be re-acquired", { timeout: 5000 }, async () => {
|
||||
await adapter.acquire("key1", 1); // 1ms TTL
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const result = await adapter.acquire("key1", 5000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DistributedMigrationLock", () => {
|
||||
let adapter: InMemoryDistributedLockAdapter;
|
||||
let lock: DistributedMigrationLock;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InMemoryDistributedLockAdapter();
|
||||
lock = new DistributedMigrationLock({
|
||||
adapter,
|
||||
key: "test_lock",
|
||||
ttl: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires when free", { timeout: 5000 }, async () => {
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("releases lock", { timeout: 5000 }, async () => {
|
||||
await lock.acquire(100);
|
||||
await lock.release();
|
||||
// Can re-acquire
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("times out when locked", { timeout: 5000 }, async () => {
|
||||
await adapter.acquire("test_lock", 60_000);
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("isLocked returns false when free", { timeout: 5000 }, async () => {
|
||||
const locked = await lock.isLocked();
|
||||
expect(locked).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default key and ttl", { timeout: 5000 }, async () => {
|
||||
const defaultLock = new DistributedMigrationLock({ adapter });
|
||||
const result = await defaultLock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("isLocked returns true when lock is held", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Hold both the main key and probe key to force isLocked true
|
||||
await adapter.acquire("test_lock", 5000);
|
||||
await adapter.acquire("test_lock__probe", 5000);
|
||||
const locked = await lock.isLocked();
|
||||
expect(locked).toBe(true);
|
||||
});
|
||||
|
||||
it("sleep uses remaining time when delay exceeds deadline", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Lock held forever, very short timeout so delay (50ms) > remaining time
|
||||
await adapter.acquire("test_lock", 60_000);
|
||||
const result = await lock.acquire(30);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("acquire uses default timeout when called without argument", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Free lock — should acquire using the default timeout (30s) without waiting
|
||||
const acquired = await lock.acquire();
|
||||
expect(acquired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
DEFAULT_LOCK_KEY,
|
||||
DEFAULT_LOCK_TIMEOUT_MS,
|
||||
DEFAULT_LOCK_TTL_MS,
|
||||
} from "../../constants/index.js";
|
||||
import type {
|
||||
DistributedLockAdapter,
|
||||
DistributedLockConfig,
|
||||
MigrationLockAdapter,
|
||||
} from "../../types/migration.types.js";
|
||||
|
||||
/**
|
||||
* In-memory implementation of the distributed lock adapter interface.
|
||||
* Suitable for testing without a real Redis or database backend.
|
||||
*/
|
||||
export class InMemoryDistributedLockAdapter implements DistributedLockAdapter {
|
||||
protected readonly locks: Map<string, number> = new Map();
|
||||
|
||||
async acquire(key: string, ttl: number): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const existing = this.locks.get(key);
|
||||
if (existing !== undefined && existing > now) {
|
||||
return false;
|
||||
}
|
||||
this.locks.set(key, now + ttl);
|
||||
return true;
|
||||
}
|
||||
|
||||
async release(key: string): Promise<void> {
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributed advisory lock that delegates acquisition and release to a
|
||||
* `DistributedLockAdapter`. Supports timeout-based retry.
|
||||
*/
|
||||
export class DistributedMigrationLock implements MigrationLockAdapter {
|
||||
protected readonly adapter: DistributedLockAdapter;
|
||||
protected readonly key: string;
|
||||
protected readonly ttl: number;
|
||||
|
||||
constructor(config: DistributedLockConfig) {
|
||||
this.adapter = config.adapter;
|
||||
this.key = config.key ?? DEFAULT_LOCK_KEY;
|
||||
this.ttl = config.ttl ?? DEFAULT_LOCK_TTL_MS;
|
||||
}
|
||||
|
||||
async acquire(timeoutMs = DEFAULT_LOCK_TIMEOUT_MS): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let delay = 50;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const acquired = await this.adapter.acquire(this.key, this.ttl);
|
||||
if (acquired) return true;
|
||||
await sleep(Math.min(delay, deadline - Date.now()));
|
||||
delay = Math.min(delay * 2, 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async release(): Promise<void> {
|
||||
await this.adapter.release(this.key);
|
||||
}
|
||||
|
||||
async isLocked(): Promise<boolean> {
|
||||
// Try to acquire with zero ttl — if it fails, something holds the lock
|
||||
const canAcquire = await this.adapter.acquire(`${this.key}__probe`, 0);
|
||||
if (canAcquire) {
|
||||
await this.adapter.release(`${this.key}__probe`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DistributedMigrationLock,
|
||||
InMemoryDistributedLockAdapter,
|
||||
} from "./distributed-lock.js";
|
||||
Reference in New Issue
Block a user