Преглед изворни кода

Initial release of Michel Lock library v1.0.0

michelphp пре 1 недеља
комит
b93e5d1cb2
8 измењених фајлова са 528 додато и 0 уклоњено
  1. 3 0
      .gitignore
  2. 89 0
      README.md
  3. 25 0
      composer.json
  4. 42 0
      src/Handler/FlockHandler.php
  5. 23 0
      src/LockHandlerInterface.php
  6. 79 0
      src/Locker.php
  7. 124 0
      tests/FlockHandlerTest.php
  8. 143 0
      tests/LockerTest.php

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+.idea/
+vendor/
+composer.lock

+ 89 - 0
README.md

@@ -0,0 +1,89 @@
+# Michel Lock
+
+A flexible PHP locking library designed to prevent race conditions in CLI and web environments. It provides a simple `Locker` class that delegates the actual locking mechanism to a `LockHandlerInterface` implementation.
+
+## Features
+
+*   **Simple API**: Easy to use `lock()` and `unlock()` methods.
+*   **Handler Abstraction**: Easily swap between different locking backends (e.g., `FlockHandler` for file-based locking).
+*   **Automatic Cleanup**: Locks are automatically released when the `Locker` instance is destroyed or (optionally) on script shutdown.
+*   **Blocking & Non-Blocking**: Supports both blocking (wait for lock) and non-blocking (fail immediately) modes.
+
+## Installation
+
+```bash
+composer require michel/lock
+```
+
+## Usage
+
+### Basic Usage (Non-Blocking)
+
+By default or when passing `false` as the second argument, the lock attempt is non-blocking. It returns `false` immediately if the lock is already held by another process.
+
+```php
+<?php
+
+use Michel\Lock\Locker;
+use Michel\Lock\Handler\FlockHandler;
+
+// 1. Create a handler (e.g., file-based locking)
+$handler = new FlockHandler('/tmp/locks');
+
+// 2. Instantiate the Locker
+$locker = new Locker($handler);
+
+// 3. Acquire a lock
+// 'my_process' is the key.
+// false = non-blocking mode (return false immediately if locked)
+if ($locker->lock('my_process', false)) {
+    echo "Lock acquired!\n";
+
+    // Do some critical work...
+    sleep(5);
+
+    // 4. Release the lock
+    $locker->unlock('my_process');
+} else {
+    echo "Could not acquire lock. Another process is running.\n";
+}
+```
+
+### Blocking Mode (Wait for Lock)
+
+If you want the script to pause and wait until the lock becomes available (e.g., waiting for another process to finish), set the second argument to `true`.
+
+```php
+// Try to acquire lock, waiting indefinitely until it is available
+// true = blocking mode
+if ($locker->lock('heavy_task', true)) {
+    echo "Lock acquired after waiting (if necessary).\n";
+    
+    // Perform task
+    // ...
+    
+    $locker->unlock('heavy_task');
+}
+```
+
+### Automatic Unlock on Shutdown
+
+You can ensure locks are released even if the script is killed or ends unexpectedly by using `unlockIfKill()`.
+
+```php
+$locker = new Locker($handler);
+$locker->unlockIfKill(); // Registers a shutdown function
+
+if ($locker->lock('critical_job')) {
+    // ... work ...
+}
+// Lock will be released automatically when script ends
+```
+
+### Key Validation
+
+Keys must be non-empty and contain only alphanumeric characters, underscores, and dots (`/^[a-zA-Z0-9_.]+$/`).
+
+## License
+
+MPL-2.0 License.

+ 25 - 0
composer.json

@@ -0,0 +1,25 @@
+{
+  "name": "michel/lock",
+  "description": "A flexible PHP locking library designed to prevent race conditions in CLI and web environments.",
+  "type": "library",
+  "license": "MPL-2.0",
+  "keywords": ["lock", "flock", "mutex", "process-synchronization", "native-php"],
+  "authors": [
+    {
+      "name": "Michel.F"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\Lock\\": "src",
+      "Test\\Michel\\Lock\\": "tests"
+    }
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-pdo": "*"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  }
+}

+ 42 - 0
src/Handler/FlockHandler.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Michel\Lock\Handler;
+
+use Michel\Lock\LockHandlerInterface;
+
+class FlockHandler implements LockHandlerInterface
+{
+    private string $lockDir;
+    private array $resources = [];
+    public function __construct(string $lockDir = null)
+    {
+        $this->lockDir = $lockDir ?? sys_get_temp_dir();
+    }
+    public function lock(string $key, bool $wait = false): bool
+    {
+        $filePath = $this->lockDir . DIRECTORY_SEPARATOR . 'michel_lock_' . $key.'.lock';
+        $handle = fopen($filePath, 'c');
+
+        if (!$handle) {
+            return false;
+        }
+
+        $flags = $wait ? LOCK_EX : LOCK_EX | LOCK_NB;
+        if (flock($handle, $flags)) {
+            $this->resources[$key] = $handle;
+            return true;
+        }
+
+        fclose($handle);
+        return false;
+    }
+
+    public function unlock(string $key): void
+    {
+        if (isset($this->resources[$key])) {
+            flock($this->resources[$key], LOCK_UN);
+            fclose($this->resources[$key]);
+            unset($this->resources[$key]);
+        }
+    }
+}

+ 23 - 0
src/LockHandlerInterface.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Michel\Lock;
+
+interface LockHandlerInterface
+{
+    /**
+     * Attempts to acquire an exclusive lock for the given key.
+     *
+     * @param string $key  The unique identifier for the lock.
+     * @param bool $wait   Whether to wait (blocking mode) until the lock becomes
+     * available or return false immediately.
+     * * @return bool True if the lock was successfully acquired, false otherwise.
+     */
+    public function lock(string $key, bool $wait = false): bool;
+
+    /**
+     * Releases the lock associated with the given key.
+     *
+     * @param string $key The unique identifier for the lock to be released.
+     */
+    public function unlock(string $key): void;
+}

+ 79 - 0
src/Locker.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Michel\Lock;
+
+use Exception;
+use InvalidArgumentException;
+
+final class Locker
+{
+    private LockHandlerInterface $lockHandler;
+    private array $handles = [];
+
+    public function __construct(LockHandlerInterface $lockHandler)
+    {
+        $this->lockHandler = $lockHandler;
+    }
+
+    public function unlockIfKill(): self
+    {
+        register_shutdown_function([$this, 'unlockAll']);
+        return $this;
+    }
+
+    public function lock(string $key, bool $wait = false): bool
+    {
+        if (empty($key)) {
+            throw new InvalidArgumentException(
+                'Locker::lock() method error: A non-empty key (ex: "my_key", "my.key") must be defined.'
+            );
+        }
+
+        if (!preg_match('/^[a-zA-Z0-9_.]+$/', $key)) {
+            throw new InvalidArgumentException(sprintf(
+                'Invalid key name "%s": only alphanumeric characters, underscores (_), and dots (.) are allowed.',
+                $key
+            ));
+        }
+        $value = $this->lockHandler->lock($key, $wait);
+        if ($value === true) {
+            $this->handles[$key] = true;
+        }
+        return $value;
+    }
+
+    public function unlock(string $key): void
+    {
+        if (empty($key)) {
+            throw new \InvalidArgumentException(
+                'Locker::unlock() failed: The key cannot be empty. Please provide the string identifier used during the lock() call.'
+            );
+        }
+
+        if (!isset($this->handles[$key])) {
+            throw new \InvalidArgumentException(sprintf(
+                'Locker::unlock() failed: The key "%s" is not currently managed by this Locker instance. ' .
+                'Ensure the key is correct and that lock() returned true before unlocking.',
+                $key
+            ));
+        }
+        $this->lockHandler->unlock($key);
+        unset($this->handles[$key]);
+    }
+
+    public function __destruct()
+    {
+        $this->unlockAll();
+    }
+
+    private function unlockAll(): void
+    {
+        foreach (array_keys($this->handles) as $key) {
+            try {
+                $this->unlock($key);
+            } catch (Exception $e) {
+                error_log(sprintf("%s::%s : %s", __CLASS__, __FUNCTION__, $e->getMessage()));
+            }
+        }
+    }
+}

+ 124 - 0
tests/FlockHandlerTest.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Test\Michel\Lock;
+
+use Michel\UniTester\TestCase;
+use Michel\Lock\Handler\FlockHandler;
+
+class FlockHandlerTest extends TestCase
+{
+    private string $testDir;
+
+    protected function setUp(): void
+    {
+        $this->testDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flock_test_' . uniqid();
+        if (!is_dir($this->testDir)) {
+            mkdir($this->testDir);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        if (is_dir($this->testDir)) {
+            $files = glob($this->testDir . '/*');
+            foreach ($files as $file) {
+                unlink($file);
+            }
+            rmdir($this->testDir);
+        }
+    }
+
+    protected function execute(): void
+    {
+        $this->testLockFile();
+        $this->testUnlockReleasesLock();
+        $this->testLockNonBlockingFailsIfLocked();
+        $this->testLockBlockingWaits();
+    }
+
+    private function testLockFile()
+    {
+        $handler = new FlockHandler($this->testDir);
+        $key = 'test_lock_file';
+
+        $bool = $handler->lock($key);
+        $this->assertTrue($bool, 'FlockHandler::lock should return true on success');
+        $expectedFile = $this->testDir . DIRECTORY_SEPARATOR . 'michel_lock_' . $key . '.lock';
+        $this->assertFileExists($expectedFile, 'FlockHandler::lock should create a lock file');
+
+        $ready = $handler->lock($key);
+        $this->assertFalse($ready, 'FlockHandler::lock should return false if already locked');
+
+        $handler->unlock($key);
+
+        $ready = $handler->lock($key);
+        $this->assertTrue($ready, 'FlockHandler::lock should return true after unlocking');
+
+    }
+
+
+
+    private function testUnlockReleasesLock()
+    {
+        $handler = new FlockHandler($this->testDir);
+        $key = 'test_unlock';
+
+        $handler->lock($key);
+        $handler->unlock($key);
+        $expectedFile = $this->testDir . DIRECTORY_SEPARATOR . 'michel_lock_' . $key . '.lock';
+        $this->assertFileExists($expectedFile, 'FlockHandler::unlock should not delete the file');
+
+    }
+
+    private function testLockNonBlockingFailsIfLocked()
+    {
+        $key = 'concurrent_lock';
+        $lockFile = $this->testDir . DIRECTORY_SEPARATOR . 'michel_lock_' . $key . '.lock';
+        $tempScript = sys_get_temp_dir() . '/holder.php';
+        file_put_contents($tempScript, '<?php $f=fopen($argv[1],"c"); flock($f,LOCK_EX); sleep(2);');
+        $cmd = sprintf('php %s %s', escapeshellarg($tempScript), escapeshellarg($lockFile));
+        $process = proc_open($cmd, [], $pipes);
+
+        usleep(200000);
+
+        $handler = new FlockHandler($this->testDir);
+        $start = microtime(true);
+        $result = $handler->lock($key, false); // Non-blocking
+        $end = microtime(true);
+
+        $this->assertFalse($result, 'Lock should fail if already locked by another process');
+        $this->assertTrue(($end - $start) < 1, 'Non-blocking lock should return immediately');
+
+        proc_terminate($process);
+        proc_close($process);
+
+        unlink($tempScript);
+
+    }
+
+    private function testLockBlockingWaits()
+    {
+        $key = 'blocking_lock';
+        $lockFile = $this->testDir . DIRECTORY_SEPARATOR . 'michel_lock_' . $key . '.lock';
+        $tempScript = sys_get_temp_dir() . '/holder.php';
+        file_put_contents($tempScript, '<?php $f=fopen($argv[1],"c"); flock($f,LOCK_EX); sleep(2);');
+        $cmd = sprintf('php %s %s', escapeshellarg($tempScript), escapeshellarg($lockFile));
+        $process = proc_open($cmd, [], $pipes);
+
+        usleep(200000);
+
+        $handler = new FlockHandler($this->testDir);
+        $start = microtime(true);
+        $result = $handler->lock($key, true); // Blocking
+        $end = microtime(true);
+
+        $elapsed = $end - $start;
+        $this->assertTrue($result, 'Blocking lock should eventually succeed');
+        $this->assertTrue($elapsed >= 1.5, 'Blocking lock should wait for lock release (approx 1.5s remaining)');
+
+        proc_terminate($process);
+        proc_close($process);
+        unlink($tempScript);
+
+    }
+}

+ 143 - 0
tests/LockerTest.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace Test\Michel\Lock;
+
+use Michel\UniTester\TestCase;
+
+class LockerTest extends TestCase
+{
+    protected function setUp(): void {}
+
+    protected function tearDown(): void {}
+
+    protected function execute(): void
+    {
+        $this->testLockDelegatesToHandler();
+        $this->testLockThrowsExceptionForInvalidKey();
+        $this->testUnlockDelegatesToHandler();
+        $this->testUnlockThrowsExceptionIfKeyNotLocked();
+        $this->testUnlockIfKillReturnsSelf();
+        $this->testDestructReleasesLocks();
+    }
+    public function testLockDelegatesToHandler()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            private bool $locked = false;
+            public function lock(string $key, bool $wait = false): bool
+            {
+                if ($this->locked) {
+                    return false;
+                }
+                $this->locked = true;
+                return true;
+            }
+            public function unlock(string $key): void {
+                $this->locked = false;
+            }
+
+            public function isLocked(): bool
+            {
+                return $this->locked;
+            }
+        };
+
+        $locker = new \Michel\Lock\Locker($handler);
+        $result = $locker->lock('test_key');
+        $this->assertTrue($result, 'Locker::lock should return true when handler succeeds');
+        $this->assertTrue($handler->isLocked(), 'Locker::lock should call handler->lock');
+
+        $ready = $locker->lock('test_key');
+        $this->assertFalse($ready, 'Locker::lock should return false if already locked');
+        $this->assertTrue($handler->isLocked() === true, 'Locker::lock should call handler->lock');
+
+        $locker->unlock('test_key');
+        $this->assertTrue($handler->isLocked() === false, 'Locker::unlock should call handler->unlock');
+    }
+
+    public function testLockThrowsExceptionForInvalidKey()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            public function lock(string $key, bool $wait = false): bool
+            {
+                return true;
+            }
+            public function unlock(string $key): void {}
+        };
+
+        $locker = new \Michel\Lock\Locker($handler);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($locker) {
+            $locker->lock('invalid key!');
+        });
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($locker) {
+            $locker->lock('');
+        });
+    }
+
+    public function testUnlockDelegatesToHandler()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            public bool $unlocked = false;
+            public function lock(string $key, bool $wait = false): bool
+            {
+                return true;
+            }
+            public function unlock(string $key): void
+            {
+                $this->unlocked = true;
+            }
+        };
+
+        $locker = new \Michel\Lock\Locker($handler);
+        $locker->lock('test_key');
+        $locker->unlock('test_key');
+
+        $this->assertTrue($handler->unlocked, 'Locker::unlock should call handler->unlock');
+    }
+
+    public function testUnlockThrowsExceptionIfKeyNotLocked()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            public function lock(string $key, bool $wait = false): bool
+            {
+                return true;
+            }
+            public function unlock(string $key): void {}
+        };
+
+        $locker = new \Michel\Lock\Locker($handler);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($locker) {
+            $locker->unlock('unknown_key');
+        });
+    }
+
+    public function testUnlockIfKillReturnsSelf()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            public function lock(string $key, bool $wait = false): bool { return true; }
+            public function unlock(string $key): void {}
+        };
+        $locker = new \Michel\Lock\Locker($handler);
+        $this->assertTrue($locker->unlockIfKill() === $locker, 'unlockIfKill should return self');
+    }
+
+    public function testDestructReleasesLocks()
+    {
+        $handler = new class implements \Michel\Lock\LockHandlerInterface {
+            public int $unlockCalls = 0;
+            public function lock(string $key, bool $wait = false): bool { return true; }
+            public function unlock(string $key): void { $this->unlockCalls++; }
+        };
+
+        $locker = new \Michel\Lock\Locker($handler);
+        $locker->lock('key1');
+        $locker->lock('key2');
+
+        unset($locker); // Trigger destruct
+
+        $this->assertTrue($handler->unlockCalls === 2, 'Destructor should unlock all held locks');
+    }
+
+}