Skip to content

Commit

Permalink
feat: implemented generic LockedResource object
Browse files Browse the repository at this point in the history
  • Loading branch information
petrknap committed Jun 27, 2024
1 parent a3db429 commit 5f82aff
Show file tree
Hide file tree
Showing 12 changed files with 267 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<LockedResource\Resource> $lockedResource */
function f(LockedResource $lockedResource) {
echo $lockedResource->value;
}

$lock = new NoLock();
$resource = LockableResource::create(new LockedResource\Resource('data'), $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
{
}
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
{
}
25 changes: 25 additions & 0 deletions src/LockableResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection;

use Symfony\Component\Lock\LockInterface;

final class LockableResource
{
/**
* @template T of mixed
*
* @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);
}
}
57 changes: 57 additions & 0 deletions src/LockedResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace PetrKnap\CriticalSection;

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

/**
* @param array<mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->get()->$name(...$arguments);
}

public function __set(string $name, mixed $value): void
{
$this->get()->$name = $value;
}

public function __get(string $name): mixed
{
return $this->get()->$name;
}

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

abstract protected function isLocked(): bool;
}
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 Base;
use Symfony\Component\Lock\LockInterface;

/**
* @template T of mixed
*
* @extends Base<T>
*/
final class LockedResource extends Base
{
/**
* @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];
}

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

declare(strict_types=1);

namespace PetrKnap\CriticalSection\LockedResource;

final class Resource
{
public function __construct(
public string $value,
) {
}

public function getValue(): string
{
return $this->value;
}
}
42 changes: 42 additions & 0 deletions tests/LockedResourceTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?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 testIsMixin(): void
{
$string = 'string';
/** @var LockedResource<LockedResource\Resource> $locked */
$locked = $this->getLockedResource(new LockedResource\Resource(''));

$locked->value = $string;
self::assertSame($string, $locked->value);
self::assertSame($string, $locked->getValue());
}

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' => 'data',
'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 5f82aff

Please sign in to comment.