Skip to content

feat: add AsyncDisposable support for automatic lock cleanup#13

Merged
koistya merged 1 commit intomainfrom
dev
Oct 17, 2025
Merged

feat: add AsyncDisposable support for automatic lock cleanup#13
koistya merged 1 commit intomainfrom
dev

Conversation

@koistya
Copy link
Member

@koistya koistya commented Oct 17, 2025

Summary

Adds await using syntax support (Node.js ≥20) for RAII-style automatic lock cleanup. Lock handles now implement Symbol.asyncDispose, ensuring cleanup on all code paths including early returns and exceptions.

Key Changes

AsyncDisposable Implementation (common/disposable.ts)

  • New decorateAcquireResult() decorator adds disposal support to all acquire operations
  • Idempotent disposal with at-most-once semantics (prevents double-release network calls)
  • Optional disposal timeout (disposeTimeoutMs) for unreliable network environments
  • Smart error handling: disposal failures route to onReleaseError callback (never throw)

Default Error Observability

  • Development: Logs disposal errors to console.error for visibility
  • Production: Silent by default, opt-in via SYNCGUARD_DEBUG=true
  • Security: Omits sensitive data (key, lockId) from default logs
  • Configurable: Override with custom callback for production observability

Backend Integration

  • All backends updated: Redis, PostgreSQL, Firestore
  • BackendConfig now accepts onReleaseError and disposeTimeoutMs options
  • Backwards compatible: existing code works unchanged

Updated Documentation

  • README examples showcase await using as primary pattern
  • Core concepts guide updated with disposal patterns
  • Contributing guide bumps Node.js requirement to 20+
  • Comprehensive JSDoc with error handling best practices

Usage Patterns

Modern (Node.js ≥20) - Recommended

{
  await using lock = await backend.acquire({ key, ttlMs: 30000 });
  if (lock.ok) {
    await doWork(lock.fence);
    await lock.extend(30000); // Extend if needed
    // Lock automatically released
  }
}

Legacy (Node.js <20) - Still supported

const result = await backend.acquire({ key, ttlMs: 30000 });
if (result.ok) {
  try {
    await doWork(result.fence);
  } finally {
    await backend.release({ lockId: result.lockId });
  }
}

Error Handling Configuration

const backend = createRedisBackend(redis, {
  onReleaseError: (err, ctx) => {
    logger.error('Disposal failed', { err, ...ctx });
    metrics.increment('syncguard.disposal.error');
  },
  disposeTimeoutMs: 5000 // Optional: abort disposal after 5s
});

Testing

  • New integration tests: test/integration/disposable.test.ts
  • New unit tests: test/unit/disposable.test.ts
  • Coverage: disposal success/failure, idempotency, timeouts, error callbacks
  • All backends tested with AsyncDisposable integration

Architectural Decision

See ADR-015 (Async RAII for Locks) and ADR-016 (Opt-In Disposal Timeout) in specs/adrs.md for design rationale.

Credits

Thanks to @alii for proposing the await using API!

@koistya koistya force-pushed the dev branch 3 times, most recently from 8606308 to 822fd90 Compare October 17, 2025 13:46
@koistya koistya merged commit f068bb3 into main Oct 17, 2025
7 checks passed
@koistya koistya deleted the dev branch October 17, 2025 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant