UNPKG

syncguard

Version:

Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.

199 lines (198 loc) 8.71 kB
/** * AsyncDisposable support for automatic lock cleanup with `await using` syntax. * * Provides RAII (Resource Acquisition Is Initialization) pattern for locks, * ensuring cleanup on all code paths including early returns and exceptions. * * ## Default Error Handling * * **NEW**: Disposal errors are now observable by default to prevent silent failures. * - Development (NODE_ENV !== 'production'): Logs to console.error * - Production: Silent unless SYNCGUARD_DEBUG=true * - Security: Omits sensitive data (key, lockId) from default logs * * **Production Best Practice**: Override with custom callback integrated with your * logging/metrics infrastructure: * * ```typescript * const backend = createRedisBackend(redis, { * onReleaseError: (err, ctx) => { * logger.error('Lock disposal failed', { err, ...ctx }); * metrics.increment('syncguard.disposal.error'); * }, * }); * ``` * * ## Configuration Patterns * * There are two independent ways to configure error callbacks, serving different APIs: * * ### Pattern A: Backend-level (for low-level `await using` API) * Configure once at backend creation for all acquisitions: * ```typescript * const backend = createRedisBackend(redis, { * onReleaseError: (err, ctx) => logger.error("Disposal error", err, ctx), * disposeTimeoutMs: 5000 // Optional: timeout disposal after 5s * }); * * await using lock = await backend.acquire({ key, ttlMs }); * // Disposal errors automatically route to backend's onReleaseError * ``` * * ### Pattern B: Lock-level (for high-level `lock()` helper) * Configure per-call for fine-grained control: * ```typescript * const backend = createRedisBackend(redis); // Uses default callback * * await lock(backend, { * key, * onReleaseError: (err, ctx) => logger.error("Lock error", err, ctx), * async fn(handle) { ... } * }); * ``` * * **Note**: These are independent configurations for different usage patterns. * Choose the pattern that matches your API usage - you typically won't mix them. * * ## Disposal Timeout Behavior * * When `disposeTimeoutMs` is configured, Symbol.asyncDispose races the release operation * against a hard ceiling timer. If timeout elapses before release completes: * - AbortSignal is triggered to signal cancellation to the backend * - Disposal returns immediately (Symbol.asyncDispose never blocks) * - Backend-specific behavior depends on signal handling: * - Redis/PostgreSQL: Network socket timeouts provide additional safety * - Firestore: Note that AbortSignal cannot interrupt in-flight gRPC calls; timeout * signals cancellation intent but may not stop the RPC. The in-flight call may * continue in the background. Timeout errors are routed to onReleaseError for observability. * * **Important**: disposeTimeoutMs bounds the async disposal wait time, not the actual * backend cleanup. Use for responsiveness guarantees in high-reliability contexts. * * @see docs/specs/interface.md#resource-management - Normative specification * @see docs/adr/015-async-raii-locks.md - Async RAII for Locks * @see docs/adr/016-disposal-timeout.md - Opt-In Disposal Timeout */ import type { AcquireOk, AcquireResult, BackendCapabilities, DecoratedAcquireResult, ExtendResult, KeyOp, LockBackend, OnReleaseError, ReleaseResult } from "./types.js"; export type { OnReleaseError } from "./types.js"; /** * Lock handle with resource management methods. * Extends acquire result with release/extend operations and async disposal. */ export interface DisposableLockHandle { /** * Manually release the lock. Idempotent - safe to call multiple times. * Returns { ok: false } if lock was already released or absent. * * **Error handling**: Throws on system errors (network failures, auth errors) * for consistency with backend API. Only automatic disposal (via `await using`) * swallows errors and routes them to onReleaseError callback. * * @param signal Optional AbortSignal to cancel the release operation * @throws Error on system failures (network timeouts, service unavailable) */ release(signal?: AbortSignal): Promise<ReleaseResult>; /** * Extend lock TTL. Returns { ok: false } if lock was already released or absent. * @param ttlMs New TTL in milliseconds (resets expiration to now + ttlMs) * @param signal Optional AbortSignal to cancel the extend operation * @throws Error on system failures (network timeouts, service unavailable) */ extend(ttlMs: number, signal?: AbortSignal): Promise<ExtendResult>; /** * Automatic cleanup on scope exit (used by `await using` syntax). * Never throws - errors are swallowed and optionally routed to onReleaseError callback. */ [Symbol.asyncDispose](): Promise<void>; } /** * Successful acquisition with automatic cleanup support. * Use with `await using` for automatic lock release on scope exit. * * @example * ```typescript * await using lock = await backend.acquire({ key, ttlMs: 15_000 }); * if (!lock.ok) throw new Error("Failed to acquire lock"); * // TypeScript narrows lock to AsyncLock<C> after ok check * await doWork(lock.fence); * // Lock automatically released on scope exit * ``` */ export type AsyncLock<C extends BackendCapabilities> = AcquireOk<C> & DisposableLockHandle; /** * Creates a disposable lock handle from a successful acquisition. * Internal utility - use decorateAcquireResult() for public API. * * @param backend Backend operations (release, extend) * @param result Successful acquisition result * @param key Original normalized key for error context * @param onReleaseError Error callback for disposal failures (defaults to defaultDisposalErrorHandler) * @param disposeTimeoutMs Optional timeout for disposal operations in ms * @returns AsyncLock with disposal support */ export declare function createDisposableHandle<C extends BackendCapabilities>(backend: Pick<LockBackend<C>, "release" | "extend">, result: AcquireOk<C>, key: string, onReleaseError?: OnReleaseError, disposeTimeoutMs?: number): AsyncLock<C>; /** * Decorates an acquire result with async disposal support. * This is the main integration point for backends. * * - If acquisition succeeded (ok: true): Returns AsyncLock with disposal methods * - If acquisition failed (ok: false): Returns result with no-op disposal * * **Error Handling**: Disposal errors are routed to the onReleaseError callback. * If not provided, uses defaultDisposalErrorHandler which logs in development * and is silent in production (unless SYNCGUARD_DEBUG=true). * * @param backend Backend instance for release/extend operations * @param result Raw acquisition result from backend * @param key Original normalized key for error context * @param onReleaseError Callback for disposal errors (defaults to defaultDisposalErrorHandler) * @param disposeTimeoutMs Optional timeout for disposal operations in ms * @returns Decorated result with disposal support * * @example * ```typescript * // In backend implementation: * const backend = { * acquire: async (opts) => { * const normalizedKey = normalizeAndValidateKey(opts.key); * const result = await acquireCore(opts); * return decorateAcquireResult( * backend, * result, * normalizedKey, * config.onReleaseError, // Pass through user config or use default * config.disposeTimeoutMs * ); * }, * // ... * }; * ``` */ export declare function decorateAcquireResult<C extends BackendCapabilities>(backend: Pick<LockBackend<C>, "release" | "extend">, result: AcquireResult<C>, key: string, onReleaseError?: OnReleaseError, disposeTimeoutMs?: number): DecoratedAcquireResult<C>; /** * Optional sugar for fully-typed RAII without manual type narrowing. * Calls backend.acquire() and returns typed handle or failure. * * @param backend Backend instance * @param opts Acquisition options * @returns AsyncLock or failure - no type narrowing needed * * @example * ```typescript * // Option A: Standard (uses TypeScript's built-in narrowing) * await using lock = await backend.acquire({ key, ttlMs }); * if (!lock.ok) return; * await lock.extend(5000); // TypeScript knows this is AsyncLock after ok check * * // Option B: Sugar (same narrowing, different API) * await using lock = await acquireHandle(backend, { key, ttlMs }); * if (!lock.ok) return; * await lock.extend(5000); // Same narrowing behavior * ``` */ export declare function acquireHandle<C extends BackendCapabilities>(backend: LockBackend<C>, opts: KeyOp & { ttlMs: number; }): Promise<AsyncLock<C> | { ok: false; reason: "locked"; }>;