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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ Design decisions are documented in `adr/` — consult these before changing hash
- PHP 8.1+ features: enums, readonly properties, named arguments, `match` expressions
- PSR-4 autoloading: `src/` → `Cog\DbLocker\`, `test/` → `Cog\Test\DbLocker\`
- All classes use `declare(strict_types=1)`
- **Abstract classes** must be prefixed with `Abstract` (e.g., `AbstractLockException`, `AbstractIntegrationTestCase`)
- SQL comments with `humanReadableValue` appended to lock queries for debugging — these must be sanitized to prevent SQL comment injection (see `sanitizeSqlComment()`)
- **DocBlock formatting**: Always separate `@throws` tags from other tags (like `@param`, `@return`) with a blank line for better readability

## Git Workflow

Expand Down
10 changes: 6 additions & 4 deletions src/ConnectionAdapterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ interface ConnectionAdapterInterface
* @param string $sql SQL query with named parameters (e.g., :class_id)
* @param array<string, mixed> $params Named parameters (e.g., ['class_id' => 1, 'object_id' => 2])
* @return mixed The value of the first column (typically bool or string)
* @throws \Throwable Database-specific exception on error
*
* @throws \Exception Database-specific exception on error
*/
public function fetchColumn(string $sql, array $params = []): mixed;

Expand All @@ -44,7 +45,8 @@ public function fetchColumn(string $sql, array $params = []): mixed;
*
* @param string $sql SQL statement with optional named parameters
* @param array<string, mixed> $params Named parameters (e.g., ['class_id' => 1])
* @throws \Throwable Database-specific exception on error
*
* @throws \Exception Database-specific exception on error
*/
public function execute(string $sql, array $params = []): void;

Expand All @@ -65,8 +67,8 @@ public function isTransactionActive(): bool;
* - Doctrine DBAL: Doctrine\DBAL\Exception with getSQLState() method
* - Cycle ORM: wraps \PDOException in its own exception
*
* @param \Throwable $exception Exception to inspect
* @param \Exception $exception Exception to inspect
* @return bool True if the exception indicates a lock timeout (SQLSTATE 55P03)
*/
public function isLockNotAvailable(\Throwable $exception): bool;
public function isLockNotAvailable(\Exception $exception): bool;
}
2 changes: 1 addition & 1 deletion src/DbConnection/PdoConnectionAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function isTransactionActive(): bool
return $this->pdo->inTransaction();
}

public function isLockNotAvailable(\Throwable $exception): bool
public function isLockNotAvailable(\Exception $exception): bool
{
// PDOException: getCode() returns SQLSTATE string
if ($exception instanceof \PDOException) {
Expand Down
25 changes: 25 additions & 0 deletions src/Exception/AbstractLockException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of PHP DB Locker.
*
* (c) Anton Komarev <anton@komarev.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Cog\DbLocker\Exception;

/**
* Base exception for all database lock-related errors.
*
* This exception is thrown only for exceptional situations (database errors, connection issues, etc.),
* NOT for normal lock contention. When a lock is unavailable due to competition from other processes,
* methods return `false` instead of throwing this exception.
*/
abstract class AbstractLockException extends \RuntimeException
{
}
40 changes: 40 additions & 0 deletions src/Exception/LockAcquireException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/*
* This file is part of PHP DB Locker.
*
* (c) Anton Komarev <anton@komarev.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Cog\DbLocker\Exception;

use Cog\DbLocker\Postgres\PostgresLockKey;

/**
* Exception thrown when a lock cannot be acquired due to a database error.
*
* This exception is NOT thrown when a lock is simply unavailable due to competition
* from other processes. In that case, methods return `false` instead.
*
* This exception IS thrown for genuine errors such as:
* - Connection failures
* - Query execution errors (excluding lock_not_available SQLSTATE 55P03)
* - Transaction state errors
*/
final class LockAcquireException extends AbstractLockException
{
public static function fromDatabaseError(
PostgresLockKey $key,
\Exception $previous,
): self {
return new self(
message: "Failed to acquire lock for key `{$key->humanReadableValue}`: {$previous->getMessage()}",
previous: $previous,
);
}
}
37 changes: 37 additions & 0 deletions src/Exception/LockReleaseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of PHP DB Locker.
*
* (c) Anton Komarev <anton@komarev.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Cog\DbLocker\Exception;

use Cog\DbLocker\Postgres\PostgresLockKey;

/**
* Exception thrown when a lock cannot be released due to a database error.
*
* This indicates a genuine error condition such as:
* - Connection failures
* - Query execution errors
* - Unexpected database state
*/
final class LockReleaseException extends AbstractLockException
{
public static function fromDatabaseError(
PostgresLockKey $key,
\Exception $previous,
): self {
return new self(
message: "Failed to release lock for key `{$key->humanReadableValue}`: {$previous->getMessage()}",
previous: $previous,
);
}
}
158 changes: 95 additions & 63 deletions src/Postgres/PostgresAdvisoryLocker.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
namespace Cog\DbLocker\Postgres;

use Cog\DbLocker\ConnectionAdapterInterface;
use Cog\DbLocker\Exception\LockAcquireException;
use Cog\DbLocker\Exception\LockReleaseException;
use Cog\DbLocker\Postgres\Enum\PostgresLockAccessModeEnum;
use Cog\DbLocker\Postgres\Enum\PostgresLockLevelEnum;
use Cog\DbLocker\Postgres\LockHandle\SessionLevelLockHandle;
Expand All @@ -26,6 +28,10 @@ final class PostgresAdvisoryLocker
* Acquire a transaction-level advisory lock with configurable timeout and access mode.
*
* @param TimeoutDuration $timeoutDuration Maximum wait time. Use TimeoutDuration::zero() for an immediate (non-blocking) attempt.
* @return TransactionLevelLockHandle Handle with wasAcquired=false if lock is held by another process (normal competition).
*
* @throws LockAcquireException If a database error occurs (connection failures, query errors, etc.). NOT thrown for normal lock contention.
* @throws \LogicException If attempting to acquire outside of an active transaction.
*/
public function acquireTransactionLevelLock(
ConnectionAdapterInterface $dbConnection,
Expand Down Expand Up @@ -63,6 +69,9 @@ public function acquireTransactionLevelLock(
* @param TimeoutDuration $timeoutDuration Maximum wait time. Use TimeoutDuration::zero() for an immediate (non-blocking) attempt.
* @return TReturn The return value of the callback.
*
* @throws LockAcquireException If a database error occurs during lock acquisition. NOT thrown for normal lock contention.
* @throws LockReleaseException If a database error occurs during lock release (only thrown if no other exception occurred during callback execution).
*
* @see acquireTransactionLevelLock() for preferred locking strategy.
*
* @template TReturn
Expand Down Expand Up @@ -117,6 +126,9 @@ public function withinSessionLevelLock(
* lock handle's release() method.
*
* @param TimeoutDuration $timeoutDuration Maximum wait time. Use TimeoutDuration::zero() for an immediate (non-blocking) attempt.
* @return SessionLevelLockHandle Handle with wasAcquired=false if lock is held by another process (normal competition).
*
* @throws LockAcquireException If a database error occurs (connection failures, query errors, etc.). NOT thrown for normal lock contention.
*
* @see acquireTransactionLevelLock() for preferred locking strategy.
* @see withinSessionLevelLock() for automatic session lock management.
Expand Down Expand Up @@ -144,25 +156,33 @@ public function acquireSessionLevelLock(

/**
* Release session level advisory lock.
*
* @return bool True if the lock was successfully released, false if it was not held by this session.
*
* @throws LockReleaseException If a database error occurs during release.
*/
public function releaseSessionLevelLock(
ConnectionAdapterInterface $dbConnection,
PostgresLockKey $key,
PostgresLockAccessModeEnum $accessMode = PostgresLockAccessModeEnum::Exclusive,
): bool {
$sql = match ($accessMode) {
PostgresLockAccessModeEnum::Exclusive
=> 'SELECT PG_ADVISORY_UNLOCK(:class_id, :object_id);',
try {
$sql = match ($accessMode) {
PostgresLockAccessModeEnum::Exclusive
=> 'SELECT PG_ADVISORY_UNLOCK(:class_id, :object_id);',

PostgresLockAccessModeEnum::Share
=> 'SELECT PG_ADVISORY_UNLOCK_SHARED(:class_id, :object_id);',
};
$sql .= " -- $key->humanReadableValue";
PostgresLockAccessModeEnum::Share
=> 'SELECT PG_ADVISORY_UNLOCK_SHARED(:class_id, :object_id);',
};
$sql .= " -- $key->humanReadableValue";

return $dbConnection->fetchColumn($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
return $dbConnection->fetchColumn($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
} catch (\Exception $exception) {
throw LockReleaseException::fromDatabaseError($key, $exception);
}
}

/**
Expand Down Expand Up @@ -209,25 +229,29 @@ private function tryAcquireLock(
PostgresLockLevelEnum $level,
PostgresLockAccessModeEnum $accessMode,
): bool {
$sql = match ([$level, $accessMode]) {
[PostgresLockLevelEnum::Session, PostgresLockAccessModeEnum::Exclusive]
=> 'SELECT PG_TRY_ADVISORY_LOCK(:class_id, :object_id);',
try {
$sql = match ([$level, $accessMode]) {
[PostgresLockLevelEnum::Session, PostgresLockAccessModeEnum::Exclusive]
=> 'SELECT PG_TRY_ADVISORY_LOCK(:class_id, :object_id);',

[PostgresLockLevelEnum::Session, PostgresLockAccessModeEnum::Share]
=> 'SELECT PG_TRY_ADVISORY_LOCK_SHARED(:class_id, :object_id);',
[PostgresLockLevelEnum::Session, PostgresLockAccessModeEnum::Share]
=> 'SELECT PG_TRY_ADVISORY_LOCK_SHARED(:class_id, :object_id);',

[PostgresLockLevelEnum::Transaction, PostgresLockAccessModeEnum::Exclusive]
=> 'SELECT PG_TRY_ADVISORY_XACT_LOCK(:class_id, :object_id);',
[PostgresLockLevelEnum::Transaction, PostgresLockAccessModeEnum::Exclusive]
=> 'SELECT PG_TRY_ADVISORY_XACT_LOCK(:class_id, :object_id);',

[PostgresLockLevelEnum::Transaction, PostgresLockAccessModeEnum::Share]
=> 'SELECT PG_TRY_ADVISORY_XACT_LOCK_SHARED(:class_id, :object_id);',
};
$sql .= " -- $key->humanReadableValue";
[PostgresLockLevelEnum::Transaction, PostgresLockAccessModeEnum::Share]
=> 'SELECT PG_TRY_ADVISORY_XACT_LOCK_SHARED(:class_id, :object_id);',
};
$sql .= " -- $key->humanReadableValue";

return $dbConnection->fetchColumn($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
return $dbConnection->fetchColumn($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
} catch (\Exception $exception) {
throw LockAcquireException::fromDatabaseError($key, $exception);
}
}

private function acquireLockWithTimeout(
Expand Down Expand Up @@ -276,32 +300,36 @@ private function acquireTransactionLockWithTimeout(
PostgresLockKey $key,
TimeoutDuration $timeoutDuration,
): bool {
$timeoutMs = $timeoutDuration->toMilliseconds();
$dbConnection->execute("SET LOCAL lock_timeout = '$timeoutMs'");
try {
$timeoutMs = $timeoutDuration->toMilliseconds();
$dbConnection->execute("SET LOCAL lock_timeout = '$timeoutMs'");

/**
* Use a savepoint so that a lock_timeout error does not abort the entire transaction.
* PostgreSQL handles same-name savepoints as a stack, so nested calls are safe.
*/
$dbConnection->execute('SAVEPOINT _lock_timeout_savepoint');
/**
* Use a savepoint so that a lock_timeout error does not abort the entire transaction.
* PostgreSQL handles same-name savepoints as a stack, so nested calls are safe.
*/
$dbConnection->execute('SAVEPOINT _lock_timeout_savepoint');

try {
$dbConnection->execute($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
try {
$dbConnection->execute($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);

$dbConnection->execute('RELEASE SAVEPOINT _lock_timeout_savepoint');
$dbConnection->execute('RELEASE SAVEPOINT _lock_timeout_savepoint');

return true;
} catch (\Throwable $exception) {
if ($dbConnection->isLockNotAvailable($exception)) {
$dbConnection->execute('ROLLBACK TO SAVEPOINT _lock_timeout_savepoint');
return true;
} catch (\Exception $exception) {
if ($dbConnection->isLockNotAvailable($exception)) {
$dbConnection->execute('ROLLBACK TO SAVEPOINT _lock_timeout_savepoint');

return false;
}
return false;
}

throw $exception;
throw $exception;
}
} catch (\Exception $exception) {
throw LockAcquireException::fromDatabaseError($key, $exception);
}
}

Expand All @@ -311,26 +339,30 @@ private function acquireSessionLockWithTimeout(
PostgresLockKey $key,
TimeoutDuration $timeoutDuration,
): bool {
$timeoutMs = $timeoutDuration->toMilliseconds();
$originalLockTimeout = $dbConnection->fetchColumn('SHOW lock_timeout');
$dbConnection->execute("SET lock_timeout = '$timeoutMs'");

try {
$dbConnection->execute($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);
$timeoutMs = $timeoutDuration->toMilliseconds();
$originalLockTimeout = $dbConnection->fetchColumn('SHOW lock_timeout');
$dbConnection->execute("SET lock_timeout = '$timeoutMs'");

try {
$dbConnection->execute($sql, [
'class_id' => $key->classId,
'object_id' => $key->objectId,
]);

return true;
} catch (\Exception $exception) {
if ($dbConnection->isLockNotAvailable($exception)) {
return false;
}

return true;
} catch (\Throwable $exception) {
if ($dbConnection->isLockNotAvailable($exception)) {
return false;
throw $exception;
}

throw $exception;
}
finally {
$dbConnection->execute("SET lock_timeout = '$originalLockTimeout'");
finally {
$dbConnection->execute("SET lock_timeout = '$originalLockTimeout'");
}
} catch (\Exception $exception) {
throw LockAcquireException::fromDatabaseError($key, $exception);
}
}
}
Loading
Loading