Distributed Locks
Prevent duplicate or overlapping execution of critical sections across workers or nodes with distributed locks.
Quick Start
Execute with Automatic Lock
use Glueful\Lock\LockManagerInterface;
$lockManager = app($context, LockManagerInterface::class);
// Execute once, safely across processes
$result = $lockManager->executeWithLock('import:daily-customers', function () {
return (new CustomerImporter())->run();
}, 900); // 15 min TTL
Wait for Lock (Blocking)
try {
$data = $lockManager->waitAndExecute('sync:warehouse', function () {
return runSync();
}, maxWait: 30.0, ttl: 600);
} catch (\Symfony\Component\Lock\Exception\LockConflictedException $e) {
// Timed out waiting
app($context, \Psr\Log\LoggerInterface::class)->warning('Warehouse sync busy, skipped');
}
Manual Lock Control
$lock = $lockManager->createLock('rebuild-search-index', ttl: 300);
if ($lock->acquire()) {
try {
rebuildIndex();
} finally {
$lock->release();
}
} else {
app($context, \Psr\Log\LoggerInterface::class)->info('Index rebuild already in progress');
}
Use Cases
Prevent Duplicate Job Execution
// In scheduled job
$lockManager->executeWithLock('daily:aggregation', function () {
AggregateJob::run();
}, 3600);
Singleton API Operations
public function rebuildAnalytics(): Response
{
try {
$this->lockManager->executeWithLock('analytics:rebuild', function () {
$this->service->rebuild();
}, 1800);
return Response::success('Rebuild complete');
} catch (\Symfony\Component\Lock\Exception\LockConflictedException) {
return Response::error('Rebuild already in progress', 409);
}
}
Batch Processing with Partition Locks
foreach (array_chunk($userIds, 500) as $chunk) {
$key = 'recalc:users:' . md5(json_encode($chunk));
$lockManager->executeWithLock($key, function () use ($chunk) {
recalcUserStats($chunk);
}, 300);
}
Conditional Execution
if ($lockManager->isLocked('cache:warm')) {
app($context, \Psr\Log\LoggerInterface::class)->info('Cache warming already in progress');
return;
}
$lockManager->executeWithLock('cache:warm', function () {
warmCacheLayers();
}, 600);
Configuration
config/lock.php:
return [
'default' => env('LOCK_DRIVER', 'file'), // file | redis | database
'stores' => [
'file' => [
'driver' => 'file',
'path' => env('LOCK_FILE_PATH', 'framework/locks'), // relative to storage/
'prefix' => 'lock_',
'extension' => '.lock',
],
'redis' => [
'driver' => 'redis',
'connection' => env('LOCK_REDIS_CONNECTION', 'default'), // a cache.stores.* block
'prefix' => env('LOCK_REDIS_PREFIX', 'glueful_lock_'),
'ttl' => 300, // seconds
],
'database' => [
'driver' => 'database',
'connection' => env('LOCK_DB_CONNECTION', null), // a DB engine; null = app connection
'table' => env('LOCK_DB_TABLE', 'locks'),
'id_col' => 'key_id',
'token_col' => 'token',
'expiration_col' => 'expiration',
],
],
'prefix' => env('LOCK_PREFIX', 'glueful_lock_'),
'ttl' => env('LOCK_TTL', 300),
'retry' => [
'delay' => env('LOCK_RETRY_DELAY', 100), // ms between attempts
'max_wait' => env('LOCK_MAX_WAIT', 10), // seconds
],
];
| Key | Effect |
|---|---|
default | Store used by LockManagerInterface |
stores.file.* | Lock directory (relative paths resolve under storage/), file prefix and extension |
stores.redis.connection | Which cache.stores.<name> block supplies host/port/password/timeout; default means cache.stores.redis (the REDIS_* variables) |
stores.redis.prefix / ttl | Redis key prefix and the store's own key TTL |
stores.database.connection | A database engine name (mysql, pgsql, sqlite). Unset or the app's default engine reuses the framework connection; another engine opens a dedicated connection to it using config/database.php |
stores.database.table / *_col | Lock table and column names |
prefix | Prepended to every lock key |
ttl | TTL for any call that doesn't pass one (createLock(), executeWithLock(), waitAndExecute()) |
retry.delay | Pause between attempts in waitAndExecute() |
retry.max_wait | waitAndExecute()'s wait budget when you don't pass maxWait |
Environment Variables
LOCK_DRIVER=file # file | redis | database
LOCK_FILE_PATH=framework/locks
LOCK_REDIS_CONNECTION=default
LOCK_REDIS_PREFIX=glueful_lock_
LOCK_DB_CONNECTION=
LOCK_DB_TABLE=locks
LOCK_PREFIX=glueful_lock_
LOCK_TTL=300
LOCK_RETRY_DELAY=100
LOCK_MAX_WAIT=10
Lock Stores
File Store (Default)
Pros:
- No external dependencies
- Simple setup
- Good for development
Cons:
- Single server only (unless shared filesystem)
- Slower on network filesystems
Use for: Development, single-server deployments
Redis Store
Pros:
- Fast and atomic
- Works across multiple servers
- Wide support
Cons:
- Requires Redis server
Use for: Production multi-server deployments
LOCK_DRIVER=redis
The Redis store works on a stock install: it opens its own phpredis connection from the cache store named by stores.redis.connection (by default cache.stores.redis, i.e. REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB, REDIS_TIMEOUT). If your app binds a connected \Redis instance in the container under \Redis::class, that instance is used instead. A connection or authentication failure throws when the lock manager is resolved.
Database Store
Pros:
- Uses existing database
- No additional infrastructure
Cons:
- Slower than Redis
- Potential table contention
Use for: Low-volume locks, minimal infrastructure
LOCK_DRIVER=database
LOCK_DB_TABLE=locks
The framework ships the locks table migration (key_id, token, expiration); php glueful migrate:run creates it. The store writes through the framework's database connection, or a dedicated connection when LOCK_DB_CONNECTION names a different engine; no container binding is needed.
Advanced Patterns
Long-Running Operations
Refresh lock during long operations:
$lock = $lockManager->createLock('large:import', ttl: 600);
if ($lock->acquire(true)) { // blocking acquire
try {
while (!done()) {
processChunk();
// Extend lock if running low
if ($lock->getRemainingLifetime() < 120) {
$lock->refresh(600);
}
}
} finally {
$lock->release();
}
}
Multiple Locks
Acquire multiple locks atomically:
$lockA = $lockManager->createLock('resource:a', ttl: 300);
$lockB = $lockManager->createLock('resource:b', ttl: 300);
if ($lockA->acquire() && $lockB->acquire()) {
try {
// Work with both resources
} finally {
$lockB->release();
$lockA->release();
}
}
Named Locks for Resources
// Per-user locks
$lockManager->executeWithLock("user:{$userId}:avatar", function () {
regenerateAvatar($userId);
});
// Per-resource locks
$lockManager->executeWithLock("report:{$reportId}:generate", function () {
generateReport($reportId);
});
Error Handling
use Symfony\Component\Lock\Exception\LockConflictedException;
try {
$lockManager->executeWithLock('inventory:sync', function () {
syncInventory();
}, 300);
} catch (LockConflictedException $e) {
// Lock already held
app($context, \Psr\Log\LoggerInterface::class)->info('Inventory sync skipped: already running');
// Optionally queue for retry
$queue = app($context, \Glueful\Queue\QueueManager::class);
$queue->push(RetryInventorySyncJob::class, [], queue: null, connection: null);
} catch (\Throwable $e) {
app($context, \Psr\Log\LoggerInterface::class)->error('Inventory sync failed', ['error' => $e->getMessage()]);
throw $e;
}
Blocking vs Non-Blocking
Non-Blocking (Default)
Returns immediately if lock unavailable:
$lock = $lockManager->createLock('resource');
if ($lock->acquire()) {
// Got lock
} else {
// Lock busy, skip or retry
}
Blocking
Waits until lock is available:
// Wait indefinitely
$lock->acquire(true);
// Wait with timeout using waitAndExecute
$lockManager->waitAndExecute('resource', function () {
// ...
}, maxWait: 30.0);
Monitoring
Track lock performance:
$start = microtime(true);
$lockManager->executeWithLock('feed:refresh', function () {
app($context, \Psr\Log\LoggerInterface::class)->info('Refreshing feed with exclusive lock');
refreshFeed();
});
$duration = microtime(true) - $start;
app($context, \Psr\Log\LoggerInterface::class)->info('Lock held for ' . $duration . 's');
Key Metrics
- Lock acquisition latency
- Conflict count per key
- Average lock hold duration
- Expired vs. explicit releases
- Wait time distribution
Naming Conventions
Use descriptive, hierarchical lock names that include the relevant identifiers — this keeps keys collision-free and makes monitoring readable:
// Good — scoped and specific
"queue:worker:{$queue}:{$workerId}"
"scheduler:job:{$jobName}"
"import:users:batch:{$batchId}"
"user:export:{$userId}:format:{$format}"
"report:generate:{$reportType}:date:{$date}"
// Avoid — generic names that collide
"lock"
"process"
"task"
Best Practices
1. Keep Critical Sections Small
// ✅ Good - minimal lock scope
$lockManager->executeWithLock('counter:increment', function () {
incrementCounter();
}, 5);
// ❌ Bad - large lock scope
$lockManager->executeWithLock('process:all', function () {
loadData();
transformData();
saveResults();
sendNotifications();
}, 600);
2. Use Idempotent Operations
$lockManager->executeWithLock('update:stats', function () {
// Safe to run multiple times
recalculateStats();
});
3. Set Realistic TTLs
// Typical duration + buffer
$lockManager->executeWithLock('import', function () {
importData(); // Usually takes 5 min
}, 600); // 10 min TTL
4. Name Locks Semantically
// ✅ Good - descriptive names
$lockManager->executeWithLock('user:42:avatar:regenerate', ...);
$lockManager->executeWithLock('report:daily-sales:generate', ...);
// ❌ Bad - generic names
$lockManager->executeWithLock('task1', ...);
$lockManager->executeWithLock('process', ...);
5. Avoid Unbounded Waits
// ✅ Good - bounded wait
$lockManager->waitAndExecute('task', $callback, maxWait: 30.0);
// ❌ Bad - indefinite wait
$lock->acquire(true); // Could wait forever
6. Partition for High Contention
// Split by shard instead of single lock
$shard = $userId % 10;
$lockManager->executeWithLock("process:shard:{$shard}", function () {
processUserData($userId);
});
Troubleshooting
Frequent lock conflicts?
- Reduce work inside critical section
- Add partitioning/sharding
- Use finer-grained lock keys
Locks not released after crashes?
- Always pass a TTL so a crashed holder's lock expires
- Keep
autoReleaseon (createLock($key, $ttl, true), the default) - Add monitoring for expired locks
High Redis usage?
- Reduce lock granularity
- Batch operations
- Review lock TTLs
Locks not working across servers?
- File store requires shared filesystem
- Switch to Redis or Database store
Redis lock store can't connect?
- Check the
cache.storesblock named byLOCK_REDIS_CONNECTION(defaultcache.stores.redis/REDIS_*)
A lock outlived the work, or expired mid-job?
- Calls without a TTL use
LOCK_TTL(300s); pass a TTL sized to the job, orrefresh()long-running locks
Database lock table growing?
- Add cleanup job for expired locks
- Reduce TTL durations
- Implement purge strategy
Scheduler Integration
The scheduler wraps every job run in executeWithLock("scheduler:job:{$jobName}", ..., 3600), so a job that is still running is skipped rather than started twice. If no lock manager can be resolved, jobs run unlocked and a warning is logged.
// config/schedule.php
'jobs' => [
[
'name' => 'daily-report',
'schedule' => '0 2 * * *',
'handler_class' => 'App\\Jobs\\DailyReportJob',
// locked automatically while it runs
],
],
Next Steps
- Queues & Jobs - Background processing
- Scheduling - Scheduled tasks
- Caching - Cache with locks
- Performance - Monitoring locks