-
Notifications
You must be signed in to change notification settings - Fork 0
Distributed Semaphores
This guide covers using @DistributedSemaphore for permit-based concurrency control across multiple servers.
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 │
└─────────┘ └─────────┘ └─────────┘
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 |
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:
- Locksmith tries to acquire a permit from semaphore
semaphore:external-apiin Redis - If a permit is available, the method executes
- After completion (or exception), the permit is released
- If no permit is available,
SemaphoreNotAcquiredExceptionis thrown
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
permitsvalue is consistent. Locksmith validates this and throwsSemaphoreConfigurationExceptionif mismatched.
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.
}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:
-
nullfor object types -
0for numeric primitives -
falsefor boolean -
'\0'for char -
Optional.empty()for Optional
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.
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.
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.
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());
}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) - ThrowsSemaphoreNotAcquiredException -
SemaphoreReturnDefaultHandler- Returns default values:falsefor boolean,0for numeric primitives,Optional.empty()for Optional,nullfor objects
See Skip Handlers for more patterns.
Locksmith's aspect runs with Ordered.HIGHEST_PRECEDENCE, meaning:
- Permit is acquired before
@Transactionalbegins - Permit is released after
@Transactionalcommits/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@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);
}
}@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);
}
}@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);
}
}@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();
}
}@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);
}
}- When a method with
@DistributedSemaphoreis called, the aspect intercepts it - It resolves the semaphore key (same SpEL rules as locks)
- On first use, initializes the semaphore with the specified permit count in Redis
- Attempts to acquire a permit from the Redis semaphore
- If acquired: executes the method, then releases the permit
- If not acquired: invokes the configured
skipHandler
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:
- Delete the Redis keys:
{semaphoreKey}and{semaphoreKey}:meta - 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"- Dynamic Keys with SpEL - Use method parameters in semaphore keys
- Lock Acquisition Modes - Wait for permit instead of failing immediately
- Skip Handlers - Custom logic when permit acquisition fails
- Troubleshooting - Common issues and solutions
