Skip to content

Commit f068bb3

Browse files
authored
feat: add AsyncDisposable support for automatic lock cleanup (#13)
1 parent dc563f8 commit f068bb3

31 files changed

Lines changed: 3844 additions & 203 deletions

.github/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! This guide will help you get started.
77
- **Bun**: Install from [bun.sh](https://bun.sh)
88
- **Docker** (for integration tests): Any recent version
99
- **PostgreSQL** (for integration tests): 14+ running on localhost:5432
10-
- **Node.js**: 18+ (Bun handles this, but good to have)
10+
- **Node.js**: 20+ (Bun handles this, but good to have)
1111

1212
## Quick Start
1313

README.md

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,50 @@ await lock(
8484
);
8585
```
8686

87-
### Manual Lock Control
87+
### Manual Lock Control with Automatic Cleanup
88+
89+
Use `await using` for automatic cleanup on all code paths (Node.js ≥20):
8890

8991
```typescript
9092
const backend = createRedisBackend(redis);
9193

92-
// Acquire lock manually
94+
// Lock automatically released on scope exit
95+
{
96+
await using lock = await backend.acquire({
97+
key: "batch:daily-report",
98+
ttlMs: 300000, // 5 minutes
99+
});
100+
101+
if (lock.ok) {
102+
// TypeScript narrows lock to include handle methods after ok check
103+
const { fence } = lock; // Fencing token for stale lock protection
104+
105+
await generateDailyReport(fence);
106+
107+
// Extend lock for long-running tasks
108+
await lock.extend(300000);
109+
await sendReportEmail();
110+
111+
// Lock released automatically here
112+
} else {
113+
console.log("Resource is locked by another process");
114+
}
115+
}
116+
```
117+
118+
**For older runtimes (Node.js <20)**, use try/finally:
119+
120+
```typescript
93121
const result = await backend.acquire({
94122
key: "batch:daily-report",
95-
ttlMs: 300000, // 5 minutes
123+
ttlMs: 300000,
96124
});
97125

98126
if (result.ok) {
99127
try {
100-
const { lockId, fence } = result; // Fencing token for stale lock protection
128+
const { lockId, fence } = result;
101129
await generateDailyReport(fence);
102130

103-
// Extend lock for long-running tasks
104131
const extended = await backend.extend({ lockId, ttlMs: 300000 });
105132
if (!extended.ok) {
106133
throw new Error("Failed to extend lock");
@@ -115,6 +142,25 @@ if (result.ok) {
115142
}
116143
```
117144

145+
**Error callbacks** for disposal failures:
146+
147+
```typescript
148+
const backend = createRedisBackend(redis, {
149+
onReleaseError: (error, context) => {
150+
logger.error("Failed to release lock", {
151+
error,
152+
lockId: context.lockId,
153+
key: context.key,
154+
});
155+
},
156+
});
157+
158+
// All acquisitions automatically use the error callback
159+
await using lock = await backend.acquire({ key: "resource", ttlMs: 30000 });
160+
```
161+
162+
**Note:** SyncGuard provides a safe-by-default error handler that automatically logs disposal failures in development mode (`NODE_ENV !== 'production'`). In production, enable logging with `SYNCGUARD_DEBUG=true` or provide a custom `onReleaseError` callback integrated with your observability stack.
163+
118164
### Ownership Checking
119165

120166
```typescript
@@ -134,12 +180,22 @@ if (info) {
134180

135181
```typescript
136182
// Basic lock options
137-
await lock(workFn, {
138-
key: "resource:123", // Required: unique identifier
139-
ttlMs: 30000, // Lock duration (default: 30s)
140-
timeoutMs: 5000, // Max acquisition wait (default: 5s)
141-
maxRetries: 10, // Retry attempts (default: 10)
142-
});
183+
await lock(
184+
async () => {
185+
// Your critical section
186+
},
187+
{
188+
key: "resource:123", // Required: unique identifier
189+
ttlMs: 30000, // Lock duration (default: 30s)
190+
acquisition: {
191+
timeoutMs: 5000, // Max acquisition wait (default: 5s)
192+
maxRetries: 10, // Retry attempts (default: 10)
193+
retryDelayMs: 100, // Initial retry delay (default: 100ms)
194+
backoff: "exponential", // Backoff strategy: "exponential" | "fixed" (default: "exponential")
195+
jitter: "equal", // Jitter strategy: "equal" | "full" | "none" (default: "equal")
196+
},
197+
},
198+
);
143199
```
144200

145201
### Backend Configuration
@@ -236,7 +292,7 @@ const checkRateLimit = async (userId: string) => {
236292

237293
- 🔒 **Bulletproof concurrency** - Atomic operations prevent race conditions
238294
- 🛡️ **Fencing tokens** - Monotonic counters protect against stale writes
239-
- 🧹 **Automatic cleanup** - TTL-based expiration, no manual cleanup needed
295+
- 🧹 **Automatic cleanup** - TTL-based expiration + `await using` (AsyncDisposable) support
240296
- 🔄 **Backend flexibility** - Redis (performance), PostgreSQL (zero overhead), or Firestore (serverless)
241297
- 🔁 **Smart retries** - Exponential backoff with jitter handles contention
242298
- 💙 **TypeScript-first** - Full type safety with compile-time guarantees

common/auto-lock.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,40 @@ import type {
99
BackendCapabilities,
1010
LockBackend,
1111
LockConfig,
12+
OnReleaseError,
1213
} from "./types.js";
1314
import { normalizeAndValidateKey } from "./validation.js";
1415

16+
/**
17+
* Default error handler for disposal failures in lock() helper.
18+
* Provides safe-by-default observability without requiring user configuration.
19+
*
20+
* **Behavior**:
21+
* - Development (NODE_ENV !== 'production'): Logs all disposal errors to console.error
22+
* - Production: Silent by default, opt-in via SYNCGUARD_DEBUG=true environment variable
23+
* - Security: Omits sensitive context (key, lockId) from logs by default
24+
*
25+
* **Note**: This is shared with the disposable.ts default handler for consistency.
26+
*
27+
* @see common/disposable.ts - Full documentation of default handler behavior
28+
*/
29+
const defaultDisposalErrorHandler: OnReleaseError = (err, ctx) => {
30+
// Only log in development or when explicitly enabled via env var
31+
const shouldLog =
32+
process.env.NODE_ENV !== "production" ||
33+
process.env.SYNCGUARD_DEBUG === "true";
34+
35+
if (shouldLog) {
36+
console.error("[SyncGuard] Lock disposal failed:", {
37+
error: err.message,
38+
errorName: err.name,
39+
source: ctx.source,
40+
// Omit key and lockId to avoid leaking sensitive data in logs
41+
// Users should provide custom callback for full context
42+
});
43+
}
44+
};
45+
1546
/**
1647
* Auto-managed lock with retry logic for acquisition contention.
1748
* Backends perform single-attempt operations (ADR-009), retries handled here.
@@ -209,13 +240,28 @@ export async function lock<T, C extends BackendCapabilities>(
209240
try {
210241
await backend.release({ lockId, signal: config.signal });
211242
} catch (releaseError) {
212-
if (config.onReleaseError) {
213-
const error =
214-
releaseError instanceof Error
215-
? releaseError
216-
: new Error(String(releaseError));
243+
// Always notify callback of disposal failure (uses default if not configured)
244+
// This ensures consistent observability across low-level and high-level APIs
245+
const errorHandler = config.onReleaseError ?? defaultDisposalErrorHandler;
246+
247+
try {
248+
// Normalize to Error instance and preserve original for debugging
249+
let normalizedError: Error;
250+
if (releaseError instanceof Error) {
251+
normalizedError = releaseError;
252+
} else {
253+
normalizedError = new Error(String(releaseError));
254+
// Preserve original error for debugging
255+
(normalizedError as any).originalError = releaseError;
256+
}
217257

218-
config.onReleaseError(error, { lockId, key: normalizedKey });
258+
errorHandler(normalizedError, {
259+
lockId,
260+
key: normalizedKey,
261+
source: "disposal", // Automatic cleanup, not manual
262+
});
263+
} catch {
264+
// Swallow callback errors - user's callback is responsible for safe error handling
219265
}
220266
// Swallow release errors: TTL cleanup handles orphaned locks
221267
}

common/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { createAutoLock, lock } from "./auto-lock.js";
1313
export * from "./config.js";
1414
export * from "./constants.js";
1515
export * from "./crypto.js";
16+
export * from "./disposable.js";
1617
export * from "./errors.js";
1718
export * from "./helpers.js";
1819
export * from "./telemetry.js";

0 commit comments

Comments
 (0)