Skip to content

Distributed Semaphores

Garvit Joshi edited this page Jan 17, 2026 · 5 revisions

Distributed Semaphores

This guide covers using @DistributedSemaphore for permit-based concurrency control across multiple servers.

What is a Distributed Semaphore?

A semaphore limits how many concurrent executions can occur. Unlike a lock (which allows only one), a semaphore allows up to N concurrent executions:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Instance A    │     │   Instance B    │     │   Instance C    │
│                 │     │                 │     │                 │
│   callApi()     │     │   callApi()     │     │   callApi()     │
│       │         │     │       │         │     │       │         │
│       ▼         │     │       ▼         │     │       ▼         │
│  Get Permit ────┼─────┼───────┼─────────┼─────┼───────┼         │
└────────┼────────┘     └───────┼─────────┘     └───────┼─────────┘
         │                      │                       │
         ▼                      ▼                       ▼
    ┌─────────────────────────────────────────────────────────┐
    │                         Redis                           │
    │                                                         │
    │   Semaphore: api-calls                                  │
    │   Total Permits: 5                                      │
    │   Available: 3                                          │
    │   Held by: Instance A, Instance B                       │
    │                                                         │
    └─────────────────────────────────────────────────────────┘
         │                      │                       │
         ▼                      ▼                       ▼
    ┌─────────┐           ┌─────────┐             ┌─────────┐
    │ACQUIRED │           │ACQUIRED │             │ACQUIRED │
    │Execute  │           │Execute  │             │Execute  │
    │ Method  │           │ Method  │             │ Method  │
    └─────────┘           └─────────┘             └─────────┘

When to Use Semaphores

Use @DistributedSemaphore when you need to:

  • Rate limit external API calls - Limit concurrent requests to avoid overloading third-party services
  • Control database connection usage - Limit concurrent heavy queries
  • Batch processing with parallelism - Allow N workers to process concurrently
  • Resource pool management - Control access to limited resources
Use Case Annotation
Only one instance at a time @DistributedLock
Up to N instances at a time @DistributedSemaphore

Your First Semaphore

Add @DistributedSemaphore to any Spring-managed bean method:

@Service
public class ExternalApiService {

    @DistributedSemaphore(key = "external-api", permits = 5)
    public Response callExternalApi() {
        // At most 5 instances can execute this concurrently
        return httpClient.get("https://api.example.com/data");
    }
}

When callExternalApi() is called:

  1. Locksmith tries to acquire a permit from semaphore semaphore:external-api in Redis
  2. If a permit is available, the method executes
  3. After completion (or exception), the permit is released
  4. If no permit is available, SemaphoreNotAcquiredException is thrown

Semaphore Key

The key attribute identifies the semaphore. All instances using the same key share the same permit pool:

// These two methods share the same semaphore (5 total permits)
@DistributedSemaphore(key = "api-pool", permits = 5)
public void method1() { }

@DistributedSemaphore(key = "api-pool", permits = 5)
public void method2() { }

// This method has its own independent semaphore
@DistributedSemaphore(key = "other-pool", permits = 10)
public void method3() { }

Important: When using the same key across multiple methods, ensure the permits value is consistent. Locksmith validates this and throws SemaphoreConfigurationException if mismatched.

Handling Permit Acquisition Failures

By default, if no permit is available, SemaphoreNotAcquiredException is thrown:

try {
    apiService.callExternalApi();
} catch (SemaphoreNotAcquiredException e) {
    System.out.println("No permit available for: " + e.getSemaphoreKey());
    // Handle accordingly - retry later, return cached result, etc.
}

Silent Skip with SemaphoreReturnDefaultHandler

For scheduled tasks or fire-and-forget operations, you might want to silently skip if no permit is available:

@Service
public class BatchService {

    @Scheduled(fixedRate = 60000)
    @DistributedSemaphore(
        key = "batch-processor",
        permits = 3,
        skipHandler = SemaphoreReturnDefaultHandler.class
    )
    public void processBatch() {
        // At most 3 instances run this concurrently
        // Additional instances silently skip (return null)
    }
}

SemaphoreReturnDefaultHandler returns:

  • null for object types
  • 0 for numeric primitives
  • false for boolean
  • '\0' for char
  • Optional.empty() for Optional

Lease Time (Permit Duration)

Permits automatically expire after the lease time to prevent permit leaks if an instance crashes:

// Default: 5 minutes (from configuration)
@DistributedSemaphore(key = "pool", permits = 5)
public void task1() { }

// Custom: 30 minutes
@DistributedSemaphore(key = "pool", permits = 5, leaseTime = "30m")
public void task2() { }

// Short: 30 seconds
@DistributedSemaphore(key = "pool", permits = 5, leaseTime = "30s")
public void task3() { }

Warning: If your method takes longer than the lease time, the permit expires and another instance can acquire it. This could lead to more than N concurrent executions. Set lease time appropriately for your workload.

Dynamic Keys with SpEL

Use Spring Expression Language (SpEL) for per-resource semaphores:

// Limit concurrent operations per user
@DistributedSemaphore(key = "#{#userId}", permits = 3)
public void processUserRequest(String userId) { }

// Limit concurrent operations per tenant
@DistributedSemaphore(key = "#{#request.tenantId}", permits = 10)
public void processTenantRequest(Request request) { }

// Combined key
@DistributedSemaphore(key = "#{'api-' + #region}", permits = 5)
public void callRegionalApi(String region) { }

See Dynamic Keys with SpEL for complete syntax reference.

Wait for Permit

Use WAIT_AND_SKIP mode to wait for an available permit before giving up:

@DistributedSemaphore(
    key = "resource-pool",
    permits = 10,
    mode = AcquisitionMode.WAIT_AND_SKIP,
    waitTime = "30s"
)
public void accessResourcePool() {
    // Will wait up to 30 seconds for a permit
}

See Lock Acquisition Modes for details.

Lease Timeout Detection

Detect when a method's execution time exceeds the configured lease duration:

@Service
public class DataService {

    // Log warning if execution exceeds lease time (default behavior)
    @DistributedSemaphore(key = "data-sync", permits = 3, leaseTime = "5m")
    public void syncData() {
        // If this takes > 5 minutes, a warning is logged
    }

    // Throw exception if execution exceeds lease time
    @DistributedSemaphore(
        key = "critical-task",
        permits = 5,
        leaseTime = "10m",
        onLeaseExpired = LeaseExpirationBehavior.THROW_EXCEPTION
    )
    public void criticalTask() {
        // If this takes > 10 minutes, SemaphoreLeaseExpiredException is thrown
    }
}

Handle SemaphoreLeaseExpiredException:

try {
    dataService.criticalTask();
} catch (SemaphoreLeaseExpiredException e) {
    log.error("Permit expired during execution: {} took {}ms but lease was {}ms",
        e.getMethodName(), e.getExecutionTimeMs(), e.getLeaseTimeMs());
}

Custom Skip Handlers

For advanced permit acquisition failure handling, implement SemaphoreSkipHandler:

import in.riido.locksmith.models.SemaphoreContext;

public class AlertingSemaphoreHandler implements SemaphoreSkipHandler {

    @Override
    public Object handle(SemaphoreContext context) {
        // Send alert, log to specific system, or execute alternative logic
        alertService.sendAlert("No permit available: " + context.semaphoreKey());

        // Return a fallback value
        return "fallback-result";
    }
}

@DistributedSemaphore(
    key = "critical-pool",
    permits = 10,
    skipHandler = AlertingSemaphoreHandler.class
)
public String criticalTask() { }

The SemaphoreContext provides:

  • semaphoreKey() - The Redis semaphore key
  • methodName() - The formatted method name
  • method() - The intercepted Method
  • args() - The method arguments
  • returnType() - The method's return type
  • permitId() - The permit ID if one was acquired, null otherwise

Built-in handlers:

  • SemaphoreThrowExceptionHandler (default) - Throws SemaphoreNotAcquiredException
  • SemaphoreReturnDefaultHandler - Returns default values: false for boolean, 0 for numeric primitives, Optional.empty() for Optional, null for objects

See Skip Handlers for more patterns.

Aspect Ordering

Locksmith's aspect runs with Ordered.HIGHEST_PRECEDENCE, meaning:

  • Permit is acquired before @Transactional begins
  • Permit is released after @Transactional commits/rollbacks
@DistributedSemaphore(key = "db-pool", permits = 10)  // 1. Permit acquired
@Transactional                                         // 2. Transaction starts
public void updateDatabase() {
    repository.save(data);                             // 3. Database operation
}                                                      // 4. Transaction commits
                                                       // 5. Permit released

Common Patterns

External API Rate Limiting

@Service
public class PaymentGateway {

    @DistributedSemaphore(key = "payment-api", permits = 10)
    public PaymentResult processPayment(Payment payment) {
        // At most 10 concurrent calls to payment provider
        return paymentClient.charge(payment);
    }
}

Database Query Throttling

@Service
public class ReportService {

    @DistributedSemaphore(key = "heavy-queries", permits = 3, leaseTime = "10m")
    public Report generateReport(ReportRequest request) {
        // At most 3 concurrent heavy report queries
        return reportRepository.generateHeavyReport(request);
    }
}

Per-Tenant Concurrency Limit

@Service
public class TenantService {

    @DistributedSemaphore(key = "#{#tenantId}", permits = 5)
    public void processForTenant(String tenantId, Task task) {
        // Each tenant gets up to 5 concurrent operations
        taskProcessor.process(task);
    }
}

Batch Processing with Parallelism

@Service
public class BatchProcessor {

    @Scheduled(fixedRate = 60000)
    @DistributedSemaphore(
        key = "batch-workers",
        permits = 5,
        skipHandler = SemaphoreReturnDefaultHandler.class
    )
    public void processBatch() {
        // Up to 5 instances process batches concurrently
        batchService.processNextBatch();
    }
}

Resource Pool Access

@Service
public class FileExportService {

    @DistributedSemaphore(key = "export-workers", permits = 3, leaseTime = "30m")
    public void exportToFile(ExportRequest request) {
        // Only 3 concurrent file exports allowed
        exporter.export(request);
    }
}

How Semaphores Work

  1. When a method with @DistributedSemaphore is called, the aspect intercepts it
  2. It resolves the semaphore key (same SpEL rules as locks)
  3. On first use, initializes the semaphore with the specified permit count in Redis
  4. Attempts to acquire a permit from the Redis semaphore
  5. If acquired: executes the method, then releases the permit
  6. If not acquired: invokes the configured skipHandler

Permit Consistency

The first server to use a semaphore key sets its permit count. If another deployment uses the same key with a different permit count, a warning is logged but the existing count is preserved.

To change permit count for an existing semaphore:

  1. Delete the Redis keys: {semaphoreKey} and {semaphoreKey}:meta
  2. Redeploy all instances with the new permit count
# Example: Change permits for "semaphore:api-pool"
redis-cli DEL "semaphore:api-pool"
redis-cli DEL "semaphore:api-pool:meta"

What's Next

Clone this wiki locally