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@Injectablemethod with{n}key templates and anonUnavailablepolicy (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
npm install @nestjs-redisx/core @nestjs-redisx/semaphore ioredisnpm install @nestjs-redisx/core @nestjs-redisx/semaphore redisQuick Start
Register the plugin:
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:
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
acquire(key, permits)runs one atomic Lua script: sweep expired permits → if fewer thanpermitslive holders remain, add this permit withscore = now + ttl.- While held, auto-renewal extends the score every
ttl × intervalFraction(CAS: only an unexpired own permit can be extended). release()removes the permit; a crash leaves it to expire - the next acquire sweeps it.- The semaphore key itself carries a
PEXPIREat least as long as its longest permit, so abandoned semaphores vanish on their own. - Every expiry decision uses the Redis server clock (a cached
TIMEoffset, 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
- Configuration - TTLs, retry backoff, auto-renewal
- Service API - acquire / tryAcquire / withPermit / counts
- @WithSemaphore - decorator options and policies
- Recipes - worker pools, per-tenant bounds, protecting fragile dependencies
- Testing - real semaphores on the in-memory driver
- Troubleshooting