Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { RedisDistributedLock } from "./redis-lock.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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)));
}