Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { RedisDistributedLock } from "./redis-lock.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# redis-lock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RedisDistributedLock` implements `MigrationLockAdapter` using Redis `SET NX PX` semantics. This provides a distributed advisory lock suitable for multi-instance deployments where a shared Redis server is available. The implementation requires only a minimal Redis client interface (`RedisLockClient`) with `set` and `del` methods, avoiding any hard dependency on a specific Redis client library.
|
||||
|
||||
## Features
|
||||
|
||||
- `SET NX PX` lock acquisition — atomic test-and-set with TTL expiry.
|
||||
- Exponential back-off polling from 50 ms up to 1 s ceiling.
|
||||
- Configurable `key`, `ttl`, and acquisition `timeoutMs`.
|
||||
- `isLocked()` probe using a disposable key to avoid reading lock state directly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RedisDistributedLock } from "@suaveplan/migration";
|
||||
import { createClient } from "redis";
|
||||
|
||||
const redisClient = createClient();
|
||||
await redisClient.connect();
|
||||
|
||||
const lock = new RedisDistributedLock({
|
||||
client: redisClient,
|
||||
key: "app_migration_lock",
|
||||
ttl: 30_000,
|
||||
});
|
||||
|
||||
const acquired = await lock.acquire(10_000);
|
||||
if (acquired) {
|
||||
try { /* run migrations */ } finally { await lock.release(); }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RedisDistributedLock } from "@suaveplan/migration";
|
||||
|
||||
// Inject a stub Redis client in tests
|
||||
const stubClient = {
|
||||
async set() { return "OK"; },
|
||||
async del() { return 1; },
|
||||
};
|
||||
|
||||
const lock = new RedisDistributedLock({ client: stubClient });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RedisDistributedLock implements MigrationLockAdapter`
|
||||
|
||||
**Constructor**: `(config: RedisLockConfig)`
|
||||
|
||||
- `config.client` — Redis-compatible client with `set(key, value, opts)` and `del(key)` methods.
|
||||
- `config.key?` — lock key (default `"migration_lock"`).
|
||||
- `config.ttl?` — lock TTL in ms (default `60_000`).
|
||||
|
||||
- `acquire(timeoutMs?): Promise<boolean>` — polls until acquired or timeout.
|
||||
- `release(): Promise<void>` — deletes the lock key.
|
||||
- `isLocked(): Promise<boolean>` — uses a probe key to check lock state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `isLocked` probe key (`<key>__probe`) uses a 1 ms TTL and is immediately deleted if acquired, minimising leakage. This approach avoids a Redis `GET` which the minimal `RedisLockClient` interface does not expose.
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { RedisLockClient } from "../../types/migration.types.js";
|
||||
import { RedisDistributedLock } from "./redis-lock.js";
|
||||
|
||||
/** Fake Redis client that simulates SET NX PX behavior. */
|
||||
class FakeRedisClient implements RedisLockClient {
|
||||
protected readonly store: Map<
|
||||
string,
|
||||
{ value: string; expiresAt: number }
|
||||
> = new Map();
|
||||
|
||||
async set(
|
||||
key: string,
|
||||
value: string,
|
||||
options: { NX?: boolean; PX?: number }
|
||||
): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const existing = this.store.get(key);
|
||||
if (options.NX && existing && existing.expiresAt > now) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = now + (options.PX ?? 60_000);
|
||||
this.store.set(key, { value, expiresAt });
|
||||
return "OK";
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
const existed = this.store.has(key);
|
||||
this.store.delete(key);
|
||||
return existed ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
describe("RedisDistributedLock", () => {
|
||||
let client: FakeRedisClient;
|
||||
let lock: RedisDistributedLock;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeRedisClient();
|
||||
lock = new RedisDistributedLock({
|
||||
client,
|
||||
key: "test_lock",
|
||||
ttl: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires a free lock", { timeout: 5000 }, async () => {
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("times out when lock is held", { timeout: 5000 }, async () => {
|
||||
await client.set("test_lock", "1", { NX: true, PX: 60_000 });
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the lock", { timeout: 5000 }, async () => {
|
||||
await lock.acquire(100);
|
||||
await lock.release();
|
||||
const result = await lock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("isLocked returns false when free", { timeout: 5000 }, async () => {
|
||||
const locked = await lock.isLocked();
|
||||
expect(locked).toBe(false);
|
||||
});
|
||||
|
||||
it("isLocked returns true when locked", { timeout: 5000 }, async () => {
|
||||
// Hold the probe key to simulate a locked state
|
||||
await client.set("test_lock__probe", "1", { NX: true, PX: 60_000 });
|
||||
const locked = await lock.isLocked();
|
||||
expect(locked).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default key and ttl", { timeout: 5000 }, async () => {
|
||||
const defaultLock = new RedisDistributedLock({ client });
|
||||
const result = await defaultLock.acquire(100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("retries after initial null and eventually acquires", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let callCount = 0;
|
||||
const retryClient: RedisLockClient = {
|
||||
async set(_key, _value, _opts): Promise<string | null> {
|
||||
callCount++;
|
||||
if (callCount === 1) return null; // First try fails
|
||||
return "OK"; // Second try succeeds
|
||||
},
|
||||
async del(): Promise<number> {
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
const retryLock = new RedisDistributedLock({
|
||||
client: retryClient,
|
||||
key: "retry_key",
|
||||
ttl: 1000,
|
||||
});
|
||||
const result = await retryLock.acquire(2000);
|
||||
expect(result).toBe(true);
|
||||
expect(callCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("sleep uses remaining time when delay exceeds deadline", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Use a very short timeout (30ms) so delay (50ms) > remaining time → Math.min uses remaining
|
||||
const neverAcquires: RedisLockClient = {
|
||||
async set(): Promise<string | null> {
|
||||
return null;
|
||||
},
|
||||
async del(): Promise<number> {
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
const shortLock = new RedisDistributedLock({
|
||||
client: neverAcquires,
|
||||
key: "short",
|
||||
ttl: 1000,
|
||||
});
|
||||
const result = await shortLock.acquire(30);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("acquire uses default timeout when called without argument", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Free lock — acquires immediately using the default timeout (30s)
|
||||
const result = await lock.acquire();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
DEFAULT_LOCK_KEY,
|
||||
DEFAULT_LOCK_TIMEOUT_MS,
|
||||
DEFAULT_LOCK_TTL_MS,
|
||||
} from "../../constants/index.js";
|
||||
import type {
|
||||
MigrationLockAdapter,
|
||||
RedisLockClient,
|
||||
RedisLockConfig,
|
||||
} from "../../types/migration.types.js";
|
||||
|
||||
const LOCK_VALUE = "1";
|
||||
|
||||
/**
|
||||
* Redis-backed advisory lock using `SET NX PX` semantics. Suitable for
|
||||
* multi-instance deployments where a shared Redis instance is available.
|
||||
*/
|
||||
export class RedisDistributedLock implements MigrationLockAdapter {
|
||||
protected readonly client: RedisLockClient;
|
||||
protected readonly key: string;
|
||||
protected readonly ttl: number;
|
||||
|
||||
constructor(config: RedisLockConfig) {
|
||||
this.client = config.client;
|
||||
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 result = await this.client.set(this.key, LOCK_VALUE, {
|
||||
NX: true,
|
||||
PX: this.ttl,
|
||||
});
|
||||
if (result !== null) return true;
|
||||
await sleep(Math.min(delay, deadline - Date.now()));
|
||||
delay = Math.min(delay * 2, 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async release(): Promise<void> {
|
||||
await this.client.del(this.key);
|
||||
}
|
||||
|
||||
async isLocked(): Promise<boolean> {
|
||||
// Attempt a probe set with minimal TTL — if it fails, lock is held
|
||||
const result = await this.client.set(`${this.key}__probe`, LOCK_VALUE, {
|
||||
NX: true,
|
||||
PX: 1,
|
||||
});
|
||||
if (result !== null) {
|
||||
await this.client.del(`${this.key}__probe`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||||
}
|
||||
Reference in New Issue
Block a user