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.
319 lines (318 loc) • 14.3 kB
JavaScript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
// ============================================================================
// Default Error Handler
// ============================================================================
/**
* Default error handler for disposal failures.
* Provides safe-by-default observability without requiring user configuration.
*
* **Behavior**:
* - Development (NODE_ENV !== 'production'): Logs all disposal errors to console.error
* - Production: Silent by default (unless SYNCGUARD_DEBUG=true environment variable is set)
* - Security: Omits sensitive context (key, lockId) from logs by default
*
* **Important Note**:
* - This default handler is ONLY used when no custom `onReleaseError` is provided
* - If you provide a custom callback, it will ALWAYS be invoked (regardless of environment)
* - The silence behavior only applies to the built-in default handler
*
* **Production Usage**:
* For production systems, strongly recommended to provide a custom onReleaseError callback
* that integrates with your logging/metrics infrastructure:
*
* ```typescript
* const backend = createRedisBackend(redis, {
* onReleaseError: (err, ctx) => {
* logger.error('Lock disposal failed', {
* error: err.message,
* source: ctx.source,
* key: ctx.key,
* lockId: ctx.lockId,
* });
* metrics.increment('syncguard.disposal.error', { source: ctx.source });
* },
* });
* ```
*
* @see docs/specs/interface.md#error-handling - Error handling best practices
* @see docs/adr/015-async-raii-locks.md - Disposal error semantics
*/
const defaultDisposalErrorHandler = (err, ctx) => {
// Only log in development or when explicitly enabled via env var
const shouldLog = process.env.NODE_ENV !== "production" ||
process.env.SYNCGUARD_DEBUG === "true";
if (shouldLog) {
console.error("[SyncGuard] Lock disposal failed:", {
error: err.message,
errorName: err.name,
source: ctx.source,
// Omit key and lockId to avoid leaking sensitive data in logs
// Users should provide custom callback for full context
});
}
};
// ============================================================================
// Core Factory
// ============================================================================
/**
* 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 function createDisposableHandle(backend, result, key, onReleaseError = defaultDisposalErrorHandler, disposeTimeoutMs) {
let state = "active";
let disposePromise = null;
// Track in-flight release() so dispose() can wait for it (fixes race condition
// where dispose() was called while release() awaited backend, returning null)
let pendingRelease = null;
const handle = {
async release(signal) {
// Idempotent: subsequent calls return { ok: false } without network call
// This provides at-most-once semantics required by spec
if (state === "disposing" || state === "disposed") {
return { ok: false };
}
// Mark as disposing before backend call to ensure at-most-once semantics
// even if the call throws (network error, timeout, etc.)
state = "disposing";
// Store promise BEFORE await so dispose() can wait for it if called concurrently.
// Wrap in try/catch to handle synchronous throws (e.g., validation failures).
let releaseOp;
try {
releaseOp = backend.release({
lockId: result.lockId,
signal,
});
}
catch (error) {
// Synchronous throw - mark disposed to maintain at-most-once semantics
state = "disposed";
throw error;
}
pendingRelease = releaseOp.then(() => {
state = "disposed";
}, () => {
state = "disposed";
});
// Throw on errors for consistency with backend API
// Only disposal swallows errors (see asyncDispose below)
// State is set to "disposed" by pendingRelease handler on both success/failure
return releaseOp;
},
async extend(ttlMs, signal) {
// Note: extend() is NOT idempotent - it's a legitimate operation
// to extend multiple times. Don't check state here.
return backend.extend({ lockId: result.lockId, ttlMs, signal });
},
async [Symbol.asyncDispose]() {
// Idempotent: subsequent calls are no-ops (at-most-once semantics)
if (state === "disposed") {
return;
}
// Re-entry during disposal: wait for in-flight operation
if (state === "disposing") {
// If release() initiated disposal, wait for it (pendingRelease is set)
// If dispose() initiated, wait for disposePromise
if (pendingRelease) {
await pendingRelease;
return;
}
return disposePromise;
}
// First disposal attempt: create and store promise
state = "disposing";
disposePromise = (async () => {
const notifyDisposalError = (error) => {
try {
let normalizedError;
if (error instanceof Error) {
normalizedError = error;
}
else {
normalizedError = new Error(String(error));
normalizedError.originalError = error;
}
onReleaseError(normalizedError, {
lockId: result.lockId,
key,
source: "disposal",
});
}
catch {
// Swallow callback errors - user's callback is responsible for safe error handling
}
};
try {
// Race release against timeout if configured.
// Hard ceiling: if timeout elapses, resolve disposal rather than hanging.
// This ensures Symbol.asyncDispose never blocks indefinitely.
// Note: Backend release may continue in background; timeout doesn't abort gRPC calls,
// only signals cancellation. Backends must respect AbortSignal to honor timeout.
if (typeof disposeTimeoutMs === "number" && disposeTimeoutMs > 0) {
const controller = new AbortController();
// Wrap release in outcome object so Promise.race never rejects.
const releaseOutcome = (async () => {
try {
await backend.release({
lockId: result.lockId,
signal: controller.signal,
});
return { kind: "release-ok" };
}
catch (error) {
return { kind: "release-error", error };
}
})();
const timeoutOutcome = new Promise((resolve) => {
const timeoutId = setTimeout(() => {
controller.abort();
resolve({ kind: "timeout" });
}, disposeTimeoutMs);
releaseOutcome.finally(() => clearTimeout(timeoutId));
});
const outcome = await Promise.race([
releaseOutcome,
timeoutOutcome,
]);
if (outcome.kind === "release-ok") {
state = "disposed";
return;
}
if (outcome.kind === "timeout") {
state = "disposed";
// Fire-and-forget: observe release outcome once it settles.
// If backend.release eventually fails, surface the error via onReleaseError.
releaseOutcome.then((finalOutcome) => {
if (finalOutcome.kind === "release-error") {
notifyDisposalError(finalOutcome.error);
}
});
return;
}
// Release failed before timeout elapsed.
throw outcome.error;
}
else {
await backend.release({ lockId: result.lockId });
state = "disposed";
}
}
catch (error) {
// Disposal failed before timeout (release error) - mark as disposed anyway (at-most-once semantics)
state = "disposed";
// Never throw from disposal per AsyncDisposable spec.
// Error is swallowed - disposal is best-effort cleanup.
notifyDisposalError(error);
}
})();
return disposePromise;
},
};
// Return object with both data and methods
// TypeScript sees this as AcquireOk<C> & DisposableLockHandle
return Object.assign({}, result, handle);
}
// ============================================================================
// Public API
// ============================================================================
/**
* 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 function decorateAcquireResult(backend, result, key, onReleaseError = defaultDisposalErrorHandler, disposeTimeoutMs) {
if (!result.ok) {
// Failed acquisition: attach no-op methods for await using compatibility
const failResult = result;
// No-op disposal for failed acquisitions
failResult[Symbol.asyncDispose] = async () => {
// No-op - acquisition failed, nothing to clean up
};
// No-op release - returns { ok: false } immediately
failResult.release = async () => {
return { ok: false };
};
// No-op extend - returns { ok: false } immediately
failResult.extend = async () => {
return { ok: false };
};
return failResult;
}
// Successful acquisition: create full disposable handle
return createDisposableHandle(backend, result, key, onReleaseError, disposeTimeoutMs);
}
/**
* 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 async function acquireHandle(backend, opts) {
const result = await backend.acquire(opts);
if (!result.ok) {
return result;
}
// Validate backend returned decorated result (catches misconfigured mocks)
const lock = result;
if (typeof lock.release !== "function" ||
typeof lock.extend !== "function" ||
typeof lock[Symbol.asyncDispose] !== "function") {
throw new Error("Backend.acquire() must return a decorated result. " +
"Use decorateAcquireResult() or implement LockBackend correctly.");
}
return result;
}