From f7d47cc419d3283ff27afea90af0d275941a422e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 21 Aug 2026 09:37:26 +0600 Subject: [PATCH 1/2] :fire: chore(hooks): cut captainhook configuration file - Remove captainhook.json configuration file :fire: --- captainhook.json | 55 ------------------------------------------------ 1 file changed, 55 deletions(-) delete mode 100644 captainhook.json diff --git a/captainhook.json b/captainhook.json deleted file mode 100644 index 782a292..0000000 --- a/captainhook.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "commit-msg": { - "enabled": false, - "actions": [] - }, - "pre-push": { - "enabled": false, - "actions": [] - }, - "pre-commit": { - "enabled": true, - "actions": [ - { - "action": "composer validate --strict", - "options": [] - }, - { - "action": "composer normalize --dry-run", - "options": [] - }, - { - "action": "composer ic:release:audit", - "options": [] - }, - { - "action": "composer ic:ci", - "options": [] - } - ] - }, - "prepare-commit-msg": { - "enabled": false, - "actions": [] - }, - "post-commit": { - "enabled": false, - "actions": [] - }, - "post-merge": { - "enabled": false, - "actions": [] - }, - "post-checkout": { - "enabled": false, - "actions": [] - }, - "post-rewrite": { - "enabled": false, - "actions": [] - }, - "post-change": { - "enabled": false, - "actions": [] - } -} From f9491cdf466b4732042319226fd1aef2e5cf0184 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 23 Aug 2026 19:34:57 +0600 Subject: [PATCH 2/2] :sparkles: feat(cache): add strict mode and validation for PDO lock providers - Introduce strict mode to `PdoLockProvider` to enforce native PDO locking without fallbacks :sparkles: - Add `UnsupportedPdoLockDriver` exception for unsupported PDO drivers :bug: - Add `PdoLockProvider::supportsNativeDriver()` method to check driver capabilities :bulb: - Update documentation and unit tests for strict and fallback PDO locking behaviors :memo: --- docs/adapters/pdo.rst | 18 +- docs/adapters/sqlite.rst | 8 +- docs/metrics-and-locking.rst | 24 ++- src/Cache/Lock/PdoLockProvider.php | 33 +++- src/Cache/Lock/UnsupportedPdoLockDriver.php | 9 + tests/Cache/LockProviderTest.php | 177 ++++++++++++++++++++ 6 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 src/Cache/Lock/UnsupportedPdoLockDriver.php diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index 71f6672..6dfed9e 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -27,8 +27,8 @@ Highlights: * batched ``multiFetch()`` via single ``IN (...)`` query * MySQL/MariaDB locking uses bounded connection-scoped named locks * PostgreSQL locking uses the two-key advisory-lock form -* SQLite and other PDO drivers without advisory locks use an injected - ``FileLockProvider`` fallback +* SQLite and other PDO drivers without advisory locks use a fallback-backed + ``FileLockProvider`` lock by default * expired data rows are misses and can be removed in bounded batches with ``PdoCacheAdapter::pruneExpired($limit)`` @@ -45,6 +45,20 @@ advisory locks are connection-owned. Treat ``leaseSeconds`` as API compatibility, not automatic expiry. The provider rejects re-entrant acquisition of the same lock through one provider instance. +For deployments that require native PDO coordination, construct the lock +provider in strict mode and attach it to the cache. Strict mode supports only +MySQL/MariaDB and PostgreSQL and throws ``UnsupportedPdoLockDriver`` at +construction for other PDO drivers; it never falls back to file locking. + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; + + $cache->setLockProvider(PdoLockProvider::strict($pdo)); + +The default ``new PdoLockProvider($pdo)`` remains fallback-enabled. It is a +fallback-backed PDO lock on unsupported drivers, not a distributed PDO lock. + Examples: .. code-block:: php diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst index 4716e78..dce543c 100644 --- a/docs/adapters/sqlite.rst +++ b/docs/adapters/sqlite.rst @@ -17,9 +17,11 @@ Use ``Cache::pdo(...)`` directly if you want to switch to MySQL/MariaDB/PostgreS without changing the rest of your cache usage pattern. SQLite does not provide the cross-process advisory-lock contract required by -``LockProviderInterface``. Its ``PdoLockProvider`` therefore delegates locking -to ``FileLockProvider``. All coordinating processes must use the same writable -lock directory and filesystem. +``LockProviderInterface``. Its default fallback-backed ``PdoLockProvider`` +therefore delegates locking to ``FileLockProvider``. All coordinating processes +must use the same writable lock directory and filesystem. SQLite cannot use +``PdoLockProvider::strict()`` because strict PDO locks require a native +MySQL/MariaDB or PostgreSQL driver. Example ------- diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index 3fb7d9a..21fb20d 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -116,9 +116,27 @@ Provider semantics: handle's lease duration does not force expiry. * File locks retain an open ``flock`` until release. Renewal verifies that the owned file resource is still open. -* SQLite and PDO drivers without native advisory locks use the file provider - fallback. Use the same writable lock directory in every process that must - coordinate. +* ``PdoLockProvider`` uses a native PDO lock only for MySQL/MariaDB and + PostgreSQL. SQLite and other drivers use its fallback provider by default; + this is a fallback-backed PDO lock, not a distributed PDO lock. Use the same + writable lock directory in every process that must coordinate when using + ``FileLockProvider``. + +Require a native PDO lock with strict mode. It validates the PDO driver during +construction and throws ``UnsupportedPdoLockDriver`` rather than silently +falling back: + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; + + $locks = PdoLockProvider::strict($pdo); + $cache->setLockProvider($locks); + +``PdoLockProvider::supportsNativeDriver($driver)`` reports whether a driver +supports native locking. Constructing ``new PdoLockProvider($pdo)`` remains +fallback-enabled for backward compatibility; pass any +``LockProviderInterface`` explicitly to choose a different fallback. Release is best effort and ownership guarded. Distributed leases may disappear after expiry, eviction, backend restart, or connection loss; callers must not diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index 6524cb7..7837555 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -21,11 +21,31 @@ public function __construct( private \PDO $pdo, private string $prefix = 'cachelayer:lock:', int $retrySleepMicros = 50_000, - private FileLockProvider $fallback = new FileLockProvider(), + private ?LockProviderInterface $fallback = new FileLockProvider(), ) { $this->retrySleepMicros = max(1_000, $retrySleepMicros); $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); - $this->driver = is_string($driver) ? $driver : ''; + $this->driver = is_string($driver) ? strtolower($driver) : ''; + + if ($this->fallback === null && !self::supportsNativeDriver($this->driver)) { + throw new UnsupportedPdoLockDriver(sprintf( + 'PDO driver "%s" does not support native cache locks.', + $this->driver === '' ? 'unknown' : $this->driver, + )); + } + } + + public static function strict( + \PDO $pdo, + string $prefix = 'cachelayer:lock:', + int $retrySleepMicros = 50_000, + ): self { + return new self($pdo, $prefix, $retrySleepMicros, fallback: null); + } + + public static function supportsNativeDriver(string $driver): bool + { + return in_array(strtolower($driver), ['mysql', 'mariadb', 'pgsql'], true); } public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle @@ -37,7 +57,7 @@ public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 3 return match ($this->driver) { 'mysql', 'mariadb' => $this->acquireMysql($key, $waitSeconds, $leaseSeconds), 'pgsql' => $this->acquirePgsql($key, $waitSeconds, $leaseSeconds), - default => $this->fallback->acquire($key, $waitSeconds, $leaseSeconds), + default => $this->fallback?->acquire($key, $waitSeconds, $leaseSeconds), }; } @@ -52,7 +72,7 @@ public function refresh(?LockHandle $handle, float $leaseSeconds): bool return match ($this->driver) { 'mysql', 'mariadb', 'pgsql' => $this->owns($handle) && $this->connectionAlive(), - default => $this->fallback->refresh($handle, $leaseSeconds), + default => $this->fallback?->refresh($handle, $leaseSeconds) ?? false, }; } @@ -62,8 +82,8 @@ public function release(?LockHandle $handle): void return; } - if (!in_array($this->driver, ['mysql', 'mariadb', 'pgsql'], true)) { - $this->fallback->release($handle); + if (!self::supportsNativeDriver($this->driver)) { + $this->fallback?->release($handle); return; } @@ -74,6 +94,7 @@ public function release(?LockHandle $handle): void $released = match ($this->driver) { 'mysql', 'mariadb' => $this->releaseMysql($handle), 'pgsql' => $this->releasePgsql($handle), + default => false, }; if ($released) { unset($this->activeTokens[$handle->key]); diff --git a/src/Cache/Lock/UnsupportedPdoLockDriver.php b/src/Cache/Lock/UnsupportedPdoLockDriver.php new file mode 100644 index 0000000..fd9b289 --- /dev/null +++ b/src/Cache/Lock/UnsupportedPdoLockDriver.php @@ -0,0 +1,9 @@ +markTestSkipped('pdo_sqlite is not available.'); + } + + $key = 'worker:default-fallback:' . bin2hex(random_bytes(5)); + $pdo = new PDO('sqlite::memory:'); + $first = new PdoLockProvider($pdo); + $second = new PdoLockProvider($pdo); + + $handle = $first->acquire($key, 0.0, 10.0); + + expect($handle)->not->toBeNull() + ->and($second->acquire($key, 0.0, 10.0))->toBeNull(); + + $first->release($handle); +}); + +test('strict PDO locks reject SQLite during construction', function (): void { + if (!extension_loaded('pdo_sqlite')) { + test()->markTestSkipped('pdo_sqlite is not available.'); + } + + expect(fn(): PdoLockProvider => PdoLockProvider::strict(new PDO('sqlite::memory:'))) + ->toThrow(UnsupportedPdoLockDriver::class); +}); + +test('strict PDO locks reject unsupported drivers during construction', function (): void { + $pdo = new class () extends PDO { + public function __construct() + { + } + + public function getAttribute(int $attribute): mixed + { + unset($attribute); + + return 'oci'; + } + }; + + expect(fn(): PdoLockProvider => PdoLockProvider::strict($pdo)) + ->toThrow(UnsupportedPdoLockDriver::class); +}); + +test('strict PDO locks allow native drivers without a fallback', function (): void { + foreach (['mysql', 'mariadb', 'pgsql'] as $driver) { + $pdo = new class ($driver) extends PDO { + public function __construct(private string $driver) + { + } + + public function getAttribute(int $attribute): mixed + { + unset($attribute); + + return $this->driver; + } + }; + + $provider = PdoLockProvider::strict($pdo); + $fallback = (new ReflectionObject($provider))->getProperty('fallback')->getValue($provider); + + expect($provider)->toBeInstanceOf(PdoLockProvider::class) + ->and($fallback)->toBeNull(); + } +}); + +test('PDO lock providers report their native driver capability', function (): void { + expect(PdoLockProvider::supportsNativeDriver('mysql'))->toBeTrue() + ->and(PdoLockProvider::supportsNativeDriver('MARIADB'))->toBeTrue() + ->and(PdoLockProvider::supportsNativeDriver('pgsql'))->toBeTrue() + ->and(PdoLockProvider::supportsNativeDriver('sqlite'))->toBeFalse() + ->and(PdoLockProvider::supportsNativeDriver('oci'))->toBeFalse(); +}); + +test('PDO lock providers accept an explicit non-file fallback', function (): void { + $fallback = new class implements LockProviderInterface { + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + unset($key, $waitSeconds, $leaseSeconds); + + return null; + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + unset($handle, $leaseSeconds); + + return false; + } + + public function release(?LockHandle $handle): void + { + unset($handle); + } + }; + $pdo = new class () extends PDO { + public function __construct() + { + } + + public function getAttribute(int $attribute): mixed + { + unset($attribute); + + return 'sqlite'; + } + }; + + expect(new PdoLockProvider($pdo, fallback: $fallback))->toBeInstanceOf(PdoLockProvider::class); +}); + +test('MySQL PDO locks use the native lock path', function (): void { + $pdo = new class () extends PDO { + public function __construct() + { + } + + public function getAttribute(int $attribute): mixed + { + unset($attribute); + + return 'mysql'; + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + unset($query, $options); + + return $this->successfulStatement(); + } + + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false + { + unset($query, $fetchMode, $fetchModeArgs); + + return $this->successfulStatement(); + } + + private function successfulStatement(): PDOStatement + { + return new class () extends PDOStatement { + public function __construct() + { + } + + public function execute(?array $params = null): bool + { + unset($params); + + return true; + } + + public function fetchColumn(int $column = 0): mixed + { + unset($column); + + return '1'; + } + }; + } + }; + $provider = PdoLockProvider::strict($pdo); + $handle = $provider->acquire('worker:mysql', 0.0, 10.0); + + expect($handle)->not->toBeNull() + ->and($provider->refresh($handle, 10.0))->toBeTrue(); + + $provider->release($handle); + + expect($provider->refresh($handle, 10.0))->toBeFalse(); +}); + test('PostgreSQL PDO locks accept native boolean results', function (): void { $pdo = new class () extends PDO { public function __construct()