diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9866c39 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index bdd4ea2..b7404a3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,10 @@ -/tests export-ignore \ No newline at end of file +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/UPGRADING.md export-ignore diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index c592f27..4eabb1b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - 1.* name: phpunit diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 75c416b..78a4b26 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - 1.* name: static analysis diff --git a/.gitignore b/.gitignore index 43c585d..1a6ed30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,11 @@ -# IDEA -.idea/ -*.iml - -# Composer -composer.phar -vendor -composer.lock - -# OS -.DS_Store -Thumbs.db -*.exe - -# Other -.phpunit.result.cache +.idea +.php_cs .php_cs.cache -clover.xml -.env -builds - +.phpunit.result.cache +build +composer.lock +coverage +docs +vendor +node_modules +.php-cs-fixer.cache diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..6fde134 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,73 @@ +preset: psr12 +risky: true + +version: 8 + +enabled: + - alpha_ordered_traits + - array_indentation + - array_push + - combine_consecutive_issets + - combine_consecutive_unsets + - combine_nested_dirname + - declare_strict_types + - dir_constant + - fully_qualified_strict_types + - function_to_constant + - is_null + - magic_constant_casing + - magic_method_casing + - method_separation + - modernize_types_casting + - native_function_casing + - native_function_type_declaration_casing + - no_alias_functions + - no_empty_comment + - no_empty_phpdoc + - no_empty_statement + - no_extra_block_blank_lines + - no_short_bool_cast + - no_superfluous_elseif + - no_unneeded_control_parentheses + - no_unneeded_curly_braces + - no_unneeded_final_method + - no_unset_cast + - no_unused_imports + - no_unused_lambda_imports + - no_useless_else + - no_useless_return + - normalize_index_brace + - php_unit_dedicate_assert + - php_unit_dedicate_assert_internal_type + - php_unit_expectation + - php_unit_mock + - php_unit_mock_short_will_return + - php_unit_namespaced + - php_unit_no_expectation_annotation + - phpdoc_no_empty_return + - phpdoc_no_useless_inheritdoc + - phpdoc_order + - phpdoc_property + - phpdoc_scalar + - phpdoc_separation + - phpdoc_singular_inheritdoc + - phpdoc_trim + - phpdoc_trim_consecutive_blank_line_separation + - phpdoc_type_to_var + - phpdoc_types + - phpdoc_types_order + - print_to_echo + - regular_callable_call + - return_assignment + - self_accessor + - self_static_accessor + - set_type_to_cast + - short_array_syntax + - short_list_syntax + - simplified_if_return + - single_quote + - standardize_not_equals + - ternary_to_null_coalescing + - trailing_comma_in_multiline_array + - unalign_double_arrow + - unalign_equals diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f218000 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Spiral Scout + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a39844 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# RoadRunner Lock Integration for Symfony + +[![PHP Version Require](https://poser.pugx.org/roadrunner-php/symfony-lock-driver/require/php)](https://packagist.org/packages/roadrunner-php/symfony-lock-driver) +[![Latest Stable Version](https://poser.pugx.org/roadrunner-php/symfony-lock-driver/v/stable)](https://packagist.org/packages/roadrunner-php/symfony-lock-driver) +[![phpunit](https://github.com/roadrunner-php/symfony-lock-driver/actions/workflows/phpunit.yml/badge.svg)](https://github.com/roadrunner-php/symfony-lock-driver/actions) +[![psalm](https://github.com/roadrunner-php/symfony-lock-driver/actions/workflows/psalm.yml/badge.svg)](https://github.com/roadrunner-php/symfony-lock-driver/actions) +[![Codecov](https://codecov.io/gh/roadrunner-php/symfony-lock-driver/branch/master/graph/badge.svg)](https://codecov.io/gh/roadrunner-php/symfony-lock-driver/) +[![Total Downloads](https://poser.pugx.org/roadrunner-php/symfony-lock-driver/downloads)](https://packagist.org/roadrunner-php/symfony-lock-driver/phpunit) + + +This package is a bridge that connects the powerful RoadRunner Lock plugin with the Symfony Lock component. It's +designed to help you easily manage distributed locks in your PHP applications, particularly when you're working with +high-traffic web applications and microservices. + +## Requirements + +Make sure that your server is configured with following PHP version and extensions: + +- PHP 8.1+ + +## Installation + +You can install the package via composer: + +```bash +composer require roadrunner-php/symfony-lock-driver + +``` + +## Usage + +Using the RoadRunner Lock with Symfony is straightforward. Here's a simple example: + +```php +use RoadRunner\Lock\Lock; +use Spiral\Goridge\RPC\RPC; +use Spiral\RoadRunner\Symfony\Lock\RoadRunnerStore; +use Symfony\Component\Lock\LockFactory; + +require __DIR__ . '/vendor/autoload.php'; + +$lock = new Lock(RPC::create('tcp://127.0.0.1:6001')); +$factory = new LockFactory( + new RoadRunnerStore($lock) +); +``` + +Read more about using Symfony Lock component [here](https://symfony.com/doc/current/components/lock.html). + +## Contributing + +Contributions are welcome! If you find an issue or have a feature request, please open +an [issue](https://github.com/roadrunner-php/issues) or submit a pull request. + +## Credits + +- [gam6itko](https://github.com/gam6itko) +- [butschster](https://github.com/butschster) + +## License + +The MIT License (MIT). Please see [License File](LICENSE) for more information. diff --git a/composer.json b/composer.json index d191c3e..bf1f6b6 100644 --- a/composer.json +++ b/composer.json @@ -1,61 +1,68 @@ { - "name": "roadrunner-php/symfony-lock-driver", - "type": "library", - "description": "RoadRunner: symfony/lock bridge", - "license": "MIT", - "authors": [ - { - "name": "Pavel Buchnev (butschster)", - "email": "pavel.buchnev@spiralscout.com" + "name": "roadrunner-php/symfony-lock-driver", + "type": "library", + "description": "RoadRunner: symfony/lock bridge", + "keywords": [ + "roadrunner-php", + "symfony", + "roadrunner", + "spiral", + "lock" + ], + "license": "MIT", + "authors": [ + { + "name": "Pavel Buchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Alexander Strizhak", + "email": "gam6itko@gmail.com" + } + ], + "homepage": "https://spiral.dev/", + "support": { + "docs": "https://roadrunner.dev/docs", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "forum": "https://forum.roadrunner.dev/", + "chat": "https://discord.gg/V6EK4he" }, - { - "name": "Alexander Strizhak", - "email": "gam6itko@gmail.com" - } - ], - "homepage": "https://spiral.dev/", - "support": { - "docs": "https://roadrunner.dev/docs", - "issues": "https://github.com/roadrunner-server/roadrunner/issues", - "forum": "https://forum.roadrunner.dev/", - "chat": "https://discord.gg/V6EK4he" - }, - "require": { - "php": ">=8.1", - "roadrunner-php/lock": "^1.0", - "symfony/lock": "^6.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.0", - "vimeo/psalm": "^5.9" - }, - "autoload": { - "psr-4": { - "Spiral\\RoadRunner\\Symfony\\Lock\\": "src" - } - }, - "autoload-dev": { - "psr-4": { - "Spiral\\RoadRunner\\Symfony\\Lock\\Tests\\": "tests" - } - }, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/roadrunner-server" - } - ], - "scripts": { - "analyze": "psalm" - }, - "config": { - "sort-packages": true - }, - "minimum-stability": "dev", - "prefer-stable": true, - "extra": { - "branch-alias": { - "dev-main": "1.0-dev" + "require": { + "php": ">=8.1", + "roadrunner-php/lock": "^1.0", + "symfony/lock": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.9" + }, + "autoload": { + "psr-4": { + "Spiral\\RoadRunner\\Symfony\\Lock\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Spiral\\RoadRunner\\Symfony\\Lock\\Tests\\": "tests" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/roadrunner-server" + } + ], + "scripts": { + "analyze": "psalm" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } } - } } diff --git a/phpunit.xml b/phpunit.xml index 6a5626d..d7dc280 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,23 +1,39 @@ - + - - ./tests/ + + tests - - - - src - - + + + ./src + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..dd76fea --- /dev/null +++ b/psalm.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/src/RandomTokenGenerator.php b/src/RandomTokenGenerator.php new file mode 100644 index 0000000..5e8efb2 --- /dev/null +++ b/src/RandomTokenGenerator.php @@ -0,0 +1,21 @@ + $length + */ + public function __construct( + private readonly int $length = 32, + ) { + } + + public function generate(): string + { + return \bin2hex(\random_bytes($this->length)); + } +} diff --git a/src/RoadRunnerStore.php b/src/RoadRunnerStore.php index 89d05ad..85ea9ff 100644 --- a/src/RoadRunnerStore.php +++ b/src/RoadRunnerStore.php @@ -6,36 +6,53 @@ use RoadRunner\Lock as RR; use Spiral\Goridge\RPC\Exception\RPCException; +use Symfony\Component\Lock\BlockingStoreInterface; use Symfony\Component\Lock\Exception\LockAcquiringException; use Symfony\Component\Lock\Exception\LockConflictedException; use Symfony\Component\Lock\Exception\LockReleasingException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\SharedLockStoreInterface; +use Symfony\Component\Lock\Store\ExpiringStoreTrait; -final class RoadRunnerStore implements SharedLockStoreInterface +final class RoadRunnerStore implements SharedLockStoreInterface, BlockingStoreInterface { + use ExpiringStoreTrait; + /** - * @param RR\LockInterface $rrLock * @param float $initialTtl The time-to-live of the lock, in seconds. Defaults to 0 (forever). * @param float $initialWaitTtl How long to wait to acquire lock until returning false. */ public function __construct( - private readonly RR\LockInterface $rrLock, - private readonly float $initialTtl = 300.0, - private readonly float $initialWaitTtl = 0, + private readonly RR\LockInterface $lock, + private readonly TokenGeneratorInterface $tokens = new RandomTokenGenerator(), + private readonly float $initialTtl = 300.0, + private readonly float $initialWaitTtl = 60, ) { \assert($this->initialTtl >= 0); \assert($this->initialWaitTtl >= 0); } + public function withTtl(float $ttl): self + { + return new self($this->lock, $this->tokens, $ttl, $this->initialWaitTtl); + } + public function save(Key $key): void { \assert(false === $key->hasState(__CLASS__)); + try { - $lockId = $this->rrLock->lock((string) $key, null, $this->initialTtl, $this->initialWaitTtl); - if (false === $lockId) { + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + + $status = $this->lock->lock($resource, $lockId, $this->initialTtl); + + if (false === $status) { throw new LockConflictedException('RoadRunner. Failed to make lock'); } + $key->setState(__CLASS__, $lockId); } catch (RPCException $e) { throw new LockAcquiringException(message: 'RoadRunner. RPC call error', previous: $e); @@ -45,24 +62,42 @@ public function save(Key $key): void public function saveRead(Key $key): void { \assert(false === $key->hasState(__CLASS__)); - $lockId = $this->rrLock->lockRead((string)$key, null, $this->initialTtl, $this->initialWaitTtl); - if (false === $lockId) { + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + $status = $this->lock->lockRead($resource, $lockId, $this->initialTtl); + + if (false === $status) { throw new LockConflictedException('RoadRunner. Failed to make read lock'); } + $key->setState(__CLASS__, $lockId); } public function exists(Key $key): bool { \assert($key->hasState(__CLASS__)); - return $this->rrLock->exists((string) $key, $key->getState(__CLASS__)); + + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + + return $this->lock->exists($resource, $lockId); } public function putOffExpiration(Key $key, float $ttl): void { \assert($key->hasState(__CLASS__)); \assert($ttl > 0); - if (false === $this->rrLock->updateTTL((string) $key, $key->getState(__CLASS__), $ttl)) { + + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + + if (false === $this->lock->updateTTL($resource, $lockId, $ttl)) { throw new LockConflictedException('RoadRunner. Failed to update lock ttl'); } } @@ -70,8 +105,43 @@ public function putOffExpiration(Key $key, float $ttl): void public function delete(Key $key): void { \assert($key->hasState(__CLASS__)); - if (false === $this->rrLock->release((string) $key, $key->getState(__CLASS__))) { - throw new LockReleasingException('RoadRunner. Failed to release lock'); + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + $this->lock->release($resource, $lockId); + } + + public function waitAndSave(Key $key): void + { + $lockId = $this->getUniqueToken($key); + + /** @var non-empty-string $resource */ + $resource = (string)$key; + + $status = $this->lock->lock($resource, $lockId, $this->initialTtl, $this->initialWaitTtl); + + $key->setState(__CLASS__, $lockId); + if (!$status) { + throw new LockConflictedException(); } + + $this->checkNotExpired($key); + } + + /** + * @return non-empty-string + */ + private function getUniqueToken(Key $key): string + { + if (!$key->hasState(__CLASS__)) { + $token = $this->tokens->generate(); + $key->setState(__CLASS__, $token); + } + + /** @var non-empty-string $state */ + $state = $key->getState(__CLASS__); + + return $state; } } diff --git a/src/TokenGeneratorInterface.php b/src/TokenGeneratorInterface.php new file mode 100644 index 0000000..61b31c2 --- /dev/null +++ b/src/TokenGeneratorInterface.php @@ -0,0 +1,15 @@ +rrLock = $this->createMock(RrLock::class); + $this->tokens = $this->createMock(TokenGeneratorInterface::class); + + $this->tokens->method('generate') + ->willReturn('random-id'); + } + public function testSaveSuccess(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('lock') - ->with('resource-name', null) + ->with('resource-name', 'random-id') ->willReturn('lock-id'); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $store->save($key); - self::assertTrue($key->hasState(RoadRunnerStore::class)); - self::assertSame('lock-id', $key->getState(RoadRunnerStore::class)); + + $this->assertTrue($key->hasState(RoadRunnerStore::class)); + $this->assertSame('random-id', $key->getState(RoadRunnerStore::class)); } public function testSaveReadSuccess(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('lockRead') - ->with('resource-name', null) + ->with('resource-name', 'random-id') ->willReturn('lock-id'); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $store->saveRead($key); } public function testExistsSuccess(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('exists') ->with('resource-name') ->willReturn(true); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); $store->exists($key); @@ -54,12 +68,12 @@ public function testExistsSuccess(): void public function testPutOffExpirationSuccess(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('updateTTL') ->with('resource-name', 'lock-id', 3600.0) ->willReturn(true); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); $store->putOffExpiration($key, 3600.0); @@ -67,12 +81,12 @@ public function testPutOffExpirationSuccess(): void public function testDeleteSuccess(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('release') ->with('resource-name') ->willReturn(true); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); $store->delete($key); @@ -80,56 +94,56 @@ public function testDeleteSuccess(): void public function testSaveFail(): void { - self::expectException(LockConflictedException::class); - self::expectExceptionMessage('RoadRunner. Failed to make lock'); + $this->expectException(LockConflictedException::class); + $this->expectExceptionMessage('RoadRunner. Failed to make lock'); - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('lock') - ->with('resource-name', null) + ->with('resource-name', 'random-id') ->willReturn(false); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $store->save(new Key('resource-name')); } public function testSaveReadFail(): void { - self::expectException(LockConflictedException::class); - self::expectExceptionMessage('RoadRunner. Failed to make read lock'); + $this->expectException(LockConflictedException::class); + $this->expectExceptionMessage('RoadRunner. Failed to make read lock'); - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('lockRead') - ->with('resource-name', null); - $store = new RoadRunnerStore($rrLock); + ->with('resource-name', 'random-id'); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $store->saveRead($key); } public function testExistsFail(): void { - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('exists') ->with('resource-name') ->willReturn(false); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); - self::assertFalse($store->exists($key)); + $this->assertFalse($store->exists($key)); } public function testPutOffExpirationFail(): void { - self::expectException(LockConflictedException::class); - self::expectExceptionMessage('RoadRunner. Failed to update lock ttl'); + $this->expectException(LockConflictedException::class); + $this->expectExceptionMessage('RoadRunner. Failed to update lock ttl'); - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('updateTTL') ->with('resource-name', 'lock-id', 3600.0) ->willReturn(false); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); $store->putOffExpiration($key, 3600.0); @@ -137,15 +151,12 @@ public function testPutOffExpirationFail(): void public function testDeleteFail(): void { - self::expectException(LockReleasingException::class); - self::expectExceptionMessage('RoadRunner. Failed to release lock'); - - $rrLock = $this->createMock(RrLock::class); - $rrLock->expects(self::once()) + $this->rrLock->expects(self::once()) ->method('release') ->with('resource-name') ->willReturn(false); - $store = new RoadRunnerStore($rrLock); + + $store = new RoadRunnerStore($this->rrLock, $this->tokens); $key = new Key('resource-name'); $key->setState(RoadRunnerStore::class, 'lock-id'); $store->delete($key);