diff --git a/docker-compose.yaml b/docker-compose.yaml index 38249c8b5..f1386d16c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,4 +13,12 @@ services: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore ports: - - 3306:3306 \ No newline at end of file + - 3306:3306 + + mariadb: + image: mariadb:12 + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD="yes" + - MYSQL_DATABASE=eventstore + ports: + - 3307:3306 \ No newline at end of file diff --git a/src/Store/DoctrineDbalStore.php b/src/Store/DoctrineDbalStore.php index 5efcaa07e..f00ea5dee 100644 --- a/src/Store/DoctrineDbalStore.php +++ b/src/Store/DoctrineDbalStore.php @@ -64,6 +64,14 @@ final class DoctrineDbalStore implements Store, SubscriptionStore, DoctrineSchem */ private const DEFAULT_LOCK_ID = 133742; + /** + * MariaDB does not support an infinite (negative) lock timeout. Very large values such as + * PHP_INT_MAX overflow its internal timeout arithmetic and make GET_LOCK return NULL. We + * therefore use a large but safe value (INT32_MAX minus a small buffer) as "effectively + * infinite" wait. + */ + private const INFINITE_MARIADB_LOCK_TIMEOUT = 2_147_482_647; + private readonly HeadersSerializer $headersSerializer; /** @var array{table_name: string, aggregate_id_type: 'string'|'uuid', locking: bool, lock_id: int, lock_timeout: int} */ @@ -493,14 +501,28 @@ private function lock(): void } if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { - $this->connection->fetchAllAssociative( + $lockTimeout = $this->config['lock_timeout']; + + if ($platform instanceof MariaDBPlatform && $lockTimeout < 0) { + $lockTimeout = self::INFINITE_MARIADB_LOCK_TIMEOUT; + } + + $result = $this->connection->fetchOne( sprintf( 'SELECT GET_LOCK("%s", %d)', $this->config['lock_id'], - $this->config['lock_timeout'], + $lockTimeout, ), ); + if ($result === 0) { + throw LockCouldNotBeAcquired::byTimeout($this->config['lock_id'], $this->config['lock_timeout']); + } + + if ($result !== 1) { + throw LockCouldNotBeAcquired::byError($this->config['lock_id']); + } + return; } @@ -522,13 +544,21 @@ private function unlock(): void } if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { - $this->connection->fetchAllAssociative( + $result = $this->connection->fetchOne( sprintf( 'SELECT RELEASE_LOCK("%s")', $this->config['lock_id'], ), ); + if ($result === 0) { + throw LockCouldNotBeFreed::notOurs($this->config['lock_id']); + } + + if ($result !== 1) { + throw LockCouldNotBeFreed::notExist($this->config['lock_id']); + } + return; } diff --git a/src/Store/LockCouldNotBeAcquired.php b/src/Store/LockCouldNotBeAcquired.php new file mode 100644 index 000000000..ce1e2b265 --- /dev/null +++ b/src/Store/LockCouldNotBeAcquired.php @@ -0,0 +1,24 @@ +connection->fetchAllAssociative( + $lockTimeout = $this->config['lock_timeout']; + + if ($platform instanceof MariaDBPlatform && $lockTimeout < 0) { + $lockTimeout = self::INFINITE_MARIADB_LOCK_TIMEOUT; + } + + $result = $this->connection->fetchOne( sprintf( 'SELECT GET_LOCK("%s", %d)', $this->config['lock_id'], - $this->config['lock_timeout'], + $lockTimeout, ), ); + if ($result === 0) { + throw LockCouldNotBeAcquired::byTimeout($this->config['lock_id'], $this->config['lock_timeout']); + } + + if ($result !== 1) { + throw LockCouldNotBeAcquired::byError($this->config['lock_id']); + } + return; } @@ -601,13 +623,21 @@ private function unlock(): void } if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { - $this->connection->fetchAllAssociative( + $result = $this->connection->fetchOne( sprintf( 'SELECT RELEASE_LOCK("%s")', $this->config['lock_id'], ), ); + if ($result === 0) { + throw LockCouldNotBeFreed::notOurs($this->config['lock_id']); + } + + if ($result !== 1) { + throw LockCouldNotBeFreed::notExist($this->config['lock_id']); + } + return; } diff --git a/tests/Integration/Store/DoctrineDbalStoreTest.php b/tests/Integration/Store/DoctrineDbalStoreTest.php index 04511fc7f..51008b9b7 100644 --- a/tests/Integration/Store/DoctrineDbalStoreTest.php +++ b/tests/Integration/Store/DoctrineDbalStoreTest.php @@ -6,12 +6,19 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Schema\Schema; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; +use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; @@ -20,6 +27,7 @@ use PHPUnit\Framework\TestCase; use function json_decode; +use function sprintf; #[CoversNothing] final class DoctrineDbalStoreTest extends TestCase @@ -34,6 +42,7 @@ public function setUp(): void $this->store = new DoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + config: ['lock_timeout' => 1], ); $schemaDirector = new DoctrineSchemaDirector( @@ -190,6 +199,45 @@ public function testSave10000Messages(): void self::assertEquals(10000, $result); } + public function testSaveLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ]; + + $connection = DriverManager::getConnection($this->connection->getParams()); + + $lock = $connection->fetchOne( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + 133742, + 1, + ), + ); + self::assertSame(1, $lock); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 1'); + try { + $this->store->save(...$messages); + } finally { + $connection->close(); + } + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index 9b83b2852..27d8c20ee 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -6,6 +6,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Schema\Schema; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Message\Message; @@ -19,6 +22,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\StreamStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -51,6 +55,7 @@ public function setUp(): void $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), clock: $this->clock, + config: ['lock_timeout' => 1], ); $schemaDirector = new DoctrineSchemaDirector( @@ -396,6 +401,45 @@ public function testSave10000Messages(): void self::assertEquals(10000, $result); } + public function testSaveLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ]; + + $connection = DriverManager::getConnection($this->connection->getParams()); + + $lock = $connection->fetchOne( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + 133742, + 1, + ), + ); + self::assertSame(1, $lock); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 1'); + try { + $this->store->save(...$messages); + } finally { + $connection->close(); + } + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Unit/Store/DoctrineDbalStoreTest.php b/tests/Unit/Store/DoctrineDbalStoreTest.php index aa887a29c..fb5b80c6e 100644 --- a/tests/Unit/Store/DoctrineDbalStoreTest.php +++ b/tests/Unit/Store/DoctrineDbalStoreTest.php @@ -29,6 +29,8 @@ use Patchlevel\EventSourcing\Serializer\SerializedEvent; use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; +use Patchlevel\EventSourcing\Store\LockCouldNotBeFreed; use Patchlevel\EventSourcing\Store\MissingDataForStorage; use Patchlevel\EventSourcing\Store\StreamStartHeader; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -557,10 +559,10 @@ public function __invoke(): void ->willReturn(new MySQLPlatform()); $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -599,10 +601,10 @@ public function __invoke(): void $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection->expects($this->atLeastOnce())->method('transactional')->willReturnCallback( @@ -737,10 +739,10 @@ public function __invoke(): void $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection->expects($this->once())->method('transactional')->willReturnCallback( @@ -761,6 +763,273 @@ public function __invoke(): void $store->transactional($callback(...)); } + public function testTransactionalWithMariaDBCustomLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 5)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDBZeroLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 0)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 0], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalLockCouldNotBeAcquiredByTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", 5)') + ->willReturn(0); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 5'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeAcquiredByError(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", -1)') + ->willReturn(null); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('There was an error when tried to get the lock with id [133742]'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotOurs(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 0], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it is not ours'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotExist(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', null], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new DoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it does not exist'); + + $store->transactional($callback(...)); + } + public function testSaveWithOneEvent(): void { $recordedOn = new DateTimeImmutable(); diff --git a/tests/Unit/Store/LockCouldNotBeAcquiredTest.php b/tests/Unit/Store/LockCouldNotBeAcquiredTest.php new file mode 100644 index 000000000..53de8ad05 --- /dev/null +++ b/tests/Unit/Store/LockCouldNotBeAcquiredTest.php @@ -0,0 +1,35 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testByError(): void + { + $exception = LockCouldNotBeAcquired::byError(133742); + + self::assertSame( + 'There was an error when tried to get the lock with id [133742]', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/LockCouldNotBeFreedTest.php b/tests/Unit/Store/LockCouldNotBeFreedTest.php new file mode 100644 index 000000000..c2f90fdb0 --- /dev/null +++ b/tests/Unit/Store/LockCouldNotBeFreedTest.php @@ -0,0 +1,35 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testNotOurs(): void + { + $exception = LockCouldNotBeFreed::notOurs(133742); + + self::assertSame( + 'The lock with id [133742] could not be freed as it is not ours', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index 35d50426c..acf3e16f0 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -32,6 +32,8 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; +use Patchlevel\EventSourcing\Store\LockCouldNotBeFreed; use Patchlevel\EventSourcing\Store\MissingDataForStorage; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -660,10 +662,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -704,10 +706,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -871,10 +873,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -896,6 +898,273 @@ public function __invoke(): void $store->transactional($callback(...)); } + public function testTransactionalWithMariaDBCustomLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 5)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDBZeroLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 0)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 0], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalLockCouldNotBeAcquiredByTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", 5)') + ->willReturn(0); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 5'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeAcquiredByError(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", -1)') + ->willReturn(null); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('There was an error when tried to get the lock with id [133742]'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotOurs(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 0], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it is not ours'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotExist(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', null], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it does not exist'); + + $store->transactional($callback(...)); + } + public function testSaveWithOneEvent(): void { $recordedOn = new DateTimeImmutable();