first commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# `distributed-guard`
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides distributed idempotency protection via a pluggable `LockProvider` that serializes concurrent access across multiple processes or nodes before consulting the idempotency store. The `DistributedIdempotencyGuard` guarantees exactly-once execution even when multiple service instances race to handle the same idempotency key simultaneously, preventing double-charge or double-write scenarios.
|
||||
|
||||
## Features
|
||||
|
||||
- `DistributedIdempotencyGuard` — wraps any store + lock provider for multi-node safety
|
||||
- `MemoryLockProvider` — in-process lock map for unit tests and single-node deployments
|
||||
- Exponential back-off with jitter on lock contention
|
||||
- Automatic lock release in a `finally` block — never leaks locks on exception
|
||||
- Configurable retry count, base delay, and lock TTL
|
||||
- Fingerprint mismatch detection under the lock (same key, different payload)
|
||||
- `AbortSignal` support propagated through lock acquisition retries
|
||||
- Injectable `clock` for deterministic testing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DistributedIdempotencyGuard, MemoryLockProvider } from "@suaveplan/idempotency/distributed";
|
||||
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
|
||||
|
||||
const guard = new DistributedIdempotencyGuard({
|
||||
store: new MemoryIdempotencyStore(),
|
||||
lockProvider: new MemoryLockProvider(),
|
||||
ttlMs: 3_600_000,
|
||||
lockTtlMs: 5_000,
|
||||
});
|
||||
|
||||
const result = await guard.execute("payment-42", () => chargeCard());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DistributedIdempotencyGuard } from "@suaveplan/idempotency/distributed";
|
||||
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
|
||||
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
|
||||
import { LockAcquisitionError } from "@suaveplan/idempotency";
|
||||
|
||||
const guard = new DistributedIdempotencyGuard({
|
||||
store: new RedisIdempotencyStore({ client: redisAdapter }),
|
||||
lockProvider: new RedisLockProvider({ client: redisAdapter, ownerId: "worker-1" }),
|
||||
retryCount: 5,
|
||||
retryBaseDelayMs: 50,
|
||||
lockTtlMs: 10_000,
|
||||
});
|
||||
|
||||
try {
|
||||
await guard.execute("order-99", () => fulfillOrder(order), { input: order });
|
||||
} catch (err) {
|
||||
if (err instanceof LockAcquisitionError) {
|
||||
console.error(`Lock failed after ${err.attempts} attempts`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new DistributedIdempotencyGuard(config: DistributedGuardConfig)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
|
||||
| `lockProvider` | `LockProvider` | required | Distributed lock backend |
|
||||
| `ttlMs` | `number` | `86_400_000` | Record TTL (24 h) |
|
||||
| `lockTtlMs` | `number` | `10_000` | Lock TTL (10 s) |
|
||||
| `retryCount` | `number` | `3` | Max lock acquisition retries |
|
||||
| `retryBaseDelayMs` | `number` | `100` | Base back-off delay (ms) |
|
||||
| `fingerprintFn` | `fn` | `fingerprintPayload` | Payload hasher |
|
||||
| `clock` | `() => number` | `Date.now` | Monotonic clock |
|
||||
|
||||
### `execute<T>(key, fn, opts?): Promise<T>`
|
||||
|
||||
Acquires the distributed lock, checks the store, and executes `fn` if no cached result exists.
|
||||
|
||||
**Throws:** `LockAcquisitionError` | `IdempotencyMismatchError`
|
||||
|
||||
### `MemoryLockProvider`
|
||||
|
||||
In-memory lock map with TTL-based auto-expiration. Suitable for testing and single-process deployments.
|
||||
|
||||
**Methods:** `acquire(resource, ttlMs)`, `release(resource)`, `isLocked(resource)`, `clear()`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The lock key is prefixed `idempotency:lock:<key>` to avoid collisions with store keys.
|
||||
- Exponential back-off delay formula: `baseDelay * 2^attempt + random(0, baseDelay)`.
|
||||
- The lock is always released in `finally` — even when `fn` throws or the AbortSignal fires.
|
||||
- `MemoryLockProvider.clock` is injectable to enable deterministic TTL expiration in tests.
|
||||
Reference in New Issue
Block a user