Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ END
| Timeout: `0` (default; immediate, no wait) | ✅ | ✅ | ✅ |
| Timeout: `positive-int` | ✅<br>(Emulated) | ✅ | ✅ |
| Timeout: `negative-int` (infinite wait) | ✅ | ✅ | ❌ |
| Timeout: `float` | ✅ | ❌ | ❌ |

- Postgres does not natively support waiting for a finite specific amount of time, but this is emulated by looping through a temporary function.
- MariaDB does not accept infinite timeouts. very large numbers can be used instead.
- Float precision is not supported on MySQL/MariaDB.
4 changes: 2 additions & 2 deletions src/Concerns/SessionLocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

trait SessionLocks
{
abstract public function lockOrFail(string $key, int $timeout = 0): SessionLock;
abstract public function lockOrFail(string $key, int|float $timeout = 0): SessionLock;

public function tryLock(string $key, int $timeout = 0): ?SessionLock
public function tryLock(string $key, int|float $timeout = 0): ?SessionLock
{
try {
return $this->lockOrFail($key, $timeout);
Expand Down
4 changes: 2 additions & 2 deletions src/Concerns/TransactionalLocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

trait TransactionalLocks
{
public function tryLock(string $key, int $timeout = 0): bool
public function tryLock(string $key, int|float $timeout = 0): bool
{
try {
$this->lockOrFail($key, $timeout);
Expand All @@ -19,5 +19,5 @@ public function tryLock(string $key, int $timeout = 0): bool
}
}

abstract public function lockOrFail(string $key, int $timeout = 0): void;
abstract public function lockOrFail(string $key, int|float $timeout = 0): void;
}
8 changes: 4 additions & 4 deletions src/Contracts/SessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,20 @@ interface SessionLocker
* @psalm-param callable(ConnectionInterface): T $callback
* @psalm-return T
*
* @param int $timeout Time to wait before acquiring a lock. This is NOT the expiry of the lock.
* @param int|float $timeout Time to wait before acquiring a lock. This is NOT the expiry of the lock.
*
* @throws LockFailedException
* @throws QueryException
*/
public function withLocking(string $key, callable $callback, int $timeout = 0): mixed;
public function withLocking(string $key, callable $callback, int|float $timeout = 0): mixed;

/**
* Attempts to acquire a lock or returns NULL if failed.
* QueryException may be thrown on connection-level errors.
*
* @throws QueryException
*/
public function tryLock(string $key, int $timeout = 0): ?SessionLock;
public function tryLock(string $key, int|float $timeout = 0): ?SessionLock;

/**
* Attempts to acquire a lock or throw LockFailedException if failed.
Expand All @@ -49,7 +49,7 @@ public function tryLock(string $key, int $timeout = 0): ?SessionLock;
* @throws LockFailedException
* @throws QueryException
*/
public function lockOrFail(string $key, int $timeout = 0): SessionLock;
public function lockOrFail(string $key, int|float $timeout = 0): SessionLock;

/**
* Indicates whether any session-level lock remains.
Expand Down
4 changes: 2 additions & 2 deletions src/Contracts/TransactionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface TransactionLocker
*
* @throws QueryException
*/
public function tryLock(string $key, int $timeout = 0): bool;
public function tryLock(string $key, int|float $timeout = 0): bool;

/**
* Attempts to acquire a lock or throw LockFailedException if failed.
Expand All @@ -29,5 +29,5 @@ public function tryLock(string $key, int $timeout = 0): bool;
* @throws LockFailedException
* @throws QueryException
*/
public function lockOrFail(string $key, int $timeout = 0): void;
public function lockOrFail(string $key, int|float $timeout = 0): void;
}
14 changes: 14 additions & 0 deletions src/Contracts/UnsupportedTimeoutPrecisionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Mpyw\LaravelDatabaseAdvisoryLock\Contracts;

use BadMethodCallException;

/**
* class UnsupportedTimeoutPrecisionException
*
* You can't use float timeout values for this connection.
*/
class UnsupportedTimeoutPrecisionException extends BadMethodCallException {}
15 changes: 13 additions & 2 deletions src/MySqlSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLock;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\SessionLocker;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedTimeoutPrecisionException;
use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;
use WeakMap;

use function array_fill;
use function is_float;
use function sprintf;

final class MySqlSessionLocker implements SessionLocker
{
Expand All @@ -29,8 +32,16 @@ public function __construct(
$this->locks = new WeakMap();
}

public function lockOrFail(string $key, int $timeout = 0): SessionLock
public function lockOrFail(string $key, int|float $timeout = 0): SessionLock
{
if (is_float($timeout)) {
throw new UnsupportedTimeoutPrecisionException(sprintf(
'Float timeout value is not allowed for MySQL/MariaDB: key=%s, timeout=%s',
$key,
$timeout,
));
}

// When key strings exceed 64 chars limit,
// it takes first 24 chars from them and appends 40 chars `sha1()` hashes.
$sql = "SELECT GET_LOCK(CASE WHEN CHAR_LENGTH(?) > 64 THEN CONCAT(SUBSTR(?, 1, 24), SHA1(?)) ELSE ? END, {$timeout})";
Expand All @@ -55,7 +66,7 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock
return $lock;
}

public function withLocking(string $key, callable $callback, int $timeout = 0): mixed
public function withLocking(string $key, callable $callback, int|float $timeout = 0): mixed
{
$lock = $this->lockOrFail($key, $timeout);

Expand Down
4 changes: 2 additions & 2 deletions src/PostgresSessionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function __construct(
*
* Use of this method is strongly discouraged in Postgres. Use withLocking() instead.
*/
public function lockOrFail(string $key, int $timeout = 0): SessionLock
public function lockOrFail(string $key, int|float $timeout = 0): SessionLock
{
if ($timeout > 0) {
// Positive timeout can be performed through temporary function
Expand Down Expand Up @@ -67,7 +67,7 @@ public function lockOrFail(string $key, int $timeout = 0): SessionLock
return $lock;
}

public function withLocking(string $key, callable $callback, int $timeout = 0): mixed
public function withLocking(string $key, callable $callback, int|float $timeout = 0): mixed
{
$lock = $this->lockOrFail($key, $timeout);

Expand Down
2 changes: 1 addition & 1 deletion src/PostgresTransactionLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function __construct(
protected PostgresConnection $connection,
) {}

public function lockOrFail(string $key, int $timeout = 0): void
public function lockOrFail(string $key, int|float $timeout = 0): void
{
if ($this->connection->transactionLevel() < 1) {
throw new InvalidTransactionLevelException('There are no transactions');
Expand Down
8 changes: 4 additions & 4 deletions src/Utilities/PostgresTimeoutEmulator.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ public function __construct(
/**
* Perform a time-limited lock acquisition.
*
* @phpstan-param positive-int $timeout
* @phpstan-param positive-int|float $timeout
* @throws QueryException
*/
public function performWithTimeout(string $key, int $timeout, bool $forTransaction = false): bool
public function performWithTimeout(string $key, int|float $timeout, bool $forTransaction = false): bool
{
// Binding parameters to procedures is only allowed when PDOStatement emulation is enabled.
return PDOStatementEmulator::emulated(
Expand All @@ -39,9 +39,9 @@ public function performWithTimeout(string $key, int $timeout, bool $forTransacti
/**
* Generates SQL to emulate time-limited lock acquisition.
*
* @phpstan-param positive-int $timeout
* @phpstan-param positive-int|float $timeout
*/
public function sql(int $timeout, bool $forTransaction): string
public function sql(int|float $timeout, bool $forTransaction): string
{
$suffix = $forTransaction ? '_xact' : '';
$modifier = $forTransaction ? 'LOCAL' : 'SESSION';
Expand Down
57 changes: 57 additions & 0 deletions tests/SessionLockerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\LockFailedException;
use Mpyw\LaravelDatabaseAdvisoryLock\Contracts\UnsupportedTimeoutPrecisionException;
use Mpyw\LaravelDatabaseAdvisoryLock\Utilities\Selector;

class SessionLockerTest extends TestCase
Expand Down Expand Up @@ -243,4 +244,60 @@ public function testInfiniteTimeoutSuccess(string $name): void
$proc->wait();
}
}

/**
* @dataProvider connectionsPostgres
*/
public function testFloatTimeoutSuccess(string $name): void
{
$proc = self::lockAsync($name, 'foo', 2);
usleep(1_800_000);

try {
$result = DB::connection($name)
->advisoryLocker()
->forSession()
->tryLock('foo', 0.4);

$this->assertSame(0, $proc->wait());
$this->assertNotNull($result);
} finally {
$proc->wait();
}
}

/**
* @dataProvider connectionsPostgres
*/
public function testFloatTimeoutExceeded(string $name): void
{
$proc = self::lockAsync($name, 'foo', 2);
usleep(1_700_000);

try {
$result = DB::connection($name)
->advisoryLocker()
->forSession()
->tryLock('foo', 0.1);

$this->assertSame(0, $proc->wait());
$this->assertNull($result);
} finally {
$proc->wait();
}
}

/**
* @dataProvider connectionsMysqlLike
*/
public function testFloatTimeoutUnsupported(string $name): void
{
$this->expectException(UnsupportedTimeoutPrecisionException::class);
$this->expectExceptionMessage('Float timeout value is not allowed for MySQL/MariaDB: key=foo, timeout=0.1');

DB::connection($name)
->advisoryLocker()
->forSession()
->tryLock('foo', 0.1);
}
}