Skip to content

Semaphore Plugin ​

Bound concurrency across instances: at most N concurrent executions fleet-wide.

Overview ​

A local p-limit bounds one process. In a 10-replica deployment "at most 3 concurrent PDF renders" or "at most 5 open connections to the legacy API" needs a shared ceiling. @nestjs-redisx/semaphore keeps the count in Redis: every holder owns a leased permit that expires if the holder crashes, so seats never leak.

A lock is the special case permits: 1 - if you need mutual exclusion with retries and auto-renewal, use @nestjs-redisx/locks; a semaphore is for "at most N".

Key Features ​

  • Permit leases - one sorted set per semaphore (member = permit id, score = expiry); expired permits are swept atomically inside acquire, so a crashed holder frees its seat after the TTL with no background job
  • Auto-renewal - long-running work keeps its seat; renewal stops the moment the permit is lost
  • @WithSemaphore - proxy-based decorator for any @Injectable method with {n} key templates and an onUnavailable policy (throw | skip | custom error)
  • Honest shutdown - app.close() never yanks a seat from under still-running work: renewal stops and seats lapse within their remaining TTL (the crash bound); work that finishes in time still releases instantly
  • Cluster-safe - one key per semaphore, no hash tags needed
  • Testable without Redis - the Lua scripts run byte-for-byte on the in-memory testing driver

Installation ​

bash
npm install @nestjs-redisx/core @nestjs-redisx/semaphore ioredis
bash
npm install @nestjs-redisx/core @nestjs-redisx/semaphore redis

Quick Start ​

Register the plugin:

typescript
import { Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-redisx/core';
import { SemaphorePlugin } from '@nestjs-redisx/semaphore';

@Module({
  imports: [
    RedisModule.forRoot({
      clients: {
        host: 'localhost',
        port: 6379,
      },
      plugins: [
        new SemaphorePlugin({
          defaultTtl: 30_000, // permit lease; a crashed holder frees its seat after this
          maxTtl: 300_000, // hard cap for per-call ttl values
          retry: { maxRetries: 3, initialDelay: 100, maxDelay: 3_000, multiplier: 2 },
          autoRenew: { enabled: true, intervalFraction: 0.5 },
        }),
      ],
    }),
  ],
})
export class AppModule {}

Bound a method:

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

How It Works ​

  1. acquire(key, permits) runs one atomic Lua script: sweep expired permits → if fewer than permits live holders remain, add this permit with score = now + ttl.
  2. While held, auto-renewal extends the score every ttl × intervalFraction (CAS: only an unexpired own permit can be extended).
  3. release() removes the permit; a crash leaves it to expire - the next acquire sweeps it.
  4. The semaphore key itself carries a PEXPIRE at least as long as its longest permit, so abandoned semaphores vanish on their own.
  5. Every expiry decision uses the Redis server clock (a cached TIME offset, re-synced every 30s) - an instance with a skewed local clock cannot sweep other instances' live permits.

The capacity travels with the call (like rate-limit limits): every caller of a key must pass the same permits value - the store enforces whatever the acquiring call provides.

Next Steps ​

Released under the MIT License.