Skip to content

@WithSemaphore ​

Proxy-based decorator (same mechanism as @WithLock): works on any @Injectable method, not just controllers.

typescript
import { Injectable } from '@nestjs/common';
import { WithSemaphore } from '@nestjs-redisx/semaphore';
import { VideoEncoder } from './types';

@Injectable()
export class EncodingService {
  constructor(private readonly encoder: VideoEncoder) {}

  // At most 2 encodes at a time ACROSS EVERY INSTANCE - the fleet-wide
  // analogue of a bounded worker pool.
  @WithSemaphore({ key: 'video:encode', permits: 2, ttl: 600_000 })
  async encode(videoId: string): Promise<void> {
    await this.encoder.encode(videoId);
  }

  // Per-tenant bound via a {n} key template; saturation skips instead of
  // throwing (good for periodic retried work).
  @WithSemaphore({
    key: 'export:{0}',
    permits: 1,
    waitTimeout: 5_000,
    onUnavailable: 'skip',
  })
  async exportTenant(tenantId: string): Promise<void> {
    // runs at most once concurrently per tenant
  }
}

Options ​

OptionTypeDescription
keystring | (...args) => stringSemaphore key; {n} interpolates the n-th argument
permitsnumberCapacity - same value on every decorator using this key
ttlnumber?Permit TTL
waitTimeoutnumber?Total wait budget for a seat
autoRenewboolean?Per-method renewal override
onUnavailable'throw' | 'skip' | (key) => ErrorWhat to do when no permit could be acquired - saturation, store outage, or shutdown alike (default 'throw'; a policy function returning a non-Error falls back to the original error)

onUnavailable Policies ​

  • 'throw' (default) - SemaphoreAcquisitionError propagates; right for request handlers where the caller should see 503/429.
  • 'skip' - the method resolves undefined without running; right for periodic work that will come back on the next tick.
  • function - map saturation to your own domain error.

The policy applies only to acquisition - and to every acquisition failure: saturation (exhausted/timeout), a store outage (error), and shutdown. An error thrown by the method body always propagates, and the permit is always released. Detection is cross-copy-safe (by error code), so a mixed CJS+ESM process keeps the policy working.

Composing with @Cron ​

Both orders are safe - the wrapper carries over metadata stamped by decorators below it, so @WithSemaphore() above @Cron() does not unregister the job:

typescript
@Cron(CronExpression.EVERY_MINUTE)
@WithSemaphore({ key: 'sync', permits: 1, onUnavailable: 'skip' })
async sync() { /* at most one sync at a time, fleet-wide */ }

Without the Plugin ​

If SemaphorePlugin is not registered, the method executes without a bound and logs a warning - fail-open. A missing ceiling is observable and recoverable; a silently skipped business method is not. (Contrast with @LeaderOnly, where fail-safe means skip: running singleton work everywhere is the unrecoverable direction there.)

Released under the MIT License.