Skip to content

Recipes ​

Fleet-Wide Worker Pool ​

Bound heavy work (video encoding, PDF rendering, ML inference) to N concurrent executions across every replica:

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
  }
}

Scale replicas freely - the ceiling stays N.

Protecting a Fragile Dependency ​

A legacy API or database that tolerates only a handful of concurrent callers:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { SEMAPHORE_SERVICE, ISemaphoreService } from '@nestjs-redisx/semaphore';
import { LegacyApiClient } from './types';

@Injectable()
export class ReportService {
  constructor(
    @Inject(SEMAPHORE_SERVICE)
    private readonly semaphore: ISemaphoreService,
    private readonly legacyApi: LegacyApiClient,
  ) {}

  // withPermit: acquire -> run -> always release.
  async fetchReport(tenantId: string): Promise<unknown> {
    return this.semaphore.withPermit('legacy-api', 5, () => this.legacyApi.fetchReport(tenantId));
  }

  // Manual permit lifecycle for long multi-step work.
  async fetchWithManualPermit(tenantId: string): Promise<unknown> {
    const permit = await this.semaphore.acquire('legacy-api', 5, {
      ttl: 60_000,
      waitTimeout: 10_000, // give up if no seat frees up in 10s
    });
    try {
      return await this.legacyApi.fetchReport(tenantId);
    } finally {
      await permit.release();
    }
  }

  // tryAcquire: skip this round instead of waiting.
  async fetchIfCapacity(tenantId: string): Promise<unknown | undefined> {
    const permit = await this.semaphore.tryAcquire('legacy-api', 5);
    if (!permit) {
      return undefined; // at capacity - caller decides what to do
    }
    try {
      return await this.legacyApi.fetchReport(tenantId);
    } finally {
      await permit.release();
    }
  }
}

Combine with the circuit breaker: the semaphore bounds concurrency, the breaker reacts to failures - they solve different halves of the problem. Use rate limiting when the contract is "requests per second" rather than "requests in flight".

Per-Tenant Bounds ​

A {n} template gives every tenant its own semaphore:

typescript
@WithSemaphore({ key: 'export:{0}', permits: 1, onUnavailable: 'skip' })
async exportTenant(tenantId: string) { /* one export per tenant at a time */ }

Skip-When-Saturated Cron ​

Periodic work that should not queue behind itself:

typescript
@Cron(CronExpression.EVERY_MINUTE)
@WithSemaphore({ key: 'warmup', permits: 2, waitTimeout: 1000, onUnavailable: 'skip' })
async warmCaches() { /* at most 2 warmups in flight; extras skip this tick */ }

Draining for Maintenance ​

Freeze a semaphore by watching it, then force-free seats stuck behind an incident:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { SEMAPHORE_SERVICE, ISemaphoreService } from '@nestjs-redisx/semaphore';

@Injectable()
export class SemaphoreAdminService {
  constructor(
    @Inject(SEMAPHORE_SERVICE)
    private readonly semaphore: ISemaphoreService,
  ) {}

  async capacitySnapshot(key: string, permits: number) {
    return {
      used: await this.semaphore.usedPermits(key),
      available: await this.semaphore.availablePermits(key, permits),
    };
  }

  // Operator recovery: free every seat on a wedged semaphore. In-flight
  // holders keep running; their eventual release() throws
  // SemaphorePermitNotHeldError (the honest taken-away signal).
  async unblock(key: string): Promise<number> {
    return this.semaphore.forceReleaseAll(key);
  }
}

forceReleaseAll never interrupts running work - in-flight holders complete, and the seats open immediately. Their eventual release() throws SemaphorePermitNotHeldError (the honest signal the seat was taken away); withPermit and @WithSemaphore absorb it into an error log.

Released under the MIT License.