Skip to content

Service API ​

Inject with the SEMAPHORE_SERVICE token:

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();
    }
  }
}

ISemaphoreService ​

MethodDescription
acquire(key, permits, options?)Acquire a permit with exponential-backoff retry; throws SemaphoreAcquisitionError - reason: 'exhausted' (retry cap), 'timeout' (waitTimeout budget), 'error' (store outage, cause carries the driver error), 'shutdown' (service destroyed mid-wait)
tryAcquire(key, permits, options?)One attempt; null when the semaphore is full
withPermit(key, permits, fn, options?)Acquire → run → always release (even when fn throws)
usedPermits(key)Live (unexpired) holders right now
availablePermits(key, permits)permits − used, never below 0
forceReleaseAll(key)Admin: free every seat; returns how many LIVE holders were displaced. Their eventual release() throws (taken-away signal)

The Permit (ISemaphorePermit) ​

acquire/tryAcquire return a permit - a leased seat:

MemberDescription
key, permitId, ttl, acquiredAt, expiresAtIdentity and lease window
release()Free the seat. Idempotent. Throws SemaphorePermitNotHeldError when the seat was already lost (expired / force-released) - the bound was NOT held for the full duration
extend(ttl)Push the expiry forward; SemaphoreExtensionError when the permit expired on the store
isHeld()Live check against the store
isAutoRenewing / stopAutoRenew()Renewal state and manual stop

Why release() can throw ​

A release that returns quietly after the seat already expired would hide a real incident: for some period the fleet ran more than permits concurrent executions. The error is your signal to raise the TTL or enable auto-renewal. withPermit logs this instead of throwing - the work already ran; the signal lands in the logs.

Shutdown Semantics ​

On app.close() the service deliberately does not release still-held permits: a permit guards work that may still be running when the destroy hooks fire (Nest does not drain in-flight handlers first), and yanking the seat would let another instance start the same bounded work concurrently. Instead:

  • auto-renewal stops - every seat of this instance lapses within its remaining TTL (exactly the documented crash bound);
  • work that finishes in time still releases instantly through its own finally;
  • an acquire still waiting in backoff is aborted with SemaphoreAcquisitionError (reason: 'shutdown'), and a seat won on the wire during shutdown is quietly given back.

Monitoring ​

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

With the Metrics plugin registered, the service emits:

MetricTypeLabels
redisx_semaphore_acquisitions_totalcounterstatus = acquired | failed
redisx_semaphore_wait_duration_secondshistogram-
redisx_semaphore_hold_duration_secondshistogram- (withPermit only)
redisx_semaphore_permits_activegauge-

With the Tracing plugin, every acquire creates a semaphore.acquire span carrying the key, capacity, attempts, and outcome.

Released under the MIT License.