Skip to content

Commit

Permalink
feat: implemented generic LockedResource objects
Browse files Browse the repository at this point in the history
  • Loading branch information
petrknap committed Jun 23, 2024
1 parent a3db429 commit 6d69459
Show file tree
Hide file tree
Showing 13 changed files with 295 additions and 1 deletion.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ $criticalOutput = CriticalSection::withLocks([$lockA, $lockB])(fn () => 'This wa
var_dump($criticalOutput);
```

## Do you need to accept only locked resources?

Use [`LockedResource`s](./src/LockedResource.php) if you need to be sure that you are not processing resource outside it's critical section.

```php
namespace PetrKnap\CriticalSection;

use Symfony\Component\Lock\NoLock;

/** @param LockedResource<string> $lockedString */
function f(LockedResource $lockedString) {
echo $lockedString->get();
}

$lock = new NoLock();
$resource = LockableResource::create('string', $lock);
CriticalSection::withLock($lock)(fn () => f($resource));
```

## Does your critical section work with database?

Use [`doctrine/dbal`](https://packagist.org/packages/doctrine/dbal) and its `transactional` method.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
"name": "petrknap/critical-section",
"require": {
"php": ">=8.1",
"petrknap/shorts": "^2.1",
"symfony/lock": "^6.0|^7.0"
},
"require-dev": {
"nunomaduro/phpinsights": "^2.11",
"petrknap/shorts": "^2.1",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.7"
Expand Down
16 changes: 16 additions & 0 deletions src/Exception/CouldNotGetUnlockedResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Exception;

use PetrKnap\Shorts\Exception\CouldNotProcessData;

/**
* @template T of mixed
*
* @extends CouldNotProcessData<T>
*/
final class CouldNotGetUnlockedResource extends CouldNotProcessData implements LockedResourceException
{
}
13 changes: 13 additions & 0 deletions src/Exception/CouldNotLockResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Exception;

use PetrKnap\Shorts\ExceptionWrapper;
use RuntimeException;

final class CouldNotLockResource extends RuntimeException implements LockableResourceException
{
use ExceptionWrapper;
}
13 changes: 13 additions & 0 deletions src/Exception/CouldNotUnlockResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Exception;

use PetrKnap\Shorts\ExceptionWrapper;
use RuntimeException;

final class CouldNotUnlockResource extends RuntimeException implements LockableResourceException
{
use ExceptionWrapper;
}
11 changes: 11 additions & 0 deletions src/Exception/LockableResourceException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Exception;

use Throwable;

interface LockableResourceException extends Throwable
{
}
9 changes: 9 additions & 0 deletions src/Exception/LockedResourceException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Exception;

interface LockedResourceException extends LockableResourceException
{
}
43 changes: 43 additions & 0 deletions src/LockableResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection;

use Symfony\Component\Lock\LockInterface;

/**
* @template T of mixed
*/
abstract class LockableResource
{
/**
* @param T $resource
*
* @return LockedResource<T>
*/
public static function create(
mixed $resource,
LockInterface $lock1,
LockInterface ...$lockN,
): LockedResource {
return new Symfony\Lock\LockedResource($resource, $lock1, ...$lockN);
}

/**
* @return T
*/
abstract public function get(): mixed;

abstract public function isLocked(): bool;

/**
* @throws Exception\CouldNotLockResource
*/
abstract public function lock(): void;

/**
* @throws Exception\CouldNotUnlockResource
*/
abstract public function unlock(): void;
}
56 changes: 56 additions & 0 deletions src/LockedResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection;

use LogicException;

/**
* @template T of mixed
*
* @extends LockableResource<T>
*/
abstract class LockedResource extends LockableResource
{
/**
* @param T $resource
*/
public function __construct(
private readonly mixed $resource,
) {
}

/**
* @return T
*
* @throws Exception\CouldNotGetUnlockedResource<T>
*/
final public function get(): mixed
{
if (!$this->isLocked()) {
throw new Exception\CouldNotGetUnlockedResource(
__METHOD__,
$this->resource,
new LogicException('Resource is not locked'),
);
}
return $this->resource;
}

/**
* @deprecated locked externally
*/
final public function lock(): void
{
throw new Exception\CouldNotLockResource(new LogicException(self::class . ' is locked externally'));
}

/**
* @deprecated locked externally
*/
final public function unlock(): void
{
throw new Exception\CouldNotUnlockResource(new LogicException(self::class . ' is locked externally'));
}
}
43 changes: 43 additions & 0 deletions src/Symfony/Lock/LockedResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Symfony\Lock;

use PetrKnap\CriticalSection\LockedResource as BaseLockedResource;
use Symfony\Component\Lock\LockInterface;

/**
* @template T of mixed
*
* @extends LockedResource<T>
*/
final class LockedResource extends BaseLockedResource
{
/**
* @var array<LockInterface>
*/
private readonly array $locks;

/**
* @param T $resource
*/
public function __construct(
mixed $resource,
LockInterface $lock1,
LockInterface ...$lockN,
) {
parent::__construct($resource);
$this->locks = [$lock1, ...$lockN];
}

public function isLocked(): bool
{
foreach ($this->locks as $lock) {
if (!$lock->isAcquired()) {
return false;
}
}
return true;
}
}
45 changes: 45 additions & 0 deletions tests/LockedResourceTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection;

use PHPUnit\Framework\TestCase;
use stdClass;

abstract class LockedResourceTestCase extends TestCase
{
public function testCouldNotGetUnlockedResource(): void
{
self::expectException(Exception\CouldNotGetUnlockedResource::class);

$this->getUnlockedResource(null)->get();
}

public function testCouldGetLockedResource(): void
{
$instance = new stdClass();

self::assertSame(
$instance,
$this->getLockedResource($instance)->get(),
);
}

public function testCouldNotLockItself(): void
{
self::expectException(Exception\CouldNotLockResource::class);

$this->getUnlockedResource(null)->lock();
}

public function testCouldNotUnlockItself(): void
{
self::expectException(Exception\CouldNotLockResource::class);

$this->getLockedResource(null)->unlock();
}

abstract protected function getUnlockedResource(mixed $resource): LockedResource;
abstract protected function getLockedResource(mixed $resource): LockedResource;
}
1 change: 1 addition & 0 deletions tests/ReadmeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static function getExpectedOutputsOfPhpExamples(): iterable
'single-lock' => 'string(18) "This was critical."' . PHP_EOL,
'double-lock' => 'string(18) "This was critical."' . PHP_EOL,
'array-lock' => 'string(18) "This was critical."' . PHP_EOL,
'resources' => 'string',
'transactional' => null,
];
}
Expand Down
25 changes: 25 additions & 0 deletions tests/Symfony/Lock/LockedResourceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection\Symfony\Lock;

use PetrKnap\CriticalSection\LockedResourceTestCase;
use Symfony\Component\Lock\LockInterface;

final class LockedResourceTest extends LockedResourceTestCase
{
protected function getUnlockedResource(mixed $resource): LockedResource
{
$lock = $this->createMock(LockInterface::class);
$lock->expects(self::any())->method('isAcquired')->willReturn(false);
return new LockedResource($resource, $lock);
}

protected function getLockedResource(mixed $resource): LockedResource
{
$lock = $this->createMock(LockInterface::class);
$lock->expects(self::any())->method('isAcquired')->willReturn(true);
return new LockedResource($resource, $lock);
}
}

0 comments on commit 6d69459

Please sign in to comment.