- Move 12 markdown files from root to docs/ subdirectories - Organize documentation by category: • docs/troubleshooting/ (1 file) - Technical troubleshooting guides • docs/deployment/ (4 files) - Deployment and security documentation • docs/guides/ (3 files) - Feature-specific guides • docs/planning/ (4 files) - Planning and improvement proposals Root directory cleanup: - Reduced from 16 to 4 markdown files in root - Only essential project files remain: • CLAUDE.md (AI instructions) • README.md (Main project readme) • CLEANUP_PLAN.md (Current cleanup plan) • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements) This improves: ✅ Documentation discoverability ✅ Logical organization by purpose ✅ Clean root directory ✅ Better maintainability
813 lines
32 KiB
PHP
813 lines
32 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Queue\ValueObjects\LockKey;
|
|
use App\Framework\Queue\ValueObjects\WorkerId;
|
|
use App\Framework\Queue\ValueObjects\JobId;
|
|
use App\Framework\Queue\ValueObjects\QueueName;
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
|
|
describe('LockKey Value Object', function () {
|
|
|
|
describe('Construction and Validation', function () {
|
|
it('can create valid lock keys', function () {
|
|
$validKeys = [
|
|
'simple-lock',
|
|
'job.12345',
|
|
'queue.email_processing',
|
|
'worker.worker-123',
|
|
'resource.database_connection',
|
|
'batch.monthly_report_2024',
|
|
'a', // minimum length
|
|
str_repeat('a', 255) // maximum length
|
|
];
|
|
|
|
foreach ($validKeys as $key) {
|
|
$lockKey = LockKey::fromString($key);
|
|
expect($lockKey->toString())->toBe($key);
|
|
}
|
|
});
|
|
|
|
it('rejects invalid lock keys', function () {
|
|
$invalidKeys = [
|
|
'', // empty
|
|
str_repeat('a', 256), // too long
|
|
'lock with spaces',
|
|
'lock@invalid',
|
|
'lock#invalid',
|
|
'lock$invalid',
|
|
'lock%invalid',
|
|
'lock&invalid',
|
|
'lock*invalid',
|
|
'lock(invalid)',
|
|
'lock+invalid',
|
|
'lock=invalid',
|
|
'lock[invalid]',
|
|
'lock{invalid}',
|
|
'lock|invalid',
|
|
'lock\\invalid',
|
|
'lock:invalid',
|
|
'lock;invalid',
|
|
'lock"invalid',
|
|
'lock\'invalid',
|
|
'lock<invalid>',
|
|
'lock,invalid',
|
|
'lock?invalid',
|
|
'lock/invalid',
|
|
'lock~invalid',
|
|
'lock`invalid'
|
|
];
|
|
|
|
foreach ($invalidKeys as $key) {
|
|
expect(fn() => LockKey::fromString($key))
|
|
->toThrow(\InvalidArgumentException::class);
|
|
}
|
|
});
|
|
|
|
it('allows valid characters', function () {
|
|
$validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.';
|
|
$lockKey = LockKey::fromString($validChars);
|
|
|
|
expect($lockKey->toString())->toBe($validChars);
|
|
});
|
|
|
|
it('is readonly and immutable', function () {
|
|
$lockKey = LockKey::fromString('test-lock');
|
|
|
|
$reflection = new ReflectionClass($lockKey);
|
|
expect($reflection->isReadOnly())->toBeTrue();
|
|
|
|
$valueProperty = $reflection->getProperty('value');
|
|
expect($valueProperty->isReadOnly())->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('Factory Methods', function () {
|
|
it('creates lock key for job', function () {
|
|
$jobId = JobId::fromString('job-12345');
|
|
$lockKey = LockKey::forJob($jobId);
|
|
|
|
expect($lockKey->toString())->toBe('job.job-12345');
|
|
});
|
|
|
|
it('creates lock key for queue', function () {
|
|
$queueName = QueueName::fromString('email-processing');
|
|
$lockKey = LockKey::forQueue($queueName);
|
|
|
|
expect($lockKey->toString())->toBe('queue.email-processing');
|
|
});
|
|
|
|
it('creates lock key for worker', function () {
|
|
$workerId = WorkerId::fromString('worker-123');
|
|
$lockKey = LockKey::forWorker($workerId);
|
|
|
|
expect($lockKey->toString())->toBe('worker.worker-123');
|
|
});
|
|
|
|
it('creates lock key for resource', function () {
|
|
$lockKey = LockKey::forResource('database', 'primary_db');
|
|
|
|
expect($lockKey->toString())->toBe('database.primary_db');
|
|
});
|
|
|
|
it('creates lock key for batch', function () {
|
|
$lockKey = LockKey::forBatch('monthly-report-2024-01');
|
|
|
|
expect($lockKey->toString())->toBe('batch.monthly-report-2024-01');
|
|
});
|
|
});
|
|
|
|
describe('String Operations', function () {
|
|
it('equals() compares lock keys correctly', function () {
|
|
$key1 = LockKey::fromString('test-lock');
|
|
$key2 = LockKey::fromString('test-lock');
|
|
$key3 = LockKey::fromString('other-lock');
|
|
|
|
expect($key1->equals($key2))->toBeTrue();
|
|
expect($key1->equals($key3))->toBeFalse();
|
|
expect($key2->equals($key3))->toBeFalse();
|
|
});
|
|
|
|
it('string representation works correctly', function () {
|
|
$value = 'test-lock-key';
|
|
$lockKey = LockKey::fromString($value);
|
|
|
|
expect($lockKey->toString())->toBe($value);
|
|
expect((string) $lockKey)->toBe($value);
|
|
expect($lockKey->jsonSerialize())->toBe($value);
|
|
});
|
|
|
|
it('withPrefix() creates new instance with prefix', function () {
|
|
$original = LockKey::fromString('test-lock');
|
|
$prefixed = $original->withPrefix('system');
|
|
|
|
expect($prefixed->toString())->toBe('system.test-lock');
|
|
expect($original->toString())->toBe('test-lock'); // Original unchanged
|
|
});
|
|
|
|
it('withSuffix() creates new instance with suffix', function () {
|
|
$original = LockKey::fromString('test-lock');
|
|
$suffixed = $original->withSuffix('v2');
|
|
|
|
expect($suffixed->toString())->toBe('test-lock.v2');
|
|
expect($original->toString())->toBe('test-lock'); // Original unchanged
|
|
});
|
|
|
|
it('can chain prefix and suffix', function () {
|
|
$original = LockKey::fromString('lock');
|
|
$chained = $original->withPrefix('system')->withSuffix('temp');
|
|
|
|
expect($chained->toString())->toBe('system.lock.temp');
|
|
expect($original->toString())->toBe('lock'); // Original unchanged
|
|
});
|
|
|
|
it('matches() supports pattern matching', function () {
|
|
$lockKey = LockKey::fromString('job.email.batch-123');
|
|
|
|
expect($lockKey->matches('job.*'))->toBeTrue();
|
|
expect($lockKey->matches('job.email.*'))->toBeTrue();
|
|
expect($lockKey->matches('*.batch-123'))->toBeTrue();
|
|
expect($lockKey->matches('*email*'))->toBeTrue();
|
|
expect($lockKey->matches('worker.*'))->toBeFalse();
|
|
expect($lockKey->matches('*.queue.*'))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', function () {
|
|
it('handles minimum and maximum length keys', function () {
|
|
$minKey = LockKey::fromString('x');
|
|
expect($minKey->toString())->toBe('x');
|
|
|
|
$maxKey = LockKey::fromString(str_repeat('a', 255));
|
|
expect($maxKey->toString())->toBe(str_repeat('a', 255));
|
|
});
|
|
|
|
it('validates prefix and suffix additions', function () {
|
|
$baseKey = LockKey::fromString(str_repeat('a', 250)); // Near max length
|
|
|
|
// This should work (255 chars total)
|
|
$withShortPrefix = $baseKey->withPrefix('x');
|
|
expect(strlen($withShortPrefix->toString()))->toBe(252); // 'x' + '.' + 250
|
|
|
|
// This should fail (would exceed 255 chars)
|
|
expect(fn() => $baseKey->withPrefix('toolong'))
|
|
->toThrow(\InvalidArgumentException::class);
|
|
});
|
|
|
|
it('handles complex key structures', function () {
|
|
$complexKey = LockKey::fromString('system.queue.email-processing.worker-123.batch-456');
|
|
|
|
expect($complexKey->matches('system.*'))->toBeTrue();
|
|
expect($complexKey->matches('*.queue.*'))->toBeTrue();
|
|
expect($complexKey->matches('*email-processing*'))->toBeTrue();
|
|
expect($complexKey->matches('*batch-456'))->toBeTrue();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Distributed Lock Mock Implementation', function () {
|
|
|
|
beforeEach(function () {
|
|
// Create a mock distributed lock for testing
|
|
$this->distributedLock = new class {
|
|
private array $locks = [];
|
|
|
|
public function acquire(LockKey $key, WorkerId $workerId, Duration $ttl): bool {
|
|
$keyStr = $key->toString();
|
|
$now = time();
|
|
|
|
// Check if lock already exists and is not expired
|
|
if (isset($this->locks[$keyStr])) {
|
|
$lock = $this->locks[$keyStr];
|
|
if ($lock['expires_at'] > $now) {
|
|
return false; // Lock already held
|
|
}
|
|
}
|
|
|
|
// Acquire lock
|
|
$this->locks[$keyStr] = [
|
|
'worker_id' => $workerId->toString(),
|
|
'acquired_at' => $now,
|
|
'expires_at' => $now + $ttl->toSeconds(),
|
|
'ttl' => $ttl->toSeconds()
|
|
];
|
|
|
|
return true;
|
|
}
|
|
|
|
public function extend(LockKey $key, WorkerId $workerId, Duration $ttl): bool {
|
|
$keyStr = $key->toString();
|
|
$now = time();
|
|
|
|
if (!isset($this->locks[$keyStr])) {
|
|
return false; // Lock doesn't exist
|
|
}
|
|
|
|
$lock = $this->locks[$keyStr];
|
|
|
|
// Only the lock owner can extend it and it must not be expired
|
|
if ($lock['worker_id'] !== $workerId->toString() || $lock['expires_at'] <= $now) {
|
|
return false;
|
|
}
|
|
|
|
// Extend the lock
|
|
$this->locks[$keyStr]['expires_at'] = $now + $ttl->toSeconds();
|
|
$this->locks[$keyStr]['ttl'] = $ttl->toSeconds();
|
|
|
|
return true;
|
|
}
|
|
|
|
public function release(LockKey $key, WorkerId $workerId): bool {
|
|
$keyStr = $key->toString();
|
|
|
|
if (!isset($this->locks[$keyStr])) {
|
|
return false; // Lock doesn't exist
|
|
}
|
|
|
|
$lock = $this->locks[$keyStr];
|
|
|
|
// Only the lock owner can release it
|
|
if ($lock['worker_id'] !== $workerId->toString()) {
|
|
return false;
|
|
}
|
|
|
|
unset($this->locks[$keyStr]);
|
|
return true;
|
|
}
|
|
|
|
public function exists(LockKey $key): bool {
|
|
$keyStr = $key->toString();
|
|
$now = time();
|
|
|
|
if (!isset($this->locks[$keyStr])) {
|
|
return false;
|
|
}
|
|
|
|
$lock = $this->locks[$keyStr];
|
|
return $lock['expires_at'] > $now;
|
|
}
|
|
|
|
public function getLockInfo(LockKey $key): ?array {
|
|
$keyStr = $key->toString();
|
|
$now = time();
|
|
|
|
if (!isset($this->locks[$keyStr])) {
|
|
return null;
|
|
}
|
|
|
|
$lock = $this->locks[$keyStr];
|
|
|
|
if ($lock['expires_at'] <= $now) {
|
|
return null; // Expired
|
|
}
|
|
|
|
return [
|
|
'lock_key' => $keyStr,
|
|
'worker_id' => $lock['worker_id'],
|
|
'acquired_at' => date('Y-m-d H:i:s', $lock['acquired_at']),
|
|
'expires_at' => date('Y-m-d H:i:s', $lock['expires_at']),
|
|
'ttl_seconds' => $lock['expires_at'] - $now
|
|
];
|
|
}
|
|
|
|
public function acquireWithTimeout(LockKey $key, WorkerId $workerId, Duration $ttl, Duration $timeout): bool {
|
|
$startTime = microtime(true);
|
|
$timeoutSeconds = $timeout->toSeconds();
|
|
|
|
while ((microtime(true) - $startTime) < $timeoutSeconds) {
|
|
if ($this->acquire($key, $workerId, $ttl)) {
|
|
return true;
|
|
}
|
|
usleep(100000); // 100ms
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function releaseAllWorkerLocks(WorkerId $workerId): int {
|
|
$workerIdStr = $workerId->toString();
|
|
$released = 0;
|
|
|
|
foreach ($this->locks as $key => $lock) {
|
|
if ($lock['worker_id'] === $workerIdStr) {
|
|
unset($this->locks[$key]);
|
|
$released++;
|
|
}
|
|
}
|
|
|
|
return $released;
|
|
}
|
|
|
|
public function cleanupExpiredLocks(): int {
|
|
$now = time();
|
|
$cleaned = 0;
|
|
|
|
foreach ($this->locks as $key => $lock) {
|
|
if ($lock['expires_at'] <= $now) {
|
|
unset($this->locks[$key]);
|
|
$cleaned++;
|
|
}
|
|
}
|
|
|
|
return $cleaned;
|
|
}
|
|
|
|
public function getLockStatistics(): array {
|
|
$now = time();
|
|
$activeLocks = 0;
|
|
$expiredLocks = 0;
|
|
$workers = [];
|
|
|
|
foreach ($this->locks as $lock) {
|
|
if ($lock['expires_at'] > $now) {
|
|
$activeLocks++;
|
|
$workers[$lock['worker_id']] = true;
|
|
} else {
|
|
$expiredLocks++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total_locks' => count($this->locks),
|
|
'active_locks' => $activeLocks,
|
|
'expired_locks' => $expiredLocks,
|
|
'unique_workers' => count($workers),
|
|
'avg_ttl_seconds' => $activeLocks > 0 ?
|
|
array_sum(array_map(fn($lock) => $lock['ttl'], array_filter($this->locks, fn($lock) => $lock['expires_at'] > $now))) / $activeLocks
|
|
: 0
|
|
];
|
|
}
|
|
};
|
|
});
|
|
|
|
describe('Basic Lock Operations', function () {
|
|
it('can acquire and release locks', function () {
|
|
$lockKey = LockKey::fromString('test-lock');
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Initially lock should not exist
|
|
expect($this->distributedLock->exists($lockKey))->toBeFalse();
|
|
|
|
// Acquire lock
|
|
$acquired = $this->distributedLock->acquire($lockKey, $workerId, $ttl);
|
|
expect($acquired)->toBeTrue();
|
|
|
|
// Lock should now exist
|
|
expect($this->distributedLock->exists($lockKey))->toBeTrue();
|
|
|
|
// Get lock info
|
|
$info = $this->distributedLock->getLockInfo($lockKey);
|
|
expect($info)->not->toBeNull();
|
|
expect($info['worker_id'])->toBe('worker-1');
|
|
expect($info['ttl_seconds'])->toBeGreaterThan(299);
|
|
|
|
// Release lock
|
|
$released = $this->distributedLock->release($lockKey, $workerId);
|
|
expect($released)->toBeTrue();
|
|
|
|
// Lock should no longer exist
|
|
expect($this->distributedLock->exists($lockKey))->toBeFalse();
|
|
});
|
|
|
|
it('prevents double acquisition of same lock', function () {
|
|
$lockKey = LockKey::fromString('exclusive-lock');
|
|
$worker1 = WorkerId::fromString('worker-1');
|
|
$worker2 = WorkerId::fromString('worker-2');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Worker 1 acquires lock
|
|
$acquired1 = $this->distributedLock->acquire($lockKey, $worker1, $ttl);
|
|
expect($acquired1)->toBeTrue();
|
|
|
|
// Worker 2 cannot acquire same lock
|
|
$acquired2 = $this->distributedLock->acquire($lockKey, $worker2, $ttl);
|
|
expect($acquired2)->toBeFalse();
|
|
|
|
// Worker 1 cannot acquire it again
|
|
$acquiredAgain = $this->distributedLock->acquire($lockKey, $worker1, $ttl);
|
|
expect($acquiredAgain)->toBeFalse();
|
|
});
|
|
|
|
it('only allows lock owner to release lock', function () {
|
|
$lockKey = LockKey::fromString('owner-lock');
|
|
$owner = WorkerId::fromString('owner-worker');
|
|
$other = WorkerId::fromString('other-worker');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Owner acquires lock
|
|
$this->distributedLock->acquire($lockKey, $owner, $ttl);
|
|
|
|
// Other worker cannot release it
|
|
$released = $this->distributedLock->release($lockKey, $other);
|
|
expect($released)->toBeFalse();
|
|
|
|
// Lock should still exist
|
|
expect($this->distributedLock->exists($lockKey))->toBeTrue();
|
|
|
|
// Owner can release it
|
|
$released = $this->distributedLock->release($lockKey, $owner);
|
|
expect($released)->toBeTrue();
|
|
|
|
// Lock should no longer exist
|
|
expect($this->distributedLock->exists($lockKey))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('Lock Extension', function () {
|
|
it('can extend lock TTL', function () {
|
|
$lockKey = LockKey::fromString('extend-test');
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$initialTtl = Duration::fromSeconds(300);
|
|
$extendedTtl = Duration::fromSeconds(600);
|
|
|
|
// Acquire lock
|
|
$this->distributedLock->acquire($lockKey, $workerId, $initialTtl);
|
|
|
|
$initialInfo = $this->distributedLock->getLockInfo($lockKey);
|
|
expect($initialInfo['ttl_seconds'])->toBeGreaterThan(299);
|
|
|
|
// Extend lock
|
|
$extended = $this->distributedLock->extend($lockKey, $workerId, $extendedTtl);
|
|
expect($extended)->toBeTrue();
|
|
|
|
$extendedInfo = $this->distributedLock->getLockInfo($lockKey);
|
|
expect($extendedInfo['ttl_seconds'])->toBeGreaterThan(599);
|
|
});
|
|
|
|
it('only allows lock owner to extend lock', function () {
|
|
$lockKey = LockKey::fromString('extend-owner-test');
|
|
$owner = WorkerId::fromString('owner-worker');
|
|
$other = WorkerId::fromString('other-worker');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Owner acquires lock
|
|
$this->distributedLock->acquire($lockKey, $owner, $ttl);
|
|
|
|
// Other worker cannot extend it
|
|
$extended = $this->distributedLock->extend($lockKey, $other, $ttl);
|
|
expect($extended)->toBeFalse();
|
|
|
|
// Owner can extend it
|
|
$extended = $this->distributedLock->extend($lockKey, $owner, $ttl);
|
|
expect($extended)->toBeTrue();
|
|
});
|
|
|
|
it('cannot extend non-existent lock', function () {
|
|
$lockKey = LockKey::fromString('non-existent-lock');
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
$extended = $this->distributedLock->extend($lockKey, $workerId, $ttl);
|
|
expect($extended)->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('Lock Timeout and Acquisition', function () {
|
|
it('can acquire lock with timeout', function () {
|
|
$lockKey = LockKey::fromString('timeout-test');
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$ttl = Duration::fromSeconds(300);
|
|
$timeout = Duration::fromSeconds(1);
|
|
|
|
// Should acquire immediately since lock doesn't exist
|
|
$start = microtime(true);
|
|
$acquired = $this->distributedLock->acquireWithTimeout($lockKey, $workerId, $ttl, $timeout);
|
|
$elapsed = microtime(true) - $start;
|
|
|
|
expect($acquired)->toBeTrue();
|
|
expect($elapsed)->toBeLessThan(0.1); // Should be immediate
|
|
});
|
|
|
|
it('times out when lock is held by another worker', function () {
|
|
$lockKey = LockKey::fromString('timeout-fail-test');
|
|
$worker1 = WorkerId::fromString('worker-1');
|
|
$worker2 = WorkerId::fromString('worker-2');
|
|
$ttl = Duration::fromSeconds(300);
|
|
$timeout = Duration::fromSeconds(0.5);
|
|
|
|
// Worker 1 acquires lock
|
|
$this->distributedLock->acquire($lockKey, $worker1, $ttl);
|
|
|
|
// Worker 2 tries to acquire with timeout
|
|
$start = microtime(true);
|
|
$acquired = $this->distributedLock->acquireWithTimeout($lockKey, $worker2, $ttl, $timeout);
|
|
$elapsed = microtime(true) - $start;
|
|
|
|
expect($acquired)->toBeFalse();
|
|
expect($elapsed)->toBeGreaterThan(0.4); // Should have waited
|
|
expect($elapsed)->toBeLessThan(0.7); // But not too long
|
|
});
|
|
});
|
|
|
|
describe('Bulk Operations', function () {
|
|
it('can release all locks for a worker', function () {
|
|
$worker1 = WorkerId::fromString('worker-1');
|
|
$worker2 = WorkerId::fromString('worker-2');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Worker 1 acquires multiple locks
|
|
$lock1 = LockKey::fromString('lock-1');
|
|
$lock2 = LockKey::fromString('lock-2');
|
|
$lock3 = LockKey::fromString('lock-3');
|
|
|
|
$this->distributedLock->acquire($lock1, $worker1, $ttl);
|
|
$this->distributedLock->acquire($lock2, $worker1, $ttl);
|
|
$this->distributedLock->acquire($lock3, $worker2, $ttl); // Different worker
|
|
|
|
// Verify locks exist
|
|
expect($this->distributedLock->exists($lock1))->toBeTrue();
|
|
expect($this->distributedLock->exists($lock2))->toBeTrue();
|
|
expect($this->distributedLock->exists($lock3))->toBeTrue();
|
|
|
|
// Release all worker1 locks
|
|
$released = $this->distributedLock->releaseAllWorkerLocks($worker1);
|
|
expect($released)->toBe(2);
|
|
|
|
// Worker1 locks should be gone, worker2 lock should remain
|
|
expect($this->distributedLock->exists($lock1))->toBeFalse();
|
|
expect($this->distributedLock->exists($lock2))->toBeFalse();
|
|
expect($this->distributedLock->exists($lock3))->toBeTrue();
|
|
});
|
|
|
|
it('can cleanup expired locks', function () {
|
|
$lockKey = LockKey::fromString('expiring-lock');
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$shortTtl = Duration::fromSeconds(1);
|
|
|
|
// Acquire lock with short TTL
|
|
$this->distributedLock->acquire($lockKey, $workerId, $shortTtl);
|
|
expect($this->distributedLock->exists($lockKey))->toBeTrue();
|
|
|
|
// Wait for expiration
|
|
sleep(2);
|
|
|
|
// Lock should appear expired but still be in storage
|
|
expect($this->distributedLock->exists($lockKey))->toBeFalse();
|
|
|
|
// Cleanup should remove expired locks
|
|
$cleaned = $this->distributedLock->cleanupExpiredLocks();
|
|
expect($cleaned)->toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Lock Statistics', function () {
|
|
it('provides accurate lock statistics', function () {
|
|
$worker1 = WorkerId::fromString('worker-1');
|
|
$worker2 = WorkerId::fromString('worker-2');
|
|
$ttl = Duration::fromSeconds(300);
|
|
|
|
// Initially no locks
|
|
$stats = $this->distributedLock->getLockStatistics();
|
|
expect($stats['total_locks'])->toBe(0);
|
|
expect($stats['active_locks'])->toBe(0);
|
|
expect($stats['unique_workers'])->toBe(0);
|
|
|
|
// Add some locks
|
|
$this->distributedLock->acquire(LockKey::fromString('lock-1'), $worker1, $ttl);
|
|
$this->distributedLock->acquire(LockKey::fromString('lock-2'), $worker1, $ttl);
|
|
$this->distributedLock->acquire(LockKey::fromString('lock-3'), $worker2, $ttl);
|
|
|
|
$stats = $this->distributedLock->getLockStatistics();
|
|
expect($stats['total_locks'])->toBe(3);
|
|
expect($stats['active_locks'])->toBe(3);
|
|
expect($stats['unique_workers'])->toBe(2);
|
|
expect($stats['avg_ttl_seconds'])->toBe(300.0);
|
|
});
|
|
|
|
it('distinguishes between active and expired locks', function () {
|
|
$workerId = WorkerId::fromString('worker-1');
|
|
$longTtl = Duration::fromSeconds(300);
|
|
$shortTtl = Duration::fromSeconds(1);
|
|
|
|
// Add active and soon-to-expire locks
|
|
$this->distributedLock->acquire(LockKey::fromString('active-lock'), $workerId, $longTtl);
|
|
$this->distributedLock->acquire(LockKey::fromString('expiring-lock'), $workerId, $shortTtl);
|
|
|
|
// Initially both active
|
|
$stats = $this->distributedLock->getLockStatistics();
|
|
expect($stats['active_locks'])->toBe(2);
|
|
expect($stats['expired_locks'])->toBe(0);
|
|
|
|
// Wait for one to expire
|
|
sleep(2);
|
|
|
|
$stats = $this->distributedLock->getLockStatistics();
|
|
expect($stats['total_locks'])->toBe(2); // Still in storage
|
|
expect($stats['active_locks'])->toBe(1); // One still active
|
|
expect($stats['expired_locks'])->toBe(1); // One expired
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Distributed Lock Integration Scenarios', function () {
|
|
|
|
beforeEach(function () {
|
|
$this->distributedLock = new class {
|
|
private array $locks = [];
|
|
public function acquire(LockKey $key, WorkerId $workerId, Duration $ttl): bool {
|
|
$keyStr = $key->toString();
|
|
$now = time();
|
|
if (isset($this->locks[$keyStr]) && $this->locks[$keyStr]['expires_at'] > $now) {
|
|
return false;
|
|
}
|
|
$this->locks[$keyStr] = [
|
|
'worker_id' => $workerId->toString(),
|
|
'acquired_at' => $now,
|
|
'expires_at' => $now + $ttl->toSeconds()
|
|
];
|
|
return true;
|
|
}
|
|
public function release(LockKey $key, WorkerId $workerId): bool {
|
|
$keyStr = $key->toString();
|
|
if (!isset($this->locks[$keyStr]) || $this->locks[$keyStr]['worker_id'] !== $workerId->toString()) {
|
|
return false;
|
|
}
|
|
unset($this->locks[$keyStr]);
|
|
return true;
|
|
}
|
|
public function exists(LockKey $key): bool {
|
|
$keyStr = $key->toString();
|
|
return isset($this->locks[$keyStr]) && $this->locks[$keyStr]['expires_at'] > time();
|
|
}
|
|
};
|
|
|
|
$this->emailJob = new class {
|
|
public function __construct(
|
|
public string $batchId = 'email-batch-123',
|
|
public int $recipientCount = 1000
|
|
) {}
|
|
};
|
|
|
|
$this->reportJob = new class {
|
|
public function __construct(
|
|
public string $reportId = 'monthly-sales-2024',
|
|
public string $resourceType = 'database'
|
|
) {}
|
|
};
|
|
});
|
|
|
|
it('demonstrates job-level locking for email batches', function () {
|
|
$batchLockKey = LockKey::forBatch($this->emailJob->batchId);
|
|
$worker1 = WorkerId::fromString('email-worker-1');
|
|
$worker2 = WorkerId::fromString('email-worker-2');
|
|
$processingTime = Duration::fromMinutes(30);
|
|
|
|
// Worker 1 acquires lock for batch processing
|
|
$acquired = $this->distributedLock->acquire($batchLockKey, $worker1, $processingTime);
|
|
expect($acquired)->toBeTrue();
|
|
|
|
// Worker 2 cannot process the same batch
|
|
$blocked = $this->distributedLock->acquire($batchLockKey, $worker2, $processingTime);
|
|
expect($blocked)->toBeFalse();
|
|
|
|
// After processing, worker 1 releases the lock
|
|
$released = $this->distributedLock->release($batchLockKey, $worker1);
|
|
expect($released)->toBeTrue();
|
|
|
|
// Now worker 2 can acquire the lock
|
|
$nowAvailable = $this->distributedLock->acquire($batchLockKey, $worker2, $processingTime);
|
|
expect($nowAvailable)->toBeTrue();
|
|
});
|
|
|
|
it('demonstrates resource-level locking for reports', function () {
|
|
$resourceLockKey = LockKey::forResource($this->reportJob->resourceType, 'primary');
|
|
$reportWorker = WorkerId::fromString('report-worker-1');
|
|
$maintenanceWorker = WorkerId::fromString('maintenance-worker');
|
|
$reportTime = Duration::fromMinutes(15);
|
|
$maintenanceTime = Duration::fromHours(2);
|
|
|
|
// Report worker acquires database resource
|
|
$reportAcquired = $this->distributedLock->acquire($resourceLockKey, $reportWorker, $reportTime);
|
|
expect($reportAcquired)->toBeTrue();
|
|
|
|
// Maintenance worker cannot access the resource
|
|
$maintenanceBlocked = $this->distributedLock->acquire($resourceLockKey, $maintenanceWorker, $maintenanceTime);
|
|
expect($maintenanceBlocked)->toBeFalse();
|
|
|
|
// Report completes and releases resource
|
|
$reportReleased = $this->distributedLock->release($resourceLockKey, $reportWorker);
|
|
expect($reportReleased)->toBeTrue();
|
|
|
|
// Maintenance can now proceed
|
|
$maintenanceAcquired = $this->distributedLock->acquire($resourceLockKey, $maintenanceWorker, $maintenanceTime);
|
|
expect($maintenanceAcquired)->toBeTrue();
|
|
});
|
|
|
|
it('demonstrates queue-level locking for exclusive processing', function () {
|
|
$emailQueueKey = LockKey::forQueue(QueueName::fromString('email-queue'));
|
|
$priorityWorker = WorkerId::fromString('priority-worker');
|
|
$normalWorker = WorkerId::fromString('normal-worker');
|
|
$exclusiveTime = Duration::fromMinutes(5);
|
|
|
|
// Priority worker takes exclusive access to queue
|
|
$exclusiveAcquired = $this->distributedLock->acquire($emailQueueKey, $priorityWorker, $exclusiveTime);
|
|
expect($exclusiveAcquired)->toBeTrue();
|
|
|
|
// Normal worker must wait
|
|
$normalBlocked = $this->distributedLock->acquire($emailQueueKey, $normalWorker, Duration::fromMinutes(1));
|
|
expect($normalBlocked)->toBeFalse();
|
|
|
|
// After priority processing, queue becomes available
|
|
$exclusiveReleased = $this->distributedLock->release($emailQueueKey, $priorityWorker);
|
|
expect($exclusiveReleased)->toBeTrue();
|
|
|
|
$normalAcquired = $this->distributedLock->acquire($emailQueueKey, $normalWorker, Duration::fromMinutes(10));
|
|
expect($normalAcquired)->toBeTrue();
|
|
});
|
|
|
|
it('demonstrates worker-level locking for maintenance operations', function () {
|
|
$worker1 = WorkerId::fromString('maintenance-worker-1');
|
|
$worker1LockKey = LockKey::forWorker($worker1);
|
|
$maintenanceSystem = WorkerId::fromString('maintenance-system');
|
|
$maintenanceTime = Duration::fromHours(1);
|
|
|
|
// Worker is performing normal operations
|
|
expect($this->distributedLock->exists($worker1LockKey))->toBeFalse();
|
|
|
|
// Maintenance system needs to pause the worker
|
|
$maintenanceLock = $this->distributedLock->acquire($worker1LockKey, $maintenanceSystem, $maintenanceTime);
|
|
expect($maintenanceLock)->toBeTrue();
|
|
|
|
// Worker cannot proceed with new jobs while maintenance lock is held
|
|
$workerBlocked = $this->distributedLock->acquire($worker1LockKey, $worker1, Duration::fromMinutes(1));
|
|
expect($workerBlocked)->toBeFalse();
|
|
|
|
// After maintenance, worker can resume
|
|
$maintenanceReleased = $this->distributedLock->release($worker1LockKey, $maintenanceSystem);
|
|
expect($maintenanceReleased)->toBeTrue();
|
|
|
|
$workerResumed = $this->distributedLock->acquire($worker1LockKey, $worker1, Duration::fromMinutes(30));
|
|
expect($workerResumed)->toBeTrue();
|
|
});
|
|
|
|
it('demonstrates hierarchical locking patterns', function () {
|
|
$systemLock = LockKey::fromString('system.maintenance');
|
|
$queueLock = LockKey::fromString('system.queue.email');
|
|
$jobLock = LockKey::fromString('system.queue.email.job-123');
|
|
|
|
$maintenanceWorker = WorkerId::fromString('maintenance-worker');
|
|
$queueWorker = WorkerId::fromString('queue-worker');
|
|
$jobWorker = WorkerId::fromString('job-worker');
|
|
|
|
// System-wide maintenance lock
|
|
$systemAcquired = $this->distributedLock->acquire($systemLock, $maintenanceWorker, Duration::fromHours(1));
|
|
expect($systemAcquired)->toBeTrue();
|
|
|
|
// Queue-level operations should be blocked during system maintenance
|
|
$queueBlocked = $this->distributedLock->acquire($queueLock, $queueWorker, Duration::fromMinutes(30));
|
|
expect($queueBlocked)->toBeFalse();
|
|
|
|
// Job-level operations should also be blocked
|
|
$jobBlocked = $this->distributedLock->acquire($jobLock, $jobWorker, Duration::fromMinutes(5));
|
|
expect($jobBlocked)->toBeFalse();
|
|
|
|
// After system maintenance
|
|
$this->distributedLock->release($systemLock, $maintenanceWorker);
|
|
|
|
// Lower-level operations can proceed
|
|
$queueNowAcquired = $this->distributedLock->acquire($queueLock, $queueWorker, Duration::fromMinutes(30));
|
|
expect($queueNowAcquired)->toBeTrue();
|
|
});
|
|
}); |